对 design §1~§12 重做「每条规则是否三类用例齐全」的审计。此前的覆盖矩阵按 「被测函数」组织,容易漏掉跨函数才体现的规则,本轮补齐 9 处: - G1/G2 §4.2 叫分:下限5与非首家不叫接受,负数/缺call/步进外/同分拒; 「不叫即退出本局叫分」——反悔再叫被 SEAT 拒、callproc 不变、仍轮到下一家 - G3 §4.5 埋牌:非庄家 SEAT、step2/step5 STEP、7张/9张/空数组 PARAM - G4 §5.1 每轮由上一轮牌面最大的一方先出(闲1赢/闲2赢两种) - G5 §6.2 闲家赢计入台面全部分牌(含庄家自己打出的)、庄家赢作废、 两个闲家谁赢结果一致 - G6 §8 算奖快照:庄家埋后28(已埋不在内)、闲家28、投降36、打出后不缩水 - G7 §3 对子只认同花色同点数两副:跨花色副7/副2、正副7、大小王均不成对 - G8 §6.3 赢末轮的牌不全是主牌就不扣底(副牌拖拉机/混合出牌/顺序颠倒) - G9 §11 无超时托管守卫:对局阶段 min_ontimeout 调用次数必须为 0, 将来有人加超时代打就会转红 每条都在副本上注入违反该规则的改动确认转红(7 组变异全部被抓住)。 顺带修一个测试自身的问题:mkBury 桩缺 do_burycard,导致「埋牌张数校验被 改松」这类变异表现为整个文件崩溃而非某条断言转红,后续断言全不执行。桩已 补完整到能走完成功路径。 全套单测 462 → 511 项全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
217 lines
15 KiB
JavaScript
217 lines
15 KiB
JavaScript
// paiju 单测:亮牌(§8.2) + 结算 get_paiju_account(§7/§8) 集成
|
||
require('./_shim');
|
||
const P = require('../class.paiju.js');
|
||
const t = require('./_assert')();
|
||
|
||
const id = (d, f, n) => (d - 1) * 54 + (f - 1) * 13 + (n - 1);
|
||
const big = d => (d - 1) * 54 + 53;
|
||
const small = d => (d - 1) * 54 + 52;
|
||
|
||
// ============ §8.2 亮牌 get_liangpai ============
|
||
// 构造庄家(seat0)手牌 o_paiju;副花色中性单张填充到28
|
||
function fill(req, n) {
|
||
const u = new Set(req), o = req.slice();
|
||
for (const f of [2, 3, 4]) for (const x of [1, 5, 6, 8, 9, 10, 11, 12, 13]) {
|
||
if (o.length >= n) break;
|
||
const c = id(1, f, x);
|
||
if (!u.has(c)) { u.add(c); o.push(c); }
|
||
}
|
||
return o;
|
||
}
|
||
const mkHand = ids => ({ flower: 1, banker: 0, cards: ids.map(x => ({ id: x, playround: -1, dealowner: 1 })) });
|
||
const lp = ids => P.get_liangpai(mkHand(fill(ids, 28)));
|
||
t.eq('亮牌 3王', lp([big(1), small(1), small(2)]), { wang: 3 });
|
||
t.eq('亮牌 4王+6个7(固定主10)', lp([big(1), big(2), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)]), { zhu: 10, zhupair: 5, zhutuo: 1, wang: 4, qi: 6 });
|
||
t.eq('亮牌 6个2', lp([id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2), id(1, 3, 2), id(2, 3, 2)]), { er: 6 });
|
||
t.eq('亮牌 2王2个7(不达标)', lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7)]), null);
|
||
t.eq('亮牌 6个7(仅qi)', lp([id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)]), { qi: 6 });
|
||
t.eq('亮牌 固定主9不达标→null', lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 1, 2), id(2, 1, 2), id(1, 2, 2)]), null);
|
||
t.eq('亮牌 固定主10边界(仅zhu)', lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2)]), { zhu: 10, zhupair: 4, zhutuo: 1 });
|
||
// design §8.2:亮牌依据是【庄家埋牌后】的静态手牌快照,全程固定、不随出牌缩水
|
||
// (闲家断线重连时 get_deskinfo 会再取一次;若按当前剩余手牌重算,亮牌会缩水甚至变 null)
|
||
const LIANG28 = fill([big(1), big(2), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)], 28);
|
||
const EXPECT_LIANG = { zhu: 10, zhupair: 5, zhutuo: 1, wang: 4, qi: 6 };
|
||
t.eq('亮牌 埋牌后快照(出牌开始)', P.get_liangpai(mkHand(LIANG28)), EXPECT_LIANG);
|
||
const played = mkHand(LIANG28); // 同一手牌,已打出2大王+1对♦7
|
||
for (const c of played.cards){
|
||
if (c.id === big(1) || c.id === big(2) || c.id === id(1, 1, 7) || c.id === id(2, 1, 7)){ c.playround = 1; }
|
||
}
|
||
t.eq('亮牌 打出4张后重连仍按快照', P.get_liangpai(played), EXPECT_LIANG);
|
||
// 埋下的牌(playround==0)不算在快照里
|
||
const buried = mkHand(LIANG28);
|
||
for (const c of buried.cards){ if (c.id === big(1) || c.id === big(2)){ c.playround = 0; } }
|
||
t.eq('亮牌 已埋的牌不计入快照', P.get_liangpai(buried), { qi: 6 });
|
||
|
||
// ============ §7/§8 结算 get_paiju_account 集成 ============
|
||
// 受控 o_paiju/o_desk/o_room:控制庄家算奖手牌(dealowner=1)与闲1捡分(playowner=1,score)
|
||
const mkDesk = roomtype => ({ seatlist: [[0, []], [0, []], [0, []]], o_room: { roomtype, asetcount: 6 }, method: { get_desk_account: m => m } });
|
||
const paiju = (call, flower, cards, roomtype) => ({ call, banker: 0, flower, cards, idx: 2, o_desk: mkDesk(roomtype), endtime: null, result: null });
|
||
// bankerIds→庄家算奖手牌(dealowner=1);xian1grade→闲1捡分(每张≤10分)
|
||
function mkCards(bankerIds, xian1grade) {
|
||
const cs = bankerIds.map(x => ({ id: x, dealowner: 1, playround: -1, playowner: -1, score: 0 }));
|
||
let g = xian1grade, i = 0;
|
||
while (g > 0) { const s = g >= 10 ? 10 : g; cs.push({ id: 200 + i, dealowner: 2, playround: 1, playowner: 1, score: s }); g -= s; i++; }
|
||
return cs;
|
||
}
|
||
const settle = (call, flower, cards, roomtype, type = 0) => P.get_paiju_account(paiju(call, flower, cards, roomtype), type, {});
|
||
const grades = msg => msg.data.aset.seatlist.map(p => p.grade);
|
||
const B0 = [id(1, 1, 1)]; // 庄家仅1张手牌(不足28→无算奖),用于纯算子用例
|
||
|
||
// 常规算子:庄赢/闲赢各档(无算奖)
|
||
t.eq('结算 65小光(grade20) X=4', grades(settle(65, 1, mkCards(B0, 20), "00000")), [8, -4, -4]);
|
||
t.eq('结算 65 upgrade/multiple', [settle(65, 1, mkCards(B0, 20), "00000").data.aset.upgrade, settle(65, 1, mkCards(B0, 20), "00000").data.aset.multiple], [2, 2]);
|
||
t.eq('结算 65大光(grade0)', grades(settle(65, 1, mkCards(B0, 0), "00000")), [12, -6, -6]);
|
||
t.eq('结算 65过庄(grade50)', grades(settle(65, 1, mkCards(B0, 50), "00000")), [4, -2, -2]);
|
||
t.eq('结算 65升2级(grade110)', grades(settle(65, 1, mkCards(B0, 110), "00000")), [-8, 4, 4]);
|
||
// 爬坡局(位3=1):40分大光 base8 → X=24
|
||
t.eq('结算 爬坡40大光', grades(settle(40, 1, mkCards(B0, 0), "00010")), [48, -24, -24]);
|
||
// 投降(type1):基础1、X=1 → 庄-2、闲各+1
|
||
t.eq('结算 投降 grade', grades(settle(70, -1, mkCards(B0, 0), "00000", 1)), [-2, 1, 1]);
|
||
t.eq('结算 投降 upgrade标识', settle(70, -1, mkCards(B0, 0), "00000", 1).data.aset.upgrade, -99);
|
||
// 傍王(位2=1) + 庄家3王:N_庄=1(常规算奖)+3(傍王)=4;65小光 X=4
|
||
// jf 庄+8/闲-4,aw 庄+32/闲-16 → 庄40、闲各-20(零和)
|
||
const B3wang = fill([big(1), small(1), small(2)], 28);
|
||
t.eq('结算 傍王65小光+庄3王', grades(settle(65, 1, mkCards(B3wang, 20), "00100")), [40, -20, -20]);
|
||
// 对照:同局不勾傍王 → N_庄=1(仅常规算奖):aw 庄+8/闲-4,jf 庄+8/闲-4 → 庄16、闲各-8
|
||
t.eq('结算 不傍王65小光+庄3王(仅常规算奖)', grades(settle(65, 1, mkCards(B3wang, 20), "00000")), [16, -8, -8]);
|
||
|
||
// ============ §6.3 扣底触发 get_bottom_account ============
|
||
// 仅当闲家(maxseat!=banker)用主牌赢末轮才扣底/翻倍
|
||
function mkBottomPaiju(maxseat, winCards, bottomScoreCards, flower) {
|
||
const cards = [];
|
||
for (let i = 0; i < 108; i++) cards[i] = { id: i, playround: -1, score: 0, dealowner: 1, playowner: -1 };
|
||
for (const [cid, sc] of bottomScoreCards) { cards[cid].playround = 0; cards[cid].score = sc; }
|
||
const pc = [[], [], []]; pc[maxseat] = winCards;
|
||
const o = { banker: 0, flower, cards, playproc: { maxseat, cards: pc } };
|
||
o.method = { get_burycard: () => P.get_burycard(o), get_grade_incard: c => P.get_grade_incard(o, c) };
|
||
return o;
|
||
}
|
||
// 闲1用主对(方块Q对)赢末轮,底2张K=20分 → 主对×2、grade2=40
|
||
const bo = P.get_bottom_account(mkBottomPaiju(1, [id(1, 1, 12), id(2, 1, 12)], [[id(1, 1, 13), 10], [id(2, 1, 13), 10]], 1), { data: {} });
|
||
t.eq('扣底 闲家主对赢 multiple', bo.data.bottom.multiple, 2);
|
||
t.eq('扣底 grade1/grade2', [bo.data.bottom.grade1, bo.data.bottom.grade2], [20, 40]);
|
||
// 闲1用副牌(副K)赢末轮 → 非主牌不扣底
|
||
const bo2 = P.get_bottom_account(mkBottomPaiju(1, [id(1, 2, 13)], [[id(1, 1, 13), 10]], 1), { data: {} });
|
||
t.eq('扣底 副牌赢不扣底', bo2.data.bottom.multiple, undefined);
|
||
// 庄家(maxseat==banker)赢末轮 → 不扣底
|
||
const bo3 = P.get_bottom_account(mkBottomPaiju(0, [id(1, 1, 12), id(2, 1, 12)], [[id(1, 1, 13), 10]], 1), { data: {} });
|
||
t.eq('扣底 庄家赢不扣底', bo3.data.bottom.multiple, undefined);
|
||
// 闲1用两连对拖拉机赢 → ×4
|
||
const bo4 = P.get_bottom_account(mkBottomPaiju(1, [id(1, 1, 13), id(2, 1, 13), id(1, 1, 12), id(2, 1, 12)], [[id(1, 4, 13), 10]], 1), { data: {} });
|
||
t.eq('扣底 两连对拖赢 ×4', bo4.data.bottom.multiple, 4);
|
||
|
||
// ============ §5.4.5 甩错惩罚执行 do_playcard ============
|
||
const A = require('../class.arith.js');
|
||
// 造首出场景:庄家(0)手牌与两闲家主牌,step5 本轮首出
|
||
function mkFirstPlay(flower, dealMap) {
|
||
const cards = [];
|
||
for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: 99, playround: -1, playowner: -1, score: 0, flower: A.id_to_flower(i), number: A.id_to_number(i) };
|
||
for (const [cid, owner] of dealMap) cards[cid].dealowner = owner;
|
||
const sl = () => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]];
|
||
const o = { step: 5, banker: 0, flower, cards, seatlist: [sl(), sl(), sl()], playproc: {} };
|
||
o.method = { have_baofu: () => o.seatlist.some(s => s[4][0] == 0) };
|
||
P.new_playround(o, 1, 0);
|
||
return o;
|
||
}
|
||
// 庄家甩 大王+主K对;seat1 持主A对(能压主K对) → 甩错:收回甩牌、只强制打出最小一张(主K)、其余留手
|
||
const fp = mkFirstPlay(1, [[big(1), 1], [id(1, 1, 13), 1], [id(2, 1, 13), 1], [id(1, 1, 1), 2], [id(2, 1, 1), 2]]);
|
||
const re = P.do_playcard(fp, [big(1), id(1, 1, 13), id(2, 1, 13)]);
|
||
t.eq('甩错执行 shuaicuo=true', re.shuaicuo, true);
|
||
t.eq('甩错执行 只打1张且为主K', re.cards.length === 1 && A.id_to_number(re.cards[0]) === 13, true);
|
||
t.eq('甩错执行 大王未出(留手)', fp.cards[big(1)].playround, -1);
|
||
t.eq('甩错执行 打出的主K已出', fp.cards[re.cards[0]].playround > 0, true);
|
||
// 对照:合法甩牌 do_playcard 正常打出整套、不甩错
|
||
const fp2 = mkFirstPlay(1, [[big(1), 1], [big(2), 1], [id(1, 1, 13), 1], [id(1, 1, 5), 2], [id(1, 1, 6), 3]]); // 大王对+主K单;对手仅小主牌
|
||
const re2 = P.do_playcard(fp2, [big(1), big(2), id(1, 1, 13)]);
|
||
t.eq('合法甩牌 do_playcard 非甩错', !!re2.shuaicuo, false);
|
||
t.eq('合法甩牌 整套3张打出', re2.cards.length, 3);
|
||
|
||
// §5.4.4 集成:首出合法甩牌 → 跟牌方违反分量拆解 → do_playcard 拒;正确对位 → 接受
|
||
const fpf = mkFirstPlay(1, [
|
||
[big(1), 1], [id(1, 1, 12), 1], [id(2, 1, 12), 1], // 庄家 大王+主Q对(合法甩牌)
|
||
[id(1, 1, 11), 2], [id(2, 1, 11), 2], [id(1, 1, 9), 2], [id(1, 1, 8), 2] // seat1 主J对+主9+主8
|
||
]);
|
||
const lead = P.do_playcard(fpf, [big(1), id(1, 1, 12), id(2, 1, 12)]);
|
||
t.eq('甩牌集成 首出合法(记录shuai_demand)', lead.result && !lead.shuaicuo, true);
|
||
const badF = P.do_playcard(fpf, [id(1, 1, 11), id(1, 1, 9), id(1, 1, 8)]); // 拆散主J对
|
||
t.eq('甩牌集成 跟牌拆散主对→拒', badF.result, false);
|
||
const goodF = P.do_playcard(fpf, [id(1, 1, 11), id(2, 1, 11), id(1, 1, 9)]); // 用主J对对位
|
||
t.eq('甩牌集成 跟牌用主对→接受', goodF.result, true);
|
||
|
||
// ============ §5.1 控制权 + §6.2 捡分归属 ============
|
||
// 造一轮三家各出 1 张同花色副牌的完整墩:主花色=♦(1),本轮花色=♥(3)。
|
||
// deal: [[牌id, 座位], ...];scores: [[牌id, 分值], ...]
|
||
function mkTrick(deal, scores) {
|
||
const cards = [];
|
||
for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: 99, playround: -1, playowner: -1, score: 0, flower: A.id_to_flower(i), number: A.id_to_number(i) };
|
||
for (const [cid, seat] of deal) cards[cid].dealowner = seat + 1;
|
||
for (const [cid, s] of scores) cards[cid].score = s;
|
||
const sl = () => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]];
|
||
const o = { step: 5, banker: 0, flower: 1, cards, seatlist: [sl(), sl(), sl()], playproc: {} };
|
||
o.method = { have_baofu: () => o.seatlist.some(s => s[4][0] == 0) };
|
||
P.new_playround(o, 1, 0); // 第 1 轮由庄家(0)首出
|
||
return o;
|
||
}
|
||
const H5 = id(1, 3, 5), H8 = id(1, 3, 8), HK = id(1, 3, 13), HA = id(1, 3, 1); // ♥5 ♥8 ♥K ♥A(副牌)
|
||
const SC = [[H5, 5], [HK, 10]]; // §6.1:5→5分、K→10分;♥8/♥A 不计分
|
||
|
||
// —— 反:庄家赢下本轮 → 台面分牌全部作废,不计入闲家捡分 ——
|
||
const wb = mkTrick([[HA, 0], [HK, 1], [H5, 2]], SC);
|
||
P.do_playcard(wb, [HA]); P.do_playcard(wb, [HK]);
|
||
const wbRe = P.do_playcard(wb, [H5]);
|
||
// 注:第三家出完后 do_playcard 已开新一轮、playproc 被重置,胜者只能从返回值读
|
||
t.eq('§6.2 反 庄家赢本轮 maxseat=庄', wbRe.maxseat, 0);
|
||
t.eq('§6.2 反 庄家赢 → 下发面无本轮得分', wbRe.grade, undefined);
|
||
t.eq('§6.2 反 庄家赢 → 闲家捡分 0(分牌作废)', P.get_jian_grade(wb).grade, 0);
|
||
|
||
// —— 正:闲家1 赢下本轮 → 台面【全部】分牌(含庄家自己打出的♥5)计入闲家 ——
|
||
const w1 = mkTrick([[H5, 0], [HK, 1], [H8, 2]], SC);
|
||
P.do_playcard(w1, [H5]); P.do_playcard(w1, [HK]);
|
||
const w1Re = P.do_playcard(w1, [H8]);
|
||
t.eq('§6.2 正 闲1赢本轮 maxseat=1', w1Re.maxseat, 1);
|
||
t.eq('§6.2 正 本轮闲家得分=5+10(含庄家打出的♥5)', w1Re.grade, 15);
|
||
t.eq('§6.2 正 累计捡分=15', P.get_jian_grade(w1).grade, 15);
|
||
// §5.1:下一轮由本轮牌面最大的一方先出(不是固定庄家、也不是顺序下家)
|
||
t.eq('§5.1 正 下一轮由本轮最大者(闲1)先出', [w1.playproc.round, w1.playproc.start, w1.playproc.currseat], [2, 1, 1]);
|
||
|
||
// —— 边界:两个闲家谁赢都算「闲家捡到」,结果与闲1赢完全一致(design §6.2)——
|
||
const w2 = mkTrick([[H5, 0], [H8, 1], [HK, 2]], SC);
|
||
P.do_playcard(w2, [H5]); P.do_playcard(w2, [H8]);
|
||
const w2Re = P.do_playcard(w2, [HK]);
|
||
t.eq('§6.2 边界 闲2赢本轮 maxseat=2', w2Re.maxseat, 2);
|
||
t.eq('§6.2 边界 闲2赢与闲1赢同样计闲家捡分', [w2Re.grade, P.get_jian_grade(w2).grade], [15, 15]);
|
||
t.eq('§5.1 边界 下一轮改由闲2先出', [w2.playproc.round, w2.playproc.start], [2, 2]);
|
||
|
||
// ============ §8 算奖快照 get_seat_cards_award ============
|
||
// design §8:庄家按【埋牌完成后】的 28 张、闲家按【发牌后】的 28 张;快照全程固定,
|
||
// 不随之后的出牌改变。例外:70 分投降局庄家未埋牌,快照为「发牌 + 暗牌」共 36 张。
|
||
function mkAward() {
|
||
const cards = [];
|
||
for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: -1, playround: -1, playowner: -1, score: 0 };
|
||
let n = 0;
|
||
const take = k => { const out = []; while (out.length < k) { out.push(n); n++; } return out; };
|
||
const bankerDeal = take(28), an = take(8), x1 = take(28), x2 = take(28);
|
||
bankerDeal.forEach(i => cards[i].dealowner = 1);
|
||
an.forEach(i => cards[i].dealowner = 0); // 暗牌
|
||
x1.forEach(i => cards[i].dealowner = 2);
|
||
x2.forEach(i => cards[i].dealowner = 3);
|
||
return { o: { banker: 0, flower: 1, cards }, bankerDeal, an, x1 };
|
||
}
|
||
// 投降局:未选主未埋牌 → 庄家快照 = 发牌 28 + 暗牌 8 = 36
|
||
const aw0 = mkAward();
|
||
t.eq('§8 边界 投降局 庄家快照=发牌+暗牌 36 张', P.get_seat_cards_award(aw0.o, 0).length, 36);
|
||
// 正常局:埋掉 8 张后 → 庄家快照 28 张,且已埋的不在内
|
||
const aw = mkAward();
|
||
const bury = aw.bankerDeal.slice(0, 5).concat(aw.an.slice(0, 3)); // 从发牌与暗牌里各埋几张
|
||
bury.forEach(i => aw.o.cards[i].playround = 0);
|
||
const snap = P.get_seat_cards_award(aw.o, 0);
|
||
t.eq('§8 正 庄家快照=埋牌后 28 张', snap.length, 28);
|
||
t.eq('§8 正 已埋的 8 张不在快照里', bury.some(i => snap.indexOf(i) >= 0), false);
|
||
t.eq('§8 正 闲家快照=发牌后 28 张', [P.get_seat_cards_award(aw.o, 1).length, P.get_seat_cards_award(aw.o, 2).length], [28, 28]);
|
||
// 快照是静态的:之后打出的牌仍算在内(否则闲家重连时算奖/亮牌会缩水)
|
||
snap.slice(0, 10).forEach(i => aw.o.cards[i].playround = 3);
|
||
t.eq('§8 边界 打出 10 张后 庄家快照仍是同样的 28 张', P.get_seat_cards_award(aw.o, 0), snap);
|
||
t.eq('§8 边界 打出后闲家快照也不缩水', P.get_seat_cards_award(aw.o, 1).length, 28);
|
||
|
||
process.exit(t.done('paiju') ? 0 : 1);
|