Files
erqiwang_youle/server/games/erqiwang/test/test_endgame.js
T
joywayerandClaude Opus 5 ce4c972b7b 二七王:修复跟牌判定依赖客户端入参顺序(可翻转本轮胜负与结算)
can_followcard 尾段计算 cardvalue/noflower/nopair 时用的是入参原始数组
followcards(客户端提交顺序),而 get_pairlist/get_tuolaji_list 都按降序
相邻取对。端到端实测:闲家用主拖拉机毙牌且为末轮,降序提交时闲家赢下本轮
(捡分 40、扣底 ×4),把同一手牌打乱成 [51,50,105,104] 后对子漏判、
cardvalue 归 0,变成庄家赢、闲家 0 分大光、不扣底——同一手合法牌仅靠数组
顺序就能翻转胜负、捡分归属与最终结算,同源问题还能抹掉 noflower 缺门标志
污染 §9 下发给全场的牌况表。

改为在 min_ary_deduct 削减 _followcards 之前另存完整排序快照 _sortfollow,
尾段 8 处全部改用它(_followcards 会被削减、不可复用)。

顺带给 get_disbandRoom 加空守卫:平台在 makewar 后立刻置 battlestate=1,
而首局是延迟 1 秒创建的,这段窗口内解散会在 curr_paiju() 的 undefined 上
解引用抛异常、打断平台解散链路;现返回 null 走平台既有的「不带 deskfree」分支。

补 8 条回归(入参顺序无关性 5 条 + 端到端 1 条 + 解散空守卫 2 条),
已用「回退修复 → 用例转红」反验有效性;全套单测 401 项全绿。

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

129 lines
7.8 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 解引用抛异常打断平台的整条解散链路
t.eq('解散·牌局尚未创建 → 返回 null 不抛异常',
EXPORT.get_disbandRoom({ o_desk: { method: { curr_paiju: () => undefined } } }), null);
t.eq('解散·牌桌尚未创建 → 返回 null 不抛异常', EXPORT.get_disbandRoom({}), 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);