二七王:补 9 处 design 规则的正/反/边界用例(均通过变异检验)
对 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>
This commit is contained in:
@@ -171,6 +171,32 @@ t.eq('相邻 副♠8-副♦6 跨花色 不连', cont(id(1, 4, 8), id(1, 1, 6)),
|
||||
// 顺带确认非 8/6 的等差2组合仍不连(防守卫写宽)
|
||||
t.eq('相邻 主A-主Q 不连(差2但非8/6)', cont(zA, id(1, 1, 12)), false);
|
||||
|
||||
// ============ §3「关于对子的判定」:对子只认同一张具体牌的两副 ============
|
||||
// design §3 注:正/副7、正/副2 虽各占一个大小等级,但**成对只认同花色同点数的两副**;
|
||||
// 两张不同花色的副7(如♠7+♥7)不构成一对,副2 同理;大王与小王点数不同也不能混成一对。
|
||||
// 拖拉机的连对判定以此为基础(每节连对里的每个对子都须是同花色同点数两副)。
|
||||
const pl = cards => A.get_pairlist(MF, A.order_cards(MF, cards.slice())).length;
|
||||
t.eq('对子 正 同花色两副♥7 成对', pl([id(1, 3, 7), id(2, 3, 7)]), 1);
|
||||
t.eq('对子 反 ♥7+♣7 跨花色副7 不成对', pl([id(1, 3, 7), id(1, 2, 7)]), 0);
|
||||
t.eq('对子 反 正7+副7 不成对', pl([id(1, 1, 7), id(1, 2, 7)]), 0);
|
||||
t.eq('对子 正 同花色两副♥2 成对', pl([id(1, 3, 2), id(2, 3, 2)]), 1);
|
||||
t.eq('对子 反 ♥2+♣2 跨花色副2 不成对', pl([id(1, 3, 2), id(1, 2, 2)]), 0);
|
||||
t.eq('对子 反 正2+副2 不成对', pl([id(1, 1, 2), id(1, 2, 2)]), 0);
|
||||
t.eq('对子 正 大王两副 成对', pl([big(1), big(2)]), 1);
|
||||
t.eq('对子 反 大王+小王 不成对', pl([big(1), small(1)]), 0);
|
||||
// 连对以「同花色同点数两副」为基础:四张跨花色的副7/副2 一对都凑不出,自然也没有拖拉机
|
||||
t.eq('对子 边界 ♥7♣7♥2♣2 四张 → 0 对', pl([id(1, 3, 7), id(1, 2, 7), id(1, 3, 2), id(1, 2, 2)]), 0);
|
||||
t.eq('连对 边界 上述四张组不出拖拉机',
|
||||
A.get_tuolaji_list(MF, A.get_pairlist(MF, A.order_cards(MF, [id(1, 3, 7), id(1, 2, 7), id(1, 3, 2), id(1, 2, 2)])), 302).length, 0);
|
||||
// 对照:正7对 + 副7对(各自同花色两副)才是合法两连对
|
||||
t.eq('连对 正 正7对+副7对 构成两连对',
|
||||
A.get_tuolaji_list(MF, A.get_pairlist(MF, A.order_cards(MF, [id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7)])), 302).length, 1);
|
||||
|
||||
// ============ §6.3 扣底触发的反面:赢末轮的牌不全是主牌就不扣底 ============
|
||||
t.eq('扣底 反 副牌两连对赢末轮 → 0', mult([id(1, 2, 13), id(2, 2, 13), id(1, 2, 12), id(2, 2, 12)]), 0);
|
||||
t.eq('扣底 反 混合出牌(主K+副K)赢末轮 → 0', mult([id(1, 1, 13), id(1, 2, 13)]), 0);
|
||||
t.eq('扣底 反 主牌在前副牌在后也判 0(与提交顺序无关)', mult([id(1, 2, 13), id(1, 1, 13)]), 0);
|
||||
|
||||
// ============ §7 算子:每档小光/过庄分界 + 升3级(补强)============
|
||||
t.eq('常规65 g39小光', judge(65, 39, false), '小光/4');
|
||||
t.eq('常规65 g40过庄(分界)', judge(65, 40, false), '过庄/2');
|
||||
|
||||
@@ -138,4 +138,79 @@ 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);
|
||||
|
||||
@@ -284,4 +284,70 @@ t.eq('提示 非法tip=9 拒(回失败包PARAM)', rej(tp, ERR.PARAM), true);
|
||||
tp = setup("00000", mkTip(5, 0)); mod.tishi(pack(1, {}));
|
||||
t.eq('提示 缺tip 拒(回失败包PARAM)', rej(tp, ERR.PARAM), true);
|
||||
|
||||
// ===================== §4.2 叫分:边界与非法入参补全 =====================
|
||||
// design §4.2:范围 5~70、步进 5;暂定庄家必须叫(不能"不叫"),其后才可以"叫更低"或"不叫"
|
||||
t.eq('叫分 边界 下限5 接受', jf(0, 5), 5);
|
||||
t.eq('叫分 正 非首家可以不叫(call=0) 接受', jf(65, 0), 0);
|
||||
t.eq('叫分 反 负数 拒', jf(0, -5), null);
|
||||
t.eq('叫分 反 缺 call 字段(NaN) 拒', jf(0, undefined), null);
|
||||
t.eq('叫分 反 步进外的小值1 拒', jf(0, 1), null);
|
||||
t.eq('叫分 反 与当前叫分相同(不更低) 拒', jf(55, 55), null);
|
||||
|
||||
// ===================== §4.2 已"不叫"的玩家不得再叫 =====================
|
||||
// design §4.2:不叫即退出本局叫分,轮次直接跳过他;他再发 jiaofen 时已不是当前叫分位,应被拒。
|
||||
const bd = P.new({ paiju_list: [] }, 0); // 真实牌局,暂定庄家 = 0 号座位
|
||||
const bdc = setup("00000", bd);
|
||||
mod.jiaofen(pack(0, { call: 65 }));
|
||||
t.eq('叫分 正 首家叫65后轮到 seat1', bd.method.get_callgrade_seat(), 1);
|
||||
mod.jiaofen(pack(1, { call: 0 })); // seat1 不叫
|
||||
t.eq('叫分 正 seat1不叫后跳到 seat2', bd.method.get_callgrade_seat(), 2);
|
||||
const bdLen = bd.callproc.length;
|
||||
bdc.sent.length = 0;
|
||||
mod.jiaofen(pack(1, { call: 60 })); // seat1 反悔想再叫
|
||||
t.eq('叫分 反 已不叫者再叫 → 拒(SEAT)', rej(bdc, ERR.SEAT), true);
|
||||
t.eq('叫分 反 已不叫者再叫 不改变叫分过程', bd.callproc.length, bdLen);
|
||||
t.eq('叫分 反 已不叫者再叫 仍轮到 seat2', bd.method.get_callgrade_seat(), 2);
|
||||
t.eq('叫分 反 已不叫者再叫 未产生庄家', bd.banker, -1);
|
||||
|
||||
// ===================== §4.5 埋牌 maipai:庄家 / 阶段 / 张数 =====================
|
||||
// design §4.5:仅"选主/打牌"路径、由庄家从 36 张里选出 **8 张** 重新扣下
|
||||
// 桩要"完整到能走完成功路径":否则一旦校验被改松,这里会**抛异常打断整个文件**,
|
||||
// 后面的断言全不执行——看到的是崩溃而不是某条断言转红,定位与信号都变差。
|
||||
const mkBury = (step, banker) => ({
|
||||
step, banker, cards: make108(), playproc: { currseat: banker },
|
||||
method: {
|
||||
check_cards_valid: c => P.check_cards_valid({ cards: make108() }, c),
|
||||
check_cards_inhand: () => true,
|
||||
do_burycard: () => { },
|
||||
get_seat_cards: () => [],
|
||||
get_liangpai: () => null
|
||||
}
|
||||
});
|
||||
let bc = setup("00000", mkBury(3, 1)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7] }));
|
||||
t.eq('埋牌 反 非庄家 → 拒(SEAT)', rej(bc, ERR.SEAT), true);
|
||||
bc = setup("00000", mkBury(2, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7] }));
|
||||
t.eq('埋牌 反 选主阶段(step2)就埋 → 拒(STEP)', rej(bc, ERR.STEP), true);
|
||||
bc = setup("00000", mkBury(5, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7] }));
|
||||
t.eq('埋牌 反 出牌阶段(step5)再埋 → 拒(STEP)', rej(bc, ERR.STEP), true);
|
||||
bc = setup("00000", mkBury(3, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6] }));
|
||||
t.eq('埋牌 边界 只埋7张 → 拒(PARAM)', rej(bc, ERR.PARAM), true);
|
||||
bc = setup("00000", mkBury(3, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7, 8] }));
|
||||
t.eq('埋牌 边界 埋9张 → 拒(PARAM)', rej(bc, ERR.PARAM), true);
|
||||
bc = setup("00000", mkBury(3, 0)); mod.maipai(pack(0, { cards: [] }));
|
||||
t.eq('埋牌 反 空数组 → 拒(PARAM)', rej(bc, ERR.PARAM), true);
|
||||
|
||||
// ===================== §11 无超时托管:对局阶段不得注册任何定时器 =====================
|
||||
// design §11 已确认「倒计时只作展示,服务端不做任何超时动作」。这不是缺口,而是规则——
|
||||
// 所以这里放的是**守卫用例**:若将来有人给服务端加「超时自动出牌 / AI 代打」,必然要注册
|
||||
// 定时器来推进对局,这条就会转红,从而把违反 §11 的改动拦在提交前。
|
||||
// (min_ontimeout 在**结算**阶段另有正当用途:延迟扣房卡与存战绩,属平台事务、不推进对局,
|
||||
// 故本守卫只覆盖到出牌阶段为止。)
|
||||
const realTO = global.min_ontimeout;
|
||||
let timerCount = 0;
|
||||
global.min_ontimeout = function () { timerCount++; };
|
||||
const gd = driveToPlay("00000");
|
||||
mod.chupai(pack(0, { cards: [P.get_seat_cards(gd.pj, 0)[0]] }));
|
||||
global.min_ontimeout = realTO;
|
||||
t.eq('§11 叫分→选主→埋牌→出牌 全程未注册任何定时器', timerCount, 0);
|
||||
|
||||
process.exit(t.done('rpc') ? 0 : 1);
|
||||
|
||||
Reference in New Issue
Block a user