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" ) 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) } } 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) } } type fakeDiceRepository struct { game gamedomain.LaunchableGame match dicedomain.Match config dicedomain.Config poolBalance int64 orders map[string]gamedomain.GameOrder levelDebitOrders int } 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, }, orders: map[string]gamedomain.GameOrder{}, } } 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, 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, 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].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].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, int64) (dicedomain.Match, error) { return dicedomain.Match{}, xerr.New(xerr.NotFound, "waiting dice match not found") } func (r *fakeDiceRepository) JoinDiceRobot(context.Context, string, string, int64, string, int64) (dicedomain.Match, error) { return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice robot not found") } 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) (dicedomain.Robot, error) { return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found") } func (r *fakeDiceRepository) AdjustDicePool(_ context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error) { if adjustment.Direction == dicedomain.PoolDirectionOut { r.poolBalance -= adjustment.AmountCoin } else { r.poolBalance += adjustment.AmountCoin } adjustment.BalanceAfter = r.poolBalance return adjustment, 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) 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 }