Files
erqiwang_youle/server/games/erqiwang/test/test_endgame.js
T
joywayerandClaude Opus 5 49ee068c5c 二七王:解散/重连空守卫用例改为 try/catch,避免回归时整个文件崩溃
复检本会话修复项时发现:`解散·牌桌尚未创建 → 返回 null` 这两条用例是直接调
get_disbandRoom 的。守卫一旦被去掉,被测函数当场抛异常,未捕获时 test_endgame
在此中断,后面的「出牌入参顺序无关性」等断言全不执行——看到的是崩溃而不是
某条断言转红,定位与信号都差。改用 noThrow 包一层,异常转成可读的断言失败。

顺带补 get_deskinfo 的同名守卫用例(平台在开战前也可能回调重连)。两个守卫
都做了变异检验:分别去掉后对应断言干净转红,且文件后续断言照常执行。

全套单测 519 → 520 项全绿。

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

134 lines
8.3 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.
// L4 端到端:用真实发牌跑完整一局(叫分→上庄→选主→埋牌→28轮出牌→小局结算),
// 校验 design §12.1 的流程闭合与一批与牌面无关的不变量(牌张守恒/捡分一致/结算零和/分配公式)。
// 另覆盖 §4.4 投降局与 §12.2 中途解散两条支线。
// 说明:牌是随机发的,但断言的都是**与具体牌面无关**的不变量,不会因发牌不同而抖动。
const { mod, setup } = require('./_rpc.js');
const t = require('./_assert')();
const P = global.cls_youle_erqiwang_paiju;
const AR = global.cls_youle_erqiwang_arith;
const pk = (seat, d) => ({ conmode: 0, fromid: seat, data: Object.assign({ agentid: 1, playerid: seat, gameid: 1, roomcode: 1, seat: seat }, d || {}) });
// 开局并让 0 号位以 call 分坐庄(另两家不叫)
function seatBanker(roomtype, call) {
const pj = P.new({ paiju_list: [] }, 0);
const ctx = setup(roomtype, pj);
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: call }));
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 }));
return { pj, ctx };
}
// 出牌驱动:首家出手上最小的单张,跟家取第一张能通过 can_followcard 的牌
function playAll(pj) {
let guard = 0;
while (pj.step === 5 && ++guard < 200) {
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) {
pick = [hand[hand.length - 1]];
} else {
for (const c of hand) {
if (AR.can_followcard(pj.flower, hand, [c], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [c]; break; }
}
}
if (!pick) { return false; }
mod.chupai(pk(seat, { cards: pick }));
}
return pj.step === 6;
}
// ============ §12.1 正常局全流程 ============
const { pj, ctx } = seatBanker("00000", 65);
t.eq('§4.2 坐庄(banker/call/step)', [pj.banker, pj.call, pj.step], [0, 65, 2]);
t.eq('§4.3 庄家摸暗牌后36张 / 闲家28张', [P.get_seat_cards(pj, 0).length, P.get_seat_cards(pj, 1).length], [36, 28]);
mod.xuanzhu(pk(0, { flower: 1 }));
t.eq('§4.4 选主后进埋牌阶段', [pj.flower, pj.step], [1, 3]);
mod.maipai(pk(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
t.eq('§4.5 埋牌后庄家28张、进出牌阶段', [P.get_seat_cards(pj, 0).length, pj.step], [28, 5]);
t.eq('§5.1 第一轮固定由庄家先出', [pj.playproc.round, pj.playproc.start], [1, 0]);
t.eq('§5/§6 28轮打完进入结算', playAll(pj) && pj.playproc.round, 28);
t.eq('§2 牌张守恒(出84 / 埋8 / 去掉3-4共16)',
[pj.cards.filter(c => c.playround > 0).length, pj.cards.filter(c => c.playround === 0).length, pj.cards.filter(c => c.number === 3 || c.number === 4).length],
[84, 8, 16]);
const last = ctx.sent[ctx.sent.length - 1];
const aset = last.data.aset;
t.eq('结算包 rpc/success', [last.rpc, last.data.success], ['jiesuan', true]);
t.eq('§11 结束亮出底牌8张', last.data.bottom.cards.length, 8);
// §6.2/§6.3:闲家捡分 = 闲家赢下的分牌 + 扣底(可能翻倍)分
let jian = 0;
for (const c of pj.cards) { if (c.playowner > -1 && c.playowner !== pj.banker) { jian += c.score; } }
const bonus = last.data.bottom.grade2 || 0;
t.eq('§6.2/§6.3 捡分 = 闲家赢得分牌 + 扣底分', aset.grade, jian + bonus);
// §6.3:扣底只在「闲家用主牌赢下末轮」时发生
const lastWinIsXian = pj.playproc.maxseat !== pj.banker;
const lastMult = lastWinIsXian ? AR.get_bottom_multiple(pj.flower, pj.playproc.cards[pj.playproc.maxseat]) : 0;
t.eq('§6.3 扣底触发条件(闲家 + 主牌)', lastMult > 0, !!last.data.bottom.multiple);
// §7 第3层:X = 基础子数 × |判定倍率|,庄家 ±2X、闲家各 ∓X
const X = aset.multiple * Math.abs(aset.upgrade);
const win = aset.upgrade > 0 ? 1 : -1;
t.eq('§7 捡分子数分配 庄±2X / 闲各∓X', aset.seatlist.map(p => p.grade_jf), [win * X * 2, -win * X, -win * X]);
// §8.4 第4层:算奖线 X×(2Ni−Nj−Nk)
const N = aset.seatlist.map(p => p.naward);
t.eq('§8.4 算奖分配 X×(2Ni−Nj−Nk)', aset.seatlist.map(p => p.grade_aw),
[X * (2 * N[0] - N[1] - N[2]), X * (2 * N[1] - N[0] - N[2]), X * (2 * N[2] - N[0] - N[1])]);
t.eq('本局结算零和', aset.seatlist.reduce((s, p) => s + p.grade, 0), 0);
t.eq('§4.6 result 与判定一致(0庄赢/1闲赢)', pj.result, aset.upgrade > 0 ? 0 : 1);
// ============ §4.4 / §7.1 投降局 ============
const tx = seatBanker("00000", 70);
t.eq('§4.3 70分坐庄:暗牌亮3秒标志 + 闲家也收到暗牌',
[tx.ctx.sent[tx.ctx.sent.length - 1].data.ancard3s, tx.ctx.sent[tx.ctx.sent.length - 2].data.bottomcards.length], [1, 8]);
mod.touxiang(pk(0));
const txa = tx.ctx.sent[tx.ctx.sent.length - 1].data.aset;
t.eq('§7.1 投降:base=1 / 庄-2、闲各+1', [txa.multiple, txa.seatlist.map(p => p.grade_jf)], [1, [-2, 1, 1]]);
t.eq('§4.4 投降不选主、不埋牌', [tx.pj.flower, tx.pj.cards.filter(c => c.playround === 0).length], [-1, 0]);
t.eq('§8.4 投降算奖按庄家36张快照', P.get_seat_cards_award(tx.pj, 0).length, 36);
t.eq('§4.6 投降 result=2(下家坐庄)', tx.pj.result, 2);
// ============ §12.2 中途解散 ============
const ds = seatBanker("00000", 65);
mod.xuanzhu(pk(0, { flower: 1 }));
const EXPORT = require('../class.export.js').new();
const dsmsg = EXPORT.get_disbandRoom(ds.ctx.o_room);
t.eq('§12.2 解散:不结算子数、result=3', [dsmsg.data.aset.multiple, dsmsg.data.aset.seatlist.map(p => p.grade), ds.pj.result], [0, [0, 0, 0], 3]);
// 平台在 makewar 后立刻置 battlestate=1,而首局是延迟 1 秒创建的:这段窗口内解散必须返回 null,
// 而不是让 curr_paiju() 的 undefined 解引用抛异常打断平台的整条解散链路。
// 这里必须 try/catch:守卫一旦被去掉,被测函数会直接抛异常,未捕获时整个文件在此中断,
// 后面的断言全不执行——看到的是崩溃而不是某条断言转红(实测过)。
const noThrow = fn => { try { return fn(); } catch (e) { return '抛异常: ' + e.message; } };
t.eq('解散·牌局尚未创建 → 返回 null 不抛异常',
noThrow(() => EXPORT.get_disbandRoom({ o_desk: { method: { curr_paiju: () => undefined } } })), null);
t.eq('解散·牌桌尚未创建 → 返回 null 不抛异常', noThrow(() => EXPORT.get_disbandRoom({})), null);
// get_deskinfo 有同一个 o_desk 守卫(平台在开战前也可能回调),一并钉住
t.eq('重连·牌桌尚未创建 → 返回 null 不抛异常', noThrow(() => EXPORT.get_deskinfo({}, 0)), null);
// ============ 出牌入参顺序无关性(端到端)============
// 同一手完全合法的跟牌,仅客户端提交的数组顺序不同,本轮胜者/捡分/扣底/结算判定必须完全一致。
// 构造:庄家(0)首出 红心KKQQ(副牌两连对,含20分),闲家(1)用 黑桃KKQQ(主拖拉机,含20分)毙牌,
// 闲家(2)垫方块杂牌;这是本局最后一轮,故同时覆盖扣底判定。
function oneTrick(followOrder) {
const p = P.new({ paiju_list: [] }, 0);
const c = setup("00000", p);
p.banker = 0; p.call = 65; p.flower = 4; p.step = 5;
// 先把所有牌置为「第1轮已由庄家赢走」,再把参战的 12 张发回三家手上
for (const card of p.cards) { card.playround = 1; card.playowner = 0; }
const give = (ids, owner) => ids.forEach(i => Object.assign(p.cards[i], { dealowner: owner + 1, playround: -1, playowner: -1 }));
give([38, 92, 37, 91], 0); // 红心 K K Q Q
give([51, 105, 50, 104], 1); // 黑桃 K K Q Q(主)
give([5, 7, 8, 10], 2); // 方块杂牌
P.new_playround(p, 1, 0);
mod.chupai(pk(0, { cards: [38, 92, 37, 91] }));
mod.chupai(pk(1, { cards: followOrder }));
mod.chupai(pk(2, { cards: [5, 7, 8, 10] }));
const m = c.ctx ? c.ctx : c;
const j = m.sent[m.sent.length - 1];
return [p.playproc.maxseat, j.data.chupai.grade, j.data.aset.grade, j.data.bottom.multiple];
}
const ordered = oneTrick([51, 105, 50, 104]); // 降序
const shuffled = oneTrick([51, 50, 105, 104]); // 乱序(把两个对子拆开摆)
t.eq('序无关 端到端·有序提交:闲家毙牌赢下末轮并扣底×4', ordered, [1, 40, 40, 4]);
t.eq('序无关 端到端·乱序提交结果完全一致', shuffled, ordered);
process.exit(t.done('endgame') ? 0 : 1);