2026-06-12 19:40:21 +08:00

971 lines
32 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"
"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"
)
// 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)
}
// 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
pub Publisher
mu sync.Mutex
configs map[string]*gamev1.RoomRPSConfig
challenges map[string]*gamev1.RoomRPSChallenge
now func() time.Time
}
// New 创建房内猜拳用例层。
// 当前阶段先把真实 HTTP、后台和 IM 事件跑通;挑战事实用进程内状态承接,后续接 MySQL 时保持同一套状态机和 RPC 契约即可。
func New(config Config, user UserClient, publisher Publisher, giftCatalog ...GiftCatalogClient) *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
if len(giftCatalog) > 0 {
// room-rps 运行配置只保存四个 gift_id后台不再携带展示字段时必须从 wallet-service 读取真实礼物快照。
gifts = giftCatalog[0]
}
return &Service{
config: config,
user: user,
gifts: gifts,
pub: publisher,
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)
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
}
s.mu.Lock()
defer s.mu.Unlock()
nowMS := s.now().UnixMilli()
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")
}
s.mu.Lock()
config := s.configForAppLocked(app)
if config.GetStatus() != "active" {
s.mu.Unlock()
return nil, 0, xerr.New(xerr.Conflict, "room rps config is disabled")
}
gift, ok := stakeGiftByID(config, giftID)
if !ok || !gift.GetEnabled() {
s.mu.Unlock()
return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled")
}
now := s.now()
nowMS := now.UnixMilli()
challenge := &gamev1.RoomRPSChallenge{
AppCode: app,
ChallengeId: newChallengeID(app),
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},
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()
_ = 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")
}
challenge.Status = StatusFinished
challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, 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()
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()
if err := s.publishChallengeEvent(ctx, eventExpired, cloned); err != nil {
return nil, 0, err
}
return cloned, nowMS, 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()),
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.GetGiftPriceCoin() > 0 {
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 == "" {
if iconURL := roomRPSGiftIconURL(item); iconURL != "" {
// Flutter 的 PK 面板只消费 gift_icon_url这里把礼物资源主图折叠成该字段保持接口契约稳定。
gift.GiftIconUrl = iconURL
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 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(),
gift.GetResource().GetAnimationUrl(),
}
for _, candidate := range candidates {
if value := strings.TrimSpace(candidate); value != "" {
return value
}
}
return ""
}
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[:]))
}