# 猜拳对战游戏方案 本文定义 `game-service` 内新增猜拳对战模块的产品规则、服务边界、数据模型、接口和 IM 事件。当前方案基于仓库现状:App HTTP 入口在 `gateway-service`,游戏事实 owner 在 `game-service`,客户端长连接和实时投递使用腾讯云 IM,服务端私有/群消息投递能力复用 `notice-service` 和 `pkg/tencentim`。 ## 结论 - 猜拳对战状态归 `game-service` 持有,不放到 `room-service`,也不让 gateway 承载业务状态。 - 客户端出拳必须走 HTTP 到 gateway,再转 `game-service` gRPC。IM 只做服务端事件下发,客户端不能通过 IM 直接提交手势。 - 1v1 对战使用腾讯云 IM C2C 自定义消息投递给双方;不为每局创建 IM 群,避免大量临时群创建和清理成本。 - 服务端所有时间使用 UTC epoch milliseconds,IM payload 带 `server_time_ms`、`deadline_ms` 和 `match_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.go`:MySQL 持久化和行锁。 - `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_id`、`op_type`、`coin_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.proto` 的 `GameAppService` 增加: - `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: - 扫描 `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`、倒计时字段。 ## 开发顺序 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 test`、`docker compose config`。