二七王:前端子项目 B(网络与数据镜像)设计定案

架构:点击 → 语义化发包 → RpcHelper;推送 → 唯一分发入口 → 一 rpc 一 handler
→ 写 GameState → emit 语义事件。handler 不引用任何 UI 组件,故 B 可脱离 C 独立完成与测试。

GameState 只镜像不派生、字段名对齐协议、缺字段不兜底。事件只带「发生了什么」,
数据由订阅者从 GameState 读,避免同一份数据两处存。

两条核心验收把红线变成断言:增量累积与 deskinfo 全量重建必须完全相等
(即「视图 = f(服务端快照)」);发包字段集合不含任何结论字段。
测试数据用服务端 _rpc.js 脚手架捕获的真实下发包,不手写假包。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 17:02:40 +08:00
co-authored by Claude Opus 5
parent 8dba099717
commit f63de40229
@@ -0,0 +1,361 @@
# 二七王前端 · 子项目 B:网络与数据镜像 · 设计
> 日期:2026-08-27 状态:待实施
> 权威源:协议 `server/games/erqiwang/docs/protocol/packet_protocol.md`;玩法 `server/games/erqiwang/docs/design/design.md`;
> 前端规范 `docs/client/development-guide/`(尤其 04 网络对接、05 §6 数据驱动);界面规格 `docs_dev/二七王-UI资源与精灵清单.md`。
> 前置:子项目 A(地基与逻辑层)已合入 master。
---
## 1. 背景与定位
子项目 A 交付了常量层、纯逻辑核心(`CardCodec`/`CardOrder`/`CardMark`/`SeatMap`/`SpriteIndex`)、布局求解器、前后端共享算法与单测框架。**B 负责把前端接上服务端**:发包、收包分发、把服务端快照镜像成前端状态、重连重画。
**B 不含任何 UI 组件与渲染**——那是 C–E。B 的全部产物都能脱离界面用单测覆盖,这既是范围约束,也是可测性的保证。
### 1.1 B 在六阶段中的位置
| # | 子项目 | 状态 |
|---|---|---|
| A | 地基与逻辑层 | ✅ 已完成(52 commit,合入 master) |
| **B** | **网络与数据镜像(本文)** | 本次 |
| C | 牌桌主界面 | 待做 |
| D | 阶段操作界面 | 待做 |
| E | 弹窗与结算 | 待做 |
| F | 建房与接入收尾 | 待做 |
---
## 2. 架构
```
点击(C–E 阶段的组件)
└─ Rpc.sendXxx(意图) 语义化发包,只带「做什么 + 目标标识」
└─ RpcHelper.sendGameRpc 框架基座,自动注入平台字段
└─ 引擎发送
服务端推送
└─ Game_Modify._ReceiveData 平台入口(转发壳,不写业务)
└─ SubGameHooks._ReceiveData
└─ Dispatcher.dispatch(msg) 唯一收包入口,纯路由表
└─ handlers.handleXxx(data) 一 rpc 一处理器
├─ ① 判 data.success
├─ ② 把包内权威字段写进 GameState
└─ ③ emit 语义事件
└─ (C–E)组件订阅事件 → 从 GameState 读 → set/refresh
```
**关键约束**:`handlers` **不引用任何 UI 组件**。这是 B 能脱离 C 独立完成与测试的前提,也避免了「网络层反向依赖表现层」。
### 2.1 目录
```
client/js/01_SubGame/codes/
net/
Rpc.js 语义化发包(一操作一方法)
Dispatcher.js 唯一收包入口 + 纯路由表
handlers/
DealHandler.js fapai
CallHandler.js jiaofen / shangzhuang
MainHandler.js xuanzhu
BuryHandler.js maipai
PlayHandler.js chupai1 / chupai2 / chupai3
QueryHandler.js mingpai / tishi
ResultHandler.js jiesuan / free_room(解散结算)
ReadyHandler.js zhunbei
ResyncHandler.js deskinfo(重连)+ StartWar(开局)
FailHandler.js 失败回包(与请求同名的 rpc)
state/
GameState.js 服务端快照的镜像,前端 SSOT
Events.js 事件常量(追加到 EventBus.Events)
```
单向依赖:`handlers` → `GameState` → `Events`;`Rpc` 独立于三者,只依赖 `GameState.room.mySeat`。
---
## 3. `GameState`:服务端快照的镜像
### 3.1 设计原则
- **只镜像、不派生**:`GameState` 只存服务端下发过的字段,**不存任何前端算出来的结论**。需要派生的(手牌排序、标记、花色统计)由 A 阶段的 `core/` 纯函数在渲染时现算,不落地成状态。
- **字段名对齐协议**:便于逐条核对「前端状态 == 服务端快照」。协议里叫 `currcall` 就叫 `currcall`,不改名成 `currentCall`。
- **缺字段不兜底**:包里没有的字段保持原值,**不填默认值掩盖**。必须存在却缺失的(如 `chupai1` 缺 `seat`)显式 `console.error` 并跳过该包——这是服务端漏发,修在服务端(前端 05 §6.1)。
### 3.2 结构
```js
EQW_GameState = {
// —— 房间级(跨小局)——
room: {
asetCount: 0, // 总局数 ← fapai.asetcount / deskinfo.count
asetIdx: 0, // 当前第几局 ← fapai.asetidx / deskinfo.idx
mySeat: -1, // 自己的座位 ← 平台 C_Player.seat
playerScores: [], // 三家总积分 ← deskinfo.PlayerInfo
options: null // roomtype 解析结果(局数/扣卡/傍王/爬坡/查牌)
},
// —— 小局级(一副牌)——
aset: {
step: 0, // 1叫分 2选主/投降 3埋牌 5出牌 6结算
banker: -1,
call: -1, // 庄家叫分
multiple: 0, // 基础子数
flower: 0, // 主牌花色(0 = 未选主)
curmultiple: 0, // 当前抓分倍数(带符号,服务端权威,前端不自算)
grade: 0, // 闲家已捡分
baozhu: 0, // 是否已有人报无主
touxiang: 0 // 是否允许投降(仅 70 分坐庄为 1)
},
// —— 当前控制权与倒计时(任何阶段都读这里)——
turn: {
seat: -1, // 该谁操作 ← 各包的 seat / nextseat
countdown: 0 // 展示用秒数 ← 各包的 countdown
},
// —— 叫分过程 ——
call: {
currcall: 0, // 当前叫到的分
calls: [null, null, null] // 三家叫分:null 未叫 / 0 不叫 / >0 叫了多少
},
// —— 自己 ——
my: {
cards: [], // 手牌
mustCard: [], // 本轮必出牌(服务端建议,只发给轮到的那家)
bottomCards: [], // 底牌(庄家恒有;闲家仅 70 分坐庄时有)
buryCards: [] // 埋牌底牌(仅庄家)
},
// —— 桌面 ——
table: {
ancard3s: 0, // 开底标志(70 分坐庄)
playproc: null, // 当前轮出牌情况
pushlist: [], // 出牌历史(仅可查牌模式)
seatlist: [], // 三家牌况(仅可查牌模式)
liangpai: null, // 亮牌(仅闲家 + 可查牌 + 庄家达标)
mingpai: null // 明牌查询结果(仅请求者)
},
// —— 结算 ——
result: {
chupai: null, // 最后一手
bottom: null, // 抠底
aset: null, // 小局结算
account: null // 大局结算(末局或解散才有)
}
}
```
### 3.3 读写接口
- `GameState.reset()`——清空到初始值(新一局发牌时调)。`room.mySeat` 与 `room.options` **不清**(跨局不变)。
- `GameState.applyXxx(data)`——每类包一个写入方法,只写包里**存在**的字段(`hasOwnProperty` 判定)。
- `GameState.snapshot()`——返回深拷贝,供测试做「增量 vs 全量」比对。
**没有 getter 语义方法**:组件直接读 `GameState.aset.banker` 这样的路径。多加一层读接口只是转发,不产生价值(YAGNI)。
---
## 4. 收包分发
### 4.1 分发表
`Dispatcher` 是**纯路由表 + 分发器**,不写业务:
```js
var _handlers = {
'fapai': function (d) { DealHandler.handle(d); },
'jiaofen': function (d) { CallHandler.handleJiaofen(d); },
'shangzhuang': function (d) { CallHandler.handleShangzhuang(d); },
'xuanzhu': function (d) { MainHandler.handleXuanzhu(d); },
'maipai': function (d) { BuryHandler.handle(d); },
'chupai1': function (d) { PlayHandler.handle(d, 1); },
'chupai2': function (d) { PlayHandler.handle(d, 2); },
'chupai3': function (d) { PlayHandler.handle(d, 3); },
'mingpai': function (d) { QueryHandler.handleMingpai(d); },
'tishi': function (d) { QueryHandler.handleTishi(d); },
'jiesuan': function (d) { ResultHandler.handleJiesuan(d); },
'zhunbei': function (d) { ReadyHandler.handle(d); }
};
```
未知 rpc 只 `console.warn`、不抛错——平台日后新增推送不该让前端崩。
### 4.2 三个容易写错的地方
**① 失败回包与成功推送同名。** 协议 §0.2:任一校验不通过,服务端回一个 **rpc 与请求同名**的失败包(`chupai` 失败回 `chupai`,而不是 `chupai1/2/3`),只含 `success:false` + `errcode`,且只回给请求者。
于是 `mingpai` / `tishi` / `jiaofen` 这些 rpc 既可能是成功推送、也可能是失败回包;而 `chupai` / `maipai` / `xuanzhu` / `touxiang` **只**会以失败回包的形式出现(成功走 `chupai1/2/3`、`maipai`、`xuanzhu` 推送)。
**判定顺序**:`dispatch` 先看 `data.success`——为 `false` 一律交给 `FailHandler`(emit `RPC_FAILED`,带 rpc 与 errcode),不进业务 handler。这样每个业务 handler 都可以假定自己拿到的是成功包。
**② 解散结算走平台路由。** 协议 §14.1:它不是子游戏发的包——`route` 是平台的 `room`、`rpc` 是 `free_room`,子游戏的结算数据嵌在 `data.deskfree.data` 里(**多一层包装**),外层 `data.success` 由平台填写。且解散若发生在首局发牌前,`get_disbandRoom` 返回 `null`,**整个 `deskfree` 缺失**——前端必须容忍。
这条不走 `_ReceiveData` 的子游戏分发表,需要在平台入口侧单独识别。
**③ `countdown` 只是展示用的秒数。** 协议 §0.3:服务端**没有**对应定时器,归零不会自动叫分/选主/埋牌/出牌,也不判负、不跳过。轮到谁而谁不操作,牌局就停在那一直等。
因此 `GameState.turn.countdown` 只是个数字,**前端绝不能在归零时做任何界面推进**(不跳阶段、不清控制权)。倒计时的插值显示是 D 阶段的事,B 只负责把服务端给的秒数存下来。
---
## 5. 发包
### 5.1 语义化方法
`Rpc.js` 一个操作一个方法,`seat` 从 `GameState.room.mySeat` 自动带上(协议要求包内 `seat` 供服务端做一致性校验,但**身份由连接反查**,前端填的 seat 不作身份依据):
```js
Rpc.jiaofen(call) // call: 5–70,0 = 不叫
Rpc.touxiang() // 仅 70 分坐庄且在选主阶段可用
Rpc.xuanzhu(flower) // flower: 1方块 2梅花 3红心 4黑桃
Rpc.maipai(cards) // 8 张牌 id
Rpc.chupai(cards) // 牌 id 列表
Rpc.mingpai() // 查看他家主牌
Rpc.tishi(tip) // tip: 1踩 2没分 3有分
Rpc.zhunbei() // 准备
```
### 5.2 只带意图,不带结论
这是防作弊的根本(前端 04 §1):**前端能自己推出来的东西,就是玩家能改的东西**。
| 可以带 | 禁止带 |
|---|---|
| 操作类型、牌 id 列表、花色、提示类型、座位号 | 分数、番数、"我胡了"之类判定、结算结果、阶段推进指令、`nextSeat`、`handCards` |
A 阶段的 `shared/` 算法只用于本地提示与预校验,**结果不回传**。
### 5.3 参数校验
发包前做**本地形状校验**(不是规则校验):牌 id 必须是 `0–107` 的整数、互不重复、数组非空。不合形状就 fail-fast 抛错,不发出残缺包——服务端会按 `PARAM` 拒绝,但让错误暴露在最近处更省事。
**规则合法性不在前端判**(能不能出这手牌、叫分够不够低),那是服务端的事。
---
## 6. 事件契约
事件常量追加到 `EventBus.Events`(框架游戏中立,玩法专属事件定义在子游戏侧):
| 事件 | 何时 emit | C–E 谁关心 |
|---|---|---|
| `EQW_RESET` | 新一局发牌 | 全部(清场) |
| `EQW_HAND_CHANGED` | 手牌变化(发牌/摸底/选主重排/埋牌/出牌后) | 手牌区 |
| `EQW_CALL_CHANGED` | 叫分推进 | 叫分面板、玩家位状态 |
| `EQW_BANKER_SET` | 上庄 | 顶部信息条、玩家位庄标、底牌区 |
| `EQW_MAIN_SET` | 选主 | 顶部信息条、手牌区(重排) |
| `EQW_BURY_DONE` | 埋牌完成 | 手牌区、亮牌面板 |
| `EQW_CARD_PLAYED` | 有人出牌 | 出牌区、手牌区、牌况角标 |
| `EQW_TRICK_END` | 一轮结束(`chupai3` 带 `maxseat`) | 出牌区(收牌)、捡分飘字 |
| `EQW_TURN_CHANGED` | 控制权或倒计时变化 | 倒计时、操作条显隐 |
| `EQW_MINGPAI` | 收到明牌数据 | 明牌面板 |
| `EQW_TIP` | 收到对家提示 | 提示气泡 |
| `EQW_ASET_RESULT` | 小局结算 | 小局结算面板 |
| `EQW_ACCOUNT_RESULT` | 大局/解散结算 | 大局结算面板 |
| `EQW_RESYNC_ALL` | 重连全量重画 | 全部 |
| `EQW_RPC_FAILED` | 失败回包 | 提示条、按钮恢复 |
**事件只带「发生了什么」,不带数据**——订阅者从 `GameState` 读。这样避免同一份数据在事件载荷与状态里各存一份(SSOT)。唯一例外是 `EQW_RPC_FAILED`,它带 `{rpc, errcode}`——这是一次性的错误信息,不属于对局状态、不该进 `GameState`。
---
## 7. 重连与开局
### 7.1 两条入口,一条路径
| 平台入口 | 场景 | 数据 |
|---|---|---|
| `SubGameHooks.StartWar(_msg)` | 开局 | `makewar` 差异化下发(`sendtype:1` + `seatlist[]`),先按本座位取自己那份 |
| `SubGameHooks.Reconnect(_deskinfo)` | 断线重连 / 硬刷新 | `get_deskinfo` 全量快照 |
两者都交给 `ResyncHandler`:**填 `GameState` → emit `EQW_RESYNC_ALL`**。差别只在「填多少」,后半段完全相同。
### 7.2 `deskinfo` 的分组
按 `step` 只带对应阶段的分组,`ResyncHandler` 逐组判在则填:
- 恒有:`count` / `idx` / `PlayerInfo` / `step` / `MyCards`
- `CallRun`(step 1):`seat` / `countdown` / `nowcall` / `multiple` / `call[]`
- `ChooseMain`(step 2):`banker` / `call` / `multiple` / `countdown` / `bottomcards`(仅庄家)/ `touxiang`
- `BuryCards`(step 3):同上 + `flower`
- `PushCards`(step 5):`banker` / `call` / `multiple` / `flower` / `countdown` / `burycards`(仅庄家)/ `grade` / `gradecards` / `playproc` / `seatlist` / `liangpai` / `pushlist` / `curmultiple` / `mustcard`
- `Balance`(step 6):`readystate` / `aset`
**重连不重放一次性事件**:协议明确 70 分坐庄的 3 秒开底是上庄时的一次性事件,`deskinfo` 不重放——前端不得在重连时补播。
---
## 8. 错误处理
| 情形 | 处理 |
|---|---|
| `data.success === false` | 交 `FailHandler`,emit `EQW_RPC_FAILED{rpc, errcode}`,不进业务 handler |
| 未知 rpc | `console.warn`,不抛错 |
| 必需字段缺失 | `console.error` 并跳过该包(服务端漏发,修在服务端) |
| 可选字段缺失 | 保持 `GameState` 原值,**不填默认值** |
| 解散包缺 `deskfree` | 容忍(首局发牌前解散的正常情形),只做房间收尾 |
| 发包参数形状非法 | fail-fast 抛错,不发出残缺包 |
**一律不做的**:用 `|| 0` / `|| []` 兜底掩盖缺失、据本地推断补齐服务端未下发的状态、在 `countdown` 归零时推进界面。
---
## 9. 测试
B 没有界面,**全部产物都用 Node 单测覆盖**,沿用 A 阶段的 `client/tests/` 框架。
| 测试 | 覆盖 |
|---|---|
| `test_gamestate.js` | 各 `applyXxx` 的字段写入;`reset` 的清空范围(`mySeat`/`options` 不清);缺字段不兜底 |
| `test_dispatcher.js` | 路由表完整性(协议里每个推送 rpc 都有 handler);`success:false` 优先走 `FailHandler`;未知 rpc 不抛错 |
| `test_handlers.js` | 逐包喂入 → 断言 `GameState` 与 emit 的事件 |
| `test_rpc.js` | **发包只带意图**:桩掉 `RpcHelper`,断言字段集合不含结论字段;参数形状校验的正反例 |
| `test_resync.js` | `deskinfo` 各 `step` 分组的填充;`StartWar` 差异化下发按座位取值 |
| `test_consistency.js` | **增量 vs 全量一致性**(见下) |
### 9.1 增量 vs 全量一致性(核心验收)
把一局的推送包按序喂进去累积出的 `GameState`,与用**同一时刻**的 `deskinfo` 全量重建出的 `GameState`,**必须完全相等**。
这是前端红线「视图 = f(服务端快照)」的可执行形式:
> 判据:任意时刻丢弃 `this.data`、仅凭最近一次服务端快照重画,界面必须完全一致——做不到即"前端私存了状态"或"服务端漏发了字段",都要修。
不一致意味着两件事之一,都要修:
- **前端私存了服务端不知道的状态**(增量路径写了 `deskinfo` 里没有的东西);
- **服务端漏发了字段**(`deskinfo` 缺了增量推送给过的信息)——这时修在服务端。
### 9.2 测试数据用服务端真实下发的包
`server/games/erqiwang/test/_rpc.js` 是现成的 **L3 RPC 脚手架**:它装配 `mod.js`、把 `SendPack`/`sendpack_toother` 全指向同一个 `sent` 数组,**逐个快照真实的下发包**。
所以 B 的测试**不手写假包**——跑一局服务端流程,把 `sent` 里的真实包喂进前端 `GameState`。这样测的是真实契约,而不是我对协议文档的理解。协议文档写错或前端理解偏差,都会在这里暴露。
具体做法:写一个夹具把服务端 `sent` 数组导出成 JSON(按座位区分,因为下发是差异化的),前端测试读它回放。**导出脚本放 `client/tests/`,不污染服务端测试目录。**
---
## 10. 范围边界
**做**:发包封装、收包分发、`GameState`、事件契约、重连/开局路径、上述单测。
**不做**:
- 任何 UI 组件与渲染调用(C–E)
- 倒计时的插值显示(D,B 只存服务端给的秒数)
- 建房界面与 `roomtype` 拼串(F)
- 战绩(F,框架 `RecordView` + 平台入口)
- 平台聊天 / 语音 / 解散投票界面(平台全包,子游戏只处理解散**结算包**)
---
## 11. 遗留与依赖
| 项 | 状态 | 处理 |
|---|---|---|
| 结算得分数字资源第 16 帧后缀 | A 阶段遗留,`+18` 还是 `+18分` 未定 | E 阶段对设计稿定,不影响 B |
| 明牌/历史面板 28 张牌每张仅露 14px | A 阶段遗留,视觉偏窄 | E 阶段复核,不影响 B |
| 出牌操作条提示按钮 55 vs 63 高度差 | A 阶段遗留 | D 阶段对设计稿定,不影响 B |
| `roomtype` 解析 | B 需要它来判「可查牌 / 爬坡 / 傍王」 | B 内实现解析(与服务端 `class.config.js` 的 `parse()` 同规则),F 阶段的建房拼串复用同一份 |