Files
erqiwang_youle/server/games/erqiwang/test/test_paiju.js
T
joywayerandClaude Opus 5 4508fe005f 二七王:出牌顺序收敛为唯一口径,重连包补发 baozhu
同一份数据「这一手打出的牌」此前有两个顺序口径:chupai*/playproc.cards
回显客户端请求原序,重连包 pushlist 则从 playround 反查后自己再排一次序。
一手多张(甩牌/对子/拖拉机)时两者必然分岔;同编码的一对牌更是排序都
区分不了,靠"两处各自排序"永远收敛不了。

改为单一写入处:
- 新增 order_playcards(),do_playcard 入口归一化一次(顺带隔离
  can_playcard 的原地排序,不再就地改写 pack.data.cards)
- 新增 paiju.playhistory,出牌当场归档;新增 get_pushlist() 只做深拷贝
- class.export.js 删掉整段反查+重排,改为直读归档
- do_playcard 三个分支都给 re.cards,mod.js 去掉回落到入参的兜底

顺带修复重连包漏发 baozhu:它是余主公示与明牌按钮的开关,此前只在
chupai 包里给,报无主后重连按钮会凭空消失。现与 chupai 同源同门控下发。

协议文档新增 §0.6「一手出牌」的顺序口径,并补 PushCards.baozhu 字段。
测试:pushlist 一节改为真实链路驱动(一手多张 + 乱序提交),断言升级为
「重连每一手与当时的 chupai.cards 逐元素相等」;三处修复均已退回验证转红。

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

263 lines
17 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)));
// 亮牌返回 { cards: [固定主牌id...] }(按主牌序排好);不达标返回 null。
// 断言用「排序后的 id 集合」比较,避免把排序细节写死进用例。
const sortIds = a => a.slice().sort((x, y) => x - y);
const lpCards = ids => { const r = lp(ids); return r === null ? null : sortIds(r.cards); };
// —— 四条门槛各自能触发,且亮出的都是【全部固定主牌】——
{
const h = [big(1), small(1), small(2)]; // 3 王
t.eq('亮牌 3王 → 亮出这3张王', lpCards(h), sortIds(h));
}
{
const h = [id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2), id(1, 3, 2), id(2, 3, 2)]; // 6 个 2
t.eq('亮牌 6个2 → 亮出这6张2', lpCards(h), sortIds(h));
}
{
const h = [id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)]; // 6 个 7
t.eq('亮牌 6个7 → 亮出这6张7', lpCards(h), sortIds(h));
}
{
const h = [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)]; // 固定主 10(各档单看都不达标)
t.eq('亮牌 固定主10边界 → 亮出全部10张', lpCards(h), sortIds(h));
}
// —— 不达标 → null ——
t.eq('亮牌 2王2个7 不达标→null', lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7)]), null);
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);
// —— 关键:亮出的只有【固定主牌】,主花色普通牌【不能】混进来 ——
// 手牌里塞满主花色(♦)普通牌 A/K/Q/J/10/9/8/6/5,它们是主牌但不是固定主牌
{
const guding = [big(1), small(1), small(2)]; // 3 王 → 达标
const zhuNormal = [id(1, 1, 1), id(1, 1, 13), id(1, 1, 12), id(1, 1, 11), id(1, 1, 10)]; // ♦A/K/Q/J/10
const r = lp(guding.concat(zhuNormal));
t.eq('亮牌 只含固定主牌 张数=3', r.cards.length, 3);
t.eq('亮牌 主花色普通牌未混入', sortIds(r.cards), sortIds(guding));
}
// —— 亮出内容与「被哪条门槛触发」无关:4王+6个7 应亮出全部 10 张固定主 ——
{
const h = [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)];
t.eq('亮牌 同时满足多档 → 仍只亮同一份固定主牌', lpCards(h), sortIds(h));
}
// —— 排序:按本局主牌序从大到小(大王 > 小王 > 正7 > 副7 > 正2 > 副2)——
{
const A = require('../class.arith.js');
const h = [big(1), small(1), id(1, 1, 7), id(1, 2, 7), id(1, 1, 2), id(1, 2, 2),
id(2, 1, 7), id(2, 2, 7), id(2, 1, 2), id(2, 2, 2)]; // 固定主 10,主花色=♦(1)
const r = lp(h);
t.eq('亮牌 按主牌序排好', r.cards, A.order_cards(1, r.cards.slice()));
t.eq('亮牌 首张为大王', r.cards[0], big(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 = sortIds([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)]);
t.eq('亮牌 埋牌后快照(出牌开始)', sortIds(P.get_liangpai(mkHand(LIANG28)).cards), 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张后重连仍按快照', sortIds(P.get_liangpai(played).cards), 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('亮牌 已埋的牌不计入快照', sortIds(P.get_liangpai(buried).cards),
sortIds([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)]));
// ============ §7/§8 结算 get_paiju_account 集成 ============
// 受控 o_paiju/o_desk/o_room:控制庄家算奖手牌(dealowner=1)与闲1捡分(playowner=1,score)
const mkDesk = roomtype => ({ seatlist: [[0, [], 0, 0, 0], [0, [], 0, 0, 0], [0, [], 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 }, playhistory: [] };
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: {}, playhistory: [] };
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: {}, playhistory: [] };
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);