1324 lines
47 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package roomrps
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"google.golang.org/protobuf/proto"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
)
const (
GameID = "room_rps"
StatusPending = "pending"
StatusMatched = "matched"
StatusRevealing = "revealing"
StatusFinished = "finished"
StatusTimeout = "timeout"
StatusSettlementFailed = "settlement_failed"
StatusCancelled = "cancelled"
SettlementPending = "pending"
SettlementSettled = "settled"
SettlementRefunded = "refunded"
SettlementFailed = "failed"
GestureRock = "rock"
GesturePaper = "paper"
GestureScissors = "scissors"
ResultWin = "win"
ResultLose = "lose"
ResultDraw = "draw"
eventChallengeCreated = "room_rps_challenge_created"
eventChallengeAccepted = "room_rps_challenge_accepted"
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 能力;这里只取展示资料,不把用户领域模型引入游戏用例层。
type UserClient interface {
GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
}
// GiftCatalogClient 只暴露 room-rps 配置补齐需要的礼物配置查询能力;礼物名称、图标和币价仍由 wallet-service 作为 owner 输出。
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)
UpsertRoomRPSConfig(ctx context.Context, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error)
}
// Publisher 是房间 IM 投递边界;生产用腾讯 IM REST测试可以注入 fakeservice 不感知具体 SDK。
type Publisher interface {
EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error
PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error
}
type Config struct {
ChallengeTimeout time.Duration
RevealCountdown time.Duration
}
type Service struct {
config Config
user UserClient
gifts GiftCatalogClient
wallet WalletClient
pub Publisher
store ConfigStore
mu sync.Mutex
configs map[string]*gamev1.RoomRPSConfig
challenges map[string]*gamev1.RoomRPSChallenge
now func() time.Time
}
// New 创建房内猜拳用例层。
// 配置已经接入 MySQL 持久化;挑战事实仍是进程内状态,后续独立迁移时继续沿用当前状态机和 RPC 契约。
func New(config Config, user UserClient, publisher Publisher, extras ...any) *Service {
if config.ChallengeTimeout <= 0 {
// 房内猜拳产品规则是 10 分钟无人应战自动超时;默认打开时必须带稳定超时,避免 pending 永久悬挂。
config.ChallengeTimeout = 10 * time.Minute
}
if config.RevealCountdown <= 0 {
// 揭晓动画固定 3 秒IM 的 countdown 事件和 Flutter 动画以这个值做时间校准。
config.RevealCountdown = 3 * time.Second
}
var gifts GiftCatalogClient
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.(WalletClient); ok {
// 发起和应战都必须进入 wallet-service 改账;只做余额查询会让用户看到 PK 成功但金币事实没有变化。
wallet = typed
}
if typed, ok := extra.(ConfigStore); ok {
// MySQL 是配置事实源;没有仓储的测试场景才落回进程内 map避免把运行态差异写成前端兜底。
store = typed
}
}
return &Service{
config: config,
user: user,
gifts: gifts,
wallet: wallet,
pub: publisher,
store: store,
configs: make(map[string]*gamev1.RoomRPSConfig),
challenges: make(map[string]*gamev1.RoomRPSChallenge),
now: time.Now,
}
}
// NewTencentIMPublisher 创建真实腾讯 IM publisher。
// game-service 的房内 PK 发起接口是同步返回给客户端的入口,因此 IM 配置必须来自已校验的 YAML而不是进程环境变量的隐式副作用。
func NewTencentIMPublisher(config tencentim.RESTConfig) (Publisher, error) {
return tencentim.NewRESTClient(config)
}
func (s *Service) GetConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, int64, error) {
if s == nil {
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
}
app = appcode.Normalize(app)
ctx = appcode.WithContext(ctx, app)
if s.store != nil {
return s.getPersistentConfig(ctx, app)
}
s.mu.Lock()
config := s.configForAppLocked(app)
cloned := cloneConfig(config)
originalUpdatedAtMS := config.GetUpdatedAtMs()
s.mu.Unlock()
enriched, changed, err := s.enrichConfigGifts(ctx, app, cloned)
if err != nil {
return nil, 0, err
}
if changed {
s.mu.Lock()
// 线上已有旧配置可能只保存 gift_id读接口补齐成功后同步回内存后续发起挑战也能使用真实币价和展示快照。
if current := s.configs[app]; current != nil && current.GetUpdatedAtMs() == originalUpdatedAtMS {
s.configs[app] = cloneConfig(enriched)
}
s.mu.Unlock()
}
return enriched, s.now().UnixMilli(), nil
}
func (s *Service) UpdateConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, int64, error) {
if s == nil {
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
}
app = appcode.Normalize(app)
ctx = appcode.WithContext(ctx, app)
normalized, err := s.normalizeConfig(ctx, app, config)
if err != nil {
return nil, 0, err
}
nowMS := s.now().UnixMilli()
if s.store != nil {
// 数据库保存时由唯一键保持 created_at_msservice 每次提交只表达“这个时刻产生了新配置版本”。
normalized.CreatedAtMs = nowMS
normalized.UpdatedAtMs = nowMS
saved, err := s.store.UpsertRoomRPSConfig(ctx, normalized)
if err != nil {
return nil, 0, err
}
s.mu.Lock()
delete(s.configs, app)
s.mu.Unlock()
return cloneConfig(saved), nowMS, nil
}
s.mu.Lock()
defer s.mu.Unlock()
if existing := s.configs[app]; existing != nil && existing.GetCreatedAtMs() > 0 {
normalized.CreatedAtMs = existing.GetCreatedAtMs()
} else {
normalized.CreatedAtMs = nowMS
}
normalized.UpdatedAtMs = nowMS
s.configs[app] = normalized
return cloneConfig(normalized), nowMS, nil
}
func (s *Service) ListChallenges(ctx context.Context, req *gamev1.ListRoomRPSChallengesRequest) ([]*gamev1.RoomRPSChallenge, string, int32, int64, error) {
if s == nil {
return nil, "", 0, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
}
app := appcode.Normalize(req.GetMeta().GetAppCode())
ctx = appcode.WithContext(ctx, app)
pageSize := req.GetPageSize()
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
// 后台列表和 App 房间补拉都走同一接口;这里硬限制每页 100防止一次请求把进程内状态全量复制出去。
pageSize = 100
}
cursorOffset := 0
if raw := strings.TrimSpace(req.GetCursor()); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 0 {
return nil, "", 0, 0, xerr.New(xerr.InvalidArgument, "room rps cursor is invalid")
}
cursorOffset = parsed
}
s.mu.Lock()
defer s.mu.Unlock()
status := normalizeStatus(req.GetStatus())
items := make([]*gamev1.RoomRPSChallenge, 0, len(s.challenges))
for _, item := range s.challenges {
if item.GetAppCode() != app {
continue
}
if roomID := strings.TrimSpace(req.GetRoomId()); roomID != "" && item.GetRoomId() != roomID {
continue
}
if status != "" && item.GetStatus() != status {
continue
}
if req.GetInitiatorUserId() > 0 && item.GetInitiator().GetUserId() != req.GetInitiatorUserId() {
continue
}
if req.GetChallengerUserId() > 0 && item.GetChallenger().GetUserId() != req.GetChallengerUserId() {
continue
}
if req.GetStartTimeMs() > 0 && item.GetCreatedAtMs() < req.GetStartTimeMs() {
continue
}
if req.GetEndTimeMs() > 0 && item.GetCreatedAtMs() > req.GetEndTimeMs() {
continue
}
items = append(items, cloneChallenge(item))
}
sort.Slice(items, func(i, j int) bool {
if items[i].GetCreatedAtMs() == items[j].GetCreatedAtMs() {
return items[i].GetChallengeId() > items[j].GetChallengeId()
}
return items[i].GetCreatedAtMs() > items[j].GetCreatedAtMs()
})
if cursorOffset >= len(items) {
return []*gamev1.RoomRPSChallenge{}, "", pageSize, s.now().UnixMilli(), nil
}
end := cursorOffset + int(pageSize)
nextCursor := ""
if end < len(items) {
nextCursor = strconv.Itoa(end)
} else {
end = len(items)
}
return items[cursorOffset:end], nextCursor, pageSize, s.now().UnixMilli(), nil
}
func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPSChallengeRequest) (*gamev1.RoomRPSChallenge, int64, error) {
if s == nil {
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
}
app := appcode.Normalize(req.GetMeta().GetAppCode())
ctx = appcode.WithContext(ctx, app)
roomID := strings.TrimSpace(req.GetRoomId())
gesture, ok := normalizeGesture(req.GetGesture())
giftID := roomRPSRequestGiftID(req)
if req.GetUserId() <= 0 || roomID == "" || giftID == "" || !ok {
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge command is incomplete")
}
config, err := s.runtimeConfig(ctx, app)
if err != nil {
return nil, 0, err
}
if config.GetStatus() != "active" {
return nil, 0, xerr.New(xerr.Conflict, "room rps config is disabled")
}
gift, ok := stakeGiftByID(config, giftID)
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: challengeID,
RoomId: roomID,
RegionId: req.GetRegionId(),
Status: StatusPending,
StakeGiftId: legacyRoomRPSGiftID(giftID),
StakeGiftIdText: giftID,
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,
UpdatedAtMs: nowMS,
}
s.challenges[challenge.GetChallengeId()] = challenge
cloned := cloneChallenge(challenge)
s.mu.Unlock()
if err := s.publishChallengeEvent(ctx, eventChallengeCreated, cloned); err != nil {
return nil, 0, err
}
return cloned, nowMS, nil
}
func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPSChallengeRequest) (*gamev1.RoomRPSChallenge, int64, error) {
if s == nil {
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
}
app := appcode.Normalize(req.GetMeta().GetAppCode())
ctx = appcode.WithContext(ctx, app)
challengeID := strings.TrimSpace(req.GetChallengeId())
gesture, ok := normalizeGesture(req.GetGesture())
if req.GetUserId() <= 0 || challengeID == "" || !ok {
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps accept command is incomplete")
}
s.mu.Lock()
challenge, ok := s.challenges[challengeID]
if !ok || challenge.GetAppCode() != app {
s.mu.Unlock()
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
}
now := s.now()
nowMS := now.UnixMilli()
if challenge.GetStatus() != StatusPending {
// pending 是唯一可应战状态matched/revealing/finished/timeout 都已经有明确归属,重复点击只能返回冲突。
s.mu.Unlock()
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending")
}
if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() {
// 读到已超时但还未收敛的 pending 时,先在锁内改成 timeout避免下一个用户还能应战同一单。
challenge.Status = StatusTimeout
challenge.SettlementStatus = SettlementRefunded
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")
}
if challenge.GetInitiator().GetUserId() == req.GetUserId() {
// 发起人不能应战自己的单;这个判断必须在 service 锁内做,不能依赖前端按钮状态。
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, BalanceAfter: challengerBalance, JoinedAtMs: nowMS}
challenge.MatchedAtMs = nowMS
challenge.RevealAtMs = now.Add(s.config.RevealCountdown).UnixMilli()
challenge.SettledAtMs = nowMS
challenge.UpdatedAtMs = nowMS
applyResult(challenge)
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
}
if err := s.publishChallengeEvent(ctx, eventRevealCountdown, cloned); err != nil {
return nil, 0, err
}
if err := s.publishChallengeEvent(ctx, eventFinished, cloned); err != nil {
return nil, 0, err
}
return cloned, nowMS, nil
}
func (s *Service) GetChallenge(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
if s == nil {
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
}
app = appcode.Normalize(app)
ctx = appcode.WithContext(ctx, app)
challengeID = strings.TrimSpace(challengeID)
if challengeID == "" {
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
}
s.mu.Lock()
defer s.mu.Unlock()
challenge, ok := s.challenges[challengeID]
if !ok || challenge.GetAppCode() != app {
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
}
return cloneChallenge(challenge), s.now().UnixMilli(), nil
}
func (s *Service) RetrySettlement(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
if s == nil {
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
}
app = appcode.Normalize(app)
ctx = appcode.WithContext(ctx, app)
challengeID = strings.TrimSpace(challengeID)
if challengeID == "" {
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
}
s.mu.Lock()
challenge, ok := s.challenges[challengeID]
if !ok || challenge.GetAppCode() != app {
s.mu.Unlock()
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
}
if challenge.GetStatus() != StatusSettlementFailed && challenge.GetSettlementStatus() != SettlementFailed {
// 只有结算失败单能进入重试已结算、已退款、pending 或 timeout 都不能被后台按钮强制改结果。
s.mu.Unlock()
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not settlement_failed")
}
nowMS := s.now().UnixMilli()
challenge.Status = StatusFinished
challenge.SettlementStatus = SettlementSettled
challenge.FailureReason = ""
challenge.SettledAtMs = nowMS
challenge.UpdatedAtMs = nowMS
cloned := cloneChallenge(challenge)
s.mu.Unlock()
if err := s.publishChallengeEvent(ctx, eventFinished, cloned); err != nil {
return nil, 0, err
}
return cloned, nowMS, nil
}
func (s *Service) ExpireChallenge(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) {
if s == nil {
return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured")
}
app = appcode.Normalize(app)
ctx = appcode.WithContext(ctx, app)
challengeID = strings.TrimSpace(challengeID)
if challengeID == "" {
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps challenge_id is required")
}
s.mu.Lock()
challenge, ok := s.challenges[challengeID]
if !ok || challenge.GetAppCode() != app {
s.mu.Unlock()
return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found")
}
if challenge.GetStatus() != StatusPending || challenge.GetChallenger().GetUserId() > 0 {
// 手动过期只允许无人应战的 pending 单;一旦有 challenger就必须走揭晓/结算链路,不能后台撤销。
s.mu.Unlock()
return nil, 0, xerr.New(xerr.Conflict, "room rps challenge can not expire")
}
nowMS := s.now().UnixMilli()
challenge.Status = StatusTimeout
challenge.SettlementStatus = SettlementRefunded
challenge.UpdatedAtMs = nowMS
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
}
return cloned, nowMS, nil
}
func (s *Service) getPersistentConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, int64, error) {
nowMS := s.now().UnixMilli()
config, err := s.store.GetRoomRPSConfig(ctx, app, GameID)
persisted := true
if err != nil {
if !xerr.IsCode(err, xerr.NotFound) {
return nil, 0, err
}
// 首次部署或本地空库没有配置行时仍返回产品默认值;只有后台保存后才写入 MySQL避免读接口偷偷覆盖运营数据。
config = defaultConfig(app, s.config.ChallengeTimeout, s.config.RevealCountdown, nowMS)
persisted = false
}
enriched, changed, err := s.enrichConfigGifts(ctx, app, cloneConfig(config))
if err != nil {
return nil, 0, err
}
if changed && persisted {
// 历史行可能只保存 gift_id 或误把 mp4 写进 icon读路径修正成功后回写后续重启和发起挑战都使用同一份快照。
enriched.UpdatedAtMs = nowMS
saved, err := s.store.UpsertRoomRPSConfig(ctx, enriched)
if err != nil {
return nil, 0, err
}
enriched = saved
}
return cloneConfig(enriched), nowMS, nil
}
func (s *Service) runtimeConfig(ctx context.Context, app string) (*gamev1.RoomRPSConfig, error) {
if s.store != nil {
config, _, err := s.GetConfig(ctx, app)
return config, err
}
s.mu.Lock()
defer s.mu.Unlock()
return cloneConfig(s.configForAppLocked(app)), nil
}
func (s *Service) configForAppLocked(app string) *gamev1.RoomRPSConfig {
if config := s.configs[app]; config != nil {
return config
}
nowMS := s.now().UnixMilli()
config := defaultConfig(app, s.config.ChallengeTimeout, s.config.RevealCountdown, nowMS)
s.configs[app] = config
return config
}
func (s *Service) normalizeConfig(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, error) {
if config == nil {
return nil, xerr.New(xerr.InvalidArgument, "room rps config is required")
}
status := normalizeStatus(config.GetStatus())
if status == "" {
// 逻辑功能配置默认打开;后台未传 status 时按 active 保存,不能静默写成关闭。
status = "active"
}
if status != "active" && status != "disabled" {
return nil, xerr.New(xerr.InvalidArgument, "room rps status is invalid")
}
timeoutMS := config.GetChallengeTimeoutMs()
if timeoutMS <= 0 {
timeoutMS = int64(s.config.ChallengeTimeout / time.Millisecond)
}
revealMS := config.GetRevealCountdownMs()
if revealMS <= 0 {
revealMS = int64(s.config.RevealCountdown / time.Millisecond)
}
if len(config.GetStakeGifts()) != 4 {
return nil, xerr.New(xerr.InvalidArgument, "room rps requires exactly 4 stake gifts")
}
defaultsByID := defaultGiftMap()
gifts := make([]*gamev1.RoomRPSStakeGift, 0, 4)
seenGift := make(map[string]struct{}, 4)
seenSort := make(map[int32]struct{}, 4)
for index, gift := range config.GetStakeGifts() {
giftID := roomRPSStakeGiftID(gift)
if giftID == "" {
return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is invalid")
}
if _, ok := seenGift[giftID]; ok {
return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is duplicated")
}
sortOrder := gift.GetSortOrder()
if sortOrder <= 0 {
sortOrder = int32(index + 1)
}
if _, ok := seenSort[sortOrder]; ok {
return nil, xerr.New(xerr.InvalidArgument, "room rps gift sort_order is duplicated")
}
seenGift[giftID] = struct{}{}
seenSort[sortOrder] = struct{}{}
normalized := &gamev1.RoomRPSStakeGift{
GiftId: legacyRoomRPSGiftID(giftID),
GiftIdText: giftID,
GiftName: strings.TrimSpace(gift.GetGiftName()),
GiftIconUrl: strings.TrimSpace(gift.GetGiftIconUrl()),
GiftAnimationUrl: strings.TrimSpace(gift.GetGiftAnimationUrl()),
GiftPriceCoin: gift.GetGiftPriceCoin(),
Enabled: gift.GetEnabled(),
SortOrder: sortOrder,
}
gifts = append(gifts, normalized)
}
if _, err := s.enrichStakeGifts(ctx, app, gifts); err != nil {
return nil, err
}
for _, normalized := range gifts {
if fallback := defaultsByID[roomRPSStakeGiftID(normalized)]; fallback != nil {
if normalized.GiftName == "" {
normalized.GiftName = fallback.GetGiftName()
}
if normalized.GiftIconUrl == "" {
normalized.GiftIconUrl = fallback.GetGiftIconUrl()
}
if normalized.GiftPriceCoin <= 0 {
normalized.GiftPriceCoin = fallback.GetGiftPriceCoin()
}
}
if normalized.GiftPriceCoin <= 0 {
// 运行时必须知道当前档位折算金币,后续接钱包托管也以这个金额作为订单事实。
normalized.GiftPriceCoin = int64(normalized.GetSortOrder()) * 100
}
}
sort.Slice(gifts, func(i, j int) bool { return gifts[i].GetSortOrder() < gifts[j].GetSortOrder() })
return &gamev1.RoomRPSConfig{
AppCode: app,
GameId: GameID,
Status: status,
ChallengeTimeoutMs: timeoutMS,
RevealCountdownMs: revealMS,
StakeGifts: gifts,
}, nil
}
func (s *Service) enrichConfigGifts(ctx context.Context, app string, config *gamev1.RoomRPSConfig) (*gamev1.RoomRPSConfig, bool, error) {
if config == nil {
return nil, false, nil
}
changed, err := s.enrichStakeGifts(ctx, app, config.GetStakeGifts())
if err != nil {
return nil, false, err
}
return config, changed, nil
}
func (s *Service) enrichStakeGifts(ctx context.Context, app string, gifts []*gamev1.RoomRPSStakeGift) (bool, error) {
if len(gifts) == 0 {
return false, nil
}
needGiftIDs := make([]string, 0, len(gifts))
seen := make(map[string]struct{}, len(gifts))
for _, gift := range gifts {
giftID := roomRPSStakeGiftID(gift)
if giftID == "" {
continue
}
if gift.GetGiftName() != "" && gift.GetGiftIconUrl() != "" && gift.GetGiftAnimationUrl() != "" && gift.GetGiftPriceCoin() > 0 && !roomRPSURLLooksAnimated(gift.GetGiftIconUrl()) {
continue
}
if _, ok := seen[giftID]; ok {
continue
}
// 只查询缺字段的档位,避免每次读配置都把 wallet-service 当成全量礼物列表使用。
seen[giftID] = struct{}{}
needGiftIDs = append(needGiftIDs, giftID)
}
if len(needGiftIDs) == 0 {
return false, nil
}
catalog, err := s.roomRPSGiftCatalog(ctx, app, needGiftIDs)
if err != nil {
return false, err
}
changed := false
for _, gift := range gifts {
if gift == nil {
continue
}
item := catalog[roomRPSStakeGiftID(gift)]
if item == nil {
continue
}
if gift.GiftName == "" {
if name := roomRPSGiftDisplayName(item); name != "" {
// 后台保存 room-rps 时只提交 gift_id运行时展示名必须来自 wallet-service 礼物配置,不能让前端猜。
gift.GiftName = name
changed = true
}
}
if gift.GiftIconUrl == "" || roomRPSURLLooksAnimated(gift.GiftIconUrl) {
if iconURL := roomRPSGiftIconURL(item); iconURL != "" {
// gift_icon_url 只给静态封面;历史配置曾把 animation_url 写进这里,读配置时要用 wallet 资源重新纠正。
gift.GiftIconUrl = iconURL
changed = true
}
}
if gift.GiftAnimationUrl == "" {
if animationURL := roomRPSGiftAnimationURL(item); animationURL != "" {
// 动效单独返回给客户端按需播放,不能再混入 icon 字段,否则列表和选择面板会把 mp4 当图片加载。
gift.GiftAnimationUrl = animationURL
changed = true
}
}
if gift.GiftPriceCoin <= 0 && item.GetCoinPrice() > 0 {
// 创建挑战会把 stake_coin 固化到对局事实,缺币价时必须使用 wallet-service 当前生效礼物价格补齐。
gift.GiftPriceCoin = item.GetCoinPrice()
changed = true
}
}
return changed, nil
}
func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs []string) (map[string]*walletv1.GiftConfig, error) {
result := make(map[string]*walletv1.GiftConfig, len(giftIDs))
if s.gifts == nil {
return result, nil
}
app = appcode.Normalize(app)
for _, giftID := range giftIDs {
giftID = strings.TrimSpace(giftID)
if giftID == "" {
continue
}
for page := int32(1); ; page++ {
resp, err := s.gifts.ListGiftConfigs(ctx, &walletv1.ListGiftConfigsRequest{
RequestId: fmt.Sprintf("room-rps-config:%s:%s:%d", app, giftID, page),
AppCode: app,
Keyword: giftID,
Page: page,
PageSize: 100,
})
if err != nil {
return nil, xerr.New(xerr.Unavailable, "room rps gift config lookup failed")
}
for _, item := range resp.GetGifts() {
if strings.TrimSpace(item.GetGiftId()) != giftID {
continue
}
result[giftID] = item
break
}
if result[giftID] != nil {
break
}
if len(resp.GetGifts()) < 100 {
break
}
if total := resp.GetTotal(); total > 0 && int64(page)*100 >= total {
break
}
}
}
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 ""
}
if name := strings.TrimSpace(gift.GetName()); name != "" {
return name
}
return strings.TrimSpace(gift.GetResource().GetName())
}
func roomRPSGiftIconURL(gift *walletv1.GiftConfig) string {
if gift == nil || gift.GetResource() == nil {
return ""
}
candidates := []string{
gift.GetResource().GetAssetUrl(),
gift.GetResource().GetPreviewUrl(),
}
for _, candidate := range candidates {
if value := strings.TrimSpace(candidate); value != "" && !roomRPSURLLooksAnimated(value) {
return value
}
}
return ""
}
func roomRPSGiftAnimationURL(gift *walletv1.GiftConfig) string {
if gift == nil || gift.GetResource() == nil {
return ""
}
return strings.TrimSpace(gift.GetResource().GetAnimationUrl())
}
func roomRPSURLLooksAnimated(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return false
}
for _, suffix := range []string{".mp4", ".svga", ".pag", ".webm", ".mov"} {
if strings.Contains(value, suffix) {
return true
}
}
return false
}
func (s *Service) publishChallengeEvent(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) error {
if s.pub == nil || challenge == nil {
return nil
}
// 每次发业务 IM 前都幂等建群;真实腾讯云已存在返回成功,临时房间或测试群也不需要额外预置。
if err := s.pub.EnsureGroup(ctx, tencentim.GroupSpec{
GroupID: challenge.GetRoomId(),
Name: challenge.GetRoomId(),
Type: tencentim.DefaultGroupType,
ApplyJoinOption: "FreeAccess",
}); err != nil {
return xerr.New(xerr.Unavailable, "room rps im group ensure failed")
}
payload, err := s.challengeEventPayload(ctx, eventType, challenge)
if err != nil {
return err
}
eventID := fmt.Sprintf("%s:%s:%d", eventType, challenge.GetChallengeId(), s.now().UnixNano())
if err := s.pub.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{
GroupID: challenge.GetRoomId(),
EventID: eventID,
Desc: eventType,
Ext: "room_rps",
PayloadJSON: payload,
}); err != nil {
return xerr.New(xerr.Unavailable, "room rps im publish failed")
}
return nil
}
func (s *Service) challengeEventPayload(ctx context.Context, eventType string, challenge *gamev1.RoomRPSChallenge) (json.RawMessage, error) {
nowMS := s.now().UnixMilli()
payload := map[string]any{
"event_id": fmt.Sprintf("%s:%s:%d", eventType, challenge.GetChallengeId(), nowMS),
"event_type": eventType,
"room_id": challenge.GetRoomId(),
"challenge_id": challenge.GetChallengeId(),
"server_time_ms": nowMS,
"challenge": s.challengeMap(ctx, challenge),
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, xerr.New(xerr.Internal, "room rps im payload marshal failed")
}
return raw, nil
}
func (s *Service) challengeMap(ctx context.Context, challenge *gamev1.RoomRPSChallenge) map[string]any {
return map[string]any{
"app_code": challenge.GetAppCode(),
"challenge_id": challenge.GetChallengeId(),
"room_id": challenge.GetRoomId(),
"region_id": challenge.GetRegionId(),
"status": challenge.GetStatus(),
"stake_gift_id": roomRPSChallengeGiftID(challenge),
"stake_gift_id_number": challenge.GetStakeGiftId(),
"stake_coin": challenge.GetStakeCoin(),
"initiator": s.playerMap(ctx, challenge.GetAppCode(), challenge.GetInitiator()),
"challenger": s.playerMap(ctx, challenge.GetAppCode(), challenge.GetChallenger()),
"winner_user_id": strconv.FormatInt(challenge.GetWinnerUserId(), 10),
"settlement_status": challenge.GetSettlementStatus(),
"failure_reason": challenge.GetFailureReason(),
"timeout_at_ms": challenge.GetTimeoutAtMs(),
"reveal_at_ms": challenge.GetRevealAtMs(),
"created_at_ms": challenge.GetCreatedAtMs(),
"matched_at_ms": challenge.GetMatchedAtMs(),
"settled_at_ms": challenge.GetSettledAtMs(),
"updated_at_ms": challenge.GetUpdatedAtMs(),
}
}
func (s *Service) playerMap(ctx context.Context, app string, player *gamev1.RoomRPSPlayer) map[string]any {
if player == nil || player.GetUserId() <= 0 {
return map[string]any{}
}
user := s.displayUser(ctx, app, player.GetUserId())
return map[string]any{
"user_id": strconv.FormatInt(player.GetUserId(), 10),
"user_id_number": player.GetUserId(),
"gesture": player.GetGesture(),
"result": player.GetResult(),
"balance_after": player.GetBalanceAfter(),
"joined_at_ms": player.GetJoinedAtMs(),
"user": user,
}
}
func (s *Service) displayUser(ctx context.Context, app string, userID int64) map[string]any {
user := map[string]any{
"user_id": strconv.FormatInt(userID, 10),
}
if s.user == nil || userID <= 0 {
return user
}
resp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{AppCode: appcode.Normalize(app), Caller: "game-service-room-rps", SentAtMs: s.now().UnixMilli()},
UserId: userID,
})
if err != nil || resp.GetUser() == nil {
// IM 资料补齐不能反向改挑战事实;失败时保留 user_idHTTP 详情仍可由 gateway 再查一次资料。
return user
}
user["display_user_id"] = resp.GetUser().GetDisplayUserId()
user["nickname"] = resp.GetUser().GetUsername()
user["avatar"] = resp.GetUser().GetAvatar()
return user
}
func defaultConfig(app string, timeout time.Duration, reveal time.Duration, nowMS int64) *gamev1.RoomRPSConfig {
return &gamev1.RoomRPSConfig{
AppCode: app,
GameId: GameID,
Status: "active",
ChallengeTimeoutMs: int64(timeout / time.Millisecond),
RevealCountdownMs: int64(reveal / time.Millisecond),
StakeGifts: defaultGifts(),
CreatedAtMs: nowMS,
UpdatedAtMs: nowMS,
}
}
func defaultGifts() []*gamev1.RoomRPSStakeGift {
return []*gamev1.RoomRPSStakeGift{
{GiftId: 10001, GiftIdText: "10001", GiftName: "RPS 100", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-100.png", GiftPriceCoin: 100, Enabled: true, SortOrder: 1},
{GiftId: 10002, GiftIdText: "10002", GiftName: "RPS 300", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-300.png", GiftPriceCoin: 300, Enabled: true, SortOrder: 2},
{GiftId: 10003, GiftIdText: "10003", GiftName: "RPS 500", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-500.png", GiftPriceCoin: 500, Enabled: true, SortOrder: 3},
{GiftId: 10004, GiftIdText: "10004", GiftName: "RPS 1000", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-1000.png", GiftPriceCoin: 1000, Enabled: true, SortOrder: 4},
}
}
func defaultGiftMap() map[string]*gamev1.RoomRPSStakeGift {
out := make(map[string]*gamev1.RoomRPSStakeGift, 4)
for _, gift := range defaultGifts() {
out[roomRPSStakeGiftID(gift)] = gift
}
return out
}
func stakeGiftByID(config *gamev1.RoomRPSConfig, giftID string) (*gamev1.RoomRPSStakeGift, bool) {
giftID = strings.TrimSpace(giftID)
for _, gift := range config.GetStakeGifts() {
if roomRPSStakeGiftID(gift) == giftID {
return gift, true
}
}
return nil, false
}
func roomRPSStakeGiftID(gift *gamev1.RoomRPSStakeGift) string {
if gift == nil {
return ""
}
if text := strings.TrimSpace(gift.GetGiftIdText()); text != "" {
return text
}
if gift.GetGiftId() > 0 {
return strconv.FormatInt(gift.GetGiftId(), 10)
}
return ""
}
func roomRPSRequestGiftID(req *gamev1.CreateRoomRPSChallengeRequest) string {
if req == nil {
return ""
}
if text := strings.TrimSpace(req.GetGiftIdText()); text != "" {
return text
}
if req.GetGiftId() > 0 {
return strconv.FormatInt(req.GetGiftId(), 10)
}
return ""
}
func roomRPSChallengeGiftID(challenge *gamev1.RoomRPSChallenge) string {
if challenge == nil {
return ""
}
if text := strings.TrimSpace(challenge.GetStakeGiftIdText()); text != "" {
return text
}
if challenge.GetStakeGiftId() > 0 {
return strconv.FormatInt(challenge.GetStakeGiftId(), 10)
}
return ""
}
func legacyRoomRPSGiftID(giftID string) int64 {
value, err := strconv.ParseInt(strings.TrimSpace(giftID), 10, 64)
if err != nil || value <= 0 {
return 0
}
return value
}
func applyResult(challenge *gamev1.RoomRPSChallenge) {
initiatorGesture := challenge.GetInitiator().GetGesture()
challengerGesture := challenge.GetChallenger().GetGesture()
switch {
case initiatorGesture == challengerGesture:
challenge.Initiator.Result = ResultDraw
challenge.Challenger.Result = ResultDraw
challenge.WinnerUserId = 0
challenge.SettlementStatus = SettlementRefunded
case beats(initiatorGesture, challengerGesture):
challenge.Initiator.Result = ResultWin
challenge.Challenger.Result = ResultLose
challenge.WinnerUserId = challenge.GetInitiator().GetUserId()
challenge.SettlementStatus = SettlementSettled
default:
challenge.Initiator.Result = ResultLose
challenge.Challenger.Result = ResultWin
challenge.WinnerUserId = challenge.GetChallenger().GetUserId()
challenge.SettlementStatus = SettlementSettled
}
}
func beats(left string, right string) bool {
return (left == GestureRock && right == GestureScissors) ||
(left == GesturePaper && right == GestureRock) ||
(left == GestureScissors && right == GesturePaper)
}
func normalizeGesture(value string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case GestureRock:
return GestureRock, true
case GesturePaper:
return GesturePaper, true
case GestureScissors:
return GestureScissors, true
default:
return "", false
}
}
func normalizeStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return ""
case StatusPending, StatusMatched, StatusRevealing, StatusFinished, StatusTimeout, StatusSettlementFailed, StatusCancelled, "active", "disabled":
return strings.ToLower(strings.TrimSpace(value))
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func cloneConfig(config *gamev1.RoomRPSConfig) *gamev1.RoomRPSConfig {
if config == nil {
return nil
}
// protobuf message 内部带锁状态不能用结构体赋值浅拷贝proto.Clone 会复制业务字段并重建 message state。
cloned, ok := proto.Clone(config).(*gamev1.RoomRPSConfig)
if !ok {
return nil
}
return cloned
}
func cloneChallenge(challenge *gamev1.RoomRPSChallenge) *gamev1.RoomRPSChallenge {
if challenge == nil {
return nil
}
// 对局快照同样必须走 protobuf 深拷贝,避免 vet copylocks 和嵌套玩家资料被调用方意外共享。
cloned, ok := proto.Clone(challenge).(*gamev1.RoomRPSChallenge)
if !ok {
return nil
}
return cloned
}
func newChallengeID(app string) string {
var entropy [8]byte
if _, err := rand.Read(entropy[:]); err != nil {
return fmt.Sprintf("rps_%s_%d", appcode.Normalize(app), time.Now().UnixNano())
}
return fmt.Sprintf("rps_%s_%d_%s", appcode.Normalize(app), time.Now().UnixMilli(), hex.EncodeToString(entropy[:]))
}