# 猜拳对战游戏方案

本文定义 `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`。
