平台 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>
124 lines
5.3 KiB
JavaScript
124 lines
5.3 KiB
JavaScript
///////////////////////////////////////////////////////////////
|
||
////////// 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',
|
||
|
||
//—— 内部:取自己的座位(fail-fast)——
|
||
//room.mySeat 由 SubGameHooks._syncMySeat 从平台 C_Player.seat 同步,初始值是 -1。
|
||
//【为什么不能透传】seat:-1 发出去,服务端 check_player 必然返回 falsy → 每个操作都回 ERR.PLAYER,
|
||
//叫分/选主/埋牌/出牌全部发不出去,而前端一声不吭。无声透传正是这类缺陷能长期潜伏的原因,
|
||
//故与本文件其余形状校验一样在最近处显式失败。
|
||
_seat: function () {
|
||
var seat = EQW_GameState.room.mySeat;
|
||
if (seat !== 0 && seat !== 1 && seat !== 2) {
|
||
throw new Error('[EQW_Rpc] 座位未同步: ' + seat +
|
||
'(须为 0/1/2;检查 SubGameHooks._syncMySeat 是否已由平台入口调用)');
|
||
}
|
||
return seat;
|
||
},
|
||
|
||
//—— 内部:发送 ——
|
||
_send: function (rpc, data) {
|
||
var pack = { seat: this._seat() };
|
||
var key;
|
||
for (key in data) {
|
||
if (data.hasOwnProperty(key)) {
|
||
pack[key] = data[key];
|
||
}
|
||
}
|
||
RpcHelper.sendRpc(this.APP, this.ROUTE, rpc, pack);
|
||
},
|
||
|
||
//—— 内部:牌 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', {});
|
||
}
|
||
};
|