package dice import ( "context" "strings" "testing" "time" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/xerr" dicedomain "hyapp/services/game-service/internal/domain/dice" gamedomain "hyapp/services/game-service/internal/domain/game" selfgame "hyapp/services/game-service/internal/domain/selfgame" ) func TestRollMatchDebitsRollsAndPaysWinner(t *testing.T) { repo := newFakeDiceRepository() wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}} svc := New(Config{FeeBPS: 500}, repo, wallet) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } rolls := []int{5, 0} svc.randomInt = func(max int) (int, error) { if len(rolls) == 0 { t.Fatal("randomInt called too many times") } value := rolls[0] rolls = rolls[1:] return value, nil } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-create", UserID: 101, GameID: "dice", RoomID: "room_1", RegionID: 100, StakeCoin: 100, MinPlayers: 2, MaxPlayers: 3, }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join", UserID: 202, MatchID: created.MatchID}); err != nil { t.Fatalf("JoinMatch failed: %v", err) } settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll", UserID: 101, MatchID: created.MatchID}) if err != nil { t.Fatalf("RollMatch failed: %v", err) } if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultWin { t.Fatalf("match not settled as win: %+v", settled) } if len(settled.Participants) != 2 { t.Fatalf("participants len = %d", len(settled.Participants)) } winner := participantByUser(settled.Participants, 101) loser := participantByUser(settled.Participants, 202) if len(winner.DicePoints) != 1 || winner.DicePoints[0] != 6 { t.Fatalf("winner should have one dice point, got %+v", winner.DicePoints) } if len(loser.DicePoints) != 1 || loser.DicePoints[0] != 1 { t.Fatalf("loser should have one dice point, got %+v", loser.DicePoints) } if winner.Result != dicedomain.ParticipantResultWin || winner.PayoutCoin != 188 || winner.BalanceAfter != 1088 { t.Fatalf("winner settlement mismatch: %+v", winner) } if loser.Result != dicedomain.ParticipantResultLose || loser.PayoutCoin != 0 || loser.BalanceAfter != 900 { t.Fatalf("loser settlement mismatch: %+v", loser) } if wallet.calls != 3 { t.Fatalf("wallet calls = %d, want 3", wallet.calls) } if repo.levelDebitOrders != 2 { t.Fatalf("debit orders should still feed game order success path, got %d", repo.levelDebitOrders) } if len(repo.exploreWinners) != 1 { t.Fatalf("explore winner records = %d, want 1", len(repo.exploreWinners)) } exploreWinner := repo.exploreWinners[0] if exploreWinner.GameCode != dicedomain.ExploreGameCodeDice || exploreWinner.GameID != "dice" || exploreWinner.UserID != 101 || exploreWinner.CoinAmount != 188 { t.Fatalf("explore winner mismatch: %+v", exploreWinner) } if exploreWinner.WinID == "" || exploreWinner.WonAtMS != 1700000000000 { t.Fatalf("explore winner identity/time mismatch: %+v", exploreWinner) } } func TestRollMatchRequiresMinimumParticipantsBeforeWalletDebit(t *testing.T) { repo := newFakeDiceRepository() wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} svc := New(Config{}, repo, wallet) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-create", UserID: 101, GameID: "dice", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 3, }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } _, _, err = svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll", UserID: 101, MatchID: created.MatchID}) if !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("RollMatch should reject not enough participants, got %v", err) } if wallet.calls != 0 { t.Fatalf("wallet should not be called, got %d", wallet.calls) } } func TestRollMatchDrawRerollsUntilWinner(t *testing.T) { repo := newFakeDiceRepository() wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}} svc := New(Config{FeeBPS: 500}, repo, wallet) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } rolls := []int{2, 2, 5, 0} svc.randomInt = func(max int) (int, error) { if len(rolls) == 0 { t.Fatal("randomInt called too many times") } value := rolls[0] rolls = rolls[1:] return value, nil } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-create", UserID: 101, GameID: "dice", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join", UserID: 202, MatchID: created.MatchID}); err != nil { t.Fatalf("JoinMatch failed: %v", err) } draw, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-1", UserID: 101, MatchID: created.MatchID}) if err != nil { t.Fatalf("first RollMatch failed: %v", err) } if draw.Status != dicedomain.MatchStatusReady || draw.Result != dicedomain.ParticipantResultDraw || draw.RoundNo != 2 { t.Fatalf("draw should keep match ready for reroll: %+v", draw) } if wallet.calls != 2 { t.Fatalf("draw should only debit both users, wallet calls = %d", wallet.calls) } for _, participant := range draw.Participants { if participant.Result != dicedomain.ParticipantResultDraw || participant.PayoutCoin != 0 || len(participant.DicePoints) != 1 || participant.DicePoints[0] != 3 { t.Fatalf("draw participant mismatch: %+v", participant) } } settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-2", UserID: 101, MatchID: created.MatchID}) if err != nil { t.Fatalf("second RollMatch failed: %v", err) } if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultWin { t.Fatalf("reroll should settle with winner: %+v", settled) } if wallet.calls != 3 { t.Fatalf("reroll should not debit again and should only pay winner, wallet calls = %d", wallet.calls) } winner := participantByUser(settled.Participants, 101) loser := participantByUser(settled.Participants, 202) if winner.DicePoints[0] != 6 || winner.PayoutCoin != 188 || winner.BalanceAfter != 1088 { t.Fatalf("winner after reroll mismatch: %+v", winner) } if loser.DicePoints[0] != 1 || loser.PayoutCoin != 0 || loser.BalanceAfter != 900 { t.Fatalf("loser after reroll mismatch: %+v", loser) } } func TestRPSRollUsesPreselectedGesturesWithoutRandomRoll(t *testing.T) { repo := newFakeDiceRepository() configureFakeRock(repo) wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}} svc := New(Config{FeeBPS: 500}, repo, wallet) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } svc.randomInt = func(max int) (int, error) { t.Fatalf("rock settlement must not call randomInt, max=%d", max) return 0, nil } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-create-rps", UserID: 101, GameID: "rock", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, RPSGesture: "rock", }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join-rps", UserID: 202, MatchID: created.MatchID, GameID: "rock", RPSGesture: "scissors"}); err != nil { t.Fatalf("JoinMatch failed: %v", err) } settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-rps", UserID: 101, MatchID: created.MatchID, GameID: "rock", RPSGesture: "rock"}) if err != nil { t.Fatalf("RollMatch failed: %v", err) } if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultWin { t.Fatalf("rock match should settle with winner: %+v", settled) } winner := participantByUser(settled.Participants, 101) loser := participantByUser(settled.Participants, 202) if winner.RPSGesture != "rock" || len(winner.DicePoints) != 1 || winner.DicePoints[0] != 1 || winner.Result != dicedomain.ParticipantResultWin || winner.PayoutCoin != 188 || winner.BalanceAfter != 1088 { t.Fatalf("winner rps settlement mismatch: %+v", winner) } if loser.RPSGesture != "scissors" || len(loser.DicePoints) != 1 || loser.DicePoints[0] != 3 || loser.Result != dicedomain.ParticipantResultLose || loser.PayoutCoin != 0 || loser.BalanceAfter != 900 { t.Fatalf("loser rps settlement mismatch: %+v", loser) } if wallet.calls != 3 { t.Fatalf("rock win should debit both and pay winner, wallet calls=%d", wallet.calls) } if len(repo.exploreWinners) != 1 || repo.exploreWinners[0].GameCode != dicedomain.ExploreGameCodeRock || repo.exploreWinners[0].UserID != 101 { t.Fatalf("rock explore winner mismatch: %+v", repo.exploreWinners) } } func TestRPSDrawRefundsBothAndSettles(t *testing.T) { repo := newFakeDiceRepository() configureFakeRock(repo) wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}} svc := New(Config{FeeBPS: 500}, repo, wallet) svc.now = func() time.Time { return time.UnixMilli(1700000000000) } svc.randomInt = func(max int) (int, error) { t.Fatalf("rock draw settlement must not call randomInt, max=%d", max) return 0, nil } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-create-rps-draw", UserID: 101, GameID: "rock", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, RPSGesture: "rock", }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join-rps-draw", UserID: 202, MatchID: created.MatchID, GameID: "rock", RPSGesture: "rock"}); err != nil { t.Fatalf("JoinMatch failed: %v", err) } settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-rps-draw", UserID: 101, MatchID: created.MatchID, GameID: "rock"}) if err != nil { t.Fatalf("RollMatch failed: %v", err) } if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultDraw || settled.RoundNo != 1 { t.Fatalf("rock draw should settle without reroll: %+v", settled) } for _, participant := range settled.Participants { if participant.Result != dicedomain.ParticipantResultDraw || participant.PayoutCoin != participant.StakeCoin || participant.BalanceAfter != 1000 || participant.RPSGesture != "rock" { t.Fatalf("draw participant should be refunded: %+v", participant) } } if wallet.calls != 4 { t.Fatalf("rock draw should debit and refund both users, wallet calls=%d", wallet.calls) } if len(repo.exploreWinners) != 0 { t.Fatalf("draw must not record explore winners: %+v", repo.exploreWinners) } } func TestRPSRequiresGestureBeforeMatchmaking(t *testing.T) { tests := []struct { name string call func(*Service) (dicedomain.Match, int64, error) }{ { name: "create", call: func(svc *Service) (dicedomain.Match, int64, error) { return svc.CreateMatch(context.Background(), CreateMatchCommand{AppCode: "lalu", UserID: 101, GameID: "rock", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2}) }, }, { name: "match", call: func(svc *Service) (dicedomain.Match, int64, error) { return svc.Match(context.Background(), MatchCommand{AppCode: "lalu", UserID: 101, GameID: "rock", StakeCoin: 100}) }, }, { name: "join", call: func(svc *Service) (dicedomain.Match, int64, error) { return svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", UserID: 202, MatchID: "match-rock-1", GameID: "rock"}) }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { repo := newFakeDiceRepository() configureFakeRock(repo) repo.match = dicedomain.Match{ AppCode: "lalu", MatchID: "match-rock-1", GameID: "rock", PlatformCode: "dice", ProviderGameID: "rock", MinPlayers: 2, MaxPlayers: 2, CurrentPlayers: 1, StakeCoin: 100, Status: dicedomain.MatchStatusCreated, Participants: []dicedomain.Participant{{ AppCode: "lalu", MatchID: "match-rock-1", UserID: 101, SeatNo: 1, Status: dicedomain.ParticipantStatusJoined, StakeCoin: 100, RPSGesture: "paper", }}, } wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}} svc := New(Config{}, repo, wallet) _, _, err := tt.call(svc) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("%s should reject missing rps gesture, got %v", tt.name, err) } if wallet.calls != 0 { t.Fatalf("%s should reject before wallet mutation, calls=%d", tt.name, wallet.calls) } }) } } func TestRPSRollRejectsGestureMismatchBeforeMutation(t *testing.T) { repo := newFakeDiceRepository() configureFakeRock(repo) repo.match = dicedomain.Match{ AppCode: "lalu", MatchID: "match-rock-1", GameID: "rock", PlatformCode: "dice", ProviderGameID: "rock", MinPlayers: 2, MaxPlayers: 2, CurrentPlayers: 2, StakeCoin: 100, Status: dicedomain.MatchStatusReady, Participants: []dicedomain.Participant{ {AppCode: "lalu", MatchID: "match-rock-1", UserID: 101, SeatNo: 1, Status: dicedomain.ParticipantStatusJoined, StakeCoin: 100, RPSGesture: "rock"}, {AppCode: "lalu", MatchID: "match-rock-1", UserID: 202, SeatNo: 2, Status: dicedomain.ParticipantStatusJoined, StakeCoin: 100, RPSGesture: "paper"}, }, } wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}} svc := New(Config{}, repo, wallet) _, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "rock", RPSGesture: "scissors"}) if !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("roll should reject changed rps gesture, got %v", err) } if repo.match.Status != dicedomain.MatchStatusReady || wallet.calls != 0 { t.Fatalf("gesture mismatch must not claim roll or call wallet: match=%+v wallet=%d", repo.match, wallet.calls) } } func TestNewUserRobotDiceMatchUsesProtectionStrategyWhenPoolIsGreen(t *testing.T) { repo := newFakeDiceRepository() repo.config.PoolBalanceCoin = 200 repo.poolBalance = 200 repo.stakePool.BalanceCoin = 200 repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9001, Status: dicedomain.RobotStatusActive} wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} svc := New(Config{}, repo, wallet) nowMS := int64(1700000000000) svc.now = func() time.Time { return time.UnixMilli(nowMS) } rolls := []int{0, 0, 5, 4} svc.randomInt = func(max int) (int, error) { if len(rolls) == 0 { t.Fatal("randomInt called too many times") } value := rolls[0] rolls = rolls[1:] return value, nil } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-first-dice-create", UserID: 101, GameID: "dice", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } nowMS += dicedomain.DefaultRobotWaitMS ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) if err != nil { t.Fatalf("GetMatch attach robot failed: %v", err) } if ready.MatchMode != dicedomain.MatchModeRobot || ready.ForcedResult != dicedomain.ForcedResultPlayerWin { t.Fatalf("new user protection should force player win when strategy hits, got %+v", ready) } if len(repo.strategyLogs) != 1 || repo.strategyLogs[0].ReasonCode != "NEW_USER_POSITIVE_FEEDBACK" { t.Fatalf("strategy log mismatch: %+v", repo.strategyLogs) } settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-first-dice-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) if err != nil { t.Fatalf("RollMatch failed: %v", err) } human := participantByUser(settled.Participants, 101) robot := participantByUser(settled.Participants, 9001) if human.Result != dicedomain.ParticipantResultWin || len(human.DicePoints) != 1 || human.PayoutCoin != 188 || human.BalanceAfter != 1088 { t.Fatalf("first dice human should win from forced points: %+v", human) } if robot.Result != dicedomain.ParticipantResultLose || len(robot.DicePoints) != 1 { t.Fatalf("first dice robot should lose from forced points: %+v", robot) } if human.DicePoints[0] <= robot.DicePoints[0] || (human.DicePoints[0] == 6 && robot.DicePoints[0] == 1) { t.Fatalf("forced win should use a random winning dice pair instead of fixed 6:1, human=%+v robot=%+v", human, robot) } if repo.poolBalance != 112 || repo.stakePool.BalanceCoin != 112 { t.Fatalf("pool should pay only human net win coin, legacy=%d stake=%d", repo.poolBalance, repo.stakePool.BalanceCoin) } if len(repo.protectionEvents) != 1 || repo.protectionEvents[0].LifetimeSubsidyCoinDelta != 88 { t.Fatalf("new user protection should consume one effective subsidized robot game, events=%+v", repo.protectionEvents) } if wallet.calls != 2 { t.Fatalf("robot match should debit and pay only human wallet, calls=%d", wallet.calls) } } func TestForcedHumanLoseUsesRandomLosingDicePair(t *testing.T) { svc := New(Config{}, newFakeDiceRepository(), &fakeDiceWallet{balances: map[int64]int64{}}) svc.randomInt = func(max int) (int, error) { if max != 14 { t.Fatalf("forced lose should random from non-fixed losing pairs, max=%d", max) } return 11, nil } participants := []dicedomain.Participant{ {AppCode: "lalu", MatchID: "match-forced-lose", UserID: 101, ParticipantType: dicedomain.ParticipantTypeUser, StakeCoin: 100}, {AppCode: "lalu", MatchID: "match-forced-lose", UserID: 9001, ParticipantType: dicedomain.ParticipantTypeRobot, StakeCoin: 100}, } if err := svc.forceHumanLose(participants); err != nil { t.Fatalf("forceHumanLose failed: %v", err) } human := participantByUser(participants, 101) robot := participantByUser(participants, 9001) if len(human.DicePoints) != 1 || len(robot.DicePoints) != 1 || human.DicePoints[0] >= robot.DicePoints[0] || (human.DicePoints[0] == 1 && robot.DicePoints[0] == 6) { t.Fatalf("forced lose should use a random losing dice pair instead of fixed 1:6, human=%+v robot=%+v", human, robot) } } func TestNewUserLoseStreakProtectionForcesRobotMatchWin(t *testing.T) { repo := newFakeDiceRepository() repo.config.PoolBalanceCoin = 200 repo.poolBalance = 200 repo.stakePool.BalanceCoin = 200 repo.userStats = selfgame.UserStats{EffectiveBotGameCount: 3, LoseStreak: 2} repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9011, Status: dicedomain.RobotStatusActive} wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} svc := New(Config{}, repo, wallet) nowMS := int64(1700000000000) svc.now = func() time.Time { return time.UnixMilli(nowMS) } svc.randomInt = func(max int) (int, error) { t.Fatalf("lose streak protection should bypass probability random, max=%d", max) return 0, nil } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-new-losing-streak-create", UserID: 101, GameID: "dice", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } nowMS += dicedomain.DefaultRobotWaitMS ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) if err != nil { t.Fatalf("GetMatch attach robot failed: %v", err) } if ready.MatchMode != dicedomain.MatchModeRobot || ready.ForcedResult != dicedomain.ForcedResultPlayerWin { t.Fatalf("new user lose streak protection should force player win, got %+v", ready) } if len(repo.strategyLogs) != 1 || repo.strategyLogs[0].ReasonCode != "NEW_USER_LOSE_STREAK_PROTECTION" { t.Fatalf("strategy log mismatch: %+v", repo.strategyLogs) } } func TestStakePoolBlackSkipsRobotAttachment(t *testing.T) { repo := newFakeDiceRepository() repo.config.PoolBalanceCoin = 87 repo.poolBalance = 87 repo.stakePool.BalanceCoin = 87 repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9004, Status: dicedomain.RobotStatusActive} wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} svc := New(Config{}, repo, wallet) nowMS := int64(1700000000000) svc.now = func() time.Time { return time.UnixMilli(nowMS) } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-short-pool-create", UserID: 101, GameID: "dice", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } nowMS += dicedomain.DefaultRobotWaitMS ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) if err != nil { t.Fatalf("GetMatch attach robot failed: %v", err) } if ready.MatchMode == dicedomain.MatchModeRobot || ready.CurrentPlayers != 1 { t.Fatalf("black stake pool should not attach robot, got %+v", ready) } if len(repo.strategyLogs) != 1 || repo.strategyLogs[0].ReasonCode != "POOL_BLACK" { t.Fatalf("black pool should write pool guard strategy log, logs=%+v", repo.strategyLogs) } } func TestStakePoolBlackCanForceRobotWinWhenEnabled(t *testing.T) { repo := newFakeDiceRepository() repo.config.PoolBalanceCoin = 87 repo.poolBalance = 87 repo.stakePool.BalanceCoin = 87 repo.newUserPolicy.BlackPoolRobotForceWinEnabled = true repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9004, Status: dicedomain.RobotStatusActive} wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} svc := New(Config{}, repo, wallet) nowMS := int64(1700000000000) svc.now = func() time.Time { return time.UnixMilli(nowMS) } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-black-force-robot-win-create", UserID: 101, GameID: "dice", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } nowMS += dicedomain.DefaultRobotWaitMS ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) if err != nil { t.Fatalf("GetMatch attach robot failed: %v", err) } if ready.MatchMode != dicedomain.MatchModeRobot || ready.ForcedResult != dicedomain.ForcedResultPlayerLose { t.Fatalf("black pool force switch should attach robot and force player lose, got %+v", ready) } if len(repo.strategyLogs) != 1 || repo.strategyLogs[0].ReasonCode != "POOL_BLACK_ROBOT_FORCE_WIN" || repo.strategyLogs[0].ForceResult != dicedomain.ForcedResultPlayerLose { t.Fatalf("black pool force switch should write force-lose strategy log, logs=%+v", repo.strategyLogs) } rolls := []struct { max int value int }{ {max: 6, value: 0}, {max: 6, value: 5}, {max: 14, value: 11}, } svc.randomInt = func(max int) (int, error) { if len(rolls) == 0 { t.Fatal("randomInt called too many times") } next := rolls[0] rolls = rolls[1:] if max != next.max { t.Fatalf("randomInt max = %d, want %d", max, next.max) } return next.value, nil } settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-black-force-robot-win-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) if err != nil { t.Fatalf("RollMatch failed: %v", err) } human := participantByUser(settled.Participants, 101) robot := participantByUser(settled.Participants, 9004) if human.Result != dicedomain.ParticipantResultLose || robot.Result != dicedomain.ParticipantResultWin || len(human.DicePoints) != 1 || len(robot.DicePoints) != 1 || human.DicePoints[0] >= robot.DicePoints[0] { t.Fatalf("black pool force switch should settle as robot win with random losing pair, human=%+v robot=%+v", human, robot) } if repo.poolBalance != 182 || repo.stakePool.BalanceCoin != 182 { t.Fatalf("black pool force robot win should put user stake net of fee into pool, legacy=%d stake=%d", repo.poolBalance, repo.stakePool.BalanceCoin) } } func TestNewUserRobotRockChoosesLosingGestureWhenProtectionHits(t *testing.T) { repo := newFakeDiceRepository() configureFakeRock(repo) repo.config.PoolBalanceCoin = 200 repo.poolBalance = 200 repo.stakePool.BalanceCoin = 200 repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "rock", UserID: 9002, Status: dicedomain.RobotStatusActive} wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} svc := New(Config{}, repo, wallet) nowMS := int64(1700000000000) svc.now = func() time.Time { return time.UnixMilli(nowMS) } svc.randomInt = func(max int) (int, error) { if max != 100 { t.Fatalf("new user strategy should only roll protection probability before forced rock gesture, max=%d", max) } return 0, nil } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-first-rock-create", UserID: 101, GameID: "rock", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, RPSGesture: "rock", }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } nowMS += dicedomain.DefaultRobotWaitMS ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "rock"}) if err != nil { t.Fatalf("GetMatch attach robot failed: %v", err) } if ready.ForcedResult != dicedomain.ForcedResultPlayerWin || repo.joinRobotRPSGesture != dicedomain.RPSGestureScissors { t.Fatalf("protected rock robot should choose losing gesture, forced=%q gesture=%q", ready.ForcedResult, repo.joinRobotRPSGesture) } settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-first-rock-roll", UserID: 101, MatchID: created.MatchID, GameID: "rock"}) if err != nil { t.Fatalf("RollMatch failed: %v", err) } human := participantByUser(settled.Participants, 101) robot := participantByUser(settled.Participants, 9002) if human.Result != dicedomain.ParticipantResultWin || human.RPSGesture != dicedomain.RPSGestureRock || human.PayoutCoin != 188 || human.BalanceAfter != 1088 { t.Fatalf("first rock human should keep selected gesture and win: %+v", human) } if robot.Result != dicedomain.ParticipantResultLose || robot.RPSGesture != dicedomain.RPSGestureScissors { t.Fatalf("first rock robot should lose with losing gesture: %+v", robot) } if len(repo.protectionEvents) != 1 { t.Fatalf("protected non-draw rock robot game should consume protection, events=%+v", repo.protectionEvents) } } func TestOldUserRobotMatchKeepsNormalRandom(t *testing.T) { repo := newFakeDiceRepository() repo.config.PoolBalanceCoin = 200 repo.poolBalance = 200 repo.stakePool.BalanceCoin = 200 repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9003, Status: dicedomain.RobotStatusActive} repo.userStats = selfgame.UserStats{EffectiveBotGameCount: 31, WinStreak: 9, TodayNetWinCoin: 900000, SevenDayNetWinCoin: 1800000} repo.newUserPolicy.NormalPhaseTargetWinRatePercent = 50 wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} svc := New(Config{}, repo, wallet) nowMS := int64(1700000000000) svc.now = func() time.Time { return time.UnixMilli(nowMS) } rolls := []int{5, 5} svc.randomInt = func(max int) (int, error) { if len(rolls) == 0 { t.Fatal("randomInt called too many times") } value := rolls[0] rolls = rolls[1:] return value, nil } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-normal-after-win-create", UserID: 101, GameID: "dice", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } nowMS += dicedomain.DefaultRobotWaitMS ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) if err != nil { t.Fatalf("GetMatch attach robot failed: %v", err) } if ready.ForcedResult != "" { t.Fatalf("old user robot match must not force result, got forced_result=%q", ready.ForcedResult) } settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-normal-after-win-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) if err != nil { t.Fatalf("RollMatch failed: %v", err) } human := participantByUser(settled.Participants, 101) if human.Result != dicedomain.ParticipantResultDraw || len(human.DicePoints) != 1 || human.DicePoints[0] != 6 || human.PayoutCoin != 0 { t.Fatalf("old user normal random dice should settle from natural dice points, got %+v", human) } if len(repo.protectionEvents) != 0 { t.Fatalf("old user normal random must not consume new user protection, events=%+v", repo.protectionEvents) } } func TestNormalPhaseTargetWinRateForcesWinWithoutProtectionConsume(t *testing.T) { repo := newFakeDiceRepository() repo.config.PoolBalanceCoin = 200 repo.poolBalance = 200 repo.stakePool.BalanceCoin = 200 repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9005, Status: dicedomain.RobotStatusActive} repo.userStats = selfgame.UserStats{EffectiveBotGameCount: 31} repo.protectionState.UsedRounds = 30 repo.newUserPolicy.NormalPhaseTargetWinRatePercent = 53 wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} svc := New(Config{}, repo, wallet) nowMS := int64(1700000000000) svc.now = func() time.Time { return time.UnixMilli(nowMS) } rolls := []int{0, 0, 5, 8} svc.randomInt = func(max int) (int, error) { if len(rolls) == 0 { t.Fatal("randomInt called too many times") } value := rolls[0] rolls = rolls[1:] return value, nil } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-normal-target-create", UserID: 101, GameID: "dice", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } nowMS += dicedomain.DefaultRobotWaitMS ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) if err != nil { t.Fatalf("GetMatch attach robot failed: %v", err) } if ready.ForcedResult != dicedomain.ForcedResultPlayerWinWithoutProtection { t.Fatalf("normal phase target should force player win without protection consume, forced=%q", ready.ForcedResult) } if len(repo.strategyLogs) != 1 || repo.strategyLogs[0].ReasonCode != "NORMAL_PHASE_TARGET_HIT" { t.Fatalf("strategy log mismatch: %+v", repo.strategyLogs) } settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-normal-target-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) if err != nil { t.Fatalf("RollMatch failed: %v", err) } human := participantByUser(settled.Participants, 101) robot := participantByUser(settled.Participants, 9005) if human.Result != dicedomain.ParticipantResultWin || human.PayoutCoin != 188 || human.BalanceAfter != 1088 { t.Fatalf("normal phase target should settle as human win, human=%+v", human) } if len(human.DicePoints) != 1 || len(robot.DicePoints) != 1 || human.DicePoints[0] <= robot.DicePoints[0] || (human.DicePoints[0] == 6 && robot.DicePoints[0] == 1) { t.Fatalf("normal phase target forced win should use a random winning dice pair, human=%+v robot=%+v", human, robot) } if len(repo.protectionEvents) != 0 { t.Fatalf("normal phase target must not consume new user protection, events=%+v", repo.protectionEvents) } } func TestUpdateSelfGameNewUserPolicyRejectsInvalidNormalPhaseTarget(t *testing.T) { repo := newFakeDiceRepository() svc := New(Config{}, repo, &fakeDiceWallet{balances: map[int64]int64{}}) for _, percent := range []int32{49, 101} { _, _, err := svc.UpdateSelfGameNewUserPolicy(context.Background(), selfgame.NewUserPolicy{ AppCode: "lalu", GameID: "dice", Enabled: true, NormalPhaseTargetWinRatePercent: percent, }) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("normal phase target %d should be rejected, got %v", percent, err) } } } func TestNewUserQuotaShortFallsBackToNormalRandom(t *testing.T) { repo := newFakeDiceRepository() repo.config.PoolBalanceCoin = 200 repo.poolBalance = 200 repo.stakePool.BalanceCoin = 200 repo.protectionState = selfgame.ProtectionState{Status: "active", LifetimeSubsidyCoin: 29950} repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9101, Status: dicedomain.RobotStatusActive} wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} svc := New(Config{}, repo, wallet) nowMS := int64(1700000000000) svc.now = func() time.Time { return time.UnixMilli(nowMS) } svc.randomInt = func(max int) (int, error) { t.Fatalf("quota short should fall back without protection probability roll, max=%d", max) return 0, nil } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-first-win-over-rate-create", UserID: 101, GameID: "dice", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } nowMS += dicedomain.DefaultRobotWaitMS ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"}) if err != nil { t.Fatalf("GetMatch attach robot failed: %v", err) } if ready.ForcedResult != "" { t.Fatalf("quota short should keep normal random forced_result, got %q", ready.ForcedResult) } } func TestRockDrawDoesNotConsumeProtectionQuota(t *testing.T) { repo := newFakeDiceRepository() configureFakeRock(repo) repo.config.PoolBalanceCoin = 200 repo.poolBalance = 200 repo.stakePool.BalanceCoin = 200 repo.protectionState = selfgame.ProtectionState{Status: "active", LifetimeSubsidyCoin: 29950} repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "rock", UserID: 9102, Status: dicedomain.RobotStatusActive} wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}} svc := New(Config{}, repo, wallet) nowMS := int64(1700000000000) svc.now = func() time.Time { return time.UnixMilli(nowMS) } svc.randomInt = func(max int) (int, error) { if max != 3 { t.Fatalf("quota short rock should only randomize robot gesture, max=%d", max) } return 0, nil } created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{ AppCode: "lalu", RequestID: "req-rock-draw-create", UserID: 101, GameID: "rock", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2, RPSGesture: "rock", }) if err != nil { t.Fatalf("CreateMatch failed: %v", err) } nowMS += dicedomain.DefaultRobotWaitMS ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "rock"}) if err != nil { t.Fatalf("GetMatch attach robot failed: %v", err) } if ready.ForcedResult != "" || repo.joinRobotRPSGesture != dicedomain.RPSGestureRock { t.Fatalf("quota short rock should be normal random draw setup, forced=%q gesture=%q", ready.ForcedResult, repo.joinRobotRPSGesture) } settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-rock-draw-roll", UserID: 101, MatchID: created.MatchID, GameID: "rock"}) if err != nil { t.Fatalf("RollMatch failed: %v", err) } if settled.Result != dicedomain.ParticipantResultDraw { t.Fatalf("rock draw expected, got result=%q", settled.Result) } if len(repo.protectionEvents) != 0 { t.Fatalf("rock draw must not consume protection quota, events=%+v", repo.protectionEvents) } } func TestExploreGameCodeForMatchUsesSelfGameIDs(t *testing.T) { tests := []struct { gameID string want string }{ {gameID: "dice", want: dicedomain.ExploreGameCodeDice}, {gameID: "rock", want: dicedomain.ExploreGameCodeRock}, {gameID: "room_rps", want: ""}, {gameID: "ocean_hunt", want: ""}, } for _, tt := range tests { if got := exploreGameCodeForMatch(tt.gameID); got != tt.want { t.Fatalf("exploreGameCodeForMatch(%q) = %q, want %q", tt.gameID, got, tt.want) } } } func TestSelfGameOperationsRejectWrongGameIDBeforeMutation(t *testing.T) { tests := []struct { name string call func(*Service) (dicedomain.Match, int64, error) }{ { name: "join", call: func(svc *Service) (dicedomain.Match, int64, error) { return svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", UserID: 202, MatchID: "match-rock-1", GameID: "dice"}) }, }, { name: "get", call: func(svc *Service) (dicedomain.Match, int64, error) { return svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "dice"}) }, }, { name: "roll", call: func(svc *Service) (dicedomain.Match, int64, error) { return svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "dice"}) }, }, { name: "cancel", call: func(svc *Service) (dicedomain.Match, int64, error) { return svc.CancelMatch(context.Background(), CancelMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "dice"}) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { repo := newFakeDiceRepository() repo.match = dicedomain.Match{ AppCode: "lalu", MatchID: "match-rock-1", GameID: "rock", PlatformCode: "dice", ProviderGameID: "rock", MinPlayers: 2, MaxPlayers: 2, CurrentPlayers: 1, StakeCoin: 100, Status: dicedomain.MatchStatusCreated, Participants: []dicedomain.Participant{{ AppCode: "lalu", MatchID: "match-rock-1", UserID: 101, SeatNo: 1, Status: dicedomain.ParticipantStatusJoined, StakeCoin: 100, }}, } svc := New(Config{}, repo, &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}}) _, _, err := tt.call(svc) if !xerr.IsCode(err, xerr.NotFound) { t.Fatalf("%s should hide cross-game match as not found, got %v", tt.name, err) } if repo.match.Status != dicedomain.MatchStatusCreated || len(repo.match.Participants) != 1 { t.Fatalf("%s must not mutate mismatched game match: %+v", tt.name, repo.match) } }) } } type fakeDiceRepository struct { game gamedomain.LaunchableGame match dicedomain.Match config dicedomain.Config robot dicedomain.Robot poolBalance int64 orders map[string]gamedomain.GameOrder userWins map[string]bool joinRobotRPSGesture string stakePool selfgame.StakePool newUserPolicy selfgame.NewUserPolicy protectionState selfgame.ProtectionState riskResult selfgame.RiskResult userStats selfgame.UserStats strategyLogs []selfgame.StrategyLog protectionEvents []selfgame.ProtectionEvent levelDebitOrders int exploreWinners []dicedomain.ExploreWinner } func newFakeDiceRepository() *fakeDiceRepository { return &fakeDiceRepository{ game: gamedomain.LaunchableGame{ CatalogItem: gamedomain.CatalogItem{ AppCode: "lalu", GameID: "dice", PlatformCode: "dice", ProviderGameID: "dice", GameName: "Dice", Status: gamedomain.StatusActive, }, PlatformStatus: gamedomain.StatusActive, }, config: dicedomain.Config{ AppCode: "lalu", GameID: "dice", Status: dicedomain.ConfigStatusActive, StakeOptions: dicedomain.NormalizeStakeOptions(nil), FeeBPS: int32(dicedomain.DefaultFeeBPS), PoolBPS: int32(dicedomain.DefaultPoolBPS), MinPlayers: dicedomain.DefaultMinPlayers, MaxPlayers: dicedomain.DefaultMaxPlayers, RobotEnabled: true, RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS, }, stakePool: selfgame.StakePool{AppCode: "lalu", GameID: "dice", StakeCoin: 100, Status: "active"}, newUserPolicy: selfgame.DefaultNewUserPolicy("lalu", "dice", 1700000000000), riskResult: selfgame.RiskResult{}, orders: map[string]gamedomain.GameOrder{}, } } func configureFakeRock(repo *fakeDiceRepository) { repo.game.CatalogItem.GameID = "rock" repo.game.CatalogItem.ProviderGameID = "rock" repo.game.CatalogItem.GameName = "Rock Paper Scissors" repo.config.GameID = "rock" repo.stakePool.GameID = "rock" repo.newUserPolicy.GameID = "rock" } func (r *fakeDiceRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) { return r.game, nil } func (r *fakeDiceRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) { if existing, ok := r.orders[order.OrderID]; ok { if existing.RequestHash != order.RequestHash { return gamedomain.GameOrder{}, true, xerr.New(xerr.IdempotencyConflict, "provider order payload conflict") } return existing, true, nil } r.orders[order.OrderID] = order return order, false, nil } func (r *fakeDiceRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error { order := r.orders[orderID] order.Status = gamedomain.OrderStatusSucceeded order.WalletTransactionID = walletTransactionID order.WalletBalanceAfter = balanceAfter order.UpdatedAtMS = nowMs r.orders[orderID] = order if order.OpType == "debit" { r.levelDebitOrders++ } return nil } func (r *fakeDiceRepository) MarkOrderFailed(_ context.Context, _ string, orderID string, status string, code string, message string, nowMs int64) error { order := r.orders[orderID] order.Status = status order.FailureCode = code order.FailureMessage = message order.UpdatedAtMS = nowMs r.orders[orderID] = order return nil } func (r *fakeDiceRepository) CreateDiceMatch(_ context.Context, match dicedomain.Match) (dicedomain.Match, error) { r.match = cloneDiceMatch(match) return cloneDiceMatch(r.match), nil } func (r *fakeDiceRepository) JoinDiceMatch(_ context.Context, _ string, _ string, userID int64, rpsGesture string, nowMs int64) (dicedomain.Match, error) { for _, participant := range r.match.Participants { if participant.UserID == userID { return cloneDiceMatch(r.match), nil } } seatNo := int32(len(r.match.Participants) + 1) r.match.Participants = append(r.match.Participants, dicedomain.Participant{ AppCode: r.match.AppCode, MatchID: r.match.MatchID, UserID: userID, SeatNo: seatNo, Status: dicedomain.ParticipantStatusJoined, StakeCoin: r.match.StakeCoin, RPSGesture: strings.TrimSpace(rpsGesture), JoinedAtMS: nowMs, UpdatedAtMS: nowMs, }) r.match.CurrentPlayers = int32(len(r.match.Participants)) r.match.Status = dicedomain.MatchStatusJoining r.match.UpdatedAtMS = nowMs return cloneDiceMatch(r.match), nil } func (r *fakeDiceRepository) GetDiceMatch(context.Context, string, string) (dicedomain.Match, error) { return cloneDiceMatch(r.match), nil } func (r *fakeDiceRepository) ClaimDiceMatchForRoll(_ context.Context, _ string, _ string, userID int64, nowMs int64) (dicedomain.Match, error) { if !hasFakeDiceParticipant(r.match.Participants, userID) { return dicedomain.Match{}, xerr.New(xerr.PermissionDenied, "dice participant is required") } if len(r.match.Participants) < int(r.match.MinPlayers) { return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match does not have enough participants") } if r.match.Result == dicedomain.ParticipantResultDraw { r.match.Result = "" for index := range r.match.Participants { r.match.Participants[index].DicePoints = nil r.match.Participants[index].Result = "" r.match.Participants[index].PayoutCoin = 0 r.match.Participants[index].UpdatedAtMS = nowMs } } r.match.Status = dicedomain.MatchStatusSettling r.match.UpdatedAtMS = nowMs return cloneDiceMatch(r.match), nil } func (r *fakeDiceRepository) SaveDiceRolls(_ context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error) { for _, source := range match.Participants { for index := range r.match.Participants { if r.match.Participants[index].UserID == source.UserID { r.match.Participants[index].DicePoints = append([]int32(nil), source.DicePoints...) r.match.Participants[index].RPSGesture = source.RPSGesture r.match.Participants[index].Result = source.Result r.match.Participants[index].PayoutCoin = source.PayoutCoin r.match.Participants[index].UpdatedAtMS = nowMs } } } r.match.Status = dicedomain.MatchStatusPayoutApplying r.match.Result = match.Result r.match.UpdatedAtMS = nowMs return cloneDiceMatch(r.match), nil } func (r *fakeDiceRepository) SaveDiceDrawForReroll(_ context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error) { for _, source := range match.Participants { for index := range r.match.Participants { if r.match.Participants[index].UserID == source.UserID { r.match.Participants[index].DicePoints = append([]int32(nil), source.DicePoints...) r.match.Participants[index].RPSGesture = source.RPSGesture r.match.Participants[index].Result = dicedomain.ParticipantResultDraw r.match.Participants[index].PayoutCoin = 0 r.match.Participants[index].UpdatedAtMS = nowMs } } } r.match.Status = dicedomain.MatchStatusReady r.match.Result = dicedomain.ParticipantResultDraw r.match.RoundNo++ r.match.UpdatedAtMS = nowMs return cloneDiceMatch(r.match), nil } func (r *fakeDiceRepository) MarkDiceParticipantDebitSucceeded(_ context.Context, _ string, _ string, userID int64, orderID string, balanceAfter int64, nowMs int64) error { return r.updateParticipant(userID, func(participant *dicedomain.Participant) { participant.Status = dicedomain.ParticipantStatusDebitSucceeded participant.DebitOrderID = orderID participant.BalanceAfter = balanceAfter participant.UpdatedAtMS = nowMs }) } func (r *fakeDiceRepository) MarkDiceParticipantDebitFailed(_ context.Context, _ string, _ string, userID int64, orderID string, nowMs int64) error { return r.updateParticipant(userID, func(participant *dicedomain.Participant) { participant.Status = dicedomain.ParticipantStatusDebitFailed participant.DebitOrderID = orderID participant.UpdatedAtMS = nowMs }) } func (r *fakeDiceRepository) MarkDiceParticipantPayoutSucceeded(_ context.Context, _ string, _ string, userID int64, orderID string, balanceAfter int64, nowMs int64) error { return r.updateParticipant(userID, func(participant *dicedomain.Participant) { participant.Status = dicedomain.ParticipantStatusPayoutSucceeded participant.PayoutOrderID = orderID participant.BalanceAfter = balanceAfter participant.UpdatedAtMS = nowMs }) } func (r *fakeDiceRepository) MarkDiceParticipantRefundSucceeded(_ context.Context, _ string, _ string, userID int64, orderID string, balanceAfter int64, nowMs int64) error { return r.updateParticipant(userID, func(participant *dicedomain.Participant) { participant.Status = dicedomain.ParticipantStatusRefundSucceeded participant.RefundOrderID = orderID participant.BalanceAfter = balanceAfter participant.UpdatedAtMS = nowMs }) } func (r *fakeDiceRepository) MarkDiceMatchSettled(_ context.Context, _ string, _ string, result string, nowMs int64) error { r.match.Status = dicedomain.MatchStatusSettled r.match.Result = result r.match.UpdatedAtMS = nowMs r.match.SettledAtMS = nowMs for index := range r.match.Participants { r.match.Participants[index].Status = dicedomain.ParticipantStatusSettled r.match.Participants[index].UpdatedAtMS = nowMs } return nil } func (r *fakeDiceRepository) MarkDiceMatchFailed(_ context.Context, _ string, _ string, result string, nowMs int64) error { r.match.Status = dicedomain.MatchStatusFailed r.match.Result = result r.match.UpdatedAtMS = nowMs return nil } func (r *fakeDiceRepository) GetDiceConfig(context.Context, string, string) (dicedomain.Config, error) { return r.config, nil } func (r *fakeDiceRepository) ListSelfGameConfigs(context.Context, string) ([]dicedomain.Config, error) { return []dicedomain.Config{r.config}, nil } func (r *fakeDiceRepository) UpsertDiceConfig(_ context.Context, config dicedomain.Config, _ int64) (dicedomain.Config, error) { r.config = config return config, nil } func (r *fakeDiceRepository) FindAndJoinWaitingDiceMatch(context.Context, string, string, int64, int64, string, int64) (dicedomain.Match, error) { return dicedomain.Match{}, xerr.New(xerr.NotFound, "waiting dice match not found") } func (r *fakeDiceRepository) JoinDiceRobot(_ context.Context, _ string, _ string, robotUserID int64, forcedResult string, rpsGesture string, nowMs int64) (dicedomain.Match, error) { if robotUserID <= 0 { return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice robot not found") } for _, participant := range r.match.Participants { if participant.UserID == robotUserID { return cloneDiceMatch(r.match), nil } } seatNo := int32(len(r.match.Participants) + 1) r.match.Participants = append(r.match.Participants, dicedomain.Participant{ AppCode: r.match.AppCode, MatchID: r.match.MatchID, UserID: robotUserID, ParticipantType: dicedomain.ParticipantTypeRobot, SeatNo: seatNo, Status: dicedomain.ParticipantStatusJoined, StakeCoin: r.match.StakeCoin, RPSGesture: strings.TrimSpace(rpsGesture), JoinedAtMS: nowMs, UpdatedAtMS: nowMs, }) r.match.CurrentPlayers = int32(len(r.match.Participants)) r.match.MatchMode = dicedomain.MatchModeRobot r.match.ForcedResult = strings.TrimSpace(forcedResult) r.match.Status = dicedomain.MatchStatusReady r.match.ReadyAtMS = nowMs r.match.UpdatedAtMS = nowMs r.joinRobotRPSGesture = strings.TrimSpace(rpsGesture) return cloneDiceMatch(r.match), nil } func (r *fakeDiceRepository) CancelDiceMatch(context.Context, string, string, int64, int64) (dicedomain.Match, error) { r.match.Status = dicedomain.MatchStatusCanceled return cloneDiceMatch(r.match), nil } func (r *fakeDiceRepository) PickActiveDiceRobot(context.Context, string, string, string, int64) (dicedomain.Robot, error) { if r.robot.UserID > 0 { return r.robot, nil } return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found") } func (r *fakeDiceRepository) AdjustSelfGameStakePool(_ context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error) { if adjustment.StakeCoin <= 0 { return dicedomain.PoolAdjustment{}, xerr.New(xerr.InvalidArgument, "self game stake coin is required") } if adjustment.Direction == dicedomain.PoolDirectionOut { r.poolBalance -= adjustment.AmountCoin r.stakePool.BalanceCoin -= adjustment.AmountCoin } else { r.poolBalance += adjustment.AmountCoin r.stakePool.BalanceCoin += adjustment.AmountCoin } adjustment.BalanceAfter = r.poolBalance return adjustment, nil } func (r *fakeDiceRepository) GetSelfGameStakePool(context.Context, string, string, int64, int64) (selfgame.StakePool, error) { pool := r.stakePool if pool.Status == "" { pool.Status = "active" } if pool.BalanceCoin == 0 && r.poolBalance > 0 { pool.BalanceCoin = r.poolBalance } if pool.StakeCoin == 0 { pool.StakeCoin = 100 } return selfgame.NormalizeStakePool(pool), nil } func (r *fakeDiceRepository) ListSelfGameStakePools(ctx context.Context, appCode string, gameID string, nowMs int64) ([]selfgame.StakePool, error) { pool, err := r.GetSelfGameStakePool(ctx, appCode, gameID, r.stakePool.StakeCoin, nowMs) if err != nil { return nil, err } return []selfgame.StakePool{pool}, nil } func (r *fakeDiceRepository) UpsertSelfGameStakePoolConfig(_ context.Context, pool selfgame.StakePool, nowMs int64) (selfgame.StakePool, error) { current := r.stakePool current.AppCode = pool.AppCode current.GameID = pool.GameID current.StakeCoin = pool.StakeCoin current.TargetBalanceCoin = pool.TargetBalanceCoin current.SafeBalanceCoin = pool.SafeBalanceCoin current.WarningBalanceCoin = pool.WarningBalanceCoin current.DangerBalanceCoin = pool.DangerBalanceCoin current.Status = pool.Status current.UpdatedAtMS = nowMs if current.CreatedAtMS == 0 { current.CreatedAtMS = nowMs } r.stakePool = selfgame.NormalizeStakePool(current) return r.stakePool, nil } func (r *fakeDiceRepository) GetSelfGameNewUserPolicy(context.Context, string, string, int64) (selfgame.NewUserPolicy, error) { if strings.TrimSpace(r.newUserPolicy.GameID) == "" { return selfgame.DefaultNewUserPolicy("lalu", "dice", 1700000000000), nil } return selfgame.NormalizeNewUserPolicy(r.newUserPolicy), nil } func (r *fakeDiceRepository) UpsertSelfGameNewUserPolicy(_ context.Context, policy selfgame.NewUserPolicy, nowMs int64) (selfgame.NewUserPolicy, error) { policy.UpdatedAtMS = nowMs if policy.CreatedAtMS == 0 { policy.CreatedAtMS = nowMs } r.newUserPolicy = selfgame.NormalizeNewUserPolicy(policy) return r.newUserPolicy, nil } func (r *fakeDiceRepository) GetSelfGameProtectionState(context.Context, string, string, int64, int64) (selfgame.ProtectionState, error) { if strings.TrimSpace(r.protectionState.Status) == "" { r.protectionState.Status = "active" } return selfgame.NormalizeProtectionState(r.protectionState), nil } func (r *fakeDiceRepository) GetSelfGameRiskResult(context.Context, string, string, int64, int64) (selfgame.RiskResult, error) { return selfgame.NormalizeRiskResult(r.riskResult), nil } func (r *fakeDiceRepository) GetSelfGameUserStats(context.Context, string, string, int64, int64) (selfgame.UserStats, error) { return r.userStats, nil } func (r *fakeDiceRepository) RecordSelfGameStrategyLog(_ context.Context, log selfgame.StrategyLog) error { r.strategyLogs = append(r.strategyLogs, log) return nil } func (r *fakeDiceRepository) ConsumeSelfGameProtection(_ context.Context, event selfgame.ProtectionEvent) (selfgame.ProtectionState, bool, error) { r.protectionEvents = append(r.protectionEvents, event) r.protectionState.UsedRounds += event.UsedRoundDelta r.protectionState.LifetimeSubsidyCoin += event.LifetimeSubsidyCoinDelta r.protectionState.Day1SubsidyCoin += event.Day1SubsidyCoinDelta return selfgame.NormalizeProtectionState(r.protectionState), true, nil } func (r *fakeDiceRepository) ListDiceRobots(context.Context, string, string, string, int32, string) ([]dicedomain.Robot, string, error) { return nil, "", nil } func (r *fakeDiceRepository) RegisterDiceRobots(context.Context, string, string, []int64, int64, int64) ([]dicedomain.Robot, error) { return nil, nil } func (r *fakeDiceRepository) SetDiceRobotStatus(context.Context, string, string, int64, string, int64) (dicedomain.Robot, error) { return dicedomain.Robot{}, nil } func (r *fakeDiceRepository) DeleteDiceRobot(context.Context, string, string, int64) error { return nil } func (r *fakeDiceRepository) RecordExploreWinner(_ context.Context, winner dicedomain.ExploreWinner) error { r.exploreWinners = append(r.exploreWinners, winner) return nil } func (r *fakeDiceRepository) ListExploreWinners(context.Context, string, int32) ([]dicedomain.ExploreWinner, error) { return nil, nil } func (r *fakeDiceRepository) updateParticipant(userID int64, apply func(*dicedomain.Participant)) error { for index := range r.match.Participants { if r.match.Participants[index].UserID == userID { apply(&r.match.Participants[index]) return nil } } return xerr.New(xerr.NotFound, "dice participant not found") } type fakeDiceWallet struct { balances map[int64]int64 calls int } func (w *fakeDiceWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) { w.calls++ if req.GetCoinAmount() <= 0 { return nil, xerr.New(xerr.InvalidArgument, "coin_amount must be positive") } switch req.GetOpType() { case "debit": if w.balances[req.GetUserId()] < req.GetCoinAmount() { return nil, xerr.New(xerr.InsufficientBalance, "insufficient balance") } w.balances[req.GetUserId()] -= req.GetCoinAmount() case "credit", "refund": w.balances[req.GetUserId()] += req.GetCoinAmount() default: return nil, xerr.New(xerr.InvalidArgument, "game op_type is invalid") } return &walletv1.ApplyGameCoinChangeResponse{ WalletTransactionId: "wtx_" + strings.ReplaceAll(req.GetProviderOrderId(), ":", "_"), BalanceAfter: w.balances[req.GetUserId()], }, nil } func (w *fakeDiceWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{ AssetType: "COIN", AvailableAmount: w.balances[req.GetUserId()], }}}, nil } func participantByUser(participants []dicedomain.Participant, userID int64) dicedomain.Participant { for _, participant := range participants { if participant.UserID == userID { return participant } } return dicedomain.Participant{} } func hasFakeDiceParticipant(participants []dicedomain.Participant, userID int64) bool { return participantByUser(participants, userID).UserID == userID } func cloneDiceMatch(match dicedomain.Match) dicedomain.Match { cloned := match cloned.Participants = append([]dicedomain.Participant(nil), match.Participants...) for index := range cloned.Participants { cloned.Participants[index].DicePoints = append([]int32(nil), match.Participants[index].DicePoints...) } return cloned }