Files
erqiwang_youle/docs/superpowers/plans/2026-08-27-二七王前端B-网络与数据镜像.md
T
joywayerandClaude Opus 5 2df7179266 二七王:前端子项目 B 实施计划
13 个任务,每个自带 TDD 循环与提交:GameState 骨架与事件契约、roomtype 解析、
语义化发包、分发器与失败回包、五组 handler、重连与开局、服务端真包夹具、
增量 vs 全量一致性、平台接线。

自检修掉两处:Task 8 一条拿自己跟自己比的恒真断言;Task 12 引用的
fx.roomtype / snapPoint.packetIndex 在 Task 11 的夹具结构里没定义
——packetIndex 是两条路径的对齐锚点,缺了一致性测试无从比对。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:16:11 +08:00

2637 lines
114 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.
# 二七王前端 · 子项目 B:网络与数据镜像 · 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把二七王前端接上服务端——语义化发包、唯一收包分发、把服务端快照镜像成 `GameState`、重连即重画,全部用单测覆盖。
**Architecture:** 点击 → `Rpc.sendXxx(意图)` → `RpcHelper.sendRpc('youle','erqiwang',…)`;推送 → `SubGameHooks._ReceiveData` → `Dispatcher`(纯路由表)→ 一 rpc 一 handler → 写 `GameState` → emit 语义事件。handler **不引用任何 UI 组件**,故 B 可脱离 C 独立完成与测试。
**Tech Stack:** 纯静态 ES5 JavaScript(浏览器 `<script>` 加载);无构建、无包管理器;测试用 Node 原生 + A 阶段建好的 `client/tests/` 框架(`_load.js` 用 `vm.runInThisContext` 加载全局脚本、`_assert.js` 极简断言、`run.js` spawn 逐个跑)。
**Spec:** `docs/superpowers/specs/2026-08-27-二七王前端B-网络与数据镜像-design.md`
## Global Constraints
- **严格 ES5**:正式代码只用 `var` / `function`,禁 `let`/`const`/箭头/模板串/`class`;`for...in` 配 `hasOwnProperty` 守卫。**git 提交闸已启用**(`.githooks/pre-commit` → `check-redlines.js`),命中即阻断提交,**不许 `--no-verify`**。测试代码可用现代语法。
- **可编辑范围**:只动 `client/js/01_SubGame/codes/`、`client/tests/`、`client/index.html`。**禁改** `js/vendor/`、`js/00_Surface/`、`server/`、`gameabc-framework/`、三个平台契约转发壳(`01_SubGame/00_/01_/02_SubGame_*.js`)。
- **发包必须用 `RpcHelper.sendRpc('youle', 'erqiwang', rpc, data)`**,**不能**用 `sendGameRpc`(它预设 `route="room"`,包会被路由到平台房间模块、永远到不了子游戏 `mod.js`,且不报错)。
- **请求包只带意图**:操作类型 + 目标标识 + 座位号。**禁止**出现 `score`/`isWin`/`phase`/`nextSeat`/`handCards` 之类由前端算出的结论字段。
- **成败只认 `data.success`**:不用 `status`/`code`,不写 `status` 兼容兜底。
- **`GameState` 只镜像不派生**:只存服务端下发过的字段,字段名对齐协议;**缺字段不兜底**(不填 `|| 0` / `|| []`),必需字段缺失则 `console.error` 并跳过该包。
- **`countdown` 不做任何界面推进**:服务端没有对应定时器,归零不会自动推进任何东西。B 只负责把秒数存下来。
- **handler 不引用 UI 组件**:这是 B 能独立测试的前提。
- **提交**:中文提交信息、聚焦一件事、结尾保留 `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`;不用 `git add -A`。
---
## 文件结构
| 文件 | 职责 |
|---|---|
| `codes/state/Events.js` | 事件常量,追加到 `EventBus.Events` |
| `codes/state/GameState.js` | 服务端快照的镜像(前端 SSOT)+ `reset`/`snapshot`/各 `applyXxx` |
| `codes/state/RoomOptions.js` | `roomtype` 位串解析(与服务端 `class.config.js` 的 `parse()` 同规则) |
| `codes/net/Rpc.js` | 语义化发包,一操作一方法 |
| `codes/net/Dispatcher.js` | 唯一收包入口 + 纯路由表 |
| `codes/net/handlers/DealHandler.js` | `fapai` |
| `codes/net/handlers/CallHandler.js` | `jiaofen` / `shangzhuang` |
| `codes/net/handlers/MainHandler.js` | `xuanzhu` |
| `codes/net/handlers/BuryHandler.js` | `maipai` |
| `codes/net/handlers/PlayHandler.js` | `chupai1` / `chupai2` / `chupai3` |
| `codes/net/handlers/QueryHandler.js` | `mingpai` / `tishi` |
| `codes/net/handlers/ReadyHandler.js` | `zhunbei` |
| `codes/net/handlers/ResultHandler.js` | `jiesuan` + `Free`(解散) |
| `codes/net/handlers/ResyncHandler.js` | `deskinfo`(重连)+ `StartWar`(开局)+ `setRoomDes` |
| `codes/net/handlers/FailHandler.js` | 失败回包 |
| `codes/SubGameHooks.js` | 改:接上 `_ReceiveData`/`StartWar`/`Reconnect`/`Free`/`setRoomDes` |
| `client/index.html` | 改:加入 `state/`、`net/` 的加载段 |
| `client/tests/test_*.js` | 8 个新测试文件 |
| `client/tests/fixtures/` | 服务端真包夹具(导出脚本 + JSON) |
---
## Task 1: 事件常量与 GameState 骨架
**Files:**
- Create: `client/js/01_SubGame/codes/state/Events.js`
- Create: `client/js/01_SubGame/codes/state/GameState.js`
- Test: `client/tests/test_gamestate.js`
**Interfaces:**
- Produces: 全局 `EQW_Events`(事件名常量,同时追加进 `EventBus.Events`);全局 `EQW_GameState`,含数据分组 `room`/`aset`/`turn`/`call`/`my`/`table`/`result`、`reset()`、`snapshot()`
- [ ] **Step 1: 写失败的测试 `client/tests/test_gamestate.js`**
```js
// GameState 骨架:初始值、reset 的清空范围、snapshot 深拷贝
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/state/GameState.js');
// ---- 事件常量已追加进 EventBus.Events ----
t.eq('事件常量非空', Object.keys(EQW_Events).length > 0, true);
t.eq('RESET 已入 EventBus.Events', EventBus.Events.EQW_RESET, EQW_Events.EQW_RESET);
t.eq('事件名互不重复',
Object.keys(EQW_Events).length,
Object.keys(EQW_Events).map(k => EQW_Events[k]).filter((v, i, a) => a.indexOf(v) === i).length);
// ---- 初始值 ----
t.eq('初始 mySeat', EQW_GameState.room.mySeat, -1);
t.eq('初始 banker', EQW_GameState.aset.banker, -1);
t.eq('初始 flower', EQW_GameState.aset.flower, 0);
t.eq('初始手牌', EQW_GameState.my.cards, []);
t.eq('初始三家叫分', EQW_GameState.call.calls, [null, null, null]);
// ---- snapshot 是深拷贝、且不含方法 ----
EQW_GameState.my.cards = [1, 2, 3];
const snap = EQW_GameState.snapshot();
t.eq('snapshot 取到值', snap.my.cards, [1, 2, 3]);
EQW_GameState.my.cards.push(4);
t.eq('snapshot 是深拷贝(不随后续修改)', snap.my.cards, [1, 2, 3]);
t.eq('snapshot 不含方法', typeof snap.reset, 'undefined');
t.eq('snapshot 含全部分组',
Object.keys(snap).sort(),
['aset', 'call', 'my', 'result', 'room', 'table', 'turn']);
// ---- reset:清对局态,但 mySeat / options 不清(跨局不变)----
EQW_GameState.room.mySeat = 2;
EQW_GameState.room.options = { climb: 1 };
EQW_GameState.aset.banker = 1;
EQW_GameState.aset.flower = 3;
EQW_GameState.table.pushlist = [[1], [2]];
EQW_GameState.result.aset = { grade: 5 };
EQW_GameState.reset();
t.eq('reset 后 mySeat 保留', EQW_GameState.room.mySeat, 2);
t.eq('reset 后 options 保留', EQW_GameState.room.options, { climb: 1 });
t.eq('reset 后 banker 清空', EQW_GameState.aset.banker, -1);
t.eq('reset 后 flower 清空', EQW_GameState.aset.flower, 0);
t.eq('reset 后 pushlist 清空', EQW_GameState.table.pushlist, []);
t.eq('reset 后 result 清空', EQW_GameState.result.aset, null);
t.eq('reset 后手牌清空', EQW_GameState.my.cards, []);
// ---- reset 两次结果一致(幂等)----
const afterFirst = EQW_GameState.snapshot();
EQW_GameState.reset();
t.eq('reset 幂等', EQW_GameState.snapshot(), afterFirst);
process.exit(t.done('gamestate') ? 0 : 1);
```
- [ ] **Step 2: 运行,确认失败**
Run: `node client/tests/test_gamestate.js`
Expected: `EQW_Events is not defined`(或文件不存在的 ENOENT)。
- [ ] **Step 3: 写 `client/js/01_SubGame/codes/state/Events.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_Events: 玩法事件常量 ///////////////////////////
///////////////////////////////////////////////////////////////
// 前端红线:框架保持游戏中立,玩法专属事件由子游戏追加到 EventBus.Events。
//
// 【事件只带「发生了什么」,不带数据】——订阅者从 EQW_GameState 读。
// 这样同一份数据不会在事件载荷与状态里各存一份(SSOT)。
// 唯一例外是 EQW_RPC_FAILED,它带 {rpc, errcode}:那是一次性的错误信息,
// 不属于对局状态、不该进 GameState。
var EQW_Events = EQW_Events || {
EQW_RESET: 'eqw.reset', //新一局发牌,全部清场
EQW_HAND_CHANGED: 'eqw.hand_changed', //自己手牌变化
EQW_CALL_CHANGED: 'eqw.call_changed', //叫分推进
EQW_BANKER_SET: 'eqw.banker_set', //上庄
EQW_MAIN_SET: 'eqw.main_set', //选主
EQW_BURY_DONE: 'eqw.bury_done', //埋牌完成
EQW_CARD_PLAYED: 'eqw.card_played', //有人出牌
EQW_TRICK_END: 'eqw.trick_end', //一轮结束(chupai3 带 maxseat)
EQW_TURN_CHANGED: 'eqw.turn_changed', //控制权或倒计时变化
EQW_MINGPAI: 'eqw.mingpai', //收到明牌数据
EQW_TIP: 'eqw.tip', //收到对家提示
EQW_ASET_RESULT: 'eqw.aset_result', //小局结算
EQW_ACCOUNT_RESULT: 'eqw.account_result', //大局/解散结算
EQW_READY_CHANGED: 'eqw.ready_changed', //有人准备
EQW_RESYNC_ALL: 'eqw.resync_all', //重连/开局全量重画
EQW_RPC_FAILED: 'eqw.rpc_failed' //失败回包 {rpc, errcode}
};
//追加到框架事件总线的事件表(框架不认识玩法事件,由子游戏注册)
if (typeof EventBus !== 'undefined' && EventBus.Events) {
for (var _eqwEvtKey in EQW_Events) {
if (EQW_Events.hasOwnProperty(_eqwEvtKey)) {
EventBus.Events[_eqwEvtKey] = EQW_Events[_eqwEvtKey];
}
}
}
```
- [ ] **Step 4: 写 `client/js/01_SubGame/codes/state/GameState.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_GameState: 服务端快照的镜像(前端 SSOT)////////
///////////////////////////////////////////////////////////////
// 前端红线「视图 = f(服务端快照)」的落点:本对象只存【服务端下发过的字段】,
// 不存任何前端算出来的结论。手牌排序、牌面标记、花色统计等由 core/ 的纯函数
// 在渲染时现算,不落地成状态。
//
// 三条硬约束:
// 1. 只镜像不派生——存进来的必须是服务端给过的
// 2. 字段名对齐协议——协议叫 currcall 就叫 currcall,便于逐条核对
// 3. 缺字段不兜底——包里没有的保持原值,不填默认值掩盖;必需字段缺失
// 则 console.error 并跳过该包(服务端漏发,修在服务端)
var EQW_GameState = EQW_GameState || {
//数据分组名(snapshot / reset 按此遍历;新增分组要同步加进来)
_GROUPS: ['room', 'aset', 'turn', 'call', 'my', 'table', 'result'],
//—— 房间级(跨小局)——
room: {
asetCount: 0, //总局数 ← fapai.asetcount / deskinfo.count / setRoomDes
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 //是否允许投降
},
//—— 当前控制权与倒计时(任何阶段都读这里)——
turn: {
seat: -1, //该谁操作
countdown: 0 //展示用秒数;服务端无对应定时器,归零【不做任何界面推进】
},
//—— 叫分过程 ——
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
},
//新一局开局时清空对局态;room.mySeat 与 room.options 跨局不变,不清
reset: function () {
this.room.asetIdx = 0;
this.room.playerScores = [];
this.aset = { step: 0, banker: -1, call: -1, multiple: 0, flower: 0,
curmultiple: 0, grade: 0, baozhu: 0, touxiang: 0 };
this.turn = { seat: -1, countdown: 0 };
this.call = { currcall: 0, calls: [null, null, null] };
this.my = { cards: [], mustCard: [], bottomCards: [], buryCards: [] };
this.table = { ancard3s: 0, playproc: null, pushlist: [], seatlist: [],
liangpai: null, mingpai: null };
this.result = { chupai: null, bottom: null, aset: null, account: null };
},
//深拷贝快照(只含数据分组,不含方法),供测试做「增量 vs 全量」比对
snapshot: function () {
var out = {};
for (var i = 0; i < this._GROUPS.length; i++) {
var g = this._GROUPS[i];
out[g] = JSON.parse(JSON.stringify(this[g]));
}
return out;
}
};
```
- [ ] **Step 5: 运行,确认通过**
Run: `node client/tests/test_gamestate.js`
Expected: 全 PASS。
- [ ] **Step 6: 提交**
```bash
git add client/js/01_SubGame/codes/state/Events.js client/js/01_SubGame/codes/state/GameState.js client/tests/test_gamestate.js
git commit -F - <<'EOF'
二七王:前端事件常量与 GameState 骨架
GameState 是服务端快照的镜像、前端 SSOT:只镜像不派生、字段名对齐协议、
缺字段不兜底。reset 清对局态但保留 mySeat 与 options(跨局不变)。
事件只带「发生了什么」,数据由订阅者从 GameState 读,避免同一份数据两处存。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 2: roomtype 位串解析
**Files:**
- Create: `client/js/01_SubGame/codes/state/RoomOptions.js`
- Test: `client/tests/test_roomoptions.js`
**Interfaces:**
- Produces: 全局 `EQW_RoomOptions_Parse`,含 `parse(roomtype)` → `{ asetCount, deductAA, bangwang, climb, nocheck }`
> 协议 §0.5:`roomtype` 是定长数字位串(字符串),每一位 `'0'/'1'`。服务端解析入口是 `class.config.js` 的 `parse()`(SSOT),前端这份必须**同规则**。
> 位定义:位0 局数(`'0'`=6局 / `'1'`=12局)、位1 扣卡(`'0'`=房主 / `'1'`=AA)、位2 傍王、位3 爬坡、位4 查牌模式(`'0'`=可查牌 / `'1'`=不查牌)。
> **对缺失、非字符串、过短的 `roomtype` 一律按 `'0'` 处理**(与服务端一致)。
- [ ] **Step 1: 写失败的测试 `client/tests/test_roomoptions.js`**
```js
// roomtype 位串解析(协议 §0.5),必须与服务端 class.config.js 的 parse() 同规则
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/state/RoomOptions.js');
const P = EQW_RoomOptions_Parse.parse;
// ---- 缺省 "00000" = 6局 / 房主扣卡 / 无傍王 / 常规算子 / 可查牌 ----
t.eq('缺省', P('00000'), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
// ---- 协议 §0.5 的示例:10100 = 12局 / 房主扣卡 / 傍王开 / 常规算子 / 可查牌 ----
t.eq('示例 10100', P('10100'), { asetCount: 12, deductAA: 0, bangwang: 1, climb: 0, nocheck: 0 });
// ---- 逐位 ----
t.eq('位0 局数', P('10000').asetCount, 12);
t.eq('位1 AA扣卡', P('01000').deductAA, 1);
t.eq('位2 傍王', P('00100').bangwang, 1);
t.eq('位3 爬坡', P('00010').climb, 1);
t.eq('位4 不查牌', P('00001').nocheck, 1);
t.eq('全开', P('11111'), { asetCount: 12, deductAA: 1, bangwang: 1, climb: 1, nocheck: 1 });
// ---- 反面:缺失/非字符串/过短一律按 '0' ----
t.eq('undefined', P(undefined), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
t.eq('null', P(null), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
t.eq('空串', P(''), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
t.eq('过短只有两位', P('11'), { asetCount: 12, deductAA: 1, bangwang: 0, climb: 0, nocheck: 0 });
t.eq('非字符串(数字)', P(11111), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
t.eq('非字符串(数组)', P([]), { asetCount: 6, deductAA: 0, bangwang: 0, climb: 0, nocheck: 0 });
// ---- 超长不报错,只取前 5 位 ----
t.eq('超长', P('1111199999').bangwang, 1);
process.exit(t.done('roomoptions') ? 0 : 1);
```
- [ ] **Step 2: 运行,确认失败**
Run: `node client/tests/test_roomoptions.js`
Expected: `EQW_RoomOptions_Parse is not defined`。
- [ ] **Step 3: 写实现 `client/js/01_SubGame/codes/state/RoomOptions.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_RoomOptions_Parse: roomtype 位串解析 ///////////
///////////////////////////////////////////////////////////////
// 协议 §0.5:roomtype 是建房时拼好的定长数字位串(字符串),每一位 '0'/'1'。
// 服务端解析入口是 class.config.js 的 parse(),本文件必须与之【同规则】。
//
// 位0 局数 '0'=6局(缺省) '1'=12局
// 位1 扣卡 '0'=房主扣卡 '1'=AA 每人扣卡
// 位2 傍王 '0'=关 '1'=开
// 位3 爬坡 '0'=常规算子 '1'=爬坡
// 位4 查牌 '0'=可查牌 '1'=不查牌
//
// 对【缺失、非字符串、过短】的 roomtype 一律按 '0' 处理(与服务端一致),
// 这是协议明文规定的行为,不是隐式兜底。
var EQW_RoomOptions_Parse = EQW_RoomOptions_Parse || {
//取第 idx 位,越界或非字符串时按 '0'
_bit: function (roomtype, idx) {
if (typeof roomtype !== 'string') { return 0; }
if (idx >= roomtype.length) { return 0; }
return roomtype.charAt(idx) === '1' ? 1 : 0;
},
parse: function (roomtype) {
return {
asetCount: this._bit(roomtype, 0) ? 12 : 6,
deductAA: this._bit(roomtype, 1),
bangwang: this._bit(roomtype, 2),
climb: this._bit(roomtype, 3),
nocheck: this._bit(roomtype, 4)
};
}
};
```
- [ ] **Step 4: 运行,确认通过**
Run: `node client/tests/test_roomoptions.js`
Expected: 全 PASS。
- [ ] **Step 5: 对照服务端确认同规则**
打开 `server/games/erqiwang/class.config.js`,找到 `parse()` 与位下标常量(`IDX_ASET` / `IDX_DEDUCT` / `IDX_BANGWANG` / `IDX_CLIMB` / `IDX_NOCHECK`),**逐位核对**你的实现与它一致。若发现不一致,**以服务端为准**改前端并在报告里说明。
- [ ] **Step 6: 提交**
```bash
git add client/js/01_SubGame/codes/state/RoomOptions.js client/tests/test_roomoptions.js
git commit -F - <<'EOF'
二七王:前端 roomtype 位串解析
与服务端 class.config.js 的 parse() 同规则;缺失/非字符串/过短一律按 '0',
这是协议 §0.5 明文规定的行为。已逐位对照服务端确认。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 3: 语义化发包
**Files:**
- Create: `client/js/01_SubGame/codes/net/Rpc.js`
- Test: `client/tests/test_rpc_send.js`
**Interfaces:**
- Consumes: `RpcHelper.sendRpc(app, route, rpc, gameData, options)`(框架);`EQW_GameState.room.mySeat`
- Produces: 全局 `EQW_Rpc`,含 `jiaofen(call)` / `touxiang()` / `xuanzhu(flower)` / `maipai(cards)` / `chupai(cards)` / `mingpai()` / `tishi(tip)` / `zhunbei()`
> **route 必须是 `erqiwang`**:`RpcHelper.sendGameRpc` 预设 `route="room"`(平台房间模块),用它包会被路由错、到不了子游戏 `mod.js`,且不报错。必须显式 `sendRpc('youle', 'erqiwang', …)`。
> `RpcHelper` 已自动注入 `agentid`/`gameid`/`playerid`/`roomcode`,这里只补 `seat` 与业务字段。
- [ ] **Step 1: 写失败的测试 `client/tests/test_rpc_send.js`**
```js
// 语义化发包:route 正确、只带意图、参数形状校验
const { load, throws } = require('./_load');
const t = require('./_assert')();
load('client/js/01_SubGame/codes/state/GameState.js');
// 桩掉 RpcHelper,捕获发出的包
const sent = [];
global.RpcHelper = {
sendRpc: function (app, route, rpc, data) { sent.push({ app, route, rpc, data }); }
};
load('client/js/01_SubGame/codes/net/Rpc.js');
EQW_GameState.room.mySeat = 2;
// ---- route 必须是 erqiwang,不是 room ----
sent.length = 0;
EQW_Rpc.jiaofen(60);
t.eq('发了一个包', sent.length, 1);
t.eq('app', sent[0].app, 'youle');
t.eq('route 是 erqiwang', sent[0].route, 'erqiwang');
t.eq('rpc', sent[0].rpc, 'jiaofen');
t.eq('业务字段', sent[0].data, { seat: 2, call: 60 });
// ---- seat 自动从 GameState 带上 ----
EQW_GameState.room.mySeat = 0;
sent.length = 0;
EQW_Rpc.zhunbei();
t.eq('seat 自动带上', sent[0].data.seat, 0);
EQW_GameState.room.mySeat = 2;
// ---- 各方法的字段集合 ----
const call = (fn) => { sent.length = 0; fn(); return sent[0]; };
t.eq('不叫', call(() => EQW_Rpc.jiaofen(0)).data, { seat: 2, call: 0 });
t.eq('投降', call(() => EQW_Rpc.touxiang()).data, { seat: 2 });
t.eq('选主', call(() => EQW_Rpc.xuanzhu(3)).data, { seat: 2, flower: 3 });
t.eq('埋牌', call(() => EQW_Rpc.maipai([1,2,3,4,5,6,7,8])).data, { seat: 2, cards: [1,2,3,4,5,6,7,8] });
t.eq('出牌', call(() => EQW_Rpc.chupai([10,11])).data, { seat: 2, cards: [10,11] });
t.eq('明牌', call(() => EQW_Rpc.mingpai()).data, { seat: 2 });
t.eq('提示', call(() => EQW_Rpc.tishi(1)).data, { seat: 2, tip: 1 });
t.eq('准备', call(() => EQW_Rpc.zhunbei()).data, { seat: 2 });
// ---- 【核心】只带意图:所有发包的字段集合不含任何结论字段 ----
const FORBIDDEN = ['score', 'isWin', 'phase', 'nextSeat', 'nextseat', 'handCards',
'grade', 'multiple', 'result', 'banker', 'curmultiple', 'upgrade'];
sent.length = 0;
EQW_Rpc.jiaofen(60); EQW_Rpc.touxiang(); EQW_Rpc.xuanzhu(1);
EQW_Rpc.maipai([1,2,3,4,5,6,7,8]); EQW_Rpc.chupai([9]);
EQW_Rpc.mingpai(); EQW_Rpc.tishi(2); EQW_Rpc.zhunbei();
const leaked = [];
sent.forEach(p => Object.keys(p.data).forEach(k => {
if (FORBIDDEN.indexOf(k) >= 0) { leaked.push(p.rpc + '.' + k); }
}));
t.eq('发包不含任何结论字段', leaked, []);
// ---- 参数形状校验:非法即抛错,不发残缺包 ----
sent.length = 0;
t.eq('牌 id 非数组抛错', throws(() => EQW_Rpc.chupai('5')), true);
t.eq('空数组抛错', throws(() => EQW_Rpc.chupai([])), true);
t.eq('字符串牌 id 抛错', throws(() => EQW_Rpc.chupai(['5'])), true);
t.eq('小数牌 id 抛错', throws(() => EQW_Rpc.chupai([1.5])), true);
t.eq('越界牌 id 抛错', throws(() => EQW_Rpc.chupai([108])), true);
t.eq('负数牌 id 抛错', throws(() => EQW_Rpc.chupai([-1])), true);
t.eq('重复牌 id 抛错', throws(() => EQW_Rpc.chupai([5, 5])), true);
t.eq('埋牌非 8 张抛错', throws(() => EQW_Rpc.maipai([1,2,3])), true);
t.eq('叫分越界抛错', throws(() => EQW_Rpc.jiaofen(71)), true);
t.eq('叫分非 5 的倍数抛错', throws(() => EQW_Rpc.jiaofen(7)), true);
t.eq('花色越界抛错', throws(() => EQW_Rpc.xuanzhu(5)), true);
t.eq('提示类型越界抛错', throws(() => EQW_Rpc.tishi(4)), true);
t.eq('校验失败时一个包都没发', sent.length, 0);
// ---- 合法边界值不抛 ----
t.eq('牌 id 0 合法', throws(() => EQW_Rpc.chupai([0])), false);
t.eq('牌 id 107 合法', throws(() => EQW_Rpc.chupai([107])), false);
t.eq('叫分 5 合法', throws(() => EQW_Rpc.jiaofen(5)), false);
t.eq('叫分 70 合法', throws(() => EQW_Rpc.jiaofen(70)), false);
process.exit(t.done('rpc_send') ? 0 : 1);
```
- [ ] **Step 2: 运行,确认失败**
Run: `node client/tests/test_rpc_send.js`
Expected: `EQW_Rpc is not defined`。
- [ ] **Step 3: 写实现 `client/js/01_SubGame/codes/net/Rpc.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_Rpc: 语义化发包(一操作一方法)/////////////////
///////////////////////////////////////////////////////////////
// 前端 04 §1:业务不直接拼包,走两层封装——
// 业务 → EQW_Rpc.xxx(意图) → RpcHelper(注入平台字段)→ 引擎发送
//
// 【route 必须是 erqiwang】RpcHelper.sendGameRpc 预设 route="room"(平台房间模块),
// 用它包会被路由到平台、永远到不了子游戏 mod.js,而且不会有任何报错。
//
// 【请求包只带意图,不带结论】(前端 04 §1 / 服务端 04 §8)
// 可以带:操作类型、目标标识(牌 id / 花色 / 提示类型)、座位号(仅供一致性校验)。
// 禁止带:分数、番数、判定结果、阶段推进指令、nextSeat、handCards。
// 前端 shared/ 的计算只用于本地提示与预校验,【结果不回传】。
// 一句话:前端能自己推出来的东西,就是玩家能改的东西——这是防作弊的根本。
//
// 本文件只做【形状】校验(牌 id 范围/去重/张数),不做【规则】校验
// (这手牌能不能出、叫分够不够低由服务端裁定)。形状不合法就 fail-fast 抛错,
// 不发出残缺包——服务端会按 PARAM 拒绝,但让错误暴露在最近处更省事。
var EQW_Rpc = EQW_Rpc || {
APP: 'youle',
ROUTE: 'erqiwang',
//—— 内部:取自己的座位 ——
_seat: function () {
return EQW_GameState.room.mySeat;
},
//—— 内部:发送 ——
_send: function (rpc, data) {
data.seat = this._seat();
RpcHelper.sendRpc(this.APP, this.ROUTE, rpc, data);
},
//—— 内部:牌 id 列表形状校验(协议 §0.2)——
//必须是非空数组,元素是 0–107 的整数,互不重复
_checkCards: function (cards, expectCount) {
if (Object.prototype.toString.call(cards) !== '[object Array]' || cards.length === 0) {
throw new Error('[EQW_Rpc] cards 必须是非空数组');
}
if (typeof expectCount === 'number' && cards.length !== expectCount) {
throw new Error('[EQW_Rpc] cards 张数应为 ' + expectCount + ',实际 ' + cards.length);
}
var seen = {};
for (var i = 0; i < cards.length; i++) {
var c = cards[i];
if (typeof c !== 'number' || c !== Math.floor(c) || c < 0 || c > 107) {
throw new Error('[EQW_Rpc] 非法牌 id: ' + c + '(须为 0–107 的整数)');
}
if (seen[c]) { throw new Error('[EQW_Rpc] 牌 id 重复: ' + c); }
seen[c] = true;
}
},
//—— 叫分(call: 5–70 的 5 的倍数;0 = 不叫)——
jiaofen: function (call) {
if (typeof call !== 'number' || call !== Math.floor(call)) {
throw new Error('[EQW_Rpc] call 必须是整数');
}
if (call !== 0 && (call < 5 || call > 70 || call % 5 !== 0)) {
throw new Error('[EQW_Rpc] 非法叫分: ' + call + '(须为 0 或 5–70 的 5 的倍数)');
}
this._send('jiaofen', { call: call });
},
//—— 投降(仅 70 分坐庄、选主阶段;合法性由服务端裁定)——
touxiang: function () {
this._send('touxiang', {});
},
//—— 选主(flower: 1方块 2梅花 3红心 4黑桃)——
xuanzhu: function (flower) {
if (typeof flower !== 'number' || flower < 1 || flower > 4) {
throw new Error('[EQW_Rpc] 非法花色: ' + flower + '(须为 1–4)');
}
this._send('xuanzhu', { flower: flower });
},
//—— 埋牌(恒 8 张)——
maipai: function (cards) {
this._checkCards(cards, 8);
this._send('maipai', { cards: cards });
},
//—— 出牌 ——
chupai: function (cards) {
this._checkCards(cards);
this._send('chupai', { cards: cards });
},
//—— 明牌(查看他家未出主牌)——
mingpai: function () {
this._send('mingpai', {});
},
//—— 出牌提示(tip: 1踩 2没分 3有分)——
tishi: function (tip) {
if (tip !== 1 && tip !== 2 && tip !== 3) {
throw new Error('[EQW_Rpc] 非法提示类型: ' + tip + '(须为 1踩 2没分 3有分)');
}
this._send('tishi', { tip: tip });
},
//—— 准备 ——
zhunbei: function () {
this._send('zhunbei', {});
}
};
```
- [ ] **Step 4: 运行,确认通过**
Run: `node client/tests/test_rpc_send.js`
Expected: 全 PASS。
- [ ] **Step 5: 提交**
```bash
git add client/js/01_SubGame/codes/net/Rpc.js client/tests/test_rpc_send.js
git commit -F - <<'EOF'
二七王:前端语义化发包
route 显式写 erqiwang——RpcHelper.sendGameRpc 预设 route="room",
用它包会被路由到平台房间模块、到不了子游戏 mod.js 且不报错。
只做形状校验(牌 id 范围/去重/张数),规则合法性由服务端裁定;
不合形状即抛错、不发残缺包。测试断言发包字段集合不含任何结论字段。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 4: 分发器与失败回包
**Files:**
- Create: `client/js/01_SubGame/codes/net/Dispatcher.js`
- Create: `client/js/01_SubGame/codes/net/handlers/FailHandler.js`
- Test: `client/tests/test_dispatcher.js`
**Interfaces:**
- Consumes: `EQW_Events`、`EventBus.emit`
- Produces: 全局 `EQW_Dispatcher`,含 `dispatch(msg)`(`msg = {rpc, data}`)、`register(rpc, handler)`;全局 `EQW_FailHandler`,含 `handle(rpc, data)`
> **判定顺序是关键**:`dispatch` 先看 `data.success`——为 `false` 一律交 `FailHandler`,不进业务 handler。这样每个业务 handler 都能假定自己拿到的是成功包。
> 原因见协议 §0.2:失败回包的 **rpc 与请求同名**(`chupai` 失败回 `chupai`,成功走 `chupai1/2/3`),若不先分流,业务 handler 就得每个都自己判一遍。
- [ ] **Step 1: 写失败的测试 `client/tests/test_dispatcher.js`**
```js
// 分发器:success 优先分流、未知 rpc 不崩、路由表完整性
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/net/handlers/FailHandler.js');
load('client/js/01_SubGame/codes/net/Dispatcher.js');
// ---- 注册与分发 ----
const got = [];
EQW_Dispatcher.register('testRpc', function (d) { got.push(d); });
EQW_Dispatcher.dispatch({ rpc: 'testRpc', data: { success: true, v: 1 } });
t.eq('成功包进业务 handler', got, [{ success: true, v: 1 }]);
// ---- success:false 优先走 FailHandler,不进业务 handler ----
const failed = [];
EventBus.on(EQW_Events.EQW_RPC_FAILED, function (e) { failed.push(e); });
got.length = 0;
EQW_Dispatcher.dispatch({ rpc: 'testRpc', data: { success: false, errcode: 5 } });
t.eq('失败包不进业务 handler', got, []);
t.eq('失败包 emit RPC_FAILED', failed, [{ rpc: 'testRpc', errcode: 5 }]);
// ---- 失败包即使 rpc 没注册过也要被处理(如 chupai 只会以失败包出现)----
failed.length = 0;
EQW_Dispatcher.dispatch({ rpc: 'chupai', data: { success: false, errcode: 6 } });
t.eq('未注册 rpc 的失败包也处理', failed, [{ rpc: 'chupai', errcode: 6 }]);
// ---- 未知 rpc 的成功包:只警告、不抛错 ----
let threw = false;
try { EQW_Dispatcher.dispatch({ rpc: 'brandNewRpc', data: { success: true } }); }
catch (e) { threw = true; }
t.eq('未知 rpc 不抛错', threw, false);
// ---- 畸形入参不崩 ----
[null, undefined, {}, { rpc: 'testRpc' }, { data: {} }].forEach((bad, i) => {
let boom = false;
try { EQW_Dispatcher.dispatch(bad); } catch (e) { boom = true; }
t.eq('畸形入参 #' + i + ' 不崩', boom, false);
});
// ---- 路由表完整性:协议里每个服务端推送 rpc 都要有 handler ----
// (本条在 Task 10 全部 handler 就位后才会真正通过;此刻先写下期望,
// Task 10 完成时它会自动转绿——这正是它存在的意义:漏接一个包就会红)
load('client/js/01_SubGame/codes/state/GameState.js');
load('client/js/01_SubGame/codes/state/RoomOptions.js');
['fapai','jiaofen','shangzhuang','xuanzhu','maipai',
'chupai1','chupai2','chupai3','mingpai','tishi','jiesuan','zhunbei'].forEach(rpc => {
t.eq('已注册 handler: ' + rpc, EQW_Dispatcher.hasHandler(rpc), true);
});
process.exit(t.done('dispatcher') ? 0 : 1);
```
> **注意**:最后那组断言在本任务结束时**会失败**(handler 还没写)。这是有意的——它是 Task 5–10 的验收信号。**本任务的 Step 4 只验证前面几组通过**,最后一组留到 Task 10。请在本任务提交时**临时注释掉**最后那组,并在注释里写明「Task 10 完成后取消注释」;Task 10 会把它放开。
- [ ] **Step 2: 运行,确认失败**
Run: `node client/tests/test_dispatcher.js`
Expected: `EQW_FailHandler is not defined`。
- [ ] **Step 3: 写 `client/js/01_SubGame/codes/net/handlers/FailHandler.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_FailHandler: 失败回包 //////////////////////////
///////////////////////////////////////////////////////////////
// 协议 §0.2:服务端受理请求时任一校验不通过,回一个【rpc 与请求同名】的失败包,
// data 只含 success:false 与 errcode,且只回给请求者。
//
// errcode(服务端 youle_erqiwang.ERR):
// 1 PLAYER 玩家/房间/座位校验不通过 2 NODESK 牌桌或牌局不存在
// 3 STEP 当前阶段不允许该操作 4 SEAT 位置不符或还没轮到
// 5 PARAM 参数非法 6 RULE 规则不允许
//
// 失败信息是【一次性】的,不属于对局状态,故不进 GameState,只随事件带出去。
var EQW_FailHandler = EQW_FailHandler || {
ERR: {
PLAYER: 1, NODESK: 2, STEP: 3, SEAT: 4, PARAM: 5, RULE: 6
},
handle: function (rpc, data) {
var errcode = (data && typeof data.errcode === 'number') ? data.errcode : 0;
console.warn('[EQW] 请求失败: rpc=' + rpc + ' errcode=' + errcode);
EventBus.emit(EQW_Events.EQW_RPC_FAILED, { rpc: rpc, errcode: errcode });
}
};
```
- [ ] **Step 4: 写 `client/js/01_SubGame/codes/net/Dispatcher.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_Dispatcher: 唯一收包入口 + 纯路由表 ////////////
///////////////////////////////////////////////////////////////
// 前端 04 §2:收包统一分发,一 rpc 一处理器。
// 【本文件只分发,不写业务】——新增一种服务端推送 = 注册一条 rpc → handler。
//
// 判定顺序(关键):先看 data.success,为 false 一律交 FailHandler、不进业务 handler。
// 因为协议 §0.2 规定失败回包的 rpc 与【请求】同名(chupai 失败回 chupai,
// 而成功走 chupai1/2/3),不先分流的话每个业务 handler 都得自己判一遍。
var EQW_Dispatcher = EQW_Dispatcher || {
_handlers: {},
//注册一条 rpc → handler
register: function (rpc, handler) {
if (typeof rpc !== 'string' || typeof handler !== 'function') {
throw new Error('[EQW_Dispatcher] register 参数非法: ' + rpc);
}
if (this._handlers.hasOwnProperty(rpc)) {
throw new Error('[EQW_Dispatcher] rpc 重复注册: ' + rpc);
}
this._handlers[rpc] = handler;
},
hasHandler: function (rpc) {
return this._handlers.hasOwnProperty(rpc);
},
//唯一收包入口。msg = { rpc, data }
dispatch: function (msg) {
if (!msg || typeof msg.rpc !== 'string') {
console.warn('[EQW_Dispatcher] 畸形数据包,已忽略');
return;
}
var data = msg.data || {};
//成败只认 data.success(前端 04 §4):失败包一律先分流
if (data.success === false) {
EQW_FailHandler.handle(msg.rpc, data);
return;
}
var h = this._handlers[msg.rpc];
if (h) {
h(data);
} else {
//平台日后新增推送不该让前端崩
console.warn('[EQW_Dispatcher] 未处理的 rpc: ' + msg.rpc);
}
}
};
```
- [ ] **Step 5: 运行,确认通过**
Run: `node client/tests/test_dispatcher.js`
Expected: 前面几组全 PASS(最后一组路由表完整性已按 Step 1 的说明临时注释掉)。
- [ ] **Step 6: 提交**
```bash
git add client/js/01_SubGame/codes/net/Dispatcher.js client/js/01_SubGame/codes/net/handlers/FailHandler.js client/tests/test_dispatcher.js
git commit -F - <<'EOF'
二七王:收包分发器与失败回包处理
分发器只分发不写业务。判定顺序上先看 data.success,失败包一律先分流给
FailHandler——协议规定失败回包的 rpc 与请求同名(chupai 失败回 chupai,
成功走 chupai1/2/3),不先分流则每个业务 handler 都要自己判一遍。
未知 rpc 只警告不抛错,畸形入参不崩。路由表完整性断言留待全部 handler 就位。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 5: 发牌与叫分 handler
**Files:**
- Create: `client/js/01_SubGame/codes/net/handlers/DealHandler.js`
- Create: `client/js/01_SubGame/codes/net/handlers/CallHandler.js`
- Modify: `client/js/01_SubGame/codes/state/GameState.js`(加 `applyFapai` / `applyJiaofen` / `applyShangzhuang` 与内部助手 `_apply`)
- Test: `client/tests/test_handlers_call.js`
**Interfaces:**
- Consumes: `EQW_GameState`、`EQW_Events`、`EventBus`
- Produces: 全局 `EQW_DealHandler.handle(data)`;`EQW_CallHandler.handleJiaofen(data)` / `handleShangzhuang(data)`;`EQW_GameState._apply(target, src, map)`(内部助手,`map` 为 `{目标键: 源键}`,源键在包里不存在则不写)
> 协议字段:
> `fapai` = `asetidx` / `asetcount` / `cards`(自己 28 张) / `seat`(当前等待叫分者) / `countdown`
> `jiaofen` = `seat` / `call` / `currcall` / `multiple` / `nextseat` / `countdown`
> `shangzhuang` = `seat` / `call` / `banker` / `grade`(庄家叫分) / `multiple` / `bottomcards` / `ancard3s` / `cards`(36 张,庄家才有) / `countdown` / `touxiang` / `curmultiple`
- [ ] **Step 1: 写失败的测试 `client/tests/test_handlers_call.js`**
```js
// 发牌 / 叫分 / 上庄 handler
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/state/GameState.js');
load('client/js/01_SubGame/codes/net/handlers/DealHandler.js');
load('client/js/01_SubGame/codes/net/handlers/CallHandler.js');
const S = EQW_GameState;
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
// ================= fapai =================
S.reset(); S.room.mySeat = 1; evts.length = 0;
EQW_DealHandler.handle({ success: true, asetidx: 2, asetcount: 6,
cards: [1, 2, 3], seat: 0, countdown: 15 });
t.eq('局数', S.room.asetIdx, 2);
t.eq('总局数', S.room.asetCount, 6);
t.eq('手牌', S.my.cards, [1, 2, 3]);
t.eq('当前叫分者', S.turn.seat, 0);
t.eq('倒计时', S.turn.countdown, 15);
t.eq('step 置为叫分', S.aset.step, 1);
t.eq('mySeat 未被 reset 冲掉', S.room.mySeat, 1);
t.eq('fapai 发了 RESET/HAND/TURN 事件',
['EQW_RESET','EQW_HAND_CHANGED','EQW_TURN_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
// fapai 必须先 reset:上一局的残留要清掉
S.aset.banker = 2; S.table.pushlist = [[9]];
EQW_DealHandler.handle({ success: true, asetidx: 3, asetcount: 6, cards: [4], seat: 1, countdown: 15 });
t.eq('fapai 清掉上局 banker', S.aset.banker, -1);
t.eq('fapai 清掉上局 pushlist', S.table.pushlist, []);
// ================= jiaofen =================
S.reset(); evts.length = 0;
EQW_CallHandler.handleJiaofen({ success: true, seat: 0, call: 60, currcall: 60,
multiple: 3, nextseat: 1, countdown: 15 });
t.eq('记录该家叫分', S.call.calls, [60, null, null]);
t.eq('当前叫到的分', S.call.currcall, 60);
t.eq('基础子数', S.aset.multiple, 3);
t.eq('下一个叫分者', S.turn.seat, 1);
t.eq('jiaofen 发事件', evts.indexOf('EQW_CALL_CHANGED') >= 0, true);
// 不叫(call = 0)也要记下来,与「还没叫」(null) 区分
EQW_CallHandler.handleJiaofen({ success: true, seat: 1, call: 0, currcall: 60,
multiple: 3, nextseat: 2, countdown: 15 });
t.eq('不叫记为 0,与未叫 null 区分', S.call.calls, [60, 0, null]);
// ================= shangzhuang =================
S.reset(); evts.length = 0;
EQW_CallHandler.handleShangzhuang({ success: true, seat: 0, call: 70, banker: 0, grade: 70,
multiple: 2, bottomcards: [1,2,3,4,5,6,7,8], ancard3s: 1,
cards: [1,2,3], countdown: 20, touxiang: 1, curmultiple: 3 });
t.eq('庄家', S.aset.banker, 0);
t.eq('庄家叫分', S.aset.call, 70);
t.eq('基础子数', S.aset.multiple, 2);
t.eq('允许投降', S.aset.touxiang, 1);
t.eq('抓分倍数', S.aset.curmultiple, 3);
t.eq('开底标志', S.table.ancard3s, 1);
t.eq('底牌', S.my.bottomCards, [1,2,3,4,5,6,7,8]);
t.eq('摸底后手牌', S.my.cards, [1,2,3]);
t.eq('step 置为选主', S.aset.step, 2);
t.eq('控制权给庄家', S.turn.seat, 0);
t.eq('shangzhuang 发事件',
['EQW_BANKER_SET','EQW_HAND_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
// 闲家视角:没有 cards / bottomcards(非 70 分),缺字段不能把已有值抹掉
S.reset(); S.my.cards = [7, 8, 9];
EQW_CallHandler.handleShangzhuang({ success: true, seat: 0, call: 55, banker: 0, grade: 55,
multiple: 4, countdown: 20, touxiang: 0, curmultiple: 3 });
t.eq('闲家手牌不被抹', S.my.cards, [7, 8, 9]);
t.eq('闲家无底牌', S.my.bottomCards, []);
t.eq('非 70 分无开底标志', S.table.ancard3s, 0);
process.exit(t.done('handlers_call') ? 0 : 1);
```
- [ ] **Step 2: 运行,确认失败**
Run: `node client/tests/test_handlers_call.js`
Expected: `EQW_DealHandler is not defined`。
- [ ] **Step 3: 给 `GameState.js` 加内部助手 `_apply`**
在 `snapshot` 方法之后加入(注意保持对象字面量的逗号正确):
```js
//把包里的字段写进目标分组。map = { 目标键: 源键 }。
//【缺字段不兜底】源键在包里不存在就不写,保持原值——不填默认值掩盖漏发。
_apply: function (target, src, map) {
for (var dst in map) {
if (!map.hasOwnProperty(dst)) { continue; }
var srcKey = map[dst];
if (src.hasOwnProperty(srcKey)) { target[dst] = src[srcKey]; }
}
},
```
- [ ] **Step 4: 写 `client/js/01_SubGame/codes/net/handlers/DealHandler.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_DealHandler: 发牌(fapai)//////////////////////
///////////////////////////////////////////////////////////////
// 协议 §1:asetidx 当前局数 / asetcount 总局数 / cards 自己的 28 张 /
// seat 当前等待叫分者 / countdown 叫分倒计时
//
// fapai 是一局的起点,先 reset 清掉上一局的残留(reset 保留 mySeat 与 options)。
var EQW_DealHandler = EQW_DealHandler || {
handle: function (data) {
if (!data.hasOwnProperty('cards')) {
console.error('[EQW_DealHandler] fapai 缺 cards,已跳过该包');
return;
}
EQW_GameState.reset();
EQW_GameState._apply(EQW_GameState.room, data, {
asetIdx: 'asetidx',
asetCount: 'asetcount'
});
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cards' });
EQW_GameState._apply(EQW_GameState.turn, data, {
seat: 'seat',
countdown: 'countdown'
});
EQW_GameState.aset.step = 1; //发完牌进入叫分阶段
EventBus.emit(EQW_Events.EQW_RESET);
EventBus.emit(EQW_Events.EQW_HAND_CHANGED);
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
}
};
```
- [ ] **Step 5: 写 `client/js/01_SubGame/codes/net/handlers/CallHandler.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_CallHandler: 叫分(jiaofen)与上庄(shangzhuang)
///////////////////////////////////////////////////////////////
// 协议 §3 jiaofen:seat 叫分者 / call 分数(0=不叫) / currcall 当前叫到的分 /
// multiple 该叫分对应基础子数 / nextseat 下一个叫分者 / countdown
// 协议 §4 shangzhuang:seat / call / banker / grade 庄家叫分 / multiple /
// bottomcards 底牌(庄家恒有,闲家仅 70 分时有) / ancard3s 开底标志 /
// cards 摸底后的 36 张(仅庄家) / countdown 选主倒计时 /
// touxiang 是否允许投降 / curmultiple 当前抓分倍数(带符号)
var EQW_CallHandler = EQW_CallHandler || {
handleJiaofen: function (data) {
if (!data.hasOwnProperty('seat')) {
console.error('[EQW_CallHandler] jiaofen 缺 seat,已跳过该包');
return;
}
//记录这一家叫了多少:0 = 不叫,与「还没叫」(null) 区分
if (data.hasOwnProperty('call') && data.seat >= 0 && data.seat < 3) {
EQW_GameState.call.calls[data.seat] = data.call;
}
EQW_GameState._apply(EQW_GameState.call, data, { currcall: 'currcall' });
EQW_GameState._apply(EQW_GameState.aset, data, { multiple: 'multiple' });
EQW_GameState._apply(EQW_GameState.turn, data, {
seat: 'nextseat',
countdown: 'countdown'
});
EventBus.emit(EQW_Events.EQW_CALL_CHANGED);
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
},
handleShangzhuang: function (data) {
if (!data.hasOwnProperty('banker')) {
console.error('[EQW_CallHandler] shangzhuang 缺 banker,已跳过该包');
return;
}
EQW_GameState._apply(EQW_GameState.aset, data, {
banker: 'banker',
call: 'grade', //协议里庄家叫分叫 grade
multiple: 'multiple',
touxiang: 'touxiang',
curmultiple:'curmultiple'
});
EQW_GameState._apply(EQW_GameState.table, data, { ancard3s: 'ancard3s' });
//底牌与摸底后的手牌只有庄家(及 70 分时的闲家)才有,缺就不写、不抹已有值
EQW_GameState._apply(EQW_GameState.my, data, {
bottomCards: 'bottomcards',
cards: 'cards'
});
EQW_GameState._apply(EQW_GameState.turn, data, { countdown: 'countdown' });
EQW_GameState.aset.step = 2; //进入选主/投降
EQW_GameState.turn.seat = EQW_GameState.aset.banker; //控制权归庄家
EventBus.emit(EQW_Events.EQW_BANKER_SET);
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
}
};
```
- [ ] **Step 6: 运行,确认通过**
Run: `node client/tests/test_handlers_call.js`
Expected: 全 PASS。
- [ ] **Step 7: 提交**
```bash
git add client/js/01_SubGame/codes/net/handlers/DealHandler.js client/js/01_SubGame/codes/net/handlers/CallHandler.js client/js/01_SubGame/codes/state/GameState.js client/tests/test_handlers_call.js
git commit -F - <<'EOF'
二七王:发牌与叫分 handler
fapai 是一局起点,先 reset 清上局残留(保留 mySeat 与 options)。
叫分里 0(不叫)与 null(还没叫)严格区分。上庄的 bottomcards/cards 只有庄家有,
缺字段时保持原值、不抹掉闲家已有手牌——GameState 的「缺字段不兜底」在此体现。
新增 GameState._apply 助手:按 {目标键:源键} 映射写入,源键不存在就不写。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 6: 选主与埋牌 handler
**Files:**
- Create: `client/js/01_SubGame/codes/net/handlers/MainHandler.js`
- Create: `client/js/01_SubGame/codes/net/handlers/BuryHandler.js`
- Test: `client/tests/test_handlers_bury.js`
**Interfaces:**
- Consumes: `EQW_GameState`、`EQW_Events`、`EventBus`
- Produces: 全局 `EQW_MainHandler.handleXuanzhu(data)`;`EQW_BuryHandler.handle(data)`
> 协议字段:
> `xuanzhu` = `banker` / `flower` / `countdown`(埋牌倒计时) / `cards`(选主后自己的牌)
> `maipai` = `cards`(埋牌后手牌,仅庄家) / `burycards`(埋牌底牌,仅庄家) / `seat`(首出者,必为庄家) / `countdown`(出牌倒计时) / `liangpai`(仅闲家 + 可查牌 + 庄家达标)
- [ ] **Step 1: 写失败的测试 `client/tests/test_handlers_bury.js`**
```js
// 选主 / 埋牌 handler
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/state/GameState.js');
load('client/js/01_SubGame/codes/net/handlers/MainHandler.js');
load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js');
const S = EQW_GameState;
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
// ================= xuanzhu =================
S.reset(); evts.length = 0;
EQW_MainHandler.handleXuanzhu({ success: true, banker: 1, flower: 3,
countdown: 30, cards: [5, 6, 7] });
t.eq('主牌花色', S.aset.flower, 3);
t.eq('庄家', S.aset.banker, 1);
t.eq('选主后手牌', S.my.cards, [5, 6, 7]);
t.eq('step 置为埋牌', S.aset.step, 3);
t.eq('控制权归庄家', S.turn.seat, 1);
t.eq('倒计时', S.turn.countdown, 30);
t.eq('选主发事件',
['EQW_MAIN_SET','EQW_HAND_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
// 闲家视角:没有 cards,不抹已有手牌
S.reset(); S.my.cards = [1, 2];
EQW_MainHandler.handleXuanzhu({ success: true, banker: 0, flower: 2, countdown: 30 });
t.eq('闲家手牌不被抹', S.my.cards, [1, 2]);
t.eq('闲家也拿到花色', S.aset.flower, 2);
// ================= maipai =================
S.reset(); evts.length = 0;
EQW_BuryHandler.handle({ success: true, cards: [1,2,3], burycards: [9,10,11,12,13,14,15,16],
seat: 1, countdown: 20 });
t.eq('埋牌后手牌', S.my.cards, [1,2,3]);
t.eq('埋牌底牌', S.my.buryCards, [9,10,11,12,13,14,15,16]);
t.eq('step 置为出牌', S.aset.step, 5);
t.eq('首出者', S.turn.seat, 1);
t.eq('埋牌发事件',
['EQW_BURY_DONE','EQW_HAND_CHANGED','EQW_TURN_CHANGED'].every(e => evts.indexOf(e) >= 0), true);
// 闲家视角:无 cards/burycards,但可能有 liangpai
S.reset(); S.my.cards = [4, 5]; evts.length = 0;
EQW_BuryHandler.handle({ success: true, seat: 0, countdown: 20,
liangpai: { cards: [52, 53, 2, 15] } });
t.eq('闲家手牌不被抹', S.my.cards, [4, 5]);
t.eq('闲家无埋牌底牌', S.my.buryCards, []);
t.eq('亮牌数据', S.table.liangpai, { cards: [52, 53, 2, 15] });
// 不达标/不查牌:无 liangpai 字段,保持 null
S.reset();
EQW_BuryHandler.handle({ success: true, seat: 0, countdown: 20 });
t.eq('无亮牌时保持 null', S.table.liangpai, null);
process.exit(t.done('handlers_bury') ? 0 : 1);
```
- [ ] **Step 2: 运行,确认失败**
Run: `node client/tests/test_handlers_bury.js`
Expected: `EQW_MainHandler is not defined`。
- [ ] **Step 3: 写 `client/js/01_SubGame/codes/net/handlers/MainHandler.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_MainHandler: 选主(xuanzhu)///////////////////
///////////////////////////////////////////////////////////////
// 协议 §7:banker 庄家 / flower 主牌花色(1方块 2梅花 3红心 4黑桃) /
// countdown 埋牌倒计时 / cards 选主后自己手上的牌
//
// 选主后正 2 / 正 7 升格、主花色普通牌并入主牌段(design §3),手牌需整体重排——
// 但【重排是渲染时的事】,由 core/CardOrder 现算,不在这里落地成状态。
//
// 投降(touxiang)没有成功推送:点投降后服务端直接结算,前端收到的是 jiesuan。
// 失败时才回一个同名的 touxiang 失败包,由 FailHandler 统一处理。
var EQW_MainHandler = EQW_MainHandler || {
handleXuanzhu: function (data) {
if (!data.hasOwnProperty('flower')) {
console.error('[EQW_MainHandler] xuanzhu 缺 flower,已跳过该包');
return;
}
EQW_GameState._apply(EQW_GameState.aset, data, {
banker: 'banker',
flower: 'flower'
});
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cards' });
EQW_GameState._apply(EQW_GameState.turn, data, { countdown: 'countdown' });
EQW_GameState.aset.step = 3; //进入埋牌
EQW_GameState.turn.seat = EQW_GameState.aset.banker; //埋牌由庄家做
EventBus.emit(EQW_Events.EQW_MAIN_SET);
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
}
};
```
- [ ] **Step 4: 写 `client/js/01_SubGame/codes/net/handlers/BuryHandler.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_BuryHandler: 埋牌(maipai)////////////////////
///////////////////////////////////////////////////////////////
// 协议 §9:cards 埋牌后手牌(仅庄家) / burycards 埋牌底牌(仅庄家) /
// seat 首出者(必为庄家) / countdown 出牌倒计时 /
// liangpai 亮牌(仅闲家 + 可查牌模式 + 庄家固定主牌达门槛)
//
// 注意 burycards(埋牌底牌,庄家埋下的 8 张)与 bottomcards(底牌,发牌留桌的 8 张)
// 是两批不同的牌,协议 §0.0 与验收清单专门警告过别写反。
var EQW_BuryHandler = EQW_BuryHandler || {
handle: function (data) {
if (!data.hasOwnProperty('seat')) {
console.error('[EQW_BuryHandler] maipai 缺 seat,已跳过该包');
return;
}
//庄家才有的两项,闲家缺字段时保持原值、不抹手牌
EQW_GameState._apply(EQW_GameState.my, data, {
cards: 'cards',
buryCards: 'burycards'
});
//闲家才可能有的亮牌
EQW_GameState._apply(EQW_GameState.table, data, { liangpai: 'liangpai' });
EQW_GameState._apply(EQW_GameState.turn, data, {
seat: 'seat',
countdown: 'countdown'
});
EQW_GameState.aset.step = 5; //进入出牌
EventBus.emit(EQW_Events.EQW_BURY_DONE);
if (data.hasOwnProperty('cards')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
}
};
```
- [ ] **Step 5: 运行,确认通过**
Run: `node client/tests/test_handlers_bury.js`
Expected: 全 PASS。
- [ ] **Step 6: 提交**
```bash
git add client/js/01_SubGame/codes/net/handlers/MainHandler.js client/js/01_SubGame/codes/net/handlers/BuryHandler.js client/tests/test_handlers_bury.js
git commit -F - <<'EOF'
二七王:选主与埋牌 handler
选主后主牌集合变化、手牌需重排,但重排是渲染时由 core/CardOrder 现算,
不在状态里落地——GameState 只镜像不派生。
埋牌区分 burycards(埋牌底牌)与 bottomcards(底牌),两批不同的牌;
闲家无 cards/burycards 但可能有 liangpai,缺字段一律不抹已有值。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 7: 出牌 handler
**Files:**
- Create: `client/js/01_SubGame/codes/net/handlers/PlayHandler.js`
- Test: `client/tests/test_handlers_play.js`
**Interfaces:**
- Consumes: `EQW_GameState`、`EQW_Events`、`EventBus`
- Produces: 全局 `EQW_PlayHandler.handle(data, order)`(`order` 为 1/2/3,对应 `chupai1/2/3`)
> 这是最复杂的一个。三个包的字段不完全相同:
> - **`chupai1`**(首家)独有:`shuaicuo` 甩错标志、`count` 出牌数量、`flower` 出牌花色、`cardtype` 牌型、`shuai` 甩牌分量
> - **`chupai1/2`** 有:`nextseat`、`mustcard`(只发给 `nextseat` 那一家)
> - **`chupai3`**(末家)独有:`maxseat` 本轮谁最大、`grade` 本轮闲家得分
> - **共有**:`seat`、`cards`、`seatlist`(仅可查牌)、`countdown`、`baozhu`、`cardsinhand`(仅出牌者)、`curmultiple`(1 和 3 有)
>
> **`cardsinhand` 只有出牌者自己有**——收到就更新自己的手牌;别人出牌时没这个字段,不能抹掉自己的手牌。
- [ ] **Step 1: 写失败的测试 `client/tests/test_handlers_play.js`**
```js
// 出牌 handler(chupai1/2/3)
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/state/GameState.js');
load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js');
const S = EQW_GameState;
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
// ================= chupai1(首家出牌,自己出的)=================
S.reset(); S.room.mySeat = 0; S.my.cards = [1,2,3,4]; evts.length = 0;
EQW_PlayHandler.handle({ success: true, seat: 0, cards: [1,2], count: 2, flower: 3,
cardtype: 201, nextseat: 1, countdown: 15, baozhu: 0,
cardsinhand: [3,4], curmultiple: 2,
seatlist: [[0,0,0,0,[5,2]],[0,0,0,0,[6,1]],[0,0,0,0,[7,3]]] }, 1);
t.eq('出牌者手牌被更新', S.my.cards, [3,4]);
t.eq('下一个出牌者', S.turn.seat, 1);
t.eq('倒计时', S.turn.countdown, 15);
t.eq('抓分倍数', S.aset.curmultiple, 2);
t.eq('牌况表', S.table.seatlist.length, 3);
t.eq('出牌发事件', evts.indexOf('EQW_CARD_PLAYED') >= 0, true);
// ================= chupai2(别人出牌)=================
// 别人出牌时没有 cardsinhand,不能抹掉自己的手牌
S.reset(); S.room.mySeat = 0; S.my.cards = [3,4]; evts.length = 0;
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 2,
countdown: 15, baozhu: 0 }, 2);
t.eq('别人出牌不抹自己手牌', S.my.cards, [3,4]);
t.eq('控制权推进', S.turn.seat, 2);
// mustcard 只发给下一个出牌者
S.reset(); S.room.mySeat = 2;
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 2,
countdown: 15, mustcard: [7,8] }, 2);
t.eq('必出牌', S.my.mustCard, [7,8]);
// 没有 mustcard 时要清空——否则上一轮的建议会残留
S.reset(); S.my.mustCard = [7,8];
EQW_PlayHandler.handle({ success: true, seat: 1, cards: [5,6], nextseat: 0, countdown: 15 }, 2);
t.eq('无 mustcard 时清空', S.my.mustCard, []);
// ================= chupai3(末家出牌,本轮结束)=================
S.reset(); evts.length = 0;
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [9,10], maxseat: 0, grade: 25,
countdown: 15, baozhu: 1, curmultiple: -2 }, 3);
t.eq('本轮最大者', S.result.chupai.maxseat, 0);
t.eq('本轮闲家得分', S.result.chupai.grade, 25);
t.eq('累计捡分', S.aset.grade, 25);
t.eq('报无主标志', S.aset.baozhu, 1);
t.eq('抓分倍数(带符号)', S.aset.curmultiple, -2);
t.eq('本轮结束发事件', evts.indexOf('EQW_TRICK_END') >= 0, true);
// 本轮闲家没得分时不带 grade,累计分不该被抹
S.reset(); S.aset.grade = 40;
EQW_PlayHandler.handle({ success: true, seat: 2, cards: [9,10], maxseat: 1, countdown: 15 }, 3);
t.eq('无 grade 时累计分保持', S.aset.grade, 40);
// ================= 甩错 =================
S.reset(); evts.length = 0;
EQW_PlayHandler.handle({ success: true, seat: 0, cards: [11], shuaicuo: 1, count: 1,
flower: 3, cardtype: 101, nextseat: 1, countdown: 15 }, 1);
t.eq('甩错标志', S.table.playproc.shuaicuo, 1);
// ================= 缺必需字段 =================
S.reset();
EQW_PlayHandler.handle({ success: true, cards: [1] }, 1); //缺 seat
t.eq('缺 seat 时跳过该包', S.turn.seat, -1);
process.exit(t.done('handlers_play') ? 0 : 1);
```
- [ ] **Step 2: 运行,确认失败**
Run: `node client/tests/test_handlers_play.js`
Expected: `EQW_PlayHandler is not defined`。
- [ ] **Step 3: 写实现 `client/js/01_SubGame/codes/net/handlers/PlayHandler.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_PlayHandler: 出牌(chupai1 / 2 / 3)///////////
///////////////////////////////////////////////////////////////
// 协议 §11–§13。三个包字段不完全相同:
// chupai1 首家独有:shuaicuo 甩错标志 / count 出牌数量 / flower 出牌花色 /
// cardtype 牌型 / shuai 甩牌分量构成
// chupai1/2 有:nextseat 下一个出牌者 / mustcard 必出牌(只发给 nextseat 那一家)
// chupai3 末家独有:maxseat 本轮谁最大 / grade 本轮闲家得分
// 共有:seat / cards / seatlist(仅可查牌) / countdown / baozhu /
// cardsinhand(仅出牌者自己有) / curmultiple(1 和 3 有)
//
// 【cardsinhand 只有出牌者自己有】:收到就更新自己的手牌;别人出牌时没这个字段,
// 绝不能抹掉自己的手牌——这正是「缺字段不兜底」要防的。
//
// 【mustcard 没有就要清空】:它是「本轮跟牌的必出牌」,只对当前这一轮有效。
// 上一轮的建议残留下来会让界面自动选中错误的牌。
var EQW_PlayHandler = EQW_PlayHandler || {
//order: 1/2/3,对应 chupai1/2/3
handle: function (data, order) {
if (!data.hasOwnProperty('seat') || !data.hasOwnProperty('cards')) {
console.error('[EQW_PlayHandler] chupai' + order + ' 缺 seat 或 cards,已跳过该包');
return;
}
//—— 本轮桌面:记下这一手(谁出的、出了什么、首家的牌型信息)——
if (!EQW_GameState.table.playproc) { EQW_GameState.table.playproc = {}; }
var proc = EQW_GameState.table.playproc;
proc.order = order;
EQW_GameState._apply(proc, data, {
seat: 'seat',
cards: 'cards',
count: 'count',
flower: 'flower',
cardtype: 'cardtype',
shuai: 'shuai',
shuaicuo: 'shuaicuo'
});
//—— 自己的手牌:只有出牌者本人才收到 cardsinhand ——
EQW_GameState._apply(EQW_GameState.my, data, { cards: 'cardsinhand' });
//—— 必出牌:本轮有效,没给就清空 ——
if (data.hasOwnProperty('mustcard')) {
EQW_GameState.my.mustCard = data.mustcard;
} else {
EQW_GameState.my.mustCard = [];
}
//—— 牌况与报无主(仅可查牌模式下发)——
EQW_GameState._apply(EQW_GameState.table, data, { seatlist: 'seatlist' });
EQW_GameState._apply(EQW_GameState.aset, data, {
baozhu: 'baozhu',
curmultiple: 'curmultiple'
});
//—— 控制权与倒计时 ——
EQW_GameState._apply(EQW_GameState.turn, data, {
seat: 'nextseat',
countdown: 'countdown'
});
EventBus.emit(EQW_Events.EQW_CARD_PLAYED);
if (data.hasOwnProperty('cardsinhand')) { EventBus.emit(EQW_Events.EQW_HAND_CHANGED); }
//—— 末家出完:本轮结束,记下谁最大、闲家得了多少分 ——
if (order === 3) {
if (!EQW_GameState.result.chupai) { EQW_GameState.result.chupai = {}; }
EQW_GameState._apply(EQW_GameState.result.chupai, data, {
seat: 'seat',
cards: 'cards',
maxseat: 'maxseat',
grade: 'grade'
});
//本轮闲家得分累加进小局捡分;没得分时不带 grade,累计值保持不变
if (data.hasOwnProperty('grade')) {
EQW_GameState.aset.grade = EQW_GameState.aset.grade + data.grade;
}
EventBus.emit(EQW_Events.EQW_TRICK_END);
}
EventBus.emit(EQW_Events.EQW_TURN_CHANGED);
}
};
```
- [ ] **Step 4: 运行,确认通过**
Run: `node client/tests/test_handlers_play.js`
Expected: 全 PASS。
**注意**:测试里「累计捡分」那条期望 `S.aset.grade === 25`(从 0 累加 25)。若你的实现改成了直接赋值而非累加,这条会过但语义错——`grade` 是**本轮**得分,小局捡分要累加。请确认实现是累加。
- [ ] **Step 5: 提交**
```bash
git add client/js/01_SubGame/codes/net/handlers/PlayHandler.js client/tests/test_handlers_play.js
git commit -F - <<'EOF'
二七王:出牌 handler
三个包字段不同:chupai1 带牌型信息、chupai1/2 带 nextseat 与 mustcard、
chupai3 带 maxseat 与本轮得分。
两个易错点已覆盖:cardsinhand 只有出牌者自己有,别人出牌时缺该字段不能抹掉
自己的手牌;mustcard 只对当前轮有效,没给就要清空,否则上一轮的建议会残留。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 8: 明牌、提示、准备 handler
**Files:**
- Create: `client/js/01_SubGame/codes/net/handlers/QueryHandler.js`
- Create: `client/js/01_SubGame/codes/net/handlers/ReadyHandler.js`
- Test: `client/tests/test_handlers_query.js`
**Interfaces:**
- Produces: 全局 `EQW_QueryHandler.handleMingpai(data)` / `handleTishi(data)`;`EQW_ReadyHandler.handle(data)`
> 协议字段:
> `mingpai`(只回给请求者)= `seat` 请求者 / `others` 另外两家各自未出的全部主牌,元素 `{seat, zhucards}`
> `tishi`(只转发给对家)= `seat` 发出提示的闲家 / `tip` 1踩 2没分 3有分
> `zhunbei` = `seat`
>
> **提示是一次性的**:它不是对局状态,收到就 emit 事件让气泡显示,**但仍需短暂存进 `GameState.table`**——因为重连时不重放,存下来只为让 C 阶段能读到"最近一次是谁提示了什么"。若判断不需要,可只 emit 不存;本计划选择**只 emit 不存**,理由见实现注释。
- [ ] **Step 1: 写失败的测试 `client/tests/test_handlers_query.js`**
```js
// 明牌 / 提示 / 准备 handler
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/state/GameState.js');
load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js');
load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js');
const S = EQW_GameState;
const payloads = [];
EventBus.on(EQW_Events.EQW_TIP, d => payloads.push(d));
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
// ================= mingpai =================
S.reset(); evts.length = 0;
EQW_QueryHandler.handleMingpai({ success: true, seat: 1,
others: [{ seat: 0, zhucards: [52, 2] }, { seat: 2, zhucards: [53, 15] }] });
t.eq('明牌数据入状态', S.table.mingpai,
[{ seat: 0, zhucards: [52, 2] }, { seat: 2, zhucards: [53, 15] }]);
t.eq('明牌发事件', evts.indexOf('EQW_MINGPAI') >= 0, true);
// ================= tishi =================
// 提示是一次性的,不进 GameState,只随事件带出去
S.reset(); payloads.length = 0; evts.length = 0;
const before = S.snapshot(); // 处理前的状态快照
EQW_QueryHandler.handleTishi({ success: true, seat: 2, tip: 1 });
t.eq('提示发事件', evts.indexOf('EQW_TIP') >= 0, true);
t.eq('提示内容随事件带出', payloads, [{ seat: 2, tip: 1 }]);
t.eq('提示未改动 GameState 任何字段', S.snapshot(), before);
// ================= zhunbei =================
S.reset(); evts.length = 0;
EQW_ReadyHandler.handle({ success: true, seat: 1 });
t.eq('准备发事件', evts.indexOf('EQW_READY_CHANGED') >= 0, true);
// ================= 缺必需字段 =================
S.reset();
EQW_QueryHandler.handleMingpai({ success: true }); //缺 others
t.eq('明牌缺 others 时不写', S.table.mingpai, null);
payloads.length = 0;
EQW_QueryHandler.handleTishi({ success: true, seat: 0 }); //缺 tip
t.eq('提示缺 tip 时不发事件', payloads, []);
process.exit(t.done('handlers_query') ? 0 : 1);
```
- [ ] **Step 2: 运行,确认失败**
Run: `node client/tests/test_handlers_query.js`
Expected: `EQW_QueryHandler is not defined`。
- [ ] **Step 3: 写 `client/js/01_SubGame/codes/net/handlers/QueryHandler.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_QueryHandler: 明牌(mingpai)与提示(tishi)///
///////////////////////////////////////////////////////////////
// 协议 §13.6 mingpai(只回给请求者):
// seat 请求者 / others 另外两家各自未出的全部主牌 [{seat, zhucards}]
// 受理条件:可查牌模式 + 出牌阶段 + 已有【任一】玩家报无主(design §9.3)
//
// 协议 §13.8 tishi(只转发给对家,即另一闲家):
// seat 发出提示的闲家 / tip 1=踩 2=没分 3=有分
// 注意:成功时【不给发送者回执】,只转发给对家;只有失败才回给发送者。
//
// 明牌数据进 GameState(面板可反复开关查看),提示不进——
// 提示是一次性的通知、不是对局状态,重连也不重放,存下来只会变成幽灵数据。
var EQW_QueryHandler = EQW_QueryHandler || {
handleMingpai: function (data) {
if (!data.hasOwnProperty('others')) {
console.error('[EQW_QueryHandler] mingpai 缺 others,已跳过该包');
return;
}
EQW_GameState.table.mingpai = data.others;
EventBus.emit(EQW_Events.EQW_MINGPAI);
},
handleTishi: function (data) {
if (!data.hasOwnProperty('seat') || !data.hasOwnProperty('tip')) {
console.error('[EQW_QueryHandler] tishi 缺 seat 或 tip,已跳过该包');
return;
}
//一次性通知:不进 GameState,随事件把内容带给订阅者
EventBus.emit(EQW_Events.EQW_TIP, { seat: data.seat, tip: data.tip });
}
};
```
- [ ] **Step 4: 写 `client/js/01_SubGame/codes/net/handlers/ReadyHandler.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_ReadyHandler: 准备(zhunbei)//////////////////
///////////////////////////////////////////////////////////////
// 协议 §16:seat 位置序号。
// 准备状态由平台的玩家信息 UI 呈现(清单 T-15:准备标识用平台的),
// 子游戏只需把事件发出去,让关心的组件(如结算面板的「下一局」按钮)响应。
var EQW_ReadyHandler = EQW_ReadyHandler || {
handle: function (data) {
if (!data.hasOwnProperty('seat')) {
console.error('[EQW_ReadyHandler] zhunbei 缺 seat,已跳过该包');
return;
}
EventBus.emit(EQW_Events.EQW_READY_CHANGED, { seat: data.seat });
}
};
```
- [ ] **Step 5: 运行,确认通过**
Run: `node client/tests/test_handlers_query.js`
Expected: 全 PASS。
- [ ] **Step 6: 提交**
```bash
git add client/js/01_SubGame/codes/net/handlers/QueryHandler.js client/js/01_SubGame/codes/net/handlers/ReadyHandler.js client/tests/test_handlers_query.js
git commit -F - <<'EOF'
二七王:明牌、提示、准备 handler
明牌数据进 GameState(面板可反复开关查看);提示不进——它是一次性通知、
不是对局状态,重连也不重放,存下来只会变成幽灵数据,故只随事件带出。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 9: 结算与解散 handler
**Files:**
- Create: `client/js/01_SubGame/codes/net/handlers/ResultHandler.js`
- Test: `client/tests/test_handlers_result.js`
**Interfaces:**
- Produces: 全局 `EQW_ResultHandler.handleJiesuan(data)` / `handleFree(deskfree)`
> 协议 §14:结算包由 `chupai` + `bottom` + `aset`(末局再加 `account`)拼装。三种来源组成不同:
> - **正常出牌结算**:含 `chupai` + `bottom` + `aset`
> - **投降结算**:只含 `aset`,无 `chupai`、无 `bottom`
> - **解散结算**:只含 `aset` + `account`,且**走另一个入口**
>
> **解散的特殊性**(协议 §14.1 + 平台代码核实):它进不了 `_ReceiveData`(`route` 是平台的 `room`),平台通过 `Game_Modify.Free(Desk.deskfree)` 交给子游戏。参数是**已经取出来的 `deskfree`**,所以取值路径是 `deskfree.data.aset`(比协议文档描述的少一层)。且 `deskfree` **可能是 `null`**(开战后、首局发牌前解散)。
- [ ] **Step 1: 写失败的测试 `client/tests/test_handlers_result.js`**
```js
// 结算 / 解散 handler
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/state/GameState.js');
load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js');
const S = EQW_GameState;
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
const ASET = { banker: 0, call: 60, multiple: 3, flower: 3, grade: 45, upgrade: 1,
bangwang: 0, climb: 0,
seatlist: [{ grade: 6, score: 6 }, { grade: -3, score: -3 }, { grade: -3, score: -3 }] };
// ================= 正常出牌结算 =================
S.reset(); evts.length = 0;
EQW_ResultHandler.handleJiesuan({ success: true,
chupai: { seat: 2, cards: [9], maxseat: 0, grade: 10 },
bottom: { cards: [1,2,3,4,5,6,7,8], multiple: 2, grade1: 20, grade2: 40 },
aset: ASET });
t.eq('小局结算入状态', S.result.aset, ASET);
t.eq('抠底入状态', S.result.bottom.grade2, 40);
t.eq('最后一手入状态', S.result.chupai.maxseat, 0);
t.eq('step 置为结算', S.aset.step, 6);
t.eq('结算发事件', evts.indexOf('EQW_ASET_RESULT') >= 0, true);
t.eq('非末局不发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') < 0, true);
// ================= 投降结算(无 chupai / 无 bottom)=================
S.reset(); evts.length = 0;
EQW_ResultHandler.handleJiesuan({ success: true, aset: { banker: 0, call: 70, multiple: 1,
upgrade: -99, seatlist: [] } });
t.eq('投降结算入状态', S.result.aset.upgrade, -99);
t.eq('投降无 chupai', S.result.chupai, null);
t.eq('投降无 bottom', S.result.bottom, null);
t.eq('投降也发结算事件', evts.indexOf('EQW_ASET_RESULT') >= 0, true);
// ================= 末局:带 account =================
const ACCOUNT = [{ score: 12, grades: [6,6], grade_jf_total: 8, grade_cg_total: 4, grade_bw_total: 0 },
{ score: -6, grades: [-3,-3], grade_jf_total: -4, grade_cg_total: -2, grade_bw_total: 0 },
{ score: -6, grades: [-3,-3], grade_jf_total: -4, grade_cg_total: -2, grade_bw_total: 0 }];
S.reset(); evts.length = 0;
EQW_ResultHandler.handleJiesuan({ success: true, aset: ASET, account: ACCOUNT });
t.eq('大局结算入状态', S.result.account, ACCOUNT);
t.eq('末局发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') >= 0, true);
// ================= 解散:走 Free 入口,取值少一层 =================
S.reset(); evts.length = 0;
EQW_ResultHandler.handleFree({ rpc: 'jiesuan',
data: { success: true, aset: { banker: -1, call: -1, multiple: 0, upgrade: 0, seatlist: [] },
account: ACCOUNT } });
t.eq('解散取到 aset', S.result.aset.multiple, 0);
t.eq('解散取到 account', S.result.account, ACCOUNT);
t.eq('解散发大局结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') >= 0, true);
// ================= 解散:deskfree 为 null(首局发牌前解散)=================
S.reset(); evts.length = 0;
let boom = false;
try { EQW_ResultHandler.handleFree(null); } catch (e) { boom = true; }
t.eq('deskfree 为 null 不崩', boom, false);
t.eq('deskfree 为 null 不写状态', S.result.aset, null);
t.eq('deskfree 为 null 不发结算事件', evts.indexOf('EQW_ACCOUNT_RESULT') < 0, true);
// deskfree 存在但 data 缺失也要容忍
boom = false;
try { EQW_ResultHandler.handleFree({ rpc: 'jiesuan' }); } catch (e) { boom = true; }
t.eq('deskfree.data 缺失不崩', boom, false);
process.exit(t.done('handlers_result') ? 0 : 1);
```
- [ ] **Step 2: 运行,确认失败**
Run: `node client/tests/test_handlers_result.js`
Expected: `EQW_ResultHandler is not defined`。
- [ ] **Step 3: 写实现 `client/js/01_SubGame/codes/net/handlers/ResultHandler.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_ResultHandler: 结算(jiesuan)与解散(Free)///
///////////////////////////////////////////////////////////////
// 协议 §14:结算包由 chupai + bottom + aset 拼装,末局再加 account。
// 三种来源的组成不同:
// 正常出牌结算:chupai + bottom + aset(末局加 account)
// 投降结算: 只有 aset(末局加 account),无 chupai、无 bottom
// 解散结算: 只有 aset + account,且【走另一个入口】,见下
//
// 【解散的特殊性】(协议 §14.1,已对平台代码核实):
// 解散包的 route 是平台的 "room",平台 12_Logic.js 按 route 分流时把它交给
// Net[rpc] 而【不是】Game_Modify._ReceiveData——所以它根本进不了子游戏的分发表。
// 平台另有专门入口:07_Desk.js 的 Game_Modify.Free(Desk.deskfree)。
// 两个后果:
// 1. 取值路径比协议文档少一层——平台已把 deskfree 取出来传进来,
// 所以是 deskfree.data.aset,不是 data.deskfree.data.aset;
// 2. 参数可能是 null——解散若发生在开战后、首局发牌前,服务端 get_disbandRoom
// 返回 null,平台走「不带 deskfree」分支。这时只做房间收尾、不弹结算。
var EQW_ResultHandler = EQW_ResultHandler || {
handleJiesuan: function (data) {
if (!data.hasOwnProperty('aset')) {
console.error('[EQW_ResultHandler] jiesuan 缺 aset,已跳过该包');
return;
}
this._applyResult(data);
},
//平台解散入口。deskfree = { rpc:"jiesuan", data:{ success, aset, account } },可能为 null
handleFree: function (deskfree) {
if (!deskfree || !deskfree.data) {
//首局发牌前解散:没有结算数据,只做房间收尾,不弹结算面板
console.warn('[EQW_ResultHandler] 解散包无 deskfree 数据(首局发牌前解散)');
return;
}
var d = deskfree.data;
if (!d.hasOwnProperty('aset')) {
console.error('[EQW_ResultHandler] 解散包缺 aset,已跳过');
return;
}
this._applyResult(d);
},
//三种来源共用的落地逻辑:有哪组就写哪组,缺的保持 null
_applyResult: function (d) {
EQW_GameState._apply(EQW_GameState.result, d, {
chupai: 'chupai',
bottom: 'bottom',
aset: 'aset',
account: 'account'
});
EQW_GameState.aset.step = 6;
EventBus.emit(EQW_Events.EQW_ASET_RESULT);
//account 只在末局或解散时才有
if (d.hasOwnProperty('account')) {
EventBus.emit(EQW_Events.EQW_ACCOUNT_RESULT);
}
}
};
```
- [ ] **Step 4: 运行,确认通过**
Run: `node client/tests/test_handlers_result.js`
Expected: 全 PASS。
- [ ] **Step 5: 提交**
```bash
git add client/js/01_SubGame/codes/net/handlers/ResultHandler.js client/tests/test_handlers_result.js
git commit -F - <<'EOF'
二七王:结算与解散 handler
三种结算来源组成不同(正常出牌 / 投降 / 解散),共用一套落地逻辑:
有哪组写哪组,缺的保持 null。
解散走平台的 Game_Modify.Free 入口而非收包分发表——它的 route 是 room,
被平台分流走了。取值路径因此比协议文档少一层,且参数可能为 null
(首局发牌前解散),已覆盖。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 10: 重连与开局 handler + 路由表注册
**Files:**
- Create: `client/js/01_SubGame/codes/net/handlers/ResyncHandler.js`
- Modify: `client/js/01_SubGame/codes/net/Dispatcher.js`(文件末尾注册全部 rpc)
- Modify: `client/tests/test_dispatcher.js`(取消 Task 4 临时注释掉的那组断言)
- Test: `client/tests/test_resync.js`
**Interfaces:**
- Consumes: 全部 handler
- Produces: 全局 `EQW_ResyncHandler.handleDeskinfo(deskinfo)` / `handleStartWar(msg)` / `handleSetRoomDes(roomcode, asetcount, roomtype)`
> `deskinfo` 按 `step` 只带对应阶段的分组(协议文末):
> 恒有 `count` / `idx` / `PlayerInfo` / `step` / `MyCards`
> `CallRun`(step1) `ChooseMain`(step2) `BuryCards`(step3) `PushCards`(step5) `Balance`(step6)
>
> **重连不重放一次性事件**:70 分坐庄的 3 秒开底是上庄时的一次性事件,`deskinfo` 不重放,前端不得补播。
>
> `StartWar` 的差异化下发(前端 04 §3):`_msg.data.deskwar || _msg.data`,若 `sendtype === 1` 且有 `seatlist[]`,按本座位取 `seatlist[i].data`。
- [ ] **Step 1: 写失败的测试 `client/tests/test_resync.js`**
```js
// 重连(deskinfo)与开局(StartWar)
const { load } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/state/GameState.js');
load('client/js/01_SubGame/codes/state/RoomOptions.js');
load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js');
const S = EQW_GameState;
const evts = [];
Object.keys(EQW_Events).forEach(k => EventBus.on(EQW_Events[k], () => evts.push(k)));
// ================= setRoomDes:房间选项 =================
S.reset(); S.room.options = null;
EQW_ResyncHandler.handleSetRoomDes(123456, 12, '10100');
t.eq('总局数', S.room.asetCount, 12);
t.eq('傍王开', S.room.options.bangwang, 1);
t.eq('可查牌', S.room.options.nocheck, 0);
// ================= deskinfo:step 5 出牌阶段 =================
S.reset(); S.room.mySeat = 1; evts.length = 0;
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 2, PlayerInfo: [10, -5, -5], step: 5, MyCards: [1,2,3],
PushCards: {
banker: 0, call: 60, multiple: 3, flower: 3, countdown: 15,
grade: 25, playproc: { round: 4, currseat: 1, cards: [[9],[],[]] },
seatlist: [[0,0,0,0,[5,2]],[0,0,0,0,[6,1]],[0,0,0,0,[7,3]]],
liangpai: { cards: [52,53] }, pushlist: [[[1],[2],[3]]],
curmultiple: 2, mustcard: [4,5]
}
});
t.eq('总局数', S.room.asetCount, 6);
t.eq('当前局', S.room.asetIdx, 2);
t.eq('三家总分', S.room.playerScores, [10, -5, -5]);
t.eq('阶段', S.aset.step, 5);
t.eq('手牌', S.my.cards, [1,2,3]);
t.eq('庄家', S.aset.banker, 0);
t.eq('主牌花色', S.aset.flower, 3);
t.eq('捡分', S.aset.grade, 25);
t.eq('抓分倍数', S.aset.curmultiple, 2);
t.eq('当前轮出牌情况', S.table.playproc.round, 4);
t.eq('出牌历史', S.table.pushlist.length, 1);
t.eq('亮牌', S.table.liangpai, { cards: [52,53] });
t.eq('必出牌', S.my.mustCard, [4,5]);
t.eq('控制权(由 playproc.currseat 给出)', S.turn.seat, 1);
t.eq('重连发全量重画事件', evts.indexOf('EQW_RESYNC_ALL') >= 0, true);
// ================= deskinfo:step 1 叫分阶段 =================
S.reset(); evts.length = 0;
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [7,8],
CallRun: { seat: 2, countdown: 15, nowcall: 65, multiple: 2, call: [null, 0, 65] }
});
t.eq('叫分阶段', S.aset.step, 1);
t.eq('当前叫分者', S.turn.seat, 2);
t.eq('当前叫到的分', S.call.currcall, 65);
t.eq('三家叫分', S.call.calls, [null, 0, 65]);
// ================= deskinfo:step 2 选主阶段(庄家有 bottomcards)=================
S.reset(); evts.length = 0;
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 1, PlayerInfo: [0,0,0], step: 2, MyCards: [1,2],
ChooseMain: { banker: 0, call: 70, multiple: 2, countdown: 20,
bottomcards: [1,2,3,4,5,6,7,8], touxiang: 1 }
});
t.eq('选主阶段', S.aset.step, 2);
t.eq('底牌', S.my.bottomCards, [1,2,3,4,5,6,7,8]);
t.eq('允许投降', S.aset.touxiang, 1);
t.eq('控制权归庄家', S.turn.seat, 0);
// 重连【不重放】70 分开底:deskinfo 里没有 ancard3s
t.eq('重连不重放开底', S.table.ancard3s, 0);
// ================= deskinfo:step 6 结算阶段 =================
S.reset(); evts.length = 0;
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 6, PlayerInfo: [12,-6,-6], step: 6, MyCards: [],
Balance: { readystate: [1,0,0], aset: { banker: 0, upgrade: 1, seatlist: [] } }
});
t.eq('结算阶段', S.aset.step, 6);
t.eq('小局结算数据', S.result.aset.upgrade, 1);
// ================= StartWar:差异化下发 =================
S.reset(); S.room.mySeat = 2; evts.length = 0;
EQW_ResyncHandler.handleStartWar({ data: { deskwar: { sendtype: 1, seatlist: [
{ seat: 0, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [90] } },
{ seat: 2, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: [11,12] } }
] } } });
t.eq('按本座位取到自己那份', S.my.cards, [11,12]);
// 非差异化下发:直接用 data
S.reset(); S.room.mySeat = 0;
EQW_ResyncHandler.handleStartWar({ data: { count: 6, idx: 1, PlayerInfo: [0,0,0],
step: 1, MyCards: [3,4] } });
t.eq('非差异化下发', S.my.cards, [3,4]);
// 畸形入参不崩
let boom = false;
try { EQW_ResyncHandler.handleStartWar(null); EQW_ResyncHandler.handleDeskinfo(null); }
catch (e) { boom = true; }
t.eq('畸形入参不崩', boom, false);
process.exit(t.done('resync') ? 0 : 1);
```
- [ ] **Step 2: 运行,确认失败**
Run: `node client/tests/test_resync.js`
Expected: `EQW_ResyncHandler is not defined`。
- [ ] **Step 3: 写实现 `client/js/01_SubGame/codes/net/handlers/ResyncHandler.js`**
```js
///////////////////////////////////////////////////////////////
////////// EQW_ResyncHandler: 重连(deskinfo)与开局(StartWar)
///////////////////////////////////////////////////////////////
// 前端 04 §3:重连 = 重画。断线重连与硬刷新本质相同,复用同一条路径:
// 填 GameState → emit EQW_RESYNC_ALL → 各组件据数据重建界面
// 【绝不为重连单写一套渲染】。
//
// deskinfo 按 step 只带对应阶段的分组(协议文末):
// 恒有 count / idx / PlayerInfo / step / MyCards
// CallRun(step1) ChooseMain(step2) BuryCards(step3) PushCards(step5) Balance(step6)
//
// 【重连不重放一次性事件】:70 分坐庄的 3 秒开底是上庄时的一次性事件,
// deskinfo 里没有 ancard3s,前端也不得补播。
var EQW_ResyncHandler = EQW_ResyncHandler || {
//平台房间信息(roomcode / 总局数 / roomtype 位串)
handleSetRoomDes: function (roomcode, asetcount, roomtype) {
if (typeof asetcount === 'number') { EQW_GameState.room.asetCount = asetcount; }
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(roomtype);
},
//开局:makewar 可能差异化下发(sendtype:1 + seatlist[]),先取自己那份
handleStartWar: function (msg) {
if (!msg || !msg.data) {
console.warn('[EQW_ResyncHandler] StartWar 无数据,已忽略');
return;
}
var raw = msg.data.deskwar || msg.data;
var mine = raw;
if (raw && raw.sendtype === 1 && Object.prototype.toString.call(raw.seatlist) === '[object Array]') {
mine = null;
for (var i = 0; i < raw.seatlist.length; i++) {
if (raw.seatlist[i] && raw.seatlist[i].seat === EQW_GameState.room.mySeat) {
mine = raw.seatlist[i].data;
break;
}
}
if (!mine) {
console.error('[EQW_ResyncHandler] StartWar 差异化下发里没有本座位的数据');
return;
}
}
this.handleDeskinfo(mine);
},
//重连:deskinfo 全量快照
handleDeskinfo: function (info) {
if (!info) {
console.warn('[EQW_ResyncHandler] deskinfo 为空,已忽略');
return;
}
EQW_GameState.reset();
//—— 恒有的部分 ——
EQW_GameState._apply(EQW_GameState.room, info, {
asetCount: 'count',
asetIdx: 'idx',
playerScores: 'PlayerInfo'
});
EQW_GameState._apply(EQW_GameState.aset, info, { step: 'step' });
EQW_GameState._apply(EQW_GameState.my, info, { cards: 'MyCards' });
//—— 按阶段分组 ——
if (info.CallRun) { this._applyCallRun(info.CallRun); }
if (info.ChooseMain) { this._applyChooseMain(info.ChooseMain); }
if (info.BuryCards) { this._applyBuryCards(info.BuryCards); }
if (info.PushCards) { this._applyPushCards(info.PushCards); }
if (info.Balance) { this._applyBalance(info.Balance); }
EventBus.emit(EQW_Events.EQW_RESYNC_ALL);
},
_applyCallRun: function (g) {
EQW_GameState._apply(EQW_GameState.call, g, {
currcall: 'nowcall',
calls: 'call'
});
EQW_GameState._apply(EQW_GameState.aset, g, { multiple: 'multiple' });
EQW_GameState._apply(EQW_GameState.turn, g, {
seat: 'seat',
countdown: 'countdown'
});
},
_applyChooseMain: function (g) {
EQW_GameState._apply(EQW_GameState.aset, g, {
banker: 'banker',
call: 'call',
multiple: 'multiple',
touxiang: 'touxiang'
});
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
EQW_GameState._apply(EQW_GameState.turn, g, { countdown: 'countdown' });
EQW_GameState.turn.seat = EQW_GameState.aset.banker; //选主由庄家做
},
_applyBuryCards: function (g) {
EQW_GameState._apply(EQW_GameState.aset, g, {
banker: 'banker',
call: 'call',
multiple: 'multiple',
flower: 'flower'
});
EQW_GameState._apply(EQW_GameState.my, g, { bottomCards: 'bottomcards' });
EQW_GameState._apply(EQW_GameState.turn, g, { countdown: 'countdown' });
EQW_GameState.turn.seat = EQW_GameState.aset.banker; //埋牌由庄家做
},
_applyPushCards: function (g) {
EQW_GameState._apply(EQW_GameState.aset, g, {
banker: 'banker',
call: 'call',
multiple: 'multiple',
flower: 'flower',
grade: 'grade',
curmultiple: 'curmultiple'
});
EQW_GameState._apply(EQW_GameState.my, g, {
buryCards: 'burycards',
mustCard: 'mustcard'
});
EQW_GameState._apply(EQW_GameState.table, g, {
playproc: 'playproc',
pushlist: 'pushlist',
seatlist: 'seatlist',
liangpai: 'liangpai'
});
EQW_GameState._apply(EQW_GameState.turn, g, { countdown: 'countdown' });
//当前该谁出牌由 playproc.currseat 给出(服务端权威,前端不推导)
if (g.playproc && typeof g.playproc.currseat === 'number') {
EQW_GameState.turn.seat = g.playproc.currseat;
}
},
_applyBalance: function (g) {
EQW_GameState._apply(EQW_GameState.result, g, { aset: 'aset' });
}
};
```
- [ ] **Step 4: 运行,确认通过**
Run: `node client/tests/test_resync.js`
Expected: 全 PASS。
- [ ] **Step 5: 在 `Dispatcher.js` 末尾注册全部 rpc**
在文件末尾(对象字面量之后)追加:
```js
//—— 路由表:一 rpc 一处理器(协议里每个服务端推送都要在这里注册)——
//新增一种推送 = 加一条注册 + 写对应 handler;本文件不写业务。
EQW_Dispatcher.register('fapai', function (d) { EQW_DealHandler.handle(d); });
EQW_Dispatcher.register('jiaofen', function (d) { EQW_CallHandler.handleJiaofen(d); });
EQW_Dispatcher.register('shangzhuang', function (d) { EQW_CallHandler.handleShangzhuang(d); });
EQW_Dispatcher.register('xuanzhu', function (d) { EQW_MainHandler.handleXuanzhu(d); });
EQW_Dispatcher.register('maipai', function (d) { EQW_BuryHandler.handle(d); });
EQW_Dispatcher.register('chupai1', function (d) { EQW_PlayHandler.handle(d, 1); });
EQW_Dispatcher.register('chupai2', function (d) { EQW_PlayHandler.handle(d, 2); });
EQW_Dispatcher.register('chupai3', function (d) { EQW_PlayHandler.handle(d, 3); });
EQW_Dispatcher.register('mingpai', function (d) { EQW_QueryHandler.handleMingpai(d); });
EQW_Dispatcher.register('tishi', function (d) { EQW_QueryHandler.handleTishi(d); });
EQW_Dispatcher.register('jiesuan', function (d) { EQW_ResultHandler.handleJiesuan(d); });
EQW_Dispatcher.register('zhunbei', function (d) { EQW_ReadyHandler.handle(d); });
```
**注意加载顺序**:`Dispatcher.js` 现在依赖全部 handler 存在,所以在 `index.html` 里必须**排在 handlers 之后**(Task 12 处理)。测试里也要先 load 全部 handler 再 load `Dispatcher.js`。
- [ ] **Step 6: 放开 `test_dispatcher.js` 里的路由表完整性断言**
把 Task 4 Step 1 里临时注释掉的那组取消注释,并在文件顶部补上全部 handler 的 `load`(顺序:state → handlers → Dispatcher)。
Run: `node client/tests/test_dispatcher.js`
Expected: 全 PASS,包括 12 条「已注册 handler」。
- [ ] **Step 7: 跑全部测试**
Run: `node client/tests/run.js`
Expected: 全绿。
- [ ] **Step 8: 提交**
```bash
git add client/js/01_SubGame/codes/net/handlers/ResyncHandler.js client/js/01_SubGame/codes/net/Dispatcher.js client/tests/test_resync.js client/tests/test_dispatcher.js
git commit -F - <<'EOF'
二七王:重连与开局 handler,路由表注册齐全
重连即重画:断线重连与硬刷新复用同一条路径(填 GameState → emit RESYNC_ALL),
不为重连单写一套渲染。deskinfo 按 step 分组填充,重连不重放 70 分开底那类
一次性事件。StartWar 支持差异化下发,按本座位取自己那份。
路由表 12 条注册齐全,test_dispatcher 的完整性断言随之转绿——
漏接一个包就会红。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 11: 服务端真包夹具
**Files:**
- Create: `client/tests/fixtures/export_packets.js`(导出脚本)
- Create: `client/tests/fixtures/packets.json`(脚本产物,提交进仓库)
- Test: `client/tests/test_fixture.js`
**Interfaces:**
- Produces: `client/tests/fixtures/packets.json`,结构:
```
{
roomtype: "00000", // 本局的 roomtype 位串(Task 12 用它还原房间选项)
seats: [ [ {rpc, data}, ... ] ×3 ], // 每个座位按时序收到的下发包
deskinfo: [ // 若干快照点
{ step: 5, // 该时点的阶段
seat: 0, // 取的是哪个座位的 deskinfo
packetIndex: 7, // 【关键】对应 seats[seat] 的前 N 个包
info: { ... } } // get_deskinfo 的返回值
]
}
```
**`packetIndex` 是一致性测试的锚点**:它表示「喂完 `seats[seat]` 的前 `packetIndex` 个包」与「此刻的 `info`」是**同一时点**。取快照时必须记下当前已发包数,否则 Task 12 无从对齐两条路径。
> **不手写假包。** 服务端 `server/games/erqiwang/test/_rpc.js` 是现成的 L3 脚手架:它装配 `mod.js`、把 `SendPack`/`sendpack_toother` 全指向同一个 `sent` 数组,**逐个快照真实的下发包**。
> 用它跑一局,把 `sent` 导出成 JSON 给前端回放——这样测的是**真实契约**,而不是我对协议文档的理解。文档写错或理解偏差都会在这里暴露。
- [ ] **Step 1: 读懂服务端脚手架**
打开 `server/games/erqiwang/test/_rpc.js`,读 `setup()` 的签名与返回值(`{ sent, o_room }` 之类),以及 `server/games/erqiwang/test/test_rpc.js` 里它是怎么被用来跑一局的。**照它的用法**写导出脚本,不要另起炉灶。
同时确认 `sent` 数组里每一项的结构——它可能是 `{seat, rpc, data}` 或嵌套形式,导出脚本要按实际结构取值。
- [ ] **Step 2: 写导出脚本 `client/tests/fixtures/export_packets.js`**
用 `_rpc.js` 的 `setup()` 跑一局完整流程(发牌 → 叫分 → 上庄 → 选主 → 埋牌 → 若干轮出牌 → 结算),把每一步的下发包按座位收集,并在若干关键时刻额外调 `youle_erqiwang.export.get_deskinfo(o_room, seat)` 取全量快照,一起写进 `packets.json`。
要点:
- **按座位分开存**:下发是差异化的(`cardsinhand` 只给出牌者、`mustcard` 只给 `nextseat`、`bottomcards` 只给庄家),必须保留这个差异,否则测不出「缺字段不兜底」。
- **同时存 `deskinfo`**:在每个阶段各取一次(step 1/2/3/5/6),供 Task 12 的一致性测试用。**取快照时必须记下该座位当前已收到的包数,写进 `packetIndex`**——它是两条路径的对齐锚点,缺了它 Task 12 无从比对。
- **记下 `roomtype`**:Task 12 要用它还原房间选项。
- 输出用 `JSON.stringify(obj, null, 2)` 便于 review diff。
- 脚本用 `node client/tests/fixtures/export_packets.js` 运行,**产物提交进仓库**(这样前端测试不依赖服务端环境)。
- [ ] **Step 3: 运行导出,检查产物**
Run: `node client/tests/fixtures/export_packets.js`
Expected: 生成 `client/tests/fixtures/packets.json`。
打开产物人工核对:包序列是否覆盖了 `fapai` / `jiaofen` / `shangzhuang` / `xuanzhu` / `maipai` / `chupai1/2/3` / `jiesuan`;三个座位的差异是否真的存在(例如只有一个座位的 `shangzhuang` 带 `bottomcards`)。
- [ ] **Step 4: 写 `client/tests/test_fixture.js` 守住夹具质量**
```js
// 夹具自检:真包必须覆盖全部阶段,且保留座位差异
const fs = require('fs');
const path = require('path');
const { ROOT } = require('./_load');
const t = require('./_assert')();
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8'));
t.eq('三个座位', fx.seats.length, 3);
const rpcsOf = seat => fx.seats[seat].map(p => p.rpc);
const allRpcs = [].concat(rpcsOf(0), rpcsOf(1), rpcsOf(2));
['fapai','jiaofen','shangzhuang','xuanzhu','maipai','chupai1','chupai2','chupai3','jiesuan']
.forEach(rpc => t.eq('夹具覆盖 ' + rpc, allRpcs.indexOf(rpc) >= 0, true));
// 差异化下发确实存在:bottomcards 不该三家都有
const withBottom = [0,1,2].filter(s =>
fx.seats[s].some(p => p.rpc === 'shangzhuang' && p.data.hasOwnProperty('bottomcards')));
t.eq('底牌只发给部分座位', withBottom.length < 3, true);
// deskinfo 快照覆盖多个阶段
const steps = fx.deskinfo.map(d => d.step).filter((v, i, a) => a.indexOf(v) === i).sort();
t.eq('deskinfo 覆盖 ≥3 个阶段', steps.length >= 3, true);
// 每个包都有 success(协议 §0.1:每个下发包的 data 必带 success)
const noSuccess = [];
[0,1,2].forEach(s => fx.seats[s].forEach(p => {
if (!p.data || !p.data.hasOwnProperty('success')) { noSuccess.push(s + ':' + p.rpc); }
}));
t.eq('每个下发包都带 success', noSuccess, []);
process.exit(t.done('fixture') ? 0 : 1);
```
- [ ] **Step 5: 运行,确认通过**
Run: `node client/tests/test_fixture.js`
Expected: 全 PASS。若「每个下发包都带 success」失败,说明**服务端有包漏了 `success`**——这是服务端红线(主动推送的 `data` 必须自带 `success`)。**报告给我,不要在前端兜底。**
- [ ] **Step 6: 提交**
```bash
git add client/tests/fixtures/export_packets.js client/tests/fixtures/packets.json client/tests/test_fixture.js
git commit -F - <<'EOF'
二七王:前端测试的服务端真包夹具
用服务端 test/_rpc.js 脚手架跑一局,把真实下发包按座位导出成 JSON 供前端回放。
不手写假包——测的是真实契约,协议文档写错或前端理解偏差都会在这里暴露。
夹具自检守住质量:覆盖全部阶段、保留座位差异(如底牌只发给部分座位)、
每个下发包都带 success。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 12: 增量 vs 全量一致性(核心验收)
**Files:**
- Test: `client/tests/test_consistency.js`
**Interfaces:**
- Consumes: `client/tests/fixtures/packets.json`、全部 handler、`EQW_GameState`
> **这是 B 阶段最重要的一条测试。** 它把前端红线「视图 = f(服务端快照)」变成可执行的断言:
>
> > 任意时刻丢弃 `this.data`、仅凭最近一次服务端快照重画,界面必须完全一致——做不到即"前端私存了状态"或"服务端漏发了字段",都要修。
>
> 不一致意味着两件事之一,**都要修**:
> - 前端私存了服务端不知道的状态(增量路径写了 `deskinfo` 里没有的东西)
> - 服务端漏发了字段(`deskinfo` 缺了增量推送给过的信息)——这时**修在服务端**
- [ ] **Step 1: 写测试 `client/tests/test_consistency.js`**
```js
// 【核心验收】增量累积的 GameState 必须与 deskinfo 全量重建的完全相等
const fs = require('fs');
const path = require('path');
const { ROOT, load } = require('./_load');
const t = require('./_assert')();
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/state/GameState.js');
load('client/js/01_SubGame/codes/state/RoomOptions.js');
load('client/js/01_SubGame/codes/net/handlers/DealHandler.js');
load('client/js/01_SubGame/codes/net/handlers/CallHandler.js');
load('client/js/01_SubGame/codes/net/handlers/MainHandler.js');
load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js');
load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js');
load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js');
load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js');
load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js');
load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js');
load('client/js/01_SubGame/codes/net/Dispatcher.js');
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8'));
// 这些字段是「一次性事件」或「重连不重放」的,不参与一致性比对。
// 每加一条都必须写清理由——这个白名单是本测试唯一的松口处,滥用它等于废掉这条守卫。
const EXCLUDE = [
// 70 分坐庄的 3 秒开底是上庄时的一次性事件,协议明确 deskinfo 不重放
'table.ancard3s',
// 本轮必出牌只对当前轮有效;deskinfo 仅在轮到本家时才带 mustcard
'my.mustCard',
// 结算明细来自 jiesuan 包,deskinfo 的 Balance 只在结算阶段带 aset
'result.chupai', 'result.bottom', 'result.account'
];
function pick(snap, exclude) {
const out = JSON.parse(JSON.stringify(snap));
exclude.forEach(p => {
const parts = p.split('.');
let o = out;
for (let i = 0; i < parts.length - 1; i++) { o = o && o[parts[i]]; }
if (o) { delete o[parts[parts.length - 1]]; }
});
return out;
}
// 对每个座位、每个 deskinfo 快照点做比对
let compared = 0;
fx.deskinfo.forEach(snapPoint => {
const seat = snapPoint.seat;
// —— 路径 A:增量累积 ——
EQW_GameState.reset();
EQW_GameState.room.mySeat = seat;
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
fx.seats[seat].slice(0, snapPoint.packetIndex).forEach(p => {
EQW_Dispatcher.dispatch({ rpc: p.rpc, data: p.data });
});
const incremental = EQW_GameState.snapshot();
// —— 路径 B:deskinfo 全量重建 ——
EQW_GameState.reset();
EQW_GameState.room.mySeat = seat;
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
EQW_ResyncHandler.handleDeskinfo(snapPoint.info);
const full = EQW_GameState.snapshot();
t.eq('座位' + seat + ' step' + snapPoint.step + ' 增量 == 全量',
pick(incremental, EXCLUDE), pick(full, EXCLUDE));
compared++;
});
t.eq('比对点数量 > 0', compared > 0, true);
process.exit(t.done('consistency') ? 0 : 1);
```
- [ ] **Step 2: 运行**
Run: `node client/tests/test_consistency.js`
**这一步很可能不会一次通过。** 出现不一致时,**先判定根因再动手**:
| 现象 | 根因 | 处置 |
|---|---|---|
| 增量有、全量没有 | 前端在增量路径写了 `deskinfo` 里没有的东西 | 改前端:那个字段不该存,或该字段确属一次性事件 → 加进 `EXCLUDE` 并写明理由 |
| 全量有、增量没有 | 增量 handler 漏写了某字段 | 改前端 handler |
| 两边都有但值不同 | 字段语义理解错(如累加 vs 赋值) | 对照协议文档判定谁对 |
| `deskinfo` 缺了增量给过的信息 | **服务端漏发** | **不要在前端兜底**——记录下来报告给我,修在服务端 |
**禁止**:为了让这条测试变绿而随意往 `EXCLUDE` 里加字段。每加一条都要在注释里写清「为什么这个字段本就不该在两条路径间一致」。
- [ ] **Step 3: 修到通过,跑全部测试**
Run: `node client/tests/run.js`
Expected: 全绿。
- [ ] **Step 4: 提交**
```bash
git add client/tests/test_consistency.js
git commit -F - <<'EOF'
二七王:增量 vs 全量一致性测试(B 阶段核心验收)
把前端红线「视图 = f(服务端快照)」变成可执行断言:按序喂推送包累积出的
GameState,必须与同一时点 deskinfo 全量重建的完全相等。
不一致只有两种可能,都要修:前端私存了服务端不知道的状态,或服务端漏发了字段。
EXCLUDE 白名单只收「一次性事件」与「重连不重放」两类,每条都写明理由。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## Task 13: 平台接线与加载
**Files:**
- Modify: `client/js/01_SubGame/codes/SubGameHooks.js`
- Modify: `client/index.html`
- Test: `client/tests/test_hooks.js`
**Interfaces:**
- Produces: `SubGameHooks._ReceiveData` / `StartWar` / `Reconnect` / `ReconnectNoMakewar` / `Free` / `setRoomDes` / `appStart` 的实现
> 平台入口与去向(均已对平台代码核实):
> - `_ReceiveData(_msg)`——`_msg` 是**完整包** `{app, route, rpc, data}`(平台 `12_Logic.js:258` 按 route 分流,只有子游戏路由才到这里)
> - `StartWar(_msg)`——开局,可能差异化下发
> - `Reconnect(_deskinfo)`——断线重连的全量快照
> - `Free(_msg)`——解散,`_msg` 即 `Desk.deskfree`,**可能为 `null`**
> - `setRoomDes(roomcode, asetcount, roomtype)`——房间信息
> - `appStart()`——启动编排(B 阶段只需初始化 `mySeat`;UI 初始化留给 C)
- [ ] **Step 1: 写失败的测试 `client/tests/test_hooks.js`**
```js
// SubGameHooks 接线:平台入口 → 新架构
const { load } = require('./_load');
const t = require('./_assert')();
global.window = global; // 转发壳用 window.SubGameHooks 判定
load('client/js/gameabc-framework/system/EventBus.js');
load('client/js/gameabc-framework/system/SpriteEventController.js');
load('client/js/01_SubGame/codes/state/Events.js');
load('client/js/01_SubGame/codes/state/GameState.js');
load('client/js/01_SubGame/codes/state/RoomOptions.js');
['DealHandler','CallHandler','MainHandler','BuryHandler','PlayHandler',
'QueryHandler','ReadyHandler','ResultHandler','ResyncHandler','FailHandler']
.forEach(h => load('client/js/01_SubGame/codes/net/handlers/' + h + '.js'));
load('client/js/01_SubGame/codes/net/Dispatcher.js');
load('client/js/01_SubGame/codes/SubGameHooks.js');
const S = EQW_GameState;
// ---- _ReceiveData:完整包 → 分发 ----
S.reset(); S.room.mySeat = 0;
SubGameHooks._ReceiveData({ app: 'youle', route: 'erqiwang', rpc: 'fapai',
data: { success: true, asetidx: 1, asetcount: 6,
cards: [1,2], seat: 0, countdown: 15 } });
t.eq('_ReceiveData 分发到 handler', S.my.cards, [1,2]);
// ---- setRoomDes ----
SubGameHooks.setRoomDes(123, 12, '00010');
t.eq('setRoomDes 解析选项', S.room.options.climb, 1);
// ---- Free:null 不崩 ----
let boom = false;
try { SubGameHooks.Free(null); } catch (e) { boom = true; }
t.eq('Free(null) 不崩', boom, false);
// ---- Reconnect ----
S.reset(); S.room.mySeat = 1;
SubGameHooks.Reconnect({ count: 6, idx: 3, PlayerInfo: [0,0,0], step: 1, MyCards: [5,6] });
t.eq('Reconnect 填状态', S.my.cards, [5,6]);
t.eq('Reconnect 填局数', S.room.asetIdx, 3);
// ---- 畸形入参一律不崩 ----
boom = false;
try {
SubGameHooks._ReceiveData(null);
SubGameHooks._ReceiveData({});
SubGameHooks.StartWar(null);
SubGameHooks.Reconnect(null);
SubGameHooks.setRoomDes();
} catch (e) { boom = true; }
t.eq('畸形入参不崩', boom, false);
process.exit(t.done('hooks') ? 0 : 1);
```
- [ ] **Step 2: 运行,确认失败**
Run: `node client/tests/test_hooks.js`
Expected: `SubGameHooks._ReceiveData is not a function`。
- [ ] **Step 3: 在 `SubGameHooks.js` 里实现这些 hook**
在文件里已有的事件转发(`utlmousedown`/`mouseup`/`gamemydraw` 等)之后追加。**只做解包与转交,不写业务**(前端 04 §3 红线:受限入口只接不写):
```js
//—— 网络与对局(B 阶段接线)——
//平台入口只解包转交,业务全在 net/ 的 handler 里。
// 对局推送。_msg 是完整包 {app, route, rpc, data}
// (平台 12_Logic.js 按 route 分流,只有子游戏路由 erqiwang 才到这里)
SubGameHooks._ReceiveData = function (_msg) {
if (!_msg) { return; }
EQW_Dispatcher.dispatch({ rpc: _msg.rpc, data: _msg.data });
};
// 开局。可能差异化下发(sendtype:1 + seatlist[])
SubGameHooks.StartWar = function (_msg) {
EQW_ResyncHandler.handleStartWar(_msg);
};
// 断线重连:拿到 get_deskinfo 的全量快照
SubGameHooks.Reconnect = function (_deskinfo) {
EQW_ResyncHandler.handleDeskinfo(_deskinfo);
};
// 重连但未开战:没有牌局数据,无需重画对局
SubGameHooks.ReconnectNoMakewar = function () {
console.log('[EQW] 重连(未开战)');
};
// 解散。_msg 即 Desk.deskfree,可能为 null(首局发牌前解散)
SubGameHooks.Free = function (_msg) {
EQW_ResultHandler.handleFree(_msg);
};
// 房间信息(房号 / 总局数 / roomtype 位串)
SubGameHooks.setRoomDes = function (roomcode, asetcount, roomtype) {
EQW_ResyncHandler.handleSetRoomDes(roomcode, asetcount, roomtype);
};
// 启动编排。B 阶段只记下自己的座位;UI 初始化留给 C 阶段
SubGameHooks.appStart = function () {
if (typeof C_Player !== 'undefined' && typeof C_Player.seat === 'number') {
EQW_GameState.room.mySeat = C_Player.seat;
}
if (typeof Desk !== 'undefined' && Desk.roomtype) {
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(Desk.roomtype);
}
};
```
- [ ] **Step 4: 运行,确认通过**
Run: `node client/tests/test_hooks.js`
Expected: 全 PASS。
- [ ] **Step 5: 在 `index.html` 加入 state/ 与 net/ 的加载段**
在现有 `codes/ui/LayoutSolver.js` 之后、`codes/SubGameHooks.js` **之前**插入。**顺序即依赖**:
```html
<!-- 5.5 对局状态与事件 -->
<script type="text/javascript" src="js/01_SubGame/codes/state/Events.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/state/GameState.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/state/RoomOptions.js"></script>
<!-- 5.6 网络:handler 在前,Dispatcher 在后(它注册时要引用全部 handler) -->
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/FailHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/DealHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/CallHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/MainHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/BuryHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/PlayHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/QueryHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ReadyHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ResultHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/handlers/ResyncHandler.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/Dispatcher.js"></script>
<script type="text/javascript" src="js/01_SubGame/codes/net/Rpc.js"></script>
```
- [ ] **Step 6: 验证加载顺序**
写一个临时脚本(放 `/tmp`,**不提交**),按 `index.html` 里 script 标签的实际顺序用 `vm.runInThisContext` 依次加载 `codes/` 下全部文件(框架依赖 `EventBus.js`、`AlignmentUtils.js`、`SpriteEventController.js` 也要按其在 index.html 中的位置先加载),确认:
- 加载过程无异常抛出(顺序错了会因引用未定义的全局而报错)
- 这些全局全部就绪:`EQW_Events`、`EQW_GameState`、`EQW_RoomOptions_Parse`、`EQW_Dispatcher`、`EQW_Rpc`、以及 10 个 handler
- `EQW_Dispatcher.hasHandler('chupai1')` 为 `true`(证明注册确实跑了)
把输出贴进报告。
- [ ] **Step 7: 跑两套测试**
Run: `node client/tests/run.js && node server/games/erqiwang/test/run.js`
Expected: 两边全绿。
- [ ] **Step 8: 提交**
```bash
git add client/js/01_SubGame/codes/SubGameHooks.js client/index.html client/tests/test_hooks.js
git commit -F - <<'EOF'
二七王:平台接线与加载段
SubGameHooks 只解包转交、不写业务:_ReceiveData 收到的是完整包,
按 rpc 分发;Free 的参数是平台已取出的 deskfree、可能为 null;
appStart 只记座位与房间选项,UI 初始化留给 C 阶段。
index.html 里 handler 排在 Dispatcher 之前——后者注册时要引用全部 handler。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EOF
```
---
## 完成检查
全部任务做完后逐条核对:
- [ ] `node client/tests/run.js` 全绿(新增 10 个测试文件)
- [ ] `node server/games/erqiwang/test/run.js` 全绿(B 不该动服务端)
- [ ] **增量 vs 全量一致性测试通过**,`EXCLUDE` 里每条都有书面理由
- [ ] 发包测试确认字段集合不含任何结论字段
- [ ] 路由表覆盖协议里全部 12 个服务端推送 rpc
- [ ] `handlers/` 下无任何一处引用 UI 组件(`grep -rn "View\|Sprite" codes/net/` 应只命中注释)
- [ ] 三个平台契约转发壳一字未动(`git diff --name-only` 无 `01_SubGame/0*.js`)
- [ ] 提交全部通过 `.githooks/pre-commit` 的红线闸(严格 ES5、可编辑范围)
- [ ] 浏览器打开 `client/index.html` 控制台无报错(人工执行)