2026-06-11 19:31:39 +08:00

746 lines
26 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"
"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)
}
// 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
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) *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
}
return &Service{
config: config,
user: user,
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()
defer s.mu.Unlock()
config := s.configForAppLocked(app)
return cloneConfig(config), 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(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())
if req.GetUserId() <= 0 || roomID == "" || req.GetGiftId() <= 0 || !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, req.GetGiftId())
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: gift.GetGiftId(),
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(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[int64]struct{}, 4)
seenSort := make(map[int32]struct{}, 4)
for index, gift := range config.GetStakeGifts() {
if gift.GetGiftId() <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "room rps gift_id is invalid")
}
if _, ok := seenGift[gift.GetGiftId()]; 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[gift.GetGiftId()] = struct{}{}
seenSort[sortOrder] = struct{}{}
normalized := &gamev1.RoomRPSStakeGift{
GiftId: gift.GetGiftId(),
GiftName: strings.TrimSpace(gift.GetGiftName()),
GiftIconUrl: strings.TrimSpace(gift.GetGiftIconUrl()),
GiftPriceCoin: gift.GetGiftPriceCoin(),
Enabled: gift.GetEnabled(),
SortOrder: sortOrder,
}
if fallback := defaultsByID[gift.GetGiftId()]; 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(sortOrder) * 100
}
gifts = append(gifts, normalized)
}
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) 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": strconv.FormatInt(challenge.GetStakeGiftId(), 10),
"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, GiftName: "RPS 100", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-100.png", GiftPriceCoin: 100, Enabled: true, SortOrder: 1},
{GiftId: 10002, GiftName: "RPS 300", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-300.png", GiftPriceCoin: 300, Enabled: true, SortOrder: 2},
{GiftId: 10003, GiftName: "RPS 500", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-500.png", GiftPriceCoin: 500, Enabled: true, SortOrder: 3},
{GiftId: 10004, GiftName: "RPS 1000", GiftIconUrl: "https://cdn.example.com/gifts/room-rps-1000.png", GiftPriceCoin: 1000, Enabled: true, SortOrder: 4},
}
}
func defaultGiftMap() map[int64]*gamev1.RoomRPSStakeGift {
out := make(map[int64]*gamev1.RoomRPSStakeGift, 4)
for _, gift := range defaultGifts() {
out[gift.GetGiftId()] = gift
}
return out
}
func stakeGiftByID(config *gamev1.RoomRPSConfig, giftID int64) (*gamev1.RoomRPSStakeGift, bool) {
for _, gift := range config.GetStakeGifts() {
if gift.GetGiftId() == giftID {
return gift, true
}
}
return nil, false
}
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[:]))
}