Files
erqiwang_youle/client/tests/fixtures/export_packets.js
T
joywayerandClaude Opus 5 e61725da40 二七王:前端测试的服务端真包夹具
用服务端 test/_rpc.js + class.desk.js 脚手架跑一局(发牌→叫分→上庄→选主→
埋牌→28轮出牌→结算),把真实下发包按座位导出成 JSON 供前端回放。不手写假
包——测的是真实契约,协议文档写错或前端理解偏差都会在这里暴露。

_rpc.js 的 setup() 只接受已构造好的 o_paiju,不走 class.desk.js,产不出
fapai;改用 test_flow.js/test_endgame.js 的 mkRoom() 模式(D.new + 全局
youle_erqiwang.app/import 与 mod.app/mod.import 同指一个 sent 数组),两条
既有用法缺一不可。

夹具自检守住质量:覆盖 fapai/jiaofen/shangzhuang/xuanzhu/maipai/
chupai1/2/3/jiesuan 全部阶段、保留座位差异(底牌只发给部分座位)、每个下发
包都带 success。

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

168 lines
7.5 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';
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 个包"对齐。
function snap(step, seat) {
drain();
deskinfo.push({
step: step,
seat: seat,
packetIndex: seatPackets[seat].length,
info: 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);