// 导出脚本(测试代码,跑在 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);