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

467 lines
11 KiB
Markdown
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.

# 猜拳对战游戏方案
本文定义 `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 millisecondsIM 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`