fix(game): settle room rps stake coins
This commit is contained in:
parent
d9433647d3
commit
c59317c6d2
@ -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,9 +67,9 @@ type GiftCatalogClient interface {
|
||||
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
|
||||
}
|
||||
|
||||
// BalanceClient 只暴露 room-rps 入场前需要的钱包余额查询;PK 状态仍由 game-service 管,金币事实仍由 wallet-service 管。
|
||||
type BalanceClient interface {
|
||||
GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, 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 仓储层。
|
||||
@ -88,7 +93,7 @@ type Service struct {
|
||||
config Config
|
||||
user UserClient
|
||||
gifts GiftCatalogClient
|
||||
wallet BalanceClient
|
||||
wallet WalletClient
|
||||
pub Publisher
|
||||
store ConfigStore
|
||||
|
||||
@ -110,15 +115,15 @@ func New(config Config, user UserClient, publisher Publisher, extras ...any) *Se
|
||||
config.RevealCountdown = 3 * time.Second
|
||||
}
|
||||
var gifts GiftCatalogClient
|
||||
var wallet BalanceClient
|
||||
var wallet WalletClient
|
||||
var store ConfigStore
|
||||
for _, extra := range extras {
|
||||
if typed, ok := extra.(GiftCatalogClient); ok {
|
||||
// room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。
|
||||
gifts = typed
|
||||
}
|
||||
if typed, ok := extra.(BalanceClient); ok {
|
||||
// 发起和应战都必须在进入状态机前查 COIN 余额;不做余额查询时会让 0 金币用户进入 PK 链路。
|
||||
if typed, ok := extra.(WalletClient); ok {
|
||||
// 发起和应战都必须进入 wallet-service 改账;只做余额查询会让用户看到 PK 成功但金币事实没有变化。
|
||||
wallet = typed
|
||||
}
|
||||
if typed, ok := extra.(ConfigStore); ok {
|
||||
@ -317,7 +322,8 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS
|
||||
if stakeCoin <= 0 {
|
||||
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid")
|
||||
}
|
||||
initiatorBalance, err := s.ensureCoinBalance(ctx, req.GetMeta().GetRequestId(), app, req.GetUserId(), stakeCoin)
|
||||
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
|
||||
}
|
||||
@ -327,7 +333,7 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS
|
||||
nowMS := now.UnixMilli()
|
||||
challenge := &gamev1.RoomRPSChallenge{
|
||||
AppCode: app,
|
||||
ChallengeId: newChallengeID(app),
|
||||
ChallengeId: challengeID,
|
||||
RoomId: roomID,
|
||||
RegionId: req.GetRegionId(),
|
||||
Status: StatusPending,
|
||||
@ -382,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")
|
||||
}
|
||||
@ -391,9 +404,10 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS
|
||||
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.ensureCoinBalance(ctx, req.GetMeta().GetRequestId(), app, req.GetUserId(), stakeCoin)
|
||||
challengerBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@ -402,28 +416,39 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS
|
||||
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 后仍然匹配成功。
|
||||
// 二次校验也要收敛超时单,避免扣款耗时跨过 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")
|
||||
}
|
||||
|
||||
@ -437,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
|
||||
}
|
||||
@ -536,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
|
||||
}
|
||||
@ -801,32 +860,141 @@ func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs []
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureCoinBalance(ctx context.Context, requestID string, app string, userID int64, stakeCoin int64) (int64, error) {
|
||||
if stakeCoin <= 0 {
|
||||
return 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid")
|
||||
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 {
|
||||
// 没有 wallet-service 余额事实时不能放用户进入 PK;否则测试服/线上配置缺依赖会退化成免费 PK。
|
||||
// 钱包不可用时必须拒绝进入 PK;否则房间状态会成功但 COIN 事实无法同步落账。
|
||||
return 0, xerr.New(xerr.Unavailable, "room rps wallet client is not configured")
|
||||
}
|
||||
resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
|
||||
RequestId: strings.TrimSpace(requestID),
|
||||
UserId: userID,
|
||||
AppCode: appcode.Normalize(app),
|
||||
AssetTypes: []string{"COIN"},
|
||||
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
|
||||
}
|
||||
for _, balance := range resp.GetBalances() {
|
||||
if strings.EqualFold(balance.GetAssetType(), "COIN") {
|
||||
if balance.GetAvailableAmount() >= stakeCoin {
|
||||
return balance.GetAvailableAmount(), nil
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return 0, xerr.New(xerr.InsufficientBalance, "coin balance is insufficient")
|
||||
return resp.GetBalanceAfter(), nil
|
||||
}
|
||||
|
||||
func roomRPSStableHash(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string {
|
||||
|
||||
@ -2,6 +2,7 @@ package roomrps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -162,7 +163,7 @@ 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() != 10 {
|
||||
if challenge.GetInitiator().GetBalanceAfter() != 9 || wallet.balances[42] != 9 {
|
||||
t.Fatalf("initiator balance snapshot mismatch: %+v", challenge.GetInitiator())
|
||||
}
|
||||
}
|
||||
@ -206,6 +207,9 @@ func TestCreateChallengeRejectsInsufficientCoinBalance(t *testing.T) {
|
||||
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) {
|
||||
@ -276,6 +280,9 @@ 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) {
|
||||
@ -310,6 +317,9 @@ func TestAcceptChallengeRejectsInsufficientCoinBalance(t *testing.T) {
|
||||
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) {
|
||||
@ -337,9 +347,88 @@ func TestAcceptChallengeRecordsChallengerCoinBalance(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptChallenge() error = %v", err)
|
||||
}
|
||||
if accepted.GetStatus() != StatusFinished || accepted.GetChallenger().GetBalanceAfter() != 500 {
|
||||
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 {
|
||||
@ -362,10 +451,12 @@ func (f *fakeGiftCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListG
|
||||
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}
|
||||
return &fakeRoomRPSWallet{balances: balances, replays: make(map[string]*walletv1.ApplyGameCoinChangeResponse)}
|
||||
}
|
||||
|
||||
func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
|
||||
@ -377,6 +468,38 @@ func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBala
|
||||
}}}, 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