Files
erqiwang_youle/client/tests/fixtures/export_packets.js
T
joywayerandClaude Opus 5 f7b87f32e8 二七王:重新生成真包夹具、补捕 StartWar、修复一致性测试用例间污染
服务端 8452535 改了下发面(chupai1/2/3 新增 playproc、deskinfo step2/3 新增
curmultiple、maipai 新增 seatlist、bottomcards/burycards 顺序统一),前端真包
夹具 packets.json 已过期,重新导出并逐项核对新字段存在与三处/两处顺序一致。

夹具新增顶层 startwar[](每座位一份,结构=deskwar=deskinfo,取自 do_new_paiju
之后、jiaofen 之前的同一时刻),独立存放、不混入 seats[],避免 packetIndex 对
齐锚点错位;test_fixture.js 补相应断言。

修复 test_consistency.js 的用例间污染:EQW_GameState.reset() 按设计不清
room.playerScores(跨局累积量),旧版增量路径从不写它,实际靠"沿用同进程里
上一次全量路径写入的残留"凑出假绿,换座位顺序或调整执行顺序就会转红。改为
每个对齐点独立起步(buildIncremental/buildFull 各自 reset),且增量路径先喂
StartWar 再喂增量包,与真实客户端时序一致,不再依赖任何隐式残留。已验证正
序/倒序跑出的 PASS/FAIL 集合逐条相同。

修完后 23 checks:15 绿、8 红。红项均已归因:3 处为任务已知的前端未接线缺口
(table.playproc 映射、table.pushlist 累积、maipai.seatlist 消费),另发现两
类同类缺口(ResyncHandler 未映射 deskinfo 新增的 curmultiple 字段、BuryHandler
埋牌后未清空 my.bottomCards),均未改动 client/js 正式代码,留给下一个前端任务。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:28:13 +08:00

206 lines
10 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();
// StartWar(开局握手,前端 04/05 讲的"增量路径真实起点"):真实链路里客户端开局收到的
// 第一包,结构就是 deskwar —— 即 get_deskinfo() 的返回值(见 server/youle/server_room/
// rpc.js 里 pack.data.deskwar = youle_room.import.makewar_deskwar(o_room) 一段)。
// 当前 erqiwang 的 exp.makewar(class.export.js)尚未把 get_deskinfo 接成返回值,这是
// 另一项任务;本脚本独立调用 get_deskinfo 反映"接线后客户端此刻会收到什么",让前端测试
// 能提前对齐这条起点(尤其是 room.playerScores 这类 fapai 包不带、只有 StartWar 能给的初值)。
// 与紧随其后的 step1 快照取同一时刻(do_new_paiju 之后、任何 jiaofen 之前),按座位各存
// 一份(三家 MyCards 视角不同),套一层 { deskwar: ... } 外壳与生产环境 pack.data.deskwar
// 的包裹方式对齐,供 EQW_ResyncHandler.handleStartWar 直接消费。
const startwar = [0, 1, 2].map(function (seat) {
return { deskwar: clone(E.get_deskinfo(o_room, seat)) };
});
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,
startwar: startwar,
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);