二七王:修复夹具导出的活引用污染并引入固定种子

审查发现 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>
This commit is contained in:
2026-08-27 18:35:02 +08:00
co-authored by Claude Opus 5
parent e61725da40
commit e2d470d37f
3 changed files with 17253 additions and 17232 deletions
+30 -5
View File
@@ -14,14 +14,39 @@ 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 不该三家都有
// 差异化下发确实存在:bottomcards 恰好只发给庄家一人(不是"少于三家"这种可被空集合蒙混过去的弱断言)
const withBottom = [0,1,2].filter(s =>
fx.seats[s].some(p => p.rpc === 'shangzhuang' && p.data.hasOwnProperty('bottomcards')));
t.eq('底牌只发给部分座位', withBottom.length < 3, true);
t.eq('底牌恰好发给1个座位(庄家)', withBottom.length, 1);
// deskinfo 快照覆盖多个阶段
const steps = fx.deskinfo.map(d => d.step).filter((v, i, a) => a.indexOf(v) === i).sort();
t.eq('deskinfo 覆盖 ≥3 个阶段', steps.length >= 3, true);
// 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 = [];