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

审查发现 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
+24 -1
View File
@@ -33,6 +33,22 @@ const OUT = path.join(ROOT, 'client/tests/fixtures/packets.json');
// 常规房:可查牌、不爬坡、不傍王(roomtype 位串全 0,见 class.config.js)
const ROOMTYPE = '00000';
// 固定种子:只在本导出脚本进程内替换 global.min_random(不改 server/_shim.js),
// 让「同一份脚本重复运行」产出逐字节相同的 packets.json,便于 review diff 与回归。
// 写法与服务端既有测试(test_flow.js/test_fuzz.js/test_leak.js)的 xorshift 种子一致。
// 改这个值会重新生成整局牌(发牌结果、叫分走向、出牌过程全变),产物 diff 会很大。
const DEAL_SEED = 0x5A5A2026;
(function seedDeal(seed) {
let state = seed >>> 0;
const rnd = function (n) {
state ^= state << 13; state >>>= 0;
state ^= state >>> 17;
state ^= state << 5; state >>>= 0;
return state % n;
};
global.min_random = function (min, max) { return min + rnd(max - min + 1); };
})(DEAL_SEED);
const clone = function (m) { return JSON.parse(JSON.stringify(m)); };
// 装配一个"真实牌桌":D.new() 建牌桌,global.youle_erqiwang.app/import 与
@@ -91,13 +107,20 @@ function drain() {
}
// packetIndex 是关键对齐锚点:取快照时先 drain,记下该座位此刻已收到的包数,
// 与 Task 12 一致性测试"喂完 seats[seat] 的前 packetIndex 个包"对齐。
//
// 【必须 clone】class.export.js 的 get_deskinfo 在 step5 分支对 PushCards.playproc /
// PushCards.seatlist 是【无拷贝的活引用赋值】(deskinfo.PushCards.playproc = paiju.playproc)。
// 真实链路里这份返回值被平台立刻序列化下发,活引用不会被观测到;但本脚本要跑完整局之后才
// 统一 JSON.stringify 写盘,若不在这里当场拷贝一份,写盘时这些字段会被后续出牌篡改成终局
// 状态,导致同一座位不同 packetIndex 下的快照"逐字节相同"——这是本脚本自身的缺陷,与服务端
// 无关(服务端没有"延后序列化"这个用法)。
function snap(step, seat) {
drain();
deskinfo.push({
step: step,
seat: seat,
packetIndex: seatPackets[seat].length,
info: E.get_deskinfo(o_room, seat)
info: clone(E.get_deskinfo(o_room, seat))
});
}