二七王:前端测试的服务端真包夹具
用服务端 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>
This commit is contained in:
+167
@@ -0,0 +1,167 @@
|
||||
// 导出脚本(测试代码,跑在 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);
|
||||
Reference in New Issue
Block a user