问题:大局结算面板要分项显示,而 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>
183 lines
12 KiB
JavaScript
183 lines
12 KiB
JavaScript
// 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);
|