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