Files
erqiwang_youle/client/tests/fixtures/export_packets.js
T
joywayerandClaude Opus 5 8b2cfdaf27 二七王:前端删掉 9 处阶段/控制权推导,一律读服务端包字段
I-3 前端不再持有对局状态机(配合服务端上一提交的下发面补齐):
- 删掉 5 处按 rpc 名硬编码的 aset.step(Deal=1 / Call=2 / Main=3 / Bury=5 / Result=6),
  一律改 _apply 读包里的 step
- 删掉 4 处 turn.seat = aset.banker(CallHandler / MainHandler / ResyncHandler×2),
  增量路径读 nextseat、重连路径读分组里的 seat;同一条规则不再有 4 个写入处

I-2 ResyncHandler._applyBalance 由只接 aset 扩为接 aset/bottom/account,
结算面板开着时重连/硬刷新不再丢抠底明细与末局大结算。
一致性测试 EXCLUDE 随之从 4 条收缩为 2 条(去掉 result.bottom / result.account,
只留一次性事件 table.ancard3s 与归下一轮的 result.chupai),未新增任何豁免条目。

夹具按新包结构重跑(种子不变,牌局不变):diff 纯为 67 行新增字段、0 行删除;
test_fixture 补上 step/nextseat/Balance 同源断言(68→82 checks)。
export_packets.js 里关于 playproc/seatlist 活引用的注释已过时,更正为仍存在的
Balance.readystate 与三份结算快照。

各 handler 单测补齐新字段并新增守卫用例:刻意构造 nextseat != banker、
step 为非预期值的包,使「退回硬编码/banker 反推」的写法必然转红(11/11 已验证)。

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

320 lines
16 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';
// 固定种子:只在本导出脚本进程内替换 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】get_deskinfo 的返回值里仍有若干【无拷贝的活引用赋值】——
// 例如 Balance.readystate = o_desk.prepare、Balance.aset/bottom/account 三份结算快照。
// (PushCards.playproc / PushCards.seatlist / pushlist / burycards 已各自走深拷贝快照函数,
// 这段注释早先把它们也算在内,已过时,现更正。)
// 真实链路里这份返回值被平台立刻序列化下发,活引用不会被观测到;但本脚本要跑完整局之后才
// 统一 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);