Files
erqiwang_youle/client/tests/fixtures/export_packets.js
T
joywayerandClaude Opus 5 e2d470d37f 二七王:修复夹具导出的活引用污染并引入固定种子
审查发现 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>
2026-08-27 18:35:02 +08:00

191 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 导出脚本(测试代码,跑在 Node,非正式代码):
// 用服务端 test/_rpc.js + class.desk.js 的既有测试脚手架跑一局完整流程
//(发牌 fapai → 叫分 jiaofen → 上庄 shangzhuang → 选主 xuanzhu → 埋牌 maipai
// → 出牌 chupai1/2/3 若干轮 → 结算 jiesuan),把真实下发包按座位快照导出成
// client/tests/fixtures/packets.json,供前端测试回放(不手写假包,测的是真实契约)。
//
// 用法:node client/tests/fixtures/export_packets.js
//
// 装配方式参照服务端既有测试的两种建立法:
// - _rpc.js 的 setup() 只接受"已构造好的 o_paiju",不走 class.desk.js,
// 因此不会产生 fapai(发牌)包——它是给"单个 RPC 处理器"用的轻量桩。
// - test_flow.js / test_endgame.js 的 mkRoom() 模式改用真实 class.desk.js
// 的 D.new()/D.do_new_paiju(),才会真的下发 fapai;本脚本需要完整流程
// (含 fapai),故照抄这一种既有用法,而不是 setup()。
// 两条路径殊途同归:都是把 mod.app / mod.import 指向同一个 sent 捕获数组,
// 区别只在"谁负责建立 o_room/o_desk/o_paiju"。
'use strict';
const fs = require('fs');
const path = require('path');
// 装配 mod.js 及其依赖类,把关键类挂到 global(与 _rpc.js 内部做法一致)
const R = require('../../../server/games/erqiwang/test/_rpc.js');
const mod = R.mod;
const P = global.cls_youle_erqiwang_paiju;
const A = global.cls_youle_erqiwang_arith;
const D = require('../../../server/games/erqiwang/class.desk.js');
const E = require('../../../server/games/erqiwang/class.export.js').new();
const ROOT = path.resolve(__dirname, '..', '..', '..');
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 与
// mod.app/mod.import 同时指向同一个 sent 捕获数组——class.desk.js 的 fapai/zhunbei
// 走前者(bare 全局引用),mod.js 的 RPC handler 走后者(mod 自身的模块变量),
// 两条路径必须都接上,否则 fapai 会走到 _shim.js 的默认空实现,捕获不到。
function makeRoom(roomtype) {
const sent = [];
const o_room = {
roomtype: roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T',
seatlist: [0, 1, 2].map(function (i) {
return { conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P' + i, avatar: '', gameinfo: {} };
}),
method: { sendpack_toother: function (m) { sent.push(clone(m)); } }
};
const desk = D.new(o_room);
o_room.o_desk = desk;
global.youle_erqiwang.app = { SendPack: function (m) { sent.push(clone(m)); } };
global.youle_erqiwang.import = { check_player: function () { return o_room; }, deduct_roomcard: function () { }, save_grade: function () { } };
mod.import = global.youle_erqiwang.import;
mod.app = global.youle_erqiwang.app;
return { o_room: o_room, desk: desk, sent: sent };
}
const pk = function (seat, extra) {
return {
conmode: 0, fromid: seat,
data: Object.assign({ agentid: 'a', playerid: seat, gameid: 'g', roomcode: 1, seat: seat }, extra || {})
};
};
const room = makeRoom(ROOMTYPE);
const o_room = room.o_room;
const desk = room.desk;
const sent = room.sent;
// ---- 按座位收集下发包(保留差异化下发:谁收到什么、谁没收到) ----
// sent 里每一项要么带明确的 fromid(0/1/2,逐座位分别下发,内容可能互不相同),
// 要么是 sendpack_toother(msg, -1) 的整体广播(未逐座位改写 conmode/fromid,
// fromid 为 undefined)——广播内容三家相同,按"三家都收到"处理。
const seatPackets = [[], [], []];
const deskinfo = [];
let drained = 0;
function drain() {
for (; drained < sent.length; drained++) {
const raw = sent[drained];
const packet = { rpc: raw.rpc, data: raw.data };
if (raw.fromid === 0 || raw.fromid === 1 || raw.fromid === 2) {
seatPackets[raw.fromid].push(packet);
} else {
seatPackets[0].push(packet);
seatPackets[1].push(packet);
seatPackets[2].push(packet);
}
}
}
// 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: clone(E.get_deskinfo(o_room, seat))
});
}
// ================= 跑一局 =================
// 发牌(fapai):class.desk.js 内部随机发牌 + 广播
D.do_new_paiju(desk, 0);
const pj = desk.method.curr_paiju();
snap(1, 0); snap(1, 1); snap(1, 2); // 叫分阶段(step1):三家快照,验证 MyCards 各不相同
// 叫分:0 号位叫 65,其余不叫 → 0 号位上庄(触发 jiaofen 广播 + shangzhuang 差异化下发)
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 65 }));
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
snap(2, 0); snap(2, 1); snap(2, 2); // 选主阶段(step2):庄家有 cards/bottomcards,闲家没有
// 选主:庄家选方块为主
mod.xuanzhu(pk(pj.banker, { flower: 1 }));
snap(3, pj.banker); // 埋牌阶段(step3):庄家视角
// 埋牌:庄家埋掉当前手上最后 8 张(get_seat_cards 已按主牌排序,任取8张即可)
mod.maipai(pk(pj.banker, { cards: P.get_seat_cards(pj, pj.banker).slice(-8) }));
snap(5, 0); snap(5, 1); snap(5, 2); // 出牌阶段开局(step5):出牌者 cardsinhand、下一位 mustcard 差异
// 出牌:首家出手上最小单张,跟家取第一张能通过 can_followcard 的单张——
// 与服务端 test/test_endgame.js 的 playAll() 同一套算法,直至打完整副牌进入结算。
let guard = 0;
let midSnapDone = false;
while (pj.step === 5 && guard < 400) {
guard++;
const seat = pj.playproc.currseat;
const hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) {
pick = [hand[hand.length - 1]];
} else {
for (let i = 0; i < hand.length; i++) {
const c = hand[i];
if (A.can_followcard(pj.flower, hand, [c], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) {
pick = [c];
break;
}
}
}
if (!pick) { break; }
mod.chupai(pk(seat, { cards: pick }));
// 出牌进行到中段时额外拍一张 step5 快照,覆盖"打到一半"的重连场景
if (!midSnapDone && guard === 6 && pj.step === 5) {
midSnapDone = true;
snap(5, pj.playproc.currseat);
}
}
snap(6, 0); snap(6, 1); snap(6, 2); // 结算阶段(step6):jiesuan 广播后
drain(); // 保险:吸收循环末尾可能残留的未 drain 包
const fixture = {
roomtype: ROOMTYPE,
seats: seatPackets,
deskinfo: deskinfo
};
fs.writeFileSync(OUT, JSON.stringify(fixture, null, 2));
console.log('已写入 ' + OUT);
console.log('座位包数: ' + seatPackets.map(function (a) { return a.length; }).join(' / '));
console.log('deskinfo 快照数: ' + deskinfo.length);
console.log('paiju.step(结束时)=' + pj.step + ' result=' + pj.result);