hyapp-server/docs/猜拳对战游戏方案.md
2026-06-09 11:04:31 +08:00

11 KiB
Raw Blame History

猜拳对战游戏方案

本文定义 game-service 内新增猜拳对战模块的产品规则、服务边界、数据模型、接口和 IM 事件。当前方案基于仓库现状App HTTP 入口在 gateway-service,游戏事实 owner 在 game-service,客户端长连接和实时投递使用腾讯云 IM服务端私有/群消息投递能力复用 notice-servicepkg/tencentim

结论

  • 猜拳对战状态归 game-service 持有,不放到 room-service,也不让 gateway 承载业务状态。
  • 客户端出拳必须走 HTTP 到 gateway再转 game-service gRPC。IM 只做服务端事件下发,客户端不能通过 IM 直接提交手势。
  • 1v1 对战使用腾讯云 IM C2C 自定义消息投递给双方;不为每局创建 IM 群,避免大量临时群创建和清理成本。
  • 服务端所有时间使用 UTC epoch millisecondsIM payload 带 server_time_msdeadline_msmatch_version,客户端按服务端时间展示倒计时。
  • 首版按免费对战设计,field.stake_coin 预留为 0。若产品后续要求金币场再用现有 wallet ApplyGameCoinChange 结算投注和派奖。

游戏规则

大厅和场次

大厅展示三个场:

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。

对战流程

  1. 用户进入大厅,选择新手场、中级场或高级场。
  2. 点击进入匹配,服务端把用户放入对应 field_code 的等待队列。
  3. 找到同场等待用户后创建 match,状态为 matched,设置 start_at_ms = now + 3000
  4. 开局后进入第 1 小局,双方在 choice_deadline_ms 前提交 rock/paper/scissor
  5. 双方都提交后,服务端设置 reveal_at_ms = now + 3000,通过 IM 通知客户端展示 3,2,1
  6. reveal_at_ms 后,服务端公开双方手势,判定本小局胜负。
  7. 先拿到 2 个胜场的用户获胜。
  8. 平局不计胜场,继续下一小局;为防止无限平局,最多 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.goMySQL 持久化和行锁。
  • services/game-service/internal/transport/grpc:新增猜拳 RPC 适配。

职责:

  • 持有匹配队列、对战、轮次、出拳、结算事实。
  • 写入实时事件 outbox。
  • 提供查询接口,供客户端丢 IM 或重连后恢复状态。

gateway-service

职责:

  • 提供 /api/v1/games/rps... HTTP 接口。
  • 从 JWT 上下文拿 user_id,不信任客户端传入的玩家 ID。
  • 转换 HTTP 参数到 game-service gRPC。

notice-service / Tencent IM

职责:

  • 消费 game-service 的猜拳实时事件 outbox。
  • 使用 pkg/tencentim.PublishUserCustomMessage 给双方发 C2C 自定义消息。
  • 记录投递状态和重试,客户端按 event_id 去重。

数据模型

game_rps_fields

场次配置。

关键字段:

  • app_code
  • field_code
  • field_name
  • status
  • sort_order
  • stake_coin
  • min_balance
  • choice_timeout_ms
  • match_start_countdown_ms
  • reveal_countdown_ms
  • created_at_ms
  • updated_at_ms

主键:(app_code, field_code)

game_rps_match_queue

匹配等待队列。

关键字段:

  • app_code
  • queue_id
  • field_code
  • user_id
  • status: waiting/matched/cancelled/expired
  • match_id
  • created_at_ms
  • updated_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_code
  • match_id
  • field_code
  • status: matched/countdown/playing/finished/cancelled
  • player1_user_id
  • player2_user_id
  • winner_user_id
  • player1_score
  • player2_score
  • round_no
  • match_version
  • start_at_ms
  • finished_at_ms
  • created_at_ms
  • updated_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_code
  • match_id
  • round_no
  • status: picking/reveal_countdown/revealed
  • choice_deadline_ms
  • reveal_at_ms
  • player1_choice
  • player2_choice
  • winner_user_id
  • result: player1_win/player2_win/draw
  • created_at_ms
  • updated_at_ms

主键:(app_code, match_id, round_no)

game_rps_realtime_outbox

实时 IM 事件 outbox。不要复用现有 game_outbox,因为现有 gamemq.GameOutboxMessage 是订单/统计事件,要求 order_idop_typecoin_amount > 0,不适合匹配、倒计时和揭晓这种非订单事件。

关键字段:

  • app_code
  • event_id
  • event_type
  • match_id
  • target_user_id
  • payload_json
  • status: pending/running/delivered/retryable/failed
  • worker_id
  • lock_until_ms
  • retry_count
  • next_retry_at_ms
  • last_error
  • created_at_ms
  • updated_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.protoGameAppService 增加:

  • ListRPSFields
  • JoinRPSMatch
  • CancelRPSMatch
  • GetRPSMatch
  • SubmitRPSChoice

核心消息:

  • RPSField
  • RPSMatch
  • RPSPlayer
  • RPSRound
  • RPSChoice

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/matched
  • queue_id
  • match_id
  • start_at_ms
  • server_time_ms

相关 IM

  • rps_match_found
  • rps_match_start_countdown

3. 取消匹配

地址:DELETE /api/v1/games/rps/match

参数:

返回值:

  • cancelled: bool
  • server_time_ms

相关 IM

  • rps_match_cancelled

4. 查询当前对战

地址:GET /api/v1/games/rps/matches/{match_id}

参数:

  • match_id

返回值:

  • match
  • current_round
  • server_time_ms

相关 IM

  • 无。这个接口用于补偿 IM 丢失、乱序和客户端重连。

5. 提交手势

地址:POST /api/v1/games/rps/matches/{match_id}/choices

参数:

  • round_no
  • choice: rock/paper/scissor

返回值:

  • accepted: bool
  • round_status
  • choice_deadline_ms
  • reveal_at_ms
  • server_time_ms

相关 IM

  • rps_choice_locked
  • rps_reveal_countdown
  • rps_round_revealed
  • rps_match_finished

IM 事件

所有 IM payload 必须包含:

  • event_id
  • event_type
  • app_code
  • match_id
  • field_code
  • target_user_id
  • match_version
  • server_time_ms

rps_match_found

用途:双方匹配成功。

关键字段:

  • players
  • start_at_ms

rps_round_start

用途:进入新小局,可以开始选择。

关键字段:

  • round_no
  • choice_deadline_ms
  • my_score
  • opponent_score

rps_choice_locked

用途:告知某个玩家已锁定手势。不给对方展示具体手势。

关键字段:

  • round_no
  • actor_user_id
  • both_ready

rps_reveal_countdown

用途:双方都出完后展示 3、2、1。

关键字段:

  • round_no
  • reveal_at_ms

rps_round_revealed

用途:公开双方手势和小局结果。

关键字段:

  • round_no
  • player1_choice
  • player2_choice
  • result
  • winner_user_id
  • player1_score
  • player2_score

rps_match_finished

用途:整场结束。

关键字段:

  • winner_user_id
  • result: player1_win/player2_win/draw
  • rounds
  • finished_at_ms

状态推进

JoinRPSMatch

事务内处理:

  1. 检查用户是否已有 waiting/matched/playing 对局。
  2. 锁定同 field_code 最早等待用户:FOR UPDATE SKIP LOCKED
  3. 找到对手则创建 game_rps_matches 和第 1 小局。
  4. 两个玩家各写一条 rps_match_found outbox。
  5. 找不到对手则插入 game_rps_match_queue

SubmitRPSChoice

事务内处理:

  1. 校验用户属于 match。
  2. 校验 match 未结束、round 正在 picking
  3. 校验未超过 choice_deadline_ms
  4. 写入对应玩家 choice重复提交返回当前状态不覆盖已选手势。
  5. 如果双方都已选择,设置 reveal_at_ms = now + 3000,写 rps_reveal_countdown outbox。

Timeout Worker

game-service 增加后台 worker

  • 扫描 pickingchoice_deadline_ms <= now 的 round。
  • 按超时规则补结算。
  • 扫描 reveal_countdownreveal_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_idmatch_idmatch_version、倒计时字段。

开发顺序

  1. 增加 proto 和 gateway HTTP 接口。
  2. 增加 game_rps_* 表和 runtime Migrate
  3. 增加 domain 胜负规则和 service 匹配/出拳/结算逻辑。
  4. 增加 realtime outbox 和 timeout worker。
  5. notice-service 增加 game realtime outbox consumer复用 pkg/tencentim C2C 自定义消息。
  6. 增加单元测试、存储测试、gateway handler 测试。
  7. 运行 make proto、目标包 go testdocker compose config