二七王: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>
This commit is contained in:
@@ -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);
|
||||
@@ -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'
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user