11 KiB
猜拳对战游戏方案
本文定义 game-service 内新增猜拳对战模块的产品规则、服务边界、数据模型、接口和 IM 事件。当前方案基于仓库现状:App HTTP 入口在 gateway-service,游戏事实 owner 在 game-service,客户端长连接和实时投递使用腾讯云 IM,服务端私有/群消息投递能力复用 notice-service 和 pkg/tencentim。
结论
- 猜拳对战状态归
game-service持有,不放到room-service,也不让 gateway 承载业务状态。 - 客户端出拳必须走 HTTP 到 gateway,再转
game-servicegRPC。IM 只做服务端事件下发,客户端不能通过 IM 直接提交手势。 - 1v1 对战使用腾讯云 IM C2C 自定义消息投递给双方;不为每局创建 IM 群,避免大量临时群创建和清理成本。
- 服务端所有时间使用 UTC epoch milliseconds,IM payload 带
server_time_ms、deadline_ms和match_version,客户端按服务端时间展示倒计时。 - 首版按免费对战设计,
field.stake_coin预留为 0。若产品后续要求金币场,再用现有 walletApplyGameCoinChange结算投注和派奖。
游戏规则
大厅和场次
大厅展示三个场:
| field_code | 名称 | 匹配池 | stake_coin |
|---|---|---|---|
| rookie | 新手场 | 新手独立池 | 0 |
| intermediate | 中级场 | 中级独立池 | 0 |
| advanced | 高级场 | 高级独立池 | 0 |
字段预留:
min_balance:付费场入场余额要求。stake_coin:每局投注额。首版为 0。choice_timeout_ms:每小局选手势时间,建议 8000。match_start_countdown_ms:匹配成功后开局倒计时,固定 3000。reveal_countdown_ms:双方都选好后展示 3、2、1,固定 3000。
对战流程
- 用户进入大厅,选择新手场、中级场或高级场。
- 点击进入匹配,服务端把用户放入对应
field_code的等待队列。 - 找到同场等待用户后创建
match,状态为matched,设置start_at_ms = now + 3000。 - 开局后进入第 1 小局,双方在
choice_deadline_ms前提交rock/paper/scissor。 - 双方都提交后,服务端设置
reveal_at_ms = now + 3000,通过 IM 通知客户端展示3,2,1。 - 到
reveal_at_ms后,服务端公开双方手势,判定本小局胜负。 - 先拿到 2 个胜场的用户获胜。
- 平局不计胜场,继续下一小局;为防止无限平局,最多 5 小局,5 小局后仍未分出 2 胜则整场平局。
超时规则
- 一方未在
choice_deadline_ms前出拳,另一方已出拳:未出拳方本小局失败。 - 双方都未出拳:本小局平局。
- 用户断线不直接判输,以提交超时为准;客户端重连后调用查询接口恢复当前状态。
服务端边界
game-service
新增模块建议:
services/game-service/internal/domain/rps:状态、规则、胜负计算。services/game-service/internal/service/rps:匹配、出拳、结算、超时推进。services/game-service/internal/storage/mysql/rps_repository.go:MySQL 持久化和行锁。services/game-service/internal/transport/grpc:新增猜拳 RPC 适配。
职责:
- 持有匹配队列、对战、轮次、出拳、结算事实。
- 写入实时事件 outbox。
- 提供查询接口,供客户端丢 IM 或重连后恢复状态。
gateway-service
职责:
- 提供
/api/v1/games/rps...HTTP 接口。 - 从 JWT 上下文拿
user_id,不信任客户端传入的玩家 ID。 - 转换 HTTP 参数到
game-servicegRPC。
notice-service / Tencent IM
职责:
- 消费 game-service 的猜拳实时事件 outbox。
- 使用
pkg/tencentim.PublishUserCustomMessage给双方发 C2C 自定义消息。 - 记录投递状态和重试,客户端按
event_id去重。
数据模型
game_rps_fields
场次配置。
关键字段:
app_codefield_codefield_namestatussort_orderstake_coinmin_balancechoice_timeout_msmatch_start_countdown_msreveal_countdown_mscreated_at_msupdated_at_ms
主键:(app_code, field_code)
game_rps_match_queue
匹配等待队列。
关键字段:
app_codequeue_idfield_codeuser_idstatus:waiting/matched/cancelled/expiredmatch_idcreated_at_msupdated_at_ms
索引:
idx_rps_queue_match(app_code, field_code, status, created_at_ms, queue_id)idx_rps_queue_user(app_code, user_id, status, created_at_ms)
game_rps_matches
对战主表。
关键字段:
app_codematch_idfield_codestatus:matched/countdown/playing/finished/cancelledplayer1_user_idplayer2_user_idwinner_user_idplayer1_scoreplayer2_scoreround_nomatch_versionstart_at_msfinished_at_mscreated_at_msupdated_at_ms
索引:
idx_rps_match_player(app_code, player1_user_id, status, updated_at_ms)idx_rps_match_player2(app_code, player2_user_id, status, updated_at_ms)idx_rps_match_timeout(app_code, status, updated_at_ms)
game_rps_rounds
小局表。
关键字段:
app_codematch_idround_nostatus:picking/reveal_countdown/revealedchoice_deadline_msreveal_at_msplayer1_choiceplayer2_choicewinner_user_idresult:player1_win/player2_win/drawcreated_at_msupdated_at_ms
主键:(app_code, match_id, round_no)
game_rps_realtime_outbox
实时 IM 事件 outbox。不要复用现有 game_outbox,因为现有 gamemq.GameOutboxMessage 是订单/统计事件,要求 order_id、op_type、coin_amount > 0,不适合匹配、倒计时和揭晓这种非订单事件。
关键字段:
app_codeevent_idevent_typematch_idtarget_user_idpayload_jsonstatus:pending/running/delivered/retryable/failedworker_idlock_until_msretry_countnext_retry_at_mslast_errorcreated_at_msupdated_at_ms
索引:
idx_rps_realtime_claim(app_code, status, next_retry_at_ms, created_at_ms, event_id)idx_rps_realtime_target(app_code, target_user_id, created_at_ms)
内部 gRPC
在 api/proto/game/v1/game.proto 的 GameAppService 增加:
ListRPSFieldsJoinRPSMatchCancelRPSMatchGetRPSMatchSubmitRPSChoice
核心消息:
RPSFieldRPSMatchRPSPlayerRPSRoundRPSChoice
RequestMeta.request_id 继续只做链路追踪,不做幂等键。出拳幂等靠 (app_code, match_id, round_no, user_id) 唯一事实保证。
HTTP 接口
1. 大厅场次
地址:GET /api/v1/games/rps/fields
参数:
- 无
返回值:
fields: 场次列表server_time_ms
相关 IM:
- 无
2. 进入匹配
地址:POST /api/v1/games/rps/fields/{field_code}/match
参数:
field_code:rookie/intermediate/advanced
返回值:
status:waiting/matchedqueue_idmatch_idstart_at_msserver_time_ms
相关 IM:
rps_match_foundrps_match_start_countdown
3. 取消匹配
地址:DELETE /api/v1/games/rps/match
参数:
- 无
返回值:
cancelled: boolserver_time_ms
相关 IM:
rps_match_cancelled
4. 查询当前对战
地址:GET /api/v1/games/rps/matches/{match_id}
参数:
match_id
返回值:
matchcurrent_roundserver_time_ms
相关 IM:
- 无。这个接口用于补偿 IM 丢失、乱序和客户端重连。
5. 提交手势
地址:POST /api/v1/games/rps/matches/{match_id}/choices
参数:
round_nochoice:rock/paper/scissor
返回值:
accepted: boolround_statuschoice_deadline_msreveal_at_msserver_time_ms
相关 IM:
rps_choice_lockedrps_reveal_countdownrps_round_revealedrps_match_finished
IM 事件
所有 IM payload 必须包含:
event_idevent_typeapp_codematch_idfield_codetarget_user_idmatch_versionserver_time_ms
rps_match_found
用途:双方匹配成功。
关键字段:
playersstart_at_ms
rps_round_start
用途:进入新小局,可以开始选择。
关键字段:
round_nochoice_deadline_msmy_scoreopponent_score
rps_choice_locked
用途:告知某个玩家已锁定手势。不给对方展示具体手势。
关键字段:
round_noactor_user_idboth_ready
rps_reveal_countdown
用途:双方都出完后展示 3、2、1。
关键字段:
round_noreveal_at_ms
rps_round_revealed
用途:公开双方手势和小局结果。
关键字段:
round_noplayer1_choiceplayer2_choiceresultwinner_user_idplayer1_scoreplayer2_score
rps_match_finished
用途:整场结束。
关键字段:
winner_user_idresult:player1_win/player2_win/drawroundsfinished_at_ms
状态推进
JoinRPSMatch
事务内处理:
- 检查用户是否已有
waiting/matched/playing对局。 - 锁定同
field_code最早等待用户:FOR UPDATE SKIP LOCKED。 - 找到对手则创建
game_rps_matches和第 1 小局。 - 两个玩家各写一条
rps_match_foundoutbox。 - 找不到对手则插入
game_rps_match_queue。
SubmitRPSChoice
事务内处理:
- 校验用户属于 match。
- 校验 match 未结束、round 正在
picking。 - 校验未超过
choice_deadline_ms。 - 写入对应玩家 choice;重复提交返回当前状态,不覆盖已选手势。
- 如果双方都已选择,设置
reveal_at_ms = now + 3000,写rps_reveal_countdownoutbox。
Timeout Worker
game-service 增加后台 worker:
- 扫描
picking且choice_deadline_ms <= now的 round。 - 按超时规则补结算。
- 扫描
reveal_countdown且reveal_at_ms <= now的 round。 - 公开本局结果,推进下一局或结束整场。
- 每次状态变化都写 outbox。
幂等和乱序
- 客户端按
event_id去重。 - 客户端按
match_version丢弃旧消息。 - 出拳天然幂等:同一用户同一局只能有一个 choice。
- IM 延迟或丢失时,客户端调用
GET /api/v1/games/rps/matches/{match_id}拉取权威状态。
测试方案
单元测试
- 胜负规则:石头剪刀布、平局、超时。
- 先两胜:2:0、2:1、含平局、最多 5 小局平局。
- 重复出拳不能覆盖已锁定手势。
- 双方都出拳后进入 reveal countdown。
存储测试
- 并发 Join 同一场,只生成一场 match。
- 同一用户不能同时进入多个 waiting/playing。
- SubmitChoice 并发只保留第一条。
- Timeout worker 能推进超时小局。
集成测试
- gateway HTTP -> game-service gRPC -> MySQL。
- game-service outbox -> notice-service fake publisher。
- IM payload 包含
event_id、match_id、match_version、倒计时字段。
开发顺序
- 增加 proto 和 gateway HTTP 接口。
- 增加
game_rps_*表和 runtimeMigrate。 - 增加 domain 胜负规则和 service 匹配/出拳/结算逻辑。
- 增加 realtime outbox 和 timeout worker。
- notice-service 增加 game realtime outbox consumer,复用
pkg/tencentimC2C 自定义消息。 - 增加单元测试、存储测试、gateway handler 测试。
- 运行
make proto、目标包go test、docker compose config。