服务端 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>
206 lines
10 KiB
JavaScript
206 lines
10 KiB
JavaScript
// 导出脚本(测试代码,跑在 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);
|