746 lines
26 KiB
Go
746 lines
26 KiB
Go
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,测试可以注入 fake,service 不感知具体 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_id,HTTP 详情仍可由 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[:]))
|
||
}
|