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>
2637 lines
114 KiB
Markdown
2637 lines
114 KiB
Markdown
# 二七王前端 · 子项目 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` 控制台无报错(人工执行)
|