审查发现 C-1(阻塞):export_packets.js 的 snap() 直接存了 class.export.js get_deskinfo() 返回的 PushCards.playproc/seatlist——这两个 字段在服务端是无拷贝的活引用赋值,脚本却在整局跑完后才统一 JSON.stringify 写盘,导致中途出牌不断原地改写这两个对象,已存的 step5 快照被事后篡改成终 局状态。修法是在 snap() 里对 get_deskinfo 的返回值套一层已有的 clone()。这 是导出脚本"延后序列化"这个用法本身的缺陷,不动 server/。 同时按裁决引入固定种子:min_random 只在 class.paiju.js 的发牌洗牌里用到, 原始实现走 Math.random();改为在导出脚本进程内 monkey-patch 一个 xorshift32 确定性 PRNG(写法与服务端既有测试 test_flow.js 等一致),种子值 DEAL_SEED 写 成具名常量。连跑两次导出脚本,packets.json 逐字节无 diff。 test_fixture.js 补强三条断言:底牌"只发给部分座位"改为精确断言恰好1个座位; 新增 packetIndex/roomtype/step 自洽性检查;step5 快照的 playproc 回归锁直接 捕获本次的活引用污染类缺陷;deskinfo 阶段覆盖从"≥3个"改为显式钉死 [1,2,3,5,6]。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
59 lines
2.9 KiB
JavaScript
59 lines
2.9 KiB
JavaScript
// 夹具自检:真包必须覆盖全部阶段,且保留座位差异
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { ROOT } = require('./_load');
|
||
const t = require('./_assert')();
|
||
|
||
const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8'));
|
||
|
||
t.eq('三个座位', fx.seats.length, 3);
|
||
|
||
const rpcsOf = seat => fx.seats[seat].map(p => p.rpc);
|
||
const allRpcs = [].concat(rpcsOf(0), rpcsOf(1), rpcsOf(2));
|
||
|
||
['fapai','jiaofen','shangzhuang','xuanzhu','maipai','chupai1','chupai2','chupai3','jiesuan']
|
||
.forEach(rpc => t.eq('夹具覆盖 ' + rpc, allRpcs.indexOf(rpc) >= 0, true));
|
||
|
||
// 差异化下发确实存在:bottomcards 恰好只发给庄家一人(不是"少于三家"这种可被空集合蒙混过去的弱断言)
|
||
const withBottom = [0,1,2].filter(s =>
|
||
fx.seats[s].some(p => p.rpc === 'shangzhuang' && p.data.hasOwnProperty('bottomcards')));
|
||
t.eq('底牌恰好发给1个座位(庄家)', withBottom.length, 1);
|
||
|
||
// deskinfo 快照必须覆盖全部 5 个阶段(下游任务要用 step3 埋牌快照做回归断言)
|
||
const steps = fx.deskinfo.map(d => d.step).filter((v, i, a) => a.indexOf(v) === i).sort((a,b) => a - b);
|
||
t.eq('deskinfo 覆盖全部阶段 1/2/3/5/6', steps, [1, 2, 3, 5, 6]);
|
||
|
||
// roomtype 必须存在且非空,Task 12 要用它还原房间选项
|
||
t.eq('roomtype 存在且非空', typeof fx.roomtype === 'string' && fx.roomtype.length > 0, true);
|
||
|
||
// packetIndex 必须落在 (0, 该座位包总数] 区间内,且与 info.step 自洽
|
||
fx.deskinfo.forEach(d => {
|
||
const inRange = d.packetIndex > 0 && d.packetIndex <= fx.seats[d.seat].length;
|
||
t.eq('packetIndex 落在有效区间 (step' + d.step + ' seat' + d.seat + ')', inRange, true);
|
||
t.eq('deskinfo.step 与 info.step 一致 (seat' + d.seat + ' pi' + d.packetIndex + ')', d.step, d.info.step);
|
||
});
|
||
|
||
// C-1 回归锁:同一座位的两条 step5 快照,playproc(当前轮桌面牌/currseat/round)不得相同——
|
||
// 否则说明 deskinfo 快照存的是活引用,被后续出牌篡改成了终局状态(曾经的真实缺陷)
|
||
const step5BySeat = {};
|
||
fx.deskinfo.filter(d => d.step === 5).forEach(d => {
|
||
(step5BySeat[d.seat] = step5BySeat[d.seat] || []).push(d);
|
||
});
|
||
Object.keys(step5BySeat).forEach(seat => {
|
||
const list = step5BySeat[seat];
|
||
if (list.length < 2) { return; }
|
||
for (let i = 1; i < list.length; i++) {
|
||
const same = JSON.stringify(list[0].info.PushCards.playproc) === JSON.stringify(list[i].info.PushCards.playproc);
|
||
t.eq('seat' + seat + ' 两条 step5 快照 playproc 不相同(未被活引用污染)', same, false);
|
||
}
|
||
});
|
||
|
||
// 每个包都有 success(协议 §0.1:每个下发包的 data 必带 success)
|
||
const noSuccess = [];
|
||
[0,1,2].forEach(s => fx.seats[s].forEach(p => {
|
||
if (!p.data || !p.data.hasOwnProperty('success')) { noSuccess.push(s + ':' + p.rpc); }
|
||
}));
|
||
t.eq('每个下发包都带 success', noSuccess, []);
|
||
|
||
process.exit(t.done('fixture') ? 0 : 1);
|