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>
114 KiB
二七王前端 · 子项目 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
// 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
///////////////////////////////////////////////////////////////
////////// 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
///////////////////////////////////////////////////////////////
////////// 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: 提交
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
// 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
///////////////////////////////////////////////////////////////
////////// 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: 提交
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
// 语义化发包: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
///////////////////////////////////////////////////////////////
////////// 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: 提交
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
// 分发器: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
///////////////////////////////////////////////////////////////
////////// 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
///////////////////////////////////////////////////////////////
////////// 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: 提交
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(当前等待叫分者) /countdownjiaofen=seat/call/currcall/multiple/nextseat/countdownshangzhuang=seat/call/banker/grade(庄家叫分) /multiple/bottomcards/ancard3s/cards(36 张,庄家才有) /countdown/touxiang/curmultiple
- Step 1: 写失败的测试
client/tests/test_handlers_call.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 方法之后加入(注意保持对象字面量的逗号正确):
//把包里的字段写进目标分组。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
///////////////////////////////////////////////////////////////
////////// 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
///////////////////////////////////////////////////////////////
////////// 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: 提交
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
// 选主 / 埋牌 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
///////////////////////////////////////////////////////////////
////////// 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
///////////////////////////////////////////////////////////////
////////// 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: 提交
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
// 出牌 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
///////////////////////////////////////////////////////////////
////////// 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: 提交
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发出提示的闲家 /tip1踩 2没分 3有分zhunbei=seat提示是一次性的:它不是对局状态,收到就 emit 事件让气泡显示,但仍需短暂存进
GameState.table——因为重连时不重放,存下来只为让 C 阶段能读到"最近一次是谁提示了什么"。若判断不需要,可只 emit 不存;本计划选择只 emit 不存,理由见实现注释。
- Step 1: 写失败的测试
client/tests/test_handlers_query.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
///////////////////////////////////////////////////////////////
////////// 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
///////////////////////////////////////////////////////////////
////////// 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: 提交
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
// 结算 / 解散 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
///////////////////////////////////////////////////////////////
////////// 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: 提交
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/MyCardsCallRun(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
// 重连(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
///////////////////////////////////////////////////////////////
////////// 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
在文件末尾(对象字面量之后)追加:
//—— 路由表:一 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: 提交
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守住夹具质量
// 夹具自检:真包必须覆盖全部阶段,且保留座位差异
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: 提交
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
// 【核心验收】增量累积的 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: 提交
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,可能为nullsetRoomDes(roomcode, asetcount, roomtype)——房间信息appStart()——启动编排(B 阶段只需初始化mySeat;UI 初始化留给 C)
- Step 1: 写失败的测试
client/tests/test_hooks.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 红线:受限入口只接不写):
//—— 网络与对局(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 之前插入。顺序即依赖:
<!-- 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: 提交
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控制台无报错(人工执行)