// 【核心验收】增量累积的 GameState 必须与 deskinfo 全量重建的完全相等 const fs = require('fs'); const path = require('path'); const { ROOT, load } = require('./_load'); const t = require('./_assert')(); load('client/js/gameabc-framework/system/EventBus.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'); load('client/js/01_SubGame/codes/net/handlers/DealHandler.js'); load('client/js/01_SubGame/codes/net/handlers/CallHandler.js'); load('client/js/01_SubGame/codes/net/handlers/MainHandler.js'); load('client/js/01_SubGame/codes/net/handlers/BuryHandler.js'); load('client/js/01_SubGame/codes/net/handlers/PlayHandler.js'); load('client/js/01_SubGame/codes/net/handlers/QueryHandler.js'); load('client/js/01_SubGame/codes/net/handlers/ReadyHandler.js'); load('client/js/01_SubGame/codes/net/handlers/ResultHandler.js'); load('client/js/01_SubGame/codes/net/handlers/ResyncHandler.js'); load('client/js/01_SubGame/codes/net/Dispatcher.js'); const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8')); // 这些字段是「一次性事件」或「重连不重放」的,不参与一致性比对。 // 每加一条都必须写清理由——这个白名单是本测试唯一的松口处,滥用它等于废掉这条守卫。 // // 【清单历史】result.bottom / result.account 已随服务端 Balance 补发而删除; // result.chupai 已随「本墩结果改走 EQW_TRICK_END 事件载荷、不进 GameState」而删除。 // 现在只剩下面这一条。 const EXCLUDE = [ // 70 分坐庄的 3 秒开底是上庄时的一次性事件,协议明确 deskinfo 不重放。 // ⚠️ 当前夹具无法触发(65 分坐庄、单局),此条未经验证——保留是因为删掉之后 // 将来真出现不一致会静默通过;但它此刻并没有在守住任何东西,不要当成已验证的豁免。 'table.ancard3s' ]; function pick(snap, exclude) { const out = JSON.parse(JSON.stringify(snap)); exclude.forEach(p => { const parts = p.split('.'); let o = out; for (let i = 0; i < parts.length - 1; i++) { o = o && o[parts[i]]; } if (o) { delete o[parts[parts.length - 1]]; } }); return out; } // 每个对齐点独立起步,不共用进程内任何残留(下面两个 build 函数各自从 // EQW_GameState.reset() + 显式赋值房间级字段开始,互不依赖调用顺序/次数): // // 【曾经的假绿】EQW_GameState.reset() 按设计不清 room.playerScores(它是"跨局不清"的 // 房间级累积量,见 GameState.js 注释)。旧版路径 A 从不写 playerScores(fapai 包不带), // 于是它的值全靠"上一次由谁最后写过"——如果上一次恰好是路径 B(deskinfo 带 PlayerInfo) // 跑过,路径 A 就会【继承】那份残留,凑巧与路径 B 的期望值相等,测试假绿;换一个座位 // 顺序、或把它放到第一个跑,残留没了就立刻转红。 // 真实客户端不存在这个残留——它在 fapai 之前先收到 StartWar(deskwar,见夹具补捕), // 由它给出 room.playerScores 的开局初值。所以路径 A 的真实起点也改成先喂 StartWar, // 每次都显式重新赋值,不再依赖任何"恰好还没被冲掉的旧值"。 function buildIncremental(seat, packetIndex) { EQW_GameState.reset(); EQW_GameState.room.mySeat = seat; EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype); EQW_ResyncHandler.handleStartWar({ data: fx.startwar[seat] }); fx.seats[seat].slice(0, packetIndex).forEach(p => { EQW_Dispatcher.dispatch({ rpc: p.rpc, data: p.data }); }); return EQW_GameState.snapshot(); } function buildFull(seat, info) { EQW_GameState.reset(); EQW_GameState.room.mySeat = seat; EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype); EQW_ResyncHandler.handleDeskinfo(info); return EQW_GameState.snapshot(); } // 对每个座位、每个 deskinfo 快照点做比对 let compared = 0; fx.deskinfo.forEach(snapPoint => { const seat = snapPoint.seat; const incremental = buildIncremental(seat, snapPoint.packetIndex); const full = buildFull(seat, snapPoint.info); t.eq('座位' + seat + ' step' + snapPoint.step + ' 增量 == 全量', pick(incremental, EXCLUDE), pick(full, EXCLUDE)); compared++; }); t.eq('比对点数量 > 0', compared > 0, true); // ================= Ruling 3:step3(埋牌)deskinfo 直读断言 ================= // 一致性比对无法单独覆盖 ResyncHandler._applyBuryCards 的字段映射正确性—— // 若增量路径压根不写某字段,两边可能「同为初始值」而比对照样绿。 // 这里独立取 step3 快照,逐字段直读 GameState 并核对具体值。 const step3 = fx.deskinfo.filter(d => d.step === 3)[0]; if (!step3) { console.warn('[test_consistency] 夹具里没有 step3 快照,Ruling 3 的直读断言无法执行'); } else { EQW_GameState.reset(); EQW_ResyncHandler.handleDeskinfo(step3.info); const bc = step3.info.BuryCards; t.eq('step3 直读 aset.banker', EQW_GameState.aset.banker, bc.banker); t.eq('step3 直读 aset.call', EQW_GameState.aset.call, bc.call); t.eq('step3 直读 aset.multiple', EQW_GameState.aset.multiple, bc.multiple); t.eq('step3 直读 aset.flower', EQW_GameState.aset.flower, bc.flower); t.eq('step3 直读 my.bottomCards', EQW_GameState.my.bottomCards, bc.bottomcards); t.eq('step3 直读 turn.countdown', EQW_GameState.turn.countdown, bc.countdown); t.eq('step3 直读 turn.seat(埋牌由庄家做)', EQW_GameState.turn.seat, bc.banker); t.eq('step3 直读 aset.step', EQW_GameState.aset.step, step3.info.step); } process.exit(t.done('consistency') ? 0 : 1);