平台 12_Logic.js 在第 389 行调 Game_Modify.appStart(),而 C_Player = new Player(-1) 要到同函数第 480 行才执行,此刻 typeof C_Player === 'undefined',appStart 整体空转; 之后玩家登录 SetSeat 拿到真座位,appStart 再也不会被调用。于是 mySeat 永远停在 -1: 发包带 seat:-1 被服务端 check_player 全部拒绝、StartWar 差异化下发认领不到本座位而 整包丢弃、SeatMap.toDisplay(-1) 抛错——且全程无一处报错。 - 新增 SubGameHooks._syncMySeat():room.mySeat 的唯一写入处(SSOT),幂等、 座位非法或 C_Player 缺席时保持原值不倒退 - 在 setRoomDes / StartWar / Reconnect / ReconnectNoMakewar / appStart 各调用一次, 不赌「所有路径都经过某一个入口」 - 新增 SubGameHooks.changeSeat 并同步座位:Desk.change_seat 是唯一「座位中途变化 且不重走 setRoomDes」的路径,漏掉会让 mySeat 静默过期 - Rpc._seat() 改 fail-fast:座位非 0/1/2 即抛错,不再无声透传 -1 - 新增 client/tests/test_appstart_timing.js:复刻平台真实初始化时序, 全程不直接赋值 mySeat;已反向验证(退回 6 个调用点后核心断言转红) - test_rpc_send 补座位非法抛错断言;test_consistency 删除空转的 my.mustCard EXCLUDE client 23 脚本全绿,server 17 脚本 / 680 checks 全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
111 lines
5.5 KiB
JavaScript
111 lines
5.5 KiB
JavaScript
// 语义化发包: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 });
|
||
|
||
// ---- 【核心】只带意图:所有发包的字段集合不含任何结论字段 ----
|
||
// FORBIDDEN 列表:协议中服务端→客户端方向的字段名。原则上都不该出现在客户端→服务端的包里。
|
||
// 作为回归网:新增发包方法时若引入了服务端字段名会被它拦下。
|
||
const FORBIDDEN = ['score', 'isWin', 'phase', 'nextSeat', 'nextseat', 'handCards',
|
||
'grade', 'multiple', 'result', 'banker', 'curmultiple', 'upgrade',
|
||
'cardsinhand', 'mustcard', 'seatlist', 'baozhu', 'liangpai', 'bottomcards', 'burycards',
|
||
'ancard3s', 'countdown', 'currcall', 'chongguan', 'wang', 'naward',
|
||
'grade_aw', 'grade_cg', 'grade_bw', 'grade_jf', 'account', 'readystate',
|
||
'shuai', 'shuaicuo', 'cardtype', 'maxseat', 'gradecards', 'others', 'zhucards',
|
||
'playproc', 'pushlist', 'touxiang'];
|
||
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);
|
||
|
||
// ---- 【C-1】座位校验:非 0/1/2 一律 fail-fast,绝不无声透传 ----
|
||
// mySeat 初始值是 -1(平台 appStart 早于 C_Player 创建,见 test_appstart_timing.js)。
|
||
// seat:-1 发出去,服务端 check_player 必然返回 falsy → 每个操作都回 ERR.PLAYER,
|
||
// 而前端一声不吭——整局玩不了却查不出原因。所以在最近处抛错。
|
||
const badSeats = [-1, 3, 1.5, undefined, null, '1', NaN];
|
||
badSeats.forEach(s => {
|
||
sent.length = 0;
|
||
EQW_GameState.room.mySeat = s;
|
||
t.eq('座位 ' + JSON.stringify(s) + ' 发包抛错', throws(() => EQW_Rpc.zhunbei()), true);
|
||
t.eq('座位 ' + JSON.stringify(s) + ' 时一个包都没发', sent.length, 0);
|
||
});
|
||
// 非法座位对每个发包方法都拦(校验在 _send 的公共入口,不是逐方法漏配)
|
||
EQW_GameState.room.mySeat = -1;
|
||
t.eq('非法座位时出牌也抛错', throws(() => EQW_Rpc.chupai([1, 2])), true);
|
||
t.eq('非法座位时叫分也抛错', throws(() => EQW_Rpc.jiaofen(5)), true);
|
||
t.eq('非法座位时埋牌也抛错', throws(() => EQW_Rpc.maipai([1,2,3,4,5,6,7,8])), true);
|
||
|
||
[0, 1, 2].forEach(s => {
|
||
EQW_GameState.room.mySeat = s;
|
||
t.eq('座位 ' + s + ' 合法不抛', throws(() => EQW_Rpc.zhunbei()), false);
|
||
});
|
||
|
||
process.exit(t.done('rpc_send') ? 0 : 1);
|