2026-06-11 01:02:16 +08:00

513 lines
19 KiB
Go

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
}