二七王:修复夹具导出的活引用污染并引入固定种子
审查发现 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:
+24
-1
@@ -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))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Vendored
+17199
-17226
File diff suppressed because it is too large
Load Diff
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user