二七王:重新生成真包夹具、补捕 StartWar、修复一致性测试用例间污染

服务端 8452535 改了下发面(chupai1/2/3 新增 playproc、deskinfo step2/3 新增
curmultiple、maipai 新增 seatlist、bottomcards/burycards 顺序统一),前端真包
夹具 packets.json 已过期,重新导出并逐项核对新字段存在与三处/两处顺序一致。

夹具新增顶层 startwar[](每座位一份,结构=deskwar=deskinfo,取自 do_new_paiju
之后、jiaofen 之前的同一时刻),独立存放、不混入 seats[],避免 packetIndex 对
齐锚点错位;test_fixture.js 补相应断言。

修复 test_consistency.js 的用例间污染:EQW_GameState.reset() 按设计不清
room.playerScores(跨局累积量),旧版增量路径从不写它,实际靠"沿用同进程里
上一次全量路径写入的残留"凑出假绿,换座位顺序或调整执行顺序就会转红。改为
每个对齐点独立起步(buildIncremental/buildFull 各自 reset),且增量路径先喂
StartWar 再喂增量包,与真实客户端时序一致,不再依赖任何隐式残留。已验证正
序/倒序跑出的 PASS/FAIL 集合逐条相同。

修完后 23 checks:15 绿、8 红。红项均已归因:3 处为任务已知的前端未接线缺口
(table.playproc 映射、table.pushlist 累积、maipai.seatlist 消费),另发现两
类同类缺口(ResyncHandler 未映射 deskinfo 新增的 curmultiple 字段、BuryHandler
埋牌后未清空 my.bottomCards),均未改动 client/js 正式代码,留给下一个前端任务。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 19:28:13 +08:00
co-authored by Claude Opus 5
parent 84525359fc
commit f7b87f32e8
4 changed files with 4928 additions and 24 deletions
+15
View File
@@ -129,6 +129,20 @@ function snap(step, seat) {
// 发牌(fapai):class.desk.js 内部随机发牌 + 广播 // 发牌(fapai):class.desk.js 内部随机发牌 + 广播
D.do_new_paiju(desk, 0); D.do_new_paiju(desk, 0);
const pj = desk.method.curr_paiju(); const pj = desk.method.curr_paiju();
// StartWar(开局握手,前端 04/05 讲的"增量路径真实起点"):真实链路里客户端开局收到的
// 第一包,结构就是 deskwar —— 即 get_deskinfo() 的返回值(见 server/youle/server_room/
// rpc.js 里 pack.data.deskwar = youle_room.import.makewar_deskwar(o_room) 一段)。
// 当前 erqiwang 的 exp.makewar(class.export.js)尚未把 get_deskinfo 接成返回值,这是
// 另一项任务;本脚本独立调用 get_deskinfo 反映"接线后客户端此刻会收到什么",让前端测试
// 能提前对齐这条起点(尤其是 room.playerScores 这类 fapai 包不带、只有 StartWar 能给的初值)。
// 与紧随其后的 step1 快照取同一时刻(do_new_paiju 之后、任何 jiaofen 之前),按座位各存
// 一份(三家 MyCards 视角不同),套一层 { deskwar: ... } 外壳与生产环境 pack.data.deskwar
// 的包裹方式对齐,供 EQW_ResyncHandler.handleStartWar 直接消费。
const startwar = [0, 1, 2].map(function (seat) {
return { deskwar: clone(E.get_deskinfo(o_room, seat)) };
});
snap(1, 0); snap(1, 1); snap(1, 2); // 叫分阶段(step1):三家快照,验证 MyCards 各不相同 snap(1, 0); snap(1, 1); snap(1, 2); // 叫分阶段(step1):三家快照,验证 MyCards 各不相同
// 叫分:0 号位叫 65,其余不叫 → 0 号位上庄(触发 jiaofen 广播 + shangzhuang 差异化下发) // 叫分:0 号位叫 65,其余不叫 → 0 号位上庄(触发 jiaofen 广播 + shangzhuang 差异化下发)
@@ -179,6 +193,7 @@ drain(); // 保险:吸收循环末尾可能残留的未 drain 包
const fixture = { const fixture = {
roomtype: ROOMTYPE, roomtype: ROOMTYPE,
startwar: startwar,
seats: seatPackets, seats: seatPackets,
deskinfo: deskinfo deskinfo: deskinfo
}; };
+4866 -9
View File
File diff suppressed because it is too large Load Diff
+32 -15
View File
@@ -43,26 +43,43 @@ function pick(snap, exclude) {
return out; 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 快照点做比对 // 对每个座位、每个 deskinfo 快照点做比对
let compared = 0; let compared = 0;
fx.deskinfo.forEach(snapPoint => { fx.deskinfo.forEach(snapPoint => {
const seat = snapPoint.seat; const seat = snapPoint.seat;
// —— 路径 A:增量累积 —— const incremental = buildIncremental(seat, snapPoint.packetIndex);
EQW_GameState.reset(); const full = buildFull(seat, snapPoint.info);
EQW_GameState.room.mySeat = seat;
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
fx.seats[seat].slice(0, snapPoint.packetIndex).forEach(p => {
EQW_Dispatcher.dispatch({ rpc: p.rpc, data: p.data });
});
const incremental = EQW_GameState.snapshot();
// —— 路径 B:deskinfo 全量重建 ——
EQW_GameState.reset();
EQW_GameState.room.mySeat = seat;
EQW_GameState.room.options = EQW_RoomOptions_Parse.parse(fx.roomtype);
EQW_ResyncHandler.handleDeskinfo(snapPoint.info);
const full = EQW_GameState.snapshot();
t.eq('座位' + seat + ' step' + snapPoint.step + ' 增量 == 全量', t.eq('座位' + seat + ' step' + snapPoint.step + ' 增量 == 全量',
pick(incremental, EXCLUDE), pick(full, EXCLUDE)); pick(incremental, EXCLUDE), pick(full, EXCLUDE));
+15
View File
@@ -26,6 +26,21 @@ t.eq('deskinfo 覆盖全部阶段 1/2/3/5/6', steps, [1, 2, 3, 5, 6]);
// roomtype 必须存在且非空,Task 12 要用它还原房间选项 // roomtype 必须存在且非空,Task 12 要用它还原房间选项
t.eq('roomtype 存在且非空', typeof fx.roomtype === 'string' && fx.roomtype.length > 0, true); t.eq('roomtype 存在且非空', typeof fx.roomtype === 'string' && fx.roomtype.length > 0, true);
// startwar 是增量路径的真实起点(开局握手,结构=deskwar=deskinfo),必须每座位各一份,
// 且不得混进 seats[] 数组头部——那会让全部 packetIndex 集体错位一位(见夹具补捕说明)
t.eq('startwar 存在且为 3 份', Array.isArray(fx.startwar) && fx.startwar.length, 3);
[0, 1, 2].forEach(s => {
const dw = fx.startwar[s] && fx.startwar[s].deskwar;
t.eq('startwar[' + s + '].deskwar 存在', !!dw, true);
t.eq('startwar[' + s + '] 结构=deskinfo(带 step/PlayerInfo/MyCards)',
dw && dw.hasOwnProperty('step') && dw.hasOwnProperty('PlayerInfo') && dw.hasOwnProperty('MyCards'), true);
t.eq('startwar[' + s + '].PlayerInfo 为开局初值(三家均 0)', dw && dw.PlayerInfo, [0, 0, 0]);
});
// seats[] 的第一包必须仍是 fapai——确认 startwar 没有被误插进 seats[] 头部
[0, 1, 2].forEach(s => {
t.eq('seats[' + s + '][0] 仍是 fapai(startwar 未混入 seats)', fx.seats[s][0].rpc, 'fapai');
});
// packetIndex 必须落在 (0, 该座位包总数] 区间内,且与 info.step 自洽 // packetIndex 必须落在 (0, 该座位包总数] 区间内,且与 info.step 自洽
fx.deskinfo.forEach(d => { fx.deskinfo.forEach(d => {
const inRange = d.packetIndex > 0 && d.packetIndex <= fx.seats[d.seat].length; const inRange = d.packetIndex > 0 && d.packetIndex <= fx.seats[d.seat].length;