Files
erqiwang_youle/server/games/erqiwang/test/test_paiju.js
T
joywayerandClaude Opus 5 916066ba96 二七王:修正 design 全量重核发现的 3 项不一致(亮牌快照/报无主下发面/甩牌张数上限)
- §8.2 亮牌改用埋牌后 28 张静态快照 get_seat_cards_award,不再用
  get_seat_cards(只含未出的牌)——否则闲家重连时 get_deskinfo 按庄家
  当前剩余手牌重算,亮牌统计缩水甚至变 null。
- §9 出牌包由只带出牌者一家的 info 改为整表下发三家 seatlist,与重连包
  PushCards.seatlist 同名同构;报无主后另两家的主牌数/对子数不再滞后两次出牌。
- §5.4.3 甩牌"无组合数量限制":can_playcard 去掉规则外的 14 张硬上限,
  改为结构性上界 28(单人手牌上限,牌型编码在此范围内不溢出)。

协议文档同步改写 chupai 三处 info 条目与 liangpai 口径;合规文档补记第四轮
核对(流程层无缺环,规则层 3 项已收口)。全套单测 368 项通过。

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

142 lines
10 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.
// 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);
process.exit(t.done('paiju') ? 0 : 1);