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

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

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

114 KiB
Raw Blame History

二七王前端 · 子项目 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(当前等待叫分者) / 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
// 发牌 / 叫分 / 上庄 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 发出提示的闲家 / tip 1踩 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 / 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
// 重连(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,可能为 null
  • setRoomDes(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 控制台无报错(人工执行)