Merge branch 'main' into test
# Conflicts: # api/proto/room/v1/room.pb.go # api/proto/room/v1/room.proto # services/gateway-service/internal/transport/http/roomapi/room_handler.go # services/room-service/internal/room/service/list.go # services/room-service/internal/storage/mysql/repository.go
This commit is contained in:
commit
e9e5d67786
@ -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 ""
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user