diff --git a/client/js/01_SubGame/codes/SubGameHooks.js b/client/js/01_SubGame/codes/SubGameHooks.js index d0ed491..63a751a 100644 --- a/client/js/01_SubGame/codes/SubGameHooks.js +++ b/client/js/01_SubGame/codes/SubGameHooks.js @@ -249,6 +249,31 @@ SubGameHooks.gamemydraw = function (gameid, spid, times, timelong, no2, no3, no4 //—— 网络与对局(B 阶段接线)—— //平台入口只解包转交,业务全在 net/ 的 handler 里。 +// --------------------------------------------------------------------------- +// 【room.mySeat 的唯一写入处】(SSOT) +// +// 为什么不能只在 appStart 里取一次: +// 平台 12_Logic.js:389 调 Game_Modify.appStart() 时,C_Player 还只是一个 +// `var C_Player;`——`C_Player = new Player(-1)` 要到【同一个函数】的第 480 行才执行。 +// 此刻 typeof C_Player === 'undefined',守卫为假、整个 appStart 空转; +// 之后玩家登录、07_Desk.js 里 C_Player.SetSeat() 拿到真座位,但 appStart 再也不会被调用, +// 于是 mySeat 永远停在 -1:发包带 seat:-1(服务端 check_player 必然拒绝)、 +// StartWar 差异化下发找不到本座位而整包丢弃、SeatMap.toDisplay(-1) 抛错。 +// ——而且【全程无报错】,这正是它能潜伏下来的原因。 +// +// 因此改为:单一写入处 + 多个调用点。凡是平台已经确定座位的入口都同步一次, +// 不赌「所有路径都会经过某一个入口」。 +// +// 幂等:可重复调用;C_Player 不可用或座位非法时【保持原值不写】—— +// 绝不用 -1 覆盖已同步好的正确座位。 +// --------------------------------------------------------------------------- +SubGameHooks._syncMySeat = function () { + if (typeof C_Player === 'undefined' || !C_Player) { return; } + var seat = C_Player.seat; + if (seat !== 0 && seat !== 1 && seat !== 2) { return; } + EQW_GameState.room.mySeat = seat; +}; + // 对局推送。_msg 是完整包 {app, route, rpc, data} // (平台 12_Logic.js 按 route 分流,只有子游戏路由 erqiwang 才到这里) SubGameHooks._ReceiveData = function (_msg) { @@ -257,17 +282,22 @@ SubGameHooks._ReceiveData = function (_msg) { }; // 开局。可能差异化下发(sendtype:1 + seatlist[]) +// 【必须先同步座位】handleStartWar 要按 mySeat 在 seatlist[] 里认领自己那份 SubGameHooks.StartWar = function (_msg) { + SubGameHooks._syncMySeat(); EQW_ResyncHandler.handleStartWar(_msg); }; // 断线重连:拿到 get_deskinfo 的全量快照 +// (handleDeskinfo 内的 GameState.reset() 不清 room.mySeat,此处同步的值不会被冲掉) SubGameHooks.Reconnect = function (_deskinfo) { + SubGameHooks._syncMySeat(); EQW_ResyncHandler.handleDeskinfo(_deskinfo); }; // 重连但未开战:没有牌局数据,无需重画对局 SubGameHooks.ReconnectNoMakewar = function () { + SubGameHooks._syncMySeat(); console.log('[EQW] 重连(未开战)'); }; @@ -277,15 +307,23 @@ SubGameHooks.Free = function (_msg) { }; // 房间信息(房号 / 总局数 / roomtype 位串) +// 平台三条进桌路径(07_Desk.js:358 登录 / 510 建房 / 631 进房)都在 C_Player.SetSeat() 之后、 +// Reconnect / StartWar / DeskInfo 之前调它——是覆盖面最广的一个同步点 SubGameHooks.setRoomDes = function (roomcode, asetcount, roomtype) { + SubGameHooks._syncMySeat(); EQW_ResyncHandler.handleSetRoomDes(roomcode, asetcount, roomtype); }; +// 换座(平台 07_Desk.js:186-191 已先把 C_Player.seat 改成新座位再回调这里)。 +// 这是唯一「座位会中途变化、且不重走 setRoomDes」的路径,漏掉这里 mySeat 会静默过期 +SubGameHooks.changeSeat = function (seat1, seat2) { + SubGameHooks._syncMySeat(); +}; + // 启动编排。B 阶段只记下自己的座位;UI 初始化留给 C 阶段 +// 注意:此刻 C_Player 通常还没创建(见 _syncMySeat 上方注释),座位靠后续入口补齐 SubGameHooks.appStart = function () { - if (typeof C_Player !== 'undefined' && typeof C_Player.seat === 'number') { - EQW_GameState.room.mySeat = C_Player.seat; - } + SubGameHooks._syncMySeat(); if (typeof Desk !== 'undefined' && Desk.roomtype) { EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(Desk.roomtype); } diff --git a/client/js/01_SubGame/codes/net/Rpc.js b/client/js/01_SubGame/codes/net/Rpc.js index 34094d2..1de1348 100644 --- a/client/js/01_SubGame/codes/net/Rpc.js +++ b/client/js/01_SubGame/codes/net/Rpc.js @@ -21,9 +21,18 @@ 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 () { - return EQW_GameState.room.mySeat; + 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; }, //—— 内部:发送 —— diff --git a/client/tests/test_appstart_timing.js b/client/tests/test_appstart_timing.js new file mode 100644 index 0000000..cd9af61 --- /dev/null +++ b/client/tests/test_appstart_timing.js @@ -0,0 +1,150 @@ +// 【C-1 回归】room.mySeat 的同步必须跑在【真实的平台初始化时序】上。 +// +// 平台 12_Logic.js 的 Logic.AppStart 跨 332–531 行,里面: +// line 389: Game_Modify.appStart() ← 此时 C_Player 只是 `var C_Player;`,未赋值 +// line 480: C_Player = new Player(-1) ← 对象到这里才存在,seat 仍是 -1 +// 之后玩家登录,07_Desk.js 的 C_Player.SetSeat(真座位), +// 但 Game_Modify.appStart() 【再也不会被调用】。 +// +// 所以「只在 appStart 里读一次 C_Player.seat」必然拿不到座位,mySeat 永远停在 -1: +// 发包带 seat:-1(服务端 check_player 必拒,叫分/选主/埋牌/出牌全发不出去)、 +// StartWar 差异化下发认领不到本座位而整包丢弃、SeatMap.toDisplay(-1) 抛错。 +// ——全程无一处报错。本测试就是拿这条时序当尺子量。 +// +// 本文件【不允许】用 `S.room.mySeat = N` 直接赋值来制造前提(其余 8 个测试文件那样做, +// 正是它们全部漏掉 C-1 的原因)。座位只能经由平台入口同步进来。 +const { load, throws } = require('./_load'); +const t = require('./_assert')(); + +global.window = global; + +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/core/SeatMap.js'); + +const sent = []; +global.RpcHelper = { sendRpc: (app, route, rpc, data) => sent.push({ rpc, data }) }; +load('client/js/01_SubGame/codes/net/Rpc.js'); + +load('client/js/01_SubGame/codes/SubGameHooks.js'); + +const S = EQW_GameState; + +// 平台 06_Player.js 的最小复刻:只有 seat 与 SetSeat 与本用例相关 +function Player(seat) { this.seat = seat; } +Player.prototype.SetSeat = function (seat) { this.seat = seat; }; + +// ============================================================================ +// 阶段 1:Logic.AppStart 第 389 行——C_Player 尚未 new +// ============================================================================ +t.eq('前提:C_Player 此刻确实不存在', typeof global.C_Player, 'undefined'); +t.eq('前提:mySeat 初始值是 -1', S.room.mySeat, -1); + +let boom = false; +try { SubGameHooks.appStart(); } catch (e) { boom = true; } +t.eq('appStart 在 C_Player 缺席时不崩', boom, false); +t.eq('appStart 拿不到座位,mySeat 仍是 -1(不是缺陷,是时序事实)', S.room.mySeat, -1); + +// 这个 -1 一旦被发包无声带出去就是 C-1 的第一重后果 +t.eq('座位未同步时发包 fail-fast', throws(() => EQW_Rpc.zhunbei()), true); +t.eq('座位未同步时一个包都没发出去', sent.length, 0); + +// ============================================================================ +// 阶段 2:Logic.AppStart 第 480 行——C_Player = new Player(-1) +// ============================================================================ +global.C_Player = new Player(-1); +SubGameHooks.setRoomDes(1234, 6, '00010'); +t.eq('C_Player 存在但座位还是 -1 时,不写坏值', S.room.mySeat, -1); +t.eq('setRoomDes 该干的正事照干(解析 roomtype)', S.room.options.climb, 1); + +// ============================================================================ +// 阶段 3:玩家登录,07_Desk.js:320 C_Player.SetSeat(真座位) +// 紧接着 07_Desk.js:358 Game_Modify.setRoomDes(...) +// ============================================================================ +C_Player.SetSeat(2); +t.eq('SetSeat 本身不写 GameState(平台不知道子游戏)', S.room.mySeat, -1); + +SubGameHooks.setRoomDes(1234, 6, '00010'); +t.eq('【核心】走完平台时序后 mySeat = 真座位', S.room.mySeat, 2); + +// ---- 第一重后果解除:发包带真座位 ---- +sent.length = 0; +EQW_Rpc.zhunbei(); +t.eq('发包带上真座位', sent[0].data.seat, 2); + +// ---- 第二重后果解除:StartWar 差异化下发能认领到自己那份 ---- +const share = (seat, cards) => ({ seat, data: { count: 6, idx: 1, PlayerInfo: [0,0,0], step: 1, MyCards: cards } }); +SubGameHooks.StartWar({ data: { deskwar: { sendtype: 1, seatlist: [ + share(0, [10, 11]), share(1, [20, 21]), share(2, [30, 31]) +] } } }); +t.eq('StartWar 认领到本座位那份手牌', S.my.cards, [30, 31]); + +// ---- 第三重后果解除:SeatMap 不再对 -1 抛错 ---- +t.eq('SeatMap.toDisplay 用 mySeat 不抛错', throws(() => EQW_SeatMap.toDisplay(0, S.room.mySeat)), false); +t.eq('自己映射到 SELF', EQW_SeatMap.toDisplay(S.room.mySeat, S.room.mySeat), 'SELF'); + +// ============================================================================ +// 阶段 4:换座(07_Desk.js:186-191 先改 C_Player.seat,再回调 changeSeat) +// 这是唯一「座位中途变化且不重走 setRoomDes」的路径 +// ============================================================================ +C_Player.SetSeat(0); +SubGameHooks.changeSeat(2, 0); +t.eq('换座后 mySeat 跟着变', S.room.mySeat, 0); +sent.length = 0; +EQW_Rpc.zhunbei(); +t.eq('换座后发包带新座位', sent[0].data.seat, 0); + +// ============================================================================ +// 阶段 5:断线重连(07_Desk.js:320 SetSeat → 358 setRoomDes → 423 Reconnect) +// 这里单独校验 Reconnect 自身也同步,不依赖前面的入口是否跑过 +// ============================================================================ +S.room.mySeat = -1; // 人为退回未同步态,验证 Reconnect 能独立自愈 +C_Player.SetSeat(1); +SubGameHooks.Reconnect({ count: 6, idx: 3, PlayerInfo: [0,0,0], step: 1, MyCards: [5,6] }); +t.eq('Reconnect 自身也同步座位', S.room.mySeat, 1); +t.eq('Reconnect 的 reset() 不会把刚同步的座位清掉', S.room.mySeat, 1); +t.eq('Reconnect 正常填状态', S.my.cards, [5, 6]); + +S.room.mySeat = -1; +C_Player.SetSeat(2); +SubGameHooks.ReconnectNoMakewar(); +t.eq('ReconnectNoMakewar 也同步座位', S.room.mySeat, 2); + +S.room.mySeat = -1; +C_Player.SetSeat(0); +SubGameHooks.StartWar({ data: { deskwar: { sendtype: 1, seatlist: [ + share(0, [7, 8]), share(1, [9]), share(2, [1]) ] } } }); +t.eq('StartWar 自身也同步座位', S.room.mySeat, 0); +t.eq('StartWar 同步后认领正确的那份', S.my.cards, [7, 8]); + +// ============================================================================ +// 幂等 / 防倒退:_syncMySeat 可重复调用,且绝不用坏值覆盖好值 +// ============================================================================ +SubGameHooks._syncMySeat(); +SubGameHooks._syncMySeat(); +t.eq('重复同步结果不变(幂等)', S.room.mySeat, 0); + +C_Player.SetSeat(-1); // 平台 new Player(-1) 的初值又冒出来 +SubGameHooks._syncMySeat(); +t.eq('座位为 -1 时保持原值,不倒退', S.room.mySeat, 0); +C_Player.SetSeat(3); +SubGameHooks._syncMySeat(); +t.eq('座位越界时保持原值,不倒退', S.room.mySeat, 0); +C_Player.SetSeat('1'); +SubGameHooks._syncMySeat(); +t.eq('座位是字符串时保持原值,不倒退', S.room.mySeat, 0); + +delete global.C_Player; +boom = false; +try { SubGameHooks._syncMySeat(); } catch (e) { boom = true; } +t.eq('C_Player 消失时同步不崩', boom, false); +t.eq('C_Player 消失时保持原值', S.room.mySeat, 0); + +process.exit(t.done('appstart_timing') ? 0 : 1); diff --git a/client/tests/test_consistency.js b/client/tests/test_consistency.js index 2c29ce8..60dd12f 100644 --- a/client/tests/test_consistency.js +++ b/client/tests/test_consistency.js @@ -26,8 +26,6 @@ const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/pac const EXCLUDE = [ // 70 分坐庄的 3 秒开底是上庄时的一次性事件,协议明确 deskinfo 不重放 'table.ancard3s', - // 本轮必出牌只对当前轮有效;deskinfo 仅在轮到本家时才带 mustcard - 'my.mustCard', // 结算明细来自 jiesuan 包,deskinfo 的 Balance 只在结算阶段带 aset 'result.chupai', 'result.bottom', 'result.account' ]; diff --git a/client/tests/test_rpc_send.js b/client/tests/test_rpc_send.js index 8b61940..2fb3b80 100644 --- a/client/tests/test_rpc_send.js +++ b/client/tests/test_rpc_send.js @@ -85,4 +85,26 @@ 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);