Files
erqiwang_youle/server/games/erqiwang/test/test_endgame.js
T
joywayerandClaude Opus 5 681be60e20 二七王:design 全量复验(无新增不一致)+ 补端到端整局单测
对 E1~E3 修复后的实现做取证式复核:把 design 的判定语句做成可执行探针直接
打在代码上(§5.1/§5.2 跟牌层级、§8.1 算奖连对链、§7 算子逐档、§5.4 甩牌最大性、
§6.3 扣底、§8.4 三条支付线、§4/§9/§10/§11 流程与门控),未发现新的不一致。

新增 test/test_endgame.js:用真实发牌跑完整一局(叫分→上庄→选主→埋牌→28 轮
出牌→小局结算)并校验与牌面无关的不变量(牌张守恒 84/8/16、捡分=闲家赢得分牌
+扣底分、X 分配庄±2X/闲∓X、算奖 X×(2Ni−Nj−Nk)、零和、result 与判定一致),
另覆盖投降局与中途解散两条支线。连跑 10 次无抖动,全套单测 389 项通过。

合规文档补记「第四轮·复验」取证表。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 19:47:01 +08:00

96 lines
5.6 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 dsmsg = require('../class.export.js').new().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]);
process.exit(t.done('endgame') ? 0 : 1);