I-3 前端不再持有对局状态机(配合服务端上一提交的下发面补齐): - 删掉 5 处按 rpc 名硬编码的 aset.step(Deal=1 / Call=2 / Main=3 / Bury=5 / Result=6), 一律改 _apply 读包里的 step - 删掉 4 处 turn.seat = aset.banker(CallHandler / MainHandler / ResyncHandler×2), 增量路径读 nextseat、重连路径读分组里的 seat;同一条规则不再有 4 个写入处 I-2 ResyncHandler._applyBalance 由只接 aset 扩为接 aset/bottom/account, 结算面板开着时重连/硬刷新不再丢抠底明细与末局大结算。 一致性测试 EXCLUDE 随之从 4 条收缩为 2 条(去掉 result.bottom / result.account, 只留一次性事件 table.ancard3s 与归下一轮的 result.chupai),未新增任何豁免条目。 夹具按新包结构重跑(种子不变,牌局不变):diff 纯为 67 行新增字段、0 行删除; test_fixture 补上 step/nextseat/Balance 同源断言(68→82 checks)。 export_packets.js 里关于 playproc/seatlist 活引用的注释已过时,更正为仍存在的 Balance.readystate 与三份结算快照。 各 handler 单测补齐新字段并新增守卫用例:刻意构造 nextseat != banker、 step 为非预期值的包,使「退回硬编码/banker 反推」的写法必然转红(11/11 已验证)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
113 lines
5.7 KiB
JavaScript
113 lines
5.7 KiB
JavaScript
// 【核心验收】增量累积的 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'));
|
||
|
||
// 这些字段是「一次性事件」或「重连不重放」的,不参与一致性比对。
|
||
// 每加一条都必须写清理由——这个白名单是本测试唯一的松口处,滥用它等于废掉这条守卫。
|
||
const EXCLUDE = [
|
||
// 70 分坐庄的 3 秒开底是上庄时的一次性事件,协议明确 deskinfo 不重放
|
||
'table.ancard3s',
|
||
// 收尾墩:牌局最后一手不发 chupai3,整包换成 jiesuan,其 data.chupai 由 ResultHandler
|
||
// 归档进 result.chupai;deskinfo 的 Balance 不带这一墩(它属于「出牌过程」而非结算数据)。
|
||
// 【本条是本清单仅剩的松口处】result.bottom / result.account 已随服务端 Balance 补发而移除。
|
||
'result.chupai'
|
||
];
|
||
|
||
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);
|