// 导出脚本(测试代码,跑在 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 差异 // ================= 出牌策略 ================= // 【为什么不再是"全出单张"】:单张一手无所谓顺序,服务端「这一手打出的牌」的两个顺序口径 // (chupai/playproc.cards 与 deskinfo.PushCards.pushlist)在单张局面下永远相等,夹具测不出 // 分岔。甩牌(design §5.4)与领对子会产生"一手多张",才真正压到这条契约。 // // 策略(确定性,不含随机): // 首家:优先甩牌(多分量主牌,且服务端 can_playcard 判定合法)→ 其次领最小的一对 // → 否则最小单张 // 跟家:先取 get_followcard 的必出牌,再从可出牌里补足张数,逐个组合试到 // can_followcard(+ 甩牌时的 flush_follow_ok)通过为止 // // 【提交顺序刻意用升序】:真实客户端提交的是玩家的点击顺序,服务端不得依赖它 //(server 04 §8「前端不是数据源」;class.arith.js can_followcard 里也有同样的告诫)。 // 服务端的权威展示顺序是 order_cards 的【降序】,所以这里一律把选好的一手【反转成升序】 // 再提交,模拟"玩家从小到大点选"。这不是伪造数据——包仍然是服务端真实产出的, // 只是让请求侧的顺序与权威顺序刻意不同,把"下发顺序是否被请求顺序污染"暴露出来。 const asc = function (cards) { return A.order_cards(pj.flower, cards.concat()).reverse(); }; // 两名对手未出的主牌(供甩牌最大性判定),与 class.paiju.js do_playcard 的取法一致 function oppZhuList(seat) { const list = []; for (let os = 0; os < 3; os++) { if (os !== seat) { list.push(P.get_seat_zhucards(pj, os)); } } return list; } // 首家:尝试甩牌——把手上主牌分解成分量,按分量牌力从大到小取前 k 个(k 从多到少), // 交给服务端 can_playcard 判定;只接受 result===true 的(合法甩牌), // 不提交会被判"甩错"的组合(那会被服务端收回成单张,反而回到单张局面)。 // 注意 can_playcard 会【就地排序】入参数组,故一律传副本。 function tryShuai(seat, hand) { const trumps = hand.filter(function (c) { return A.id_to_code(pj.flower, c) > 1000; }); if (trumps.length < 2) { return null; } const comps = A.decompose_trump(pj.flower, trumps).slice() .sort(function (a, b) { return b.value - a.value; }); const opp = oppZhuList(seat); for (let k = Math.min(comps.length, 3); k >= 2; k--) { let pick = []; for (let i = 0; i < k; i++) { pick = pick.concat(comps[i].cards); } if (A.can_playcard(pj.flower, pick.concat(), seat, pj.seatlist, opp).result) { return pick; } } return null; } // 首家:尝试领一对。取【最大的一对】——甩牌合法的前提是两名对手都压不过任一分量 //(§5.4.2 opp_can_beat_flush),大牌先走能更快拔掉对手的主牌,后续局面才会出现合法甩牌。 // 这不是"为了凑测试"的取巧:本夹具的目的就是覆盖甩牌,而甩牌本来就只在这种局面下成立。 // 同一颗种子下:领最小的一对 → 全局只有 1 手合法甩牌;领最大的一对 → 4 手。 function tryLeadPair(seat, hand) { const pairs = A.get_pairlist(pj.flower, hand.concat()); if (!pairs.length) { return null; } const pick = pairs[0].concat(); if (A.can_playcard(pj.flower, pick.concat(), seat, pj.seatlist, oppZhuList(seat)).result) { return pick; } return null; } // 跟家:这一手是否被服务端接受(与 class.paiju.js do_playcard 的跟牌校验同一组判定) function followOk(hand, cand) { const proc = pj.playproc; if (!A.can_followcard(pj.flower, hand, cand, proc.startcount, proc.startflower, proc.starttype).result) { return false; } if (proc.shuai_demand && !A.flush_follow_ok(pj.flower, hand, cand, proc.shuai_demand)) { return false; } return true; } // 跟家:凑出 startcount 张的合法跟牌 function pickFollow(seat, hand) { const proc = pj.playproc; const n = proc.startcount; const get = A.get_followcard(pj.flower, hand.concat(), n, proc.startflower, proc.starttype); const base = (get && get.mustcard) ? get.mustcard.concat() : []; if (base.length === n) { return followOk(hand, base) ? base : null; } const need = n - base.length; if (need < 0) { return null; } // 候选池:优先服务端给出的"可出的牌",为空则退回整手牌 let pool = (get && get.cancard && get.cancard.length) ? get.cancard.concat() : hand.concat(); pool = pool.filter(function (c) { return base.indexOf(c) < 0; }); let found = null; const combo = []; (function walk(start) { if (found) { return; } if (combo.length === need) { const cand = base.concat(combo); if (followOk(hand, cand)) { found = cand; } return; } for (let i = start; i < pool.length && !found; i++) { combo.push(pool[i]); walk(i + 1); combo.pop(); } })(0); return found; } // 打到一半时的 step5 快照锚点(按已出手数计)。 // 【为什么要不止一个】:出牌阶段开局那三张 step5 快照的 pushlist 都还是空的, // 只有"打到一半"的快照才真的比对到出牌历史(一致性测试里唯一压到 pushlist 的点)。 // 一个锚点=一个比对点,太薄;多取几个覆盖不同轮次/不同座位/轮中不同位次。 const MID_SNAP_AT = [4, 9, 16, 25, 34]; let guard = 0; let shuaiHands = 0; // 统计:合法甩牌手数 let multiHands = 0; // 统计:一手多张的手数(含跟牌) 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 = tryShuai(seat, hand); if (pick) { shuaiHands++; } if (!pick) { pick = tryLeadPair(seat, hand); } if (!pick) { pick = [hand[hand.length - 1]]; } } else { pick = pickFollow(seat, hand); } // 找不到合法出牌属脚本缺陷(服务端保证当前座位总有牌可出),显式失败、不静默跳过 if (!pick) { throw new Error('export_packets: 座位 ' + seat + ' 第 ' + guard + ' 手凑不出合法出牌'); } if (pick.length > 1) { multiHands++; } mod.chupai(pk(seat, { cards: asc(pick) })); // 出牌进行到中段时额外拍 step5 快照,覆盖"打到一半"的重连场景 if (MID_SNAP_AT.indexOf(guard) >= 0 && pj.step === 5) { 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('出牌手数: ' + guard + '(其中一手多张 ' + multiHands + ' 手,合法甩牌 ' + shuaiHands + ' 手)'); console.log('paiju.step(结束时)=' + pj.step + ' result=' + pj.result);