Files
joywayerandClaude Opus 5 af779f473a 二七王:C-1 修复 room.mySeat 永远为 -1
平台 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>
2026-08-27 21:05:46 +08:00

111 lines
5.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 语义化发包: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);