diff --git a/services/game-service/internal/service/roomrps/service.go b/services/game-service/internal/service/roomrps/service.go index 64a0e318..d72ea84a 100644 --- a/services/game-service/internal/service/roomrps/service.go +++ b/services/game-service/internal/service/roomrps/service.go @@ -3,6 +3,7 @@ package roomrps import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -50,6 +51,10 @@ const ( eventRevealCountdown = "room_rps_reveal_countdown" eventFinished = "room_rps_finished" eventExpired = "room_rps_expired" + + roomRPSWalletPlatform = "room_rps" + roomRPSOpDebit = "debit" + roomRPSOpRefund = "refund" ) // UserClient 是 room-rps 唯一需要的 user-service 能力;这里只取展示资料,不把用户领域模型引入游戏用例层。 @@ -62,6 +67,11 @@ type GiftCatalogClient interface { ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) } +// WalletClient 只暴露 room-rps 需要的钱包能力;PK 状态仍由 game-service 管,金币事实和幂等改账由 wallet-service 管。 +type WalletClient interface { + ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) +} + // ConfigStore 是 room-rps 配置的持久化边界;service 只关心完整配置快照,表结构和 JSON 编码留在 MySQL 仓储层。 type ConfigStore interface { GetRoomRPSConfig(ctx context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error) @@ -83,6 +93,7 @@ type Service struct { config Config user UserClient gifts GiftCatalogClient + wallet WalletClient pub Publisher store ConfigStore @@ -104,13 +115,18 @@ func New(config Config, user UserClient, publisher Publisher, extras ...any) *Se config.RevealCountdown = 3 * time.Second } var gifts GiftCatalogClient + var wallet WalletClient var store ConfigStore for _, extra := range extras { - switch typed := extra.(type) { - case GiftCatalogClient: + if typed, ok := extra.(GiftCatalogClient); ok { // room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。 gifts = typed - case ConfigStore: + } + if typed, ok := extra.(WalletClient); ok { + // 发起和应战都必须进入 wallet-service 改账;只做余额查询会让用户看到 PK 成功但金币事实没有变化。 + wallet = typed + } + if typed, ok := extra.(ConfigStore); ok { // MySQL 是配置事实源;没有仓储的测试场景才落回进程内 map,避免把运行态差异写成前端兜底。 store = typed } @@ -119,6 +135,7 @@ func New(config Config, user UserClient, publisher Publisher, extras ...any) *Se config: config, user: user, gifts: gifts, + wallet: wallet, pub: publisher, store: store, configs: make(map[string]*gamev1.RoomRPSConfig), @@ -301,20 +318,29 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS if !ok || !gift.GetEnabled() { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled") } + stakeCoin := gift.GetGiftPriceCoin() + if stakeCoin <= 0 { + return nil, 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid") + } + challengeID := newChallengeID(app) + initiatorBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit) + if err != nil { + return nil, 0, err + } s.mu.Lock() now := s.now() nowMS := now.UnixMilli() challenge := &gamev1.RoomRPSChallenge{ AppCode: app, - ChallengeId: newChallengeID(app), + ChallengeId: challengeID, RoomId: roomID, RegionId: req.GetRegionId(), Status: StatusPending, StakeGiftId: legacyRoomRPSGiftID(giftID), StakeGiftIdText: giftID, - StakeCoin: gift.GetGiftPriceCoin(), - Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, JoinedAtMs: nowMS}, + StakeCoin: stakeCoin, + Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: initiatorBalance, JoinedAtMs: nowMS}, SettlementStatus: SettlementPending, TimeoutAtMs: now.Add(s.config.ChallengeTimeout).UnixMilli(), CreatedAtMs: nowMS, @@ -362,6 +388,13 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS challenge.UpdatedAtMs = nowMS cloned := cloneChallenge(challenge) s.mu.Unlock() + refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned) + if refundErr != nil { + s.markSettlementFailed(app, challengeID, refundErr) + return nil, 0, refundErr + } + s.replaceChallengeSnapshot(app, challengeID, refunded) + cloned = refunded _ = s.publishChallengeEvent(ctx, eventExpired, cloned) return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout") } @@ -370,9 +403,57 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS s.mu.Unlock() return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") } + stakeCoin := challenge.GetStakeCoin() + roomID := challenge.GetRoomId() + s.mu.Unlock() + + challengerBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit) + if err != nil { + return nil, 0, err + } + + s.mu.Lock() + challenge, ok = s.challenges[challengeID] + if !ok || challenge.GetAppCode() != app { + s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) + return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found") + } + now = s.now() + nowMS = now.UnixMilli() + if challenge.GetStatus() != StatusPending { + // 钱包扣款期间可能已有其他用户抢先应战;状态机必须以锁内二次校验为准,失败时立即退回刚扣的挑战方本金。 + s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) + return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending") + } + if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() { + // 二次校验也要收敛超时单,避免扣款耗时跨过 timeout 后仍然匹配成功。 + challenge.Status = StatusTimeout + challenge.SettlementStatus = SettlementRefunded + challenge.UpdatedAtMs = nowMS + cloned := cloneChallenge(challenge) + s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) + refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned) + if refundErr != nil { + s.markSettlementFailed(app, challengeID, refundErr) + return nil, 0, refundErr + } + s.replaceChallengeSnapshot(app, challengeID, refunded) + cloned = refunded + _ = s.publishChallengeEvent(ctx, eventExpired, cloned) + return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout") + } + if challenge.GetInitiator().GetUserId() == req.GetUserId() { + // 二次校验保留同一条权限边界,防止测试或未来接口在第一次检查后篡改挑战归属。 + s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) + return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") + } challenge.Status = StatusFinished - challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, JoinedAtMs: nowMS} + challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: challengerBalance, JoinedAtMs: nowMS} challenge.MatchedAtMs = nowMS challenge.RevealAtMs = now.Add(s.config.RevealCountdown).UnixMilli() challenge.SettledAtMs = nowMS @@ -381,6 +462,32 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS cloned := cloneChallenge(challenge) s.mu.Unlock() + settled, err := s.settleChallenge(ctx, req.GetMeta().GetRequestId(), cloned) + if err != nil { + s.mu.Lock() + if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app { + current.Status = StatusSettlementFailed + current.SettlementStatus = SettlementFailed + current.FailureReason = err.Error() + current.UpdatedAtMs = s.now().UnixMilli() + } + s.mu.Unlock() + return nil, 0, err + } + s.mu.Lock() + if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app { + current.Initiator.BalanceAfter = settled.GetInitiator().GetBalanceAfter() + current.Challenger.BalanceAfter = settled.GetChallenger().GetBalanceAfter() + current.SettlementStatus = settled.GetSettlementStatus() + current.FailureReason = "" + current.SettledAtMs = s.now().UnixMilli() + current.UpdatedAtMs = current.GetSettledAtMs() + cloned = cloneChallenge(current) + } else { + cloned = settled + } + s.mu.Unlock() + if err := s.publishChallengeEvent(ctx, eventChallengeAccepted, cloned); err != nil { return nil, 0, err } @@ -480,6 +587,14 @@ func (s *Service) ExpireChallenge(ctx context.Context, app string, challengeID s cloned := cloneChallenge(challenge) s.mu.Unlock() + refunded, err := s.refundPendingChallenge(ctx, "room-rps-expire:"+challengeID, cloned) + if err != nil { + s.markSettlementFailed(app, challengeID, err) + return nil, 0, err + } + s.replaceChallengeSnapshot(app, challengeID, refunded) + cloned = refunded + if err := s.publishChallengeEvent(ctx, eventExpired, cloned); err != nil { return nil, 0, err } @@ -745,6 +860,143 @@ func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs [] return result, nil } +func (s *Service) settleChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) { + if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetChallenger().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "room rps settlement challenge is incomplete") + } + settled := cloneChallenge(challenge) + app := settled.GetAppCode() + challengeID := settled.GetChallengeId() + roomID := settled.GetRoomId() + stakeCoin := settled.GetStakeCoin() + + if settled.GetInitiator().GetResult() == ResultDraw && settled.GetChallenger().GetResult() == ResultDraw { + // 平局没有赢家,也不应产生任何礼物流;双方前置扣款都按原金额退款,客户端只展示 refunded 事实。 + initiatorBalance, initiatorErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund) + if initiatorErr == nil { + settled.Initiator.BalanceAfter = initiatorBalance + } + challengerBalance, challengerErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund) + if challengerErr == nil { + settled.Challenger.BalanceAfter = challengerBalance + } + if initiatorErr != nil { + return settled, initiatorErr + } + if challengerErr != nil { + return settled, challengerErr + } + settled.SettlementStatus = SettlementRefunded + return settled, nil + } + + switch settled.GetWinnerUserId() { + case settled.GetInitiator().GetUserId(): + // 胜者本金必须退回;败者在点击 PK 时已经扣款,这笔扣款保留为败者成本,不再追加第二次扣费。 + balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund) + if err != nil { + return settled, err + } + settled.Initiator.BalanceAfter = balanceAfter + settled.SettlementStatus = SettlementSettled + return settled, nil + case settled.GetChallenger().GetUserId(): + // 只退赢家本金;输家的余额保持前置扣款后的余额,避免“输了但金币没少”的账务错误。 + balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund) + if err != nil { + return settled, err + } + settled.Challenger.BalanceAfter = balanceAfter + settled.SettlementStatus = SettlementSettled + return settled, nil + default: + return settled, xerr.New(xerr.InvalidArgument, "room rps winner is invalid") + } +} + +func (s *Service) refundPendingChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) { + if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "room rps pending refund challenge is incomplete") + } + refunded := cloneChallenge(challenge) + // 无人应战或已超时的 pending 单只存在发起人前置扣款;过期时退回发起人,不产生礼物或赢家收益。 + balanceAfter, err := s.applyCoinChange(ctx, requestID, refunded.GetAppCode(), refunded.GetChallengeId(), refunded.GetRoomId(), refunded.GetInitiator().GetUserId(), refunded.GetStakeCoin(), roomRPSOpRefund) + if err != nil { + return refunded, err + } + refunded.Initiator.BalanceAfter = balanceAfter + refunded.SettlementStatus = SettlementRefunded + refunded.FailureReason = "" + refunded.UpdatedAtMs = s.now().UnixMilli() + return refunded, nil +} + +func (s *Service) replaceChallengeSnapshot(app string, challengeID string, snapshot *gamev1.RoomRPSChallenge) { + if s == nil || snapshot == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + current := s.challenges[strings.TrimSpace(challengeID)] + if current == nil || current.GetAppCode() != appcode.Normalize(app) { + return + } + s.challenges[strings.TrimSpace(challengeID)] = cloneChallenge(snapshot) +} + +func (s *Service) markSettlementFailed(app string, challengeID string, cause error) { + if s == nil || cause == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + current := s.challenges[strings.TrimSpace(challengeID)] + if current == nil || current.GetAppCode() != appcode.Normalize(app) { + return + } + current.Status = StatusSettlementFailed + current.SettlementStatus = SettlementFailed + current.FailureReason = cause.Error() + current.UpdatedAtMs = s.now().UnixMilli() +} + +func (s *Service) applyCoinChange(ctx context.Context, requestID string, app string, challengeID string, roomID string, userID int64, amount int64, opType string) (int64, error) { + if amount <= 0 || userID <= 0 || strings.TrimSpace(challengeID) == "" { + return 0, xerr.New(xerr.InvalidArgument, "room rps coin command is incomplete") + } + if s.wallet == nil { + // 钱包不可用时必须拒绝进入 PK;否则房间状态会成功但 COIN 事实无法同步落账。 + return 0, xerr.New(xerr.Unavailable, "room rps wallet client is not configured") + } + app = appcode.Normalize(app) + opType = strings.TrimSpace(opType) + providerOrderID := fmt.Sprintf("room_rps:%s:%d:%s", strings.TrimSpace(challengeID), userID, opType) + requestHash := roomRPSStableHash(fmt.Sprintf("%s|%s|%s|%s|%d|%d", app, strings.TrimSpace(challengeID), strings.TrimSpace(roomID), opType, userID, amount)) + resp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{ + RequestId: strings.TrimSpace(requestID), + AppCode: app, + CommandId: "game:" + providerOrderID, + UserId: userID, + PlatformCode: roomRPSWalletPlatform, + GameId: GameID, + ProviderOrderId: providerOrderID, + ProviderRoundId: strings.TrimSpace(challengeID), + OpType: opType, + CoinAmount: amount, + RoomId: strings.TrimSpace(roomID), + RequestHash: requestHash, + }) + if err != nil { + return 0, err + } + return resp.GetBalanceAfter(), nil +} + +func roomRPSStableHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string { if gift == nil { return "" diff --git a/services/game-service/internal/service/roomrps/service_test.go b/services/game-service/internal/service/roomrps/service_test.go index f628dab0..614c8475 100644 --- a/services/game-service/internal/service/roomrps/service_test.go +++ b/services/game-service/internal/service/roomrps/service_test.go @@ -2,6 +2,7 @@ package roomrps import ( "context" + "fmt" "testing" "time" @@ -129,7 +130,8 @@ func TestCreateChallengeMatchesStringGiftID(t *testing.T) { "Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 1), "lw1": roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 111), }) - svc := New(Config{}, nil, nil, catalog) + wallet := newFakeRoomRPSWallet(map[int64]int64{42: 10}) + svc := New(Config{}, nil, nil, catalog, wallet) svc.now = func() time.Time { return time.UnixMilli(1770000000000) } _, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{ @@ -161,6 +163,53 @@ func TestCreateChallengeMatchesStringGiftID(t *testing.T) { if challenge.GetStakeGiftIdText() != "Gifi-3" || challenge.GetStakeGiftId() != 0 || challenge.GetStakeCoin() != 1 { t.Fatalf("string gift challenge mismatch: %+v", challenge) } + if challenge.GetInitiator().GetBalanceAfter() != 9 || wallet.balances[42] != 9 { + t.Fatalf("initiator balance snapshot mismatch: %+v", challenge.GetInitiator()) + } +} + +func TestCreateChallengeRejectsInsufficientCoinBalance(t *testing.T) { + catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{ + "Gifi-3": roomRPSWalletGift("Gifi-3", "Tiger", "https://cdn.example.test/tiger.png", "", "", 100), + "Gifi-2": roomRPSWalletGift("Gifi-2", "Cowboy", "https://cdn.example.test/cowboy.png", "", "", 200), + "Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 300), + "lw1": roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 400), + }) + wallet := newFakeRoomRPSWallet(map[int64]int64{42: 99}) + svc := New(Config{}, nil, nil, catalog, wallet) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + _, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{ + Status: "active", + ChallengeTimeoutMs: 600000, + RevealCountdownMs: 3000, + StakeGifts: []*gamev1.RoomRPSStakeGift{ + {GiftIdText: "Gifi-3", Enabled: true, SortOrder: 1}, + {GiftIdText: "Gifi-2", Enabled: true, SortOrder: 2}, + {GiftIdText: "Gifi-1", Enabled: true, SortOrder: 3}, + {GiftIdText: "lw1", Enabled: true, SortOrder: 4}, + }, + }) + if err != nil { + t.Fatalf("UpdateConfig() error = %v", err) + } + + _, _, err = svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-low-balance"}, + UserId: 42, + RoomId: "room-1", + GiftIdText: "Gifi-3", + Gesture: "rock", + }) + if !xerr.IsCode(err, xerr.InsufficientBalance) { + t.Fatalf("CreateChallenge() error = %v, want InsufficientBalance", err) + } + if len(svc.challenges) != 0 { + t.Fatalf("insufficient balance must not create pending challenge: %+v", svc.challenges) + } + if wallet.balances[42] != 99 || len(wallet.applies) != 1 { + t.Fatalf("insufficient create must not mutate wallet balance: balance=%d applies=%d", wallet.balances[42], len(wallet.applies)) + } } func TestRoomRPSConfigPersistsAcrossServiceInstances(t *testing.T) { @@ -214,7 +263,8 @@ func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) { CreatedAtMs: 1000, UpdatedAtMs: 2000, } - svc := New(Config{}, nil, nil, store) + wallet := newFakeRoomRPSWallet(map[int64]int64{42: 1000}) + svc := New(Config{}, nil, nil, store, wallet) svc.now = func() time.Time { return time.UnixMilli(1770000000000) } challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ @@ -230,6 +280,155 @@ func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) { if challenge.GetStakeGiftIdText() != "db-gift" || challenge.GetStakeCoin() != 222 { t.Fatalf("challenge did not use persistent config: %+v", challenge) } + if challenge.GetInitiator().GetBalanceAfter() != 778 || wallet.balances[42] != 778 { + t.Fatalf("persistent config challenge did not debit stake: challenge=%+v wallet=%d", challenge, wallet.balances[42]) + } +} + +func TestAcceptChallengeRejectsInsufficientCoinBalance(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 99}) + svc := New(Config{}, nil, nil, wallet) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create"}, + UserId: 41, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + + _, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-low-balance"}, + UserId: 42, + ChallengeId: created.GetChallengeId(), + Gesture: "paper", + }) + if !xerr.IsCode(err, xerr.InsufficientBalance) { + t.Fatalf("AcceptChallenge() error = %v, want InsufficientBalance", err) + } + current, _, err := svc.GetChallenge(context.Background(), "lalu", created.GetChallengeId()) + if err != nil { + t.Fatalf("GetChallenge() error = %v", err) + } + if current.GetStatus() != StatusPending || current.GetChallenger().GetUserId() != 0 { + t.Fatalf("insufficient balance must leave challenge pending without challenger: %+v", current) + } + if wallet.balances[41] != 900 || wallet.balances[42] != 99 { + t.Fatalf("accept failure should keep initiator debit and avoid challenger debit: balances=%+v", wallet.balances) + } +} + +func TestAcceptChallengeRecordsChallengerCoinBalance(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500}) + svc := New(Config{}, nil, nil, wallet) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create"}, + UserId: 41, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + + accepted, _, err := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept"}, + UserId: 42, + ChallengeId: created.GetChallengeId(), + Gesture: "paper", + }) + if err != nil { + t.Fatalf("AcceptChallenge() error = %v", err) + } + if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementSettled || accepted.GetInitiator().GetBalanceAfter() != 900 || accepted.GetChallenger().GetBalanceAfter() != 500 { + t.Fatalf("accepted challenge balance snapshot mismatch: %+v", accepted) + } + if wallet.balances[41] != 900 || wallet.balances[42] != 500 { + t.Fatalf("winner refund and loser debit mismatch: balances=%+v", wallet.balances) + } + if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund]" { + t.Fatalf("winner settlement should only debit both and refund winner, got ops=%v", got) + } +} + +func TestAcceptChallengeDrawRefundsBothPlayers(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500}) + svc := New(Config{}, nil, nil, wallet) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-draw"}, + UserId: 41, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + + accepted, _, err := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-draw"}, + UserId: 42, + ChallengeId: created.GetChallengeId(), + Gesture: "rock", + }) + if err != nil { + t.Fatalf("AcceptChallenge() error = %v", err) + } + if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementRefunded || accepted.GetWinnerUserId() != 0 { + t.Fatalf("draw challenge settlement mismatch: %+v", accepted) + } + if accepted.GetInitiator().GetBalanceAfter() != 1000 || accepted.GetChallenger().GetBalanceAfter() != 500 { + t.Fatalf("draw must refund both players in response: %+v", accepted) + } + if wallet.balances[41] != 1000 || wallet.balances[42] != 500 { + t.Fatalf("draw must refund both players in wallet: balances=%+v", wallet.balances) + } + if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund refund]" { + t.Fatalf("draw should only debit both and refund both without gift op, got ops=%v", got) + } +} + +func TestExpireChallengeRefundsInitiatorDebit(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000}) + svc := New(Config{}, nil, nil, wallet) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-expire"}, + UserId: 41, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + if wallet.balances[41] != 900 || created.GetInitiator().GetBalanceAfter() != 900 { + t.Fatalf("create should debit initiator before pending challenge: challenge=%+v wallet=%d", created, wallet.balances[41]) + } + + expired, _, err := svc.ExpireChallenge(context.Background(), "lalu", created.GetChallengeId()) + if err != nil { + t.Fatalf("ExpireChallenge() error = %v", err) + } + if expired.GetStatus() != StatusTimeout || expired.GetSettlementStatus() != SettlementRefunded || expired.GetInitiator().GetBalanceAfter() != 1000 { + t.Fatalf("expired challenge should refund initiator: %+v", expired) + } + if wallet.balances[41] != 1000 { + t.Fatalf("expired challenge wallet balance mismatch: %d", wallet.balances[41]) + } + if got := wallet.ops(); fmt.Sprint(got) != "[debit refund]" { + t.Fatalf("expire should only debit then refund initiator, got ops=%v", got) + } } type fakeGiftCatalog struct { @@ -249,6 +448,58 @@ func (f *fakeGiftCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListG return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{}, Total: 0}, nil } +type fakeRoomRPSWallet struct { + balances map[int64]int64 + requests []*walletv1.GetBalancesRequest + applies []*walletv1.ApplyGameCoinChangeRequest + replays map[string]*walletv1.ApplyGameCoinChangeResponse +} + +func newFakeRoomRPSWallet(balances map[int64]int64) *fakeRoomRPSWallet { + return &fakeRoomRPSWallet{balances: balances, replays: make(map[string]*walletv1.ApplyGameCoinChangeResponse)} +} + +func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { + f.requests = append(f.requests, req) + return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{ + AppCode: req.GetAppCode(), + AssetType: "COIN", + AvailableAmount: f.balances[req.GetUserId()], + }}}, nil +} + +func (f *fakeRoomRPSWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) { + f.applies = append(f.applies, req) + if replay := f.replays[req.GetCommandId()]; replay != nil { + return replay, nil + } + switch req.GetOpType() { + case roomRPSOpDebit: + if f.balances[req.GetUserId()] < req.GetCoinAmount() { + return nil, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + f.balances[req.GetUserId()] -= req.GetCoinAmount() + case roomRPSOpRefund: + f.balances[req.GetUserId()] += req.GetCoinAmount() + default: + return nil, xerr.New(xerr.InvalidArgument, "unsupported op_type") + } + resp := &walletv1.ApplyGameCoinChangeResponse{ + WalletTransactionId: "wtx-" + req.GetCommandId(), + BalanceAfter: f.balances[req.GetUserId()], + } + f.replays[req.GetCommandId()] = resp + return resp, nil +} + +func (f *fakeRoomRPSWallet) ops() []string { + out := make([]string, 0, len(f.applies)) + for _, req := range f.applies { + out = append(out, req.GetOpType()) + } + return out +} + func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL string, animationURL string, coinPrice int64) *walletv1.GiftConfig { return &walletv1.GiftConfig{ AppCode: "lalu",