// 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);