467 lines
11 KiB
Markdown
467 lines
11 KiB
Markdown
# 猜拳对战游戏方案
|
||
|
||
本文定义 `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`。
|