二七王测试:完成 P0 用例(正常跟牌/结算集成/叫分坐庄/扣底触发)

- test_follow.js(新):§5.1/5.2 正常跟牌/毙牌/垫牌 17 例——单张/对子/拖拉机的
  必出、缺门毙牌数量对应(副单→主单/副对→主对/副N连→主N连)、垫牌、有对/拖却出散张被拒
- test_callgrade.js(新):§4.2 叫分坐庄 do_callgrade 6 例(叫5立即/两家不叫/后叫更低)
- test_paiju.js:§7/§8 结算集成扩充(大光/过庄/升级/爬坡/傍王+算奖参与/对照)、
  §6.3 扣底触发 get_bottom_account(主对×2/两连对×4/副牌不扣/庄赢不扣)
- 02-测试计划.md/README:P0 全部标记完成

全部单测 123 项断言全绿(node server/games/erqiwang/test/run.js)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 01:03:01 +08:00
co-authored by Claude Opus 4.8
parent 5a5165a319
commit 0049754988
5 changed files with 145 additions and 30 deletions
+3 -1
View File
@@ -29,8 +29,10 @@ node server/games/erqiwang/test/test_arith.js # 单独跑某一组
## 覆盖
- `test_arith.js`:扣底倍数(§6.3)、算子常规+爬坡逐档(§7)、算奖 get_chongguan(§8.1)、甩牌分解/最大性/合法性(§5.4)、跟甩牌强制分量拆解(§5.4.4)。
- `test_follow.js`:正常跟牌/毙牌/垫牌,单张/对子/拖拉机的必出与缺门毙牌数量对应(§5.1/5.2)。
- `test_callgrade.js`:叫分坐庄 do_callgrade(叫5立即/两家不叫/后叫更低)(§4.2)。
- `test_config.js`:roomtype 位串解析、局数/扣卡映射(§10.1)。
- `test_paiju.js`:亮牌 get_liangpai(§8.2)、结算 get_paiju_account 集成(§7/§8,含投降)。
- `test_paiju.js`:亮牌 get_liangpai(§8.2)、结算 get_paiju_account 集成(§7/§8,大光/过庄/升级/爬坡/傍王/投降)、扣底触发 get_bottom_account(§6.3)。
## 约定(dev-guide §10 测试纪律)
@@ -0,0 +1,28 @@
// §4.2 叫分坐庄 do_callgrade(纯函数,operate on 最小 o_paiju)。期望取自 design §4.2
require('./_shim');
const P = require('../class.paiju.js');
const t = require('./_assert')();
const mkCall = fs => ({ callproc: [{ seat: fs, call: null }], banker: -1, call: -1, step: 1 });
// 依次叫分序列(calls 里每个值 = 当前轮到者的叫分,0=不叫)
function seq(firstseat, calls) {
const p = mkCall(firstseat);
for (const c of calls) P.do_callgrade(p, c);
return p;
}
const bcs = p => [p.banker, p.call, p.step];
// 叫5立即上庄
t.eq('叫5立即上庄', bcs(seq(0, [5])), [0, 5, 2]);
// 暂定庄叫65,另两家都不叫 → 暂定庄(0)上庄
t.eq('0叫65 两家不叫→0上庄', bcs(seq(0, [65, 0, 0])), [0, 65, 2]);
// 0叫65、1叫更低60、2不叫、0不叫 → 60 最低者(1)上庄
t.eq('0叫65 1叫60 余不叫→1上庄', bcs(seq(0, [65, 60, 0, 0])), [1, 60, 2]);
// 首家叫5路径:0叫5 → 0直接上庄(其余无需叫)
t.eq('首家叫5直接坐庄', bcs(seq(0, [5])), [0, 5, 2]);
// 递减到5:0叫65,1叫60,2叫5 → 2上庄(叫5立即)
t.eq('2叫5立即上庄', bcs(seq(0, [65, 60, 5])), [2, 5, 2]);
// 不同首家:1起叫,1叫50,2不叫,0不叫 → 1上庄
t.eq('首家1叫50 余不叫→1上庄', bcs(seq(1, [50, 0, 0])), [1, 50, 2]);
process.exit(t.done('callgrade') ? 0 : 1);
+46
View File
@@ -0,0 +1,46 @@
// §5.1/5.2 正常跟牌/毙牌/垫牌(can_followcard 纯函数)。期望值取自 design §5.2
require('./_shim');
const A = require('../class.arith.js');
const t = require('./_assert')();
const id = (d, f, n) => (d - 1) * 54 + (f - 1) * 13 + (n - 1);
const MF = 1; // 主花色=方块(1)
// 副花色2的牌(number 避开 2/7,否则是固定主)
const K = d => id(d, 2, 13), Q = d => id(d, 2, 12), J = d => id(d, 2, 11), T = d => id(d, 2, 10), N9 = d => id(d, 2, 9), N8 = d => id(d, 2, 8);
// 主牌(flower1)
const zK = d => id(d, 1, 13), zQ = d => id(d, 1, 12), zJ = d => id(d, 1, 11), z9 = d => id(d, 1, 9);
// 另一副花色3(用于垫牌)
const oth = n => id(1, 3, n);
// follow(inhand, cards, startcount, startflower, startcardtype) -> can_followcard 结果
const f = (inhand, cards, cnt, flw, typ) => A.can_followcard(MF, inhand, cards, cnt, flw, typ);
const res = r => r.result; // 能否跟
const beat = r => (r.cardvalue || 0) > 0; // 是否压过(cardvalue>0)
// ===================== 单张跟牌(首家 副K 单张,flower2, 101)=====================
// §5.2: 跟同花色任意单张;缺门则任意补(毙牌用主牌可压、垫副牌不压)
t.eq('单-跟同花色单张(合法)', res(f([N9(1), z9(1)], [N9(1)], 1, 2, 101)), true);
t.eq('单-有同花色却出主牌(拒)', res(f([N9(1), zK(1)], [zK(1)], 1, 2, 101)), false);
t.eq('单-缺门垫副牌(合法)', res(f([oth(9), zK(1)], [oth(9)], 1, 2, 101)), true);
t.eq('单-缺门垫副牌不压', beat(f([oth(9), zK(1)], [oth(9)], 1, 2, 101)), false);
t.eq('单-缺门毙牌主单(合法)', res(f([oth(9), zK(1)], [zK(1)], 1, 2, 101)), true);
t.eq('单-缺门毙牌主单压过', beat(f([oth(9), zK(1)], [zK(1)], 1, 2, 101)), true);
// ===================== 对子跟牌(首家 副K对,flower2, 201, 2张)=====================
// §2: 有同花色对必出对;没对用两张同花色单;不足两张任意补;缺门毙牌必须主对(不能任意两张)
t.eq('对-有同花色对必出对(合法)', res(f([N9(1), N9(2), N8(1)], [N9(1), N9(2)], 2, 2, 201)), true);
t.eq('对-有对却出两散张(拒)', res(f([N9(1), N9(2), N8(1)], [N9(1), N8(1)], 2, 2, 201)), false);
t.eq('对-无对出两同花色单(合法)', res(f([N9(1), N8(1), Q(1)], [N9(1), N8(1)], 2, 2, 201)), true);
t.eq('对-缺门毙主对(合法且压)', beat(f([zK(1), zK(2), oth(9)], [zK(1), zK(2)], 2, 2, 201)), true);
t.eq('对-缺门两散主顶对(可出但不压)', res(f([zK(1), zQ(1), oth(9)], [zK(1), zQ(1)], 2, 2, 201)), true);
t.eq('对-缺门两散主顶对 不压', beat(f([zK(1), zQ(1), oth(9)], [zK(1), zQ(1)], 2, 2, 201)), false);
// ===================== 拖拉机跟牌(首家 副KQ两连对,flower2, 302, 4张)=====================
// §2: 有同花色拖拉机必出拖;无拖但有两对不连也必出两对
t.eq('拖-有同长拖必出拖(合法)', res(f([J(1), J(2), T(1), T(2), N8(1)], [J(1), J(2), T(1), T(2)], 4, 2, 302)), true);
t.eq('拖-有拖却出4散(拒)', res(f([J(1), J(2), T(1), T(2), N8(1)], [J(1), T(1), N9(1), N8(1)], 4, 2, 302)), false);
t.eq('拖-无拖两对不连必出两对(合法)', res(f([J(1), J(2), N9(1), N9(2), N8(1)], [J(1), J(2), N9(1), N9(2)], 4, 2, 302)), true);
t.eq('拖-有两对却出散张(拒)', res(f([J(1), J(2), N9(1), N9(2), N8(1)], [J(1), N9(1), N8(1), Q(1)], 4, 2, 302)), false);
t.eq('拖-缺门毙同长主拖(合法且压)', beat(f([zK(1), zK(2), zQ(1), zQ(2), oth(9)], [zK(1), zK(2), zQ(1), zQ(2)], 4, 2, 302)), true);
process.exit(t.done('follow') ? 0 : 1);
+55 -22
View File
@@ -26,28 +26,61 @@ t.eq('亮牌 6个2', lp([id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2), id(
t.eq('亮牌 2王2个7(不达标)', lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7)]), null);
// ============ §7/§8 结算 get_paiju_account 集成 ============
// 最小 o_paiju/o_desk/o_room:控制捡分与手牌,验证最终 seatlist 各家 grade
function mkPaiju(call, flower, cards, roomtype) {
const o_desk = {
seatlist: [[0, []], [0, []], [0, []]],
o_room: { roomtype: roomtype, asetcount: 6 },
method: { get_desk_account: msg => msg }
};
return { call, banker: 0, flower, cards, idx: 2, o_desk, endtime: null, result: null };
// 受控 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;
}
// 闲家1(seat1) 赢两张K(各10分)=捡分20;庄家(seat0)仅1张手牌(不足28→无算奖)
const cards = [
{ id: 0, dealowner: 2, playround: 1, playowner: 1, score: 10, number: 13, flower: 1 },
{ id: 1, dealowner: 3, playround: 1, playowner: 1, score: 10, number: 13, flower: 1 },
{ id: 2, dealowner: 1, playround: -1, playowner: -1, score: 0, number: 5, flower: 1 }
];
// 常规 65 分、捡分20 → 小光(×2)、基础2 → X=4:庄+8、闲各 -4
let msg = P.get_paiju_account(mkPaiju(65, 1, cards, "00000"), 0, {});
t.eq('结算 65小光 grade', msg.data.aset.seatlist.map(p => p.grade), [8, -4, -4]);
t.eq('结算 65小光 upgrade/multiple', [msg.data.aset.upgrade, msg.data.aset.multiple], [2, 2]);
// 投降(type1):基础1、X=1、庄输1子/闲 → 庄-2、闲各 +1
let msg2 = P.get_paiju_account(mkPaiju(70, -1, cards, "00000"), 1, {});
t.eq('结算 投降 grade', msg2.data.aset.seatlist.map(p => p.grade), [-2, 1, 1]);
t.eq('结算 投降 upgrade(标识)', msg2.data.aset.upgrade, -99);
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);
process.exit(t.done('paiju') ? 0 : 1);