Files
erqiwang_youle/server/games/erqiwang/test/test_endgame.js
T
joywayerandClaude Opus 5 76e82405e1 二七王:S-6 大局结算按 基础分/冲关分/傍王分 三项分解
问题:大局结算面板要分项显示,而 account 只有「累积得分 + 每局得分数组」。
傍王分尤其拆不出来——N = 冲关奖数 + 傍王王数,两个来源【求和之后】才代入
grade_aw = X×(2Ni−Nj−Nk),事后无法反推各自占比。

解法:在算钱时就拆。把 N 分成 N_冲关(只计庄家)与 N_傍王(勾选后庄闲都算),
分别代入同一公式各算一次。公式对 N 线性,故恒有
  grade_cg + grade_bw == grade_aw
这条恒等式正好当回归断言用。

服务端:
- class.paiju.js 抽出 award_pay(n) 复用同一公式,新增 grade_cg / grade_bw;
  desk.seatlist 追加三个累计位(基础/冲关/傍王)。
- class.desk.js seatlist 初始化为 5 元素;account 下发改为【对象数组】
  { score, grades, grade_jf_total, grade_cg_total, grade_bw_total }。
  原二元数组下标语义不清,且前端未开工,此时改代价最小(同 S-4 的判断)。

协议:aset.seatlist 增补 grade_cg / grade_bw;account 结构与恒等式
  grade_jf_total + grade_cg_total + grade_bw_total == score 写明;
  并记录这次结构变更的时间与理由。

测试(endgame 26 → 39 checks):
- 核心不变量:两分量之和 == grade_aw;两分量各自零和。
- 未勾傍王局:grade_bw 恒 0、grade_cg == grade_aw。
- 【新增一整局开傍王的端到端】:不开傍王时 grade_bw 恒 0,只验到平凡情形,
  必须有傍王局才算真验证了拆分。该局逐项复核两个分量的公式、naward 口径,
  并钉住「王数不全相等时 grade_bw 必有非零」防止又退化成平凡。
- 大局累计恒等式:基础+冲关+傍王 == 累积总分。

反向验证:把 _awbw 置零 → 4 条断言失败;把冲关分量误算到闲家头上 →
2 条断言失败。均已还原、全量 17 文件全绿。

另修四处测试桩:_rpc.js / test_paiju / test_rpc / test_success 里手工构造的
o_desk.seatlist 仍是 2 元素,未跟上 class.desk.js 的结构变更(脚本缺陷,
非业务缺陷)。

顺带解决小局结算「冲关分」标签的不严谨:改取 grade_cg 而非 grade_aw,
开傍王时不再把傍王贡献算进来。

遗留 T-31:参考图「基础分」与「总得分」两行数值相同且与右侧大字对不上
(30/30 vs +135),mock 不自洽。当前按 基础分=grade_jf_total、总分=score
提供。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 00:34:46 +08:00

183 lines
12 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])]);
// S-6:算奖得分按【冲关 / 傍王】两个分量分别再算一次,供大局结算分项展示。
// 公式对 N 线性,故两分量之和必须恒等于 grade_aw——这是拆分正确性的核心不变量。
t.eq('S-6 grade_cg + grade_bw == grade_aw',
aset.seatlist.map(p => p.grade_cg + p.grade_bw), aset.seatlist.map(p => p.grade_aw));
// 分量口径:冲关只计庄家;傍王未勾选(roomtype "00000" 位2=0)时应恒为 0
t.eq('S-6 未勾傍王 → grade_bw 全 0', aset.seatlist.map(p => p.grade_bw), [0, 0, 0]);
t.eq('S-6 未勾傍王 → grade_cg == grade_aw', aset.seatlist.map(p => p.grade_cg), aset.seatlist.map(p => p.grade_aw));
t.eq('本局结算零和', aset.seatlist.reduce((s, p) => s + p.grade, 0), 0);
t.eq('S-6 两个分量各自零和', [
aset.seatlist.reduce((s, p) => s + p.grade_cg, 0),
aset.seatlist.reduce((s, p) => s + p.grade_bw, 0)], [0, 0]);
t.eq('§4.6 result 与判定一致(0庄赢/1闲赢)', pj.result, aset.upgrade > 0 ? 0 : 1);
// ============ S-6:开傍王局的分量拆分 + 大局累计 ============
// 上面那局没开傍王,grade_bw 恒 0,只验到平凡情形。这里开傍王(roomtype 位2=1)跑一整局,
// 让两个分量都非零,才算真的验证了「N 拆成冲关/傍王分别代入公式」这件事。
{
const bw = seatBanker("00100", 65); // 位2=1 → 傍王开
mod.xuanzhu(pk(0, { flower: 1 }));
mod.maipai(pk(0, { cards: P.get_seat_cards(bw.pj, 0).slice(-8) }));
t.eq('S-6 傍王局 打满28轮', playAll(bw.pj) && bw.pj.playproc.round, 28);
const a = bw.ctx.sent[bw.ctx.sent.length - 1].data.aset;
t.eq('S-6 傍王局 bangwang 标志', a.bangwang, 1);
// 核心不变量:两分量之和 == grade_aw
t.eq('S-6 傍王局 grade_cg + grade_bw == grade_aw',
a.seatlist.map(p => p.grade_cg + p.grade_bw), a.seatlist.map(p => p.grade_aw));
// 分量各自零和(每条支付线都是三家之间的转移,不产生也不消灭分数)
t.eq('S-6 傍王局 两分量各自零和', [
a.seatlist.reduce((s, p) => s + p.grade_cg, 0),
a.seatlist.reduce((s, p) => s + p.grade_bw, 0)], [0, 0]);
// 口径:naward = 冲关(仅庄家) + 王数(傍王开)
const X = a.multiple * Math.abs(a.upgrade);
t.eq('S-6 傍王局 naward 口径', a.seatlist.map((p, i) => p.naward),
a.seatlist.map((p, i) => (i === a.banker ? p.chongguan : 0) + p.wang));
// 傍王分量必须真的非零,否则这一整块又是平凡情形(三家王数全相等时净额才会是 0)
const wangs = a.seatlist.map(p => p.wang);
const wangAllSame = wangs[0] === wangs[1] && wangs[1] === wangs[2];
t.eq('S-6 傍王局 王数不全相等时 grade_bw 必有非零', wangAllSame || a.seatlist.some(p => p.grade_bw !== 0), true);
// 逐项复核傍王分量的公式
const NB = a.seatlist.map(p => p.wang);
t.eq('S-6 傍王分量 == X×(2Ni−Nj−Nk)', a.seatlist.map(p => p.grade_bw),
[X * (2 * NB[0] - NB[1] - NB[2]), X * (2 * NB[1] - NB[0] - NB[2]), X * (2 * NB[2] - NB[0] - NB[1])]);
// 冲关分量:只有庄家有 N_cg,闲家为 0
const NC = a.seatlist.map((p, i) => (i === a.banker ? p.chongguan : 0));
t.eq('S-6 冲关分量 == X×(2Ni−Nj−Nk)(仅庄家有 N)', a.seatlist.map(p => p.grade_cg),
[X * (2 * NC[0] - NC[1] - NC[2]), X * (2 * NC[1] - NC[0] - NC[2]), X * (2 * NC[2] - NC[0] - NC[1])]);
// 大局累计:desk.seatlist 后三位分别累计 基础/冲关/傍王,三者之和 == 累积总分
const dsk = bw.pj.o_desk.seatlist;
t.eq('S-6 大局累计 基础+冲关+傍王 == 累积总分',
dsk.map(s => s[2] + s[3] + s[4]), dsk.map(s => s[0]));
}
// ============ §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);