Files
erqiwang_youle/server/games/erqiwang/test/test_arith.js
T
joywayerandClaude Opus 5 a4361a802d 二七王:补 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>
2026-08-23 13:36:09 +08:00

210 lines
16 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.
// arith 纯算法单测:扣底(§6.3) / 算子(§7) / 算奖(§8.1) / 甩牌(§5.4) / 跟甩牌(§5.4.4)
require('./_shim');
const A = require('../class.arith.js');
const t = require('./_assert')();
// 牌id构造:deck d(1-2), flower f(1-4), number n(1-13)
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; // 小王
const MF = 1; // 主花色=方块(1)
// ============ §6.3 扣底倍数 get_bottom_multiple ============
const mult = cards => A.get_bottom_multiple(MF, cards);
t.eq('扣底 单张主A', mult([id(1, 1, 1)]), 1);
t.eq('扣底 单张正7', mult([id(1, 1, 7)]), 1);
t.eq('扣底 主对K', mult([id(1, 1, 13), id(2, 1, 13)]), 2);
t.eq('扣底 两连对KQ', mult([id(1, 1, 13), id(2, 1, 13), id(1, 1, 12), id(2, 1, 12)]), 4);
t.eq('扣底 三连对KQJ', mult([id(1, 1, 13), id(2, 1, 13), id(1, 1, 12), id(2, 1, 12), id(1, 1, 11), id(2, 1, 11)]), 6);
t.eq('扣底 单张副A(非主)', mult([id(1, 2, 1)]), 0);
t.eq('扣底 小王对', mult([small(1), small(2)]), 2);
t.eq('扣底 8-6连对', mult([id(1, 1, 8), id(2, 1, 8), id(1, 1, 6), id(2, 1, 6)]), 4);
t.eq('扣底 两连对+单(取最高规格)', mult([id(1, 1, 13), id(2, 1, 13), id(1, 1, 12), id(2, 1, 12), id(1, 1, 9)]), 4);
// ============ §7 算子:判定标签 + 每对子子数 X ============
function judge(call, grade, climb) {
const base = A.get_base_bycall(call, climb);
const q = A.get_qvalue(call, climb);
const upg = A.get_upgrade(call, grade, q);
const X = Math.abs(upg) * base;
let label = upg === 3 ? '大光' : upg === 2 ? '小光' : upg === 1 ? '过庄' : upg < 0 ? '升' + (-upg) + '级' : '?';
return label + '/' + X;
}
// 常规算子 (design §7.2.1)
t.eq('常规65 大光', judge(65, 0, false), '大光/6');
t.eq('常规65 小光', judge(65, 20, false), '小光/4');
t.eq('常规65 过庄', judge(65, 50, false), '过庄/2');
t.eq('常规65 升1', judge(65, 65, false), '升1级/2');
t.eq('常规65 升2', judge(65, 105, false), '升2级/4');
t.eq('常规60 大光', judge(60, 0, false), '大光/9');
t.eq('常规55 大光', judge(55, 0, false), '大光/12');
t.eq('常规50 大光', judge(50, 0, false), '大光/18');
t.eq('常规45 过庄', judge(45, 44, false), '过庄/6');
t.eq('常规40 grade20 小光(无过庄)', judge(40, 20, false), '小光/12');
t.eq('常规40 升1', judge(40, 40, false), '升1级/6');
t.eq('常规10 grade5 小光', judge(10, 5, false), '小光/12');
t.eq('常规10 升1', judge(10, 10, false), '升1级/6');
t.eq('常规5 大光', judge(5, 0, false), '大光/18');
t.eq('常规5 升1(无小光)', judge(5, 5, false), '升1级/6');
t.eq('常规70打牌 大光', judge(70, 0, false), '大光/6');
t.eq('常规70打牌 过庄', judge(70, 69, false), '过庄/2');
// 爬坡 (design §7.3.3)
t.eq('爬坡40 大光', judge(40, 0, true), '大光/24');
t.eq('爬坡40 小光', judge(40, 19, true), '小光/16');
t.eq('爬坡40 过庄', judge(40, 20, true), '过庄/8');
t.eq('爬坡40 升1', judge(40, 40, true), '升1级/8');
t.eq('爬坡40 升2', judge(40, 60, true), '升2级/16');
t.eq('爬坡30 过庄', judge(30, 15, true), '过庄/10');
t.eq('爬坡20 升1', judge(20, 20, true), '升1级/12');
t.eq('爬坡10 过庄(无小光)', judge(10, 5, true), '过庄/14');
t.eq('爬坡10 升1', judge(10, 10, true), '升1级/14');
t.eq('爬坡5 大光', judge(5, 0, true), '大光/45');
t.eq('爬坡5 升1(无小光过庄)', judge(5, 5, true), '升1级/15');
t.eq('爬坡45 过庄', judge(45, 44, true), '过庄/7');
// ============ §8.1 算奖 get_chongguan ============
function fill28(req) {
const used = new Set(req), out = req.slice(), NN = [1, 5, 6, 8, 9, 10, 11, 12, 13];
for (const f of [1, 2, 3, 4]) for (const n of NN) {
if (out.length >= 28) break;
const c = id(1, f, n);
if (!used.has(c)) { used.add(c); out.push(c); }
}
return out;
}
const cg = (req, key) => A.get_chongguan(MF, fill28(req))[key];
t.eq('算奖 3王 count', cg([big(1), small(1), small(2)], 'count'), 1);
t.eq('算奖 3王 wang', cg([big(1), small(1), small(2)], 'wang'), 3);
t.eq('算奖 4王 count', cg([big(1), big(2), small(1), small(2)], 'count'), 3);
t.eq('算奖 6个7(无王)', cg([id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)], 'count'), 1);
t.eq('算奖 8个2(无王)', cg([id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2), id(1, 3, 2), id(2, 3, 2), id(1, 4, 2), id(2, 4, 2)], 'count'), 3);
t.eq('算奖 3王+正7对+副7对(连对链)', cg([big(1), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7)], 'count'), 3);
// ≥10固定主但互不成对 => 只3王(旧代码会误+1)
t.eq('算奖 10固定主无对无链', cg([big(1), small(1), small(2), id(1, 1, 7), id(1, 2, 7), id(1, 3, 7), id(1, 4, 7), id(1, 1, 2), id(1, 2, 2), id(1, 3, 2)], 'count'), 1);
// 连对链延伸到主A / 副花色A不算链
t.eq('算奖 连对链到主A对', cg([big(1), small(1), small(2), 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), id(1, 1, 1), id(2, 1, 1)], 'count'), 6);
t.eq('算奖 副花色A对不续链', cg([big(1), small(1), small(2), 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), id(1, 2, 1), id(2, 2, 1)], 'count'), 5);
// ============ §5.4 甩牌 decompose / 最大性 / can_playcard ============
const types = cards => A.decompose_trump(MF, cards).map(c => c.type + (c.len > 1 ? c.len : '')).sort().join(',');
t.eq('分解 大王对+主K单', types([big(1), big(2), id(1, 1, 13)]), 'pair,single');
t.eq('分解 主K主Q两连对', types([id(1, 1, 13), id(2, 1, 13), id(1, 1, 12), id(2, 1, 12)]), 'tractor2');
t.eq('分解 主K对+主9对(不连)', types([id(1, 1, 13), id(2, 1, 13), id(1, 1, 9), id(2, 1, 9)]), 'pair,pair');
const play = (cards, opps) => A.can_playcard(MF, cards, 0, null, opps);
const K1 = id(1, 1, 13), K2 = id(2, 1, 13), A1 = id(1, 1, 1), P5 = id(1, 1, 5), P6 = id(1, 1, 6), fK1 = id(1, 2, 13), f9 = id(1, 2, 9), fK2 = id(2, 2, 13), Q1 = id(1, 1, 12), Q2 = id(2, 1, 12);
t.eq('甩牌 合法(大王对+主K单)', play([big(1), big(2), K1], [[P5, P6], []]).result, true);
let r = play([big(1), big(2), K1], [[A1, P5], []]);
t.eq('甩牌 甩错(对手有主A>主K) shuaicuo', r.shuaicuo, true);
t.eq('甩牌 甩错 smallest=主K', r.smallest, K1);
r = play([fK1, f9], [[], []]);
t.eq('甩牌 副牌禁甩 result', r.result, false);
t.eq('甩牌 副牌禁甩 无shuaicuo', r.shuaicuo, undefined);
t.eq('甩牌 主单张(非甩)可出', play([big(1)], [[A1], [K1]]).result, true);
t.eq('甩牌 副对子(非甩)可出', play([fK1, fK2], [[], []]).result, true);
t.eq('甩牌 主拖拉机(单分量不受最大性约束)', play([K1, K2, Q1, Q2], [[big(1), big(2)], []]).result, true);
// —— 甩牌首出补全:各分量被压导致甩错 + 各牌型组合合法(design §5.4.2/§5.4.3)——
r = play([big(1), K1, K2], [[A1, id(2, 1, 1)], []]); // 大王单+主K对;对手主A对
t.eq('甩牌 甩错·主对被更大主对压', r.shuaicuo, true);
r = play([big(1), Q1, Q2, id(1, 1, 11), id(2, 1, 11)], [[A1, id(2, 1, 1), K1, K2], []]); // 大王+主QJ两连对;对手主AK两连对
t.eq('甩牌 甩错·拖拉机被同长更大主拖压', r.shuaicuo, true);
t.eq('甩牌 合法·纯双单张(大王+主K)', play([big(1), K1], [[P5], []]).result, true);
t.eq('甩牌 合法·纯双对不连(主K对+主9对)', play([K1, K2, id(1, 1, 9), id(2, 1, 9)], [[P5, P6], []]).result, true);
r = play([K1, K2, id(1, 1, 9), id(2, 1, 9)], [[id(1, 1, 11), id(2, 1, 11)], []]); // 主9对被主J对压
t.eq('甩牌 甩错·双对之一被压', r.shuaicuo, true);
t.eq('甩牌 合法·单张+拖拉机', play([big(1), K1, K2, Q1, Q2], [[P5, P6], []]).result, true);
t.eq('甩牌 合法·单+对+拖三混', play([big(1), id(1, 1, 10), id(2, 1, 10), K1, K2, Q1, Q2], [[P5, P6], []]).result, true);
t.eq('甩牌 副禁甩·对手非缺门仍禁', play([fK1, f9], [[fK2, id(1, 2, 10)], []]).result, false);
// design §5.4.3「无组合数量限制」:曾有「一次最多14张」的规则外硬上限,会把 15 张以上的
// 合法甩牌整个拒掉。16 张全固定主牌(4王 + 8个7 + 正2对 + 副2对)、两对手无主牌 → 必须可甩
const SHUAI16 = [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), id(1, 4, 7), id(2, 4, 7),
id(1, 1, 2), id(2, 1, 2), id(1, 4, 2), id(2, 4, 2)];
t.eq('甩牌 16张(超14张上限)仍可甩', play(SHUAI16, [[], []]).result, true);
t.eq('甩牌 16张 有分量需求下发', !!play(SHUAI16, [[], []]).shuai_demand, true);
// ============ §5.4.4 跟甩牌强制分量拆解 flush_follow_ok ============
const J1 = id(1, 1, 11), N9a = id(1, 1, 9), N9b = id(2, 1, 9), P8 = id(1, 1, 8);
const fA = id(1, 2, 1), fB = id(1, 2, 5), fC = id(1, 2, 6), fD = id(1, 2, 8);
const ffo = (hand, follow, demand) => A.flush_follow_ok(MF, hand, follow, demand);
t.eq('跟牌 含主对KK', ffo([K1, K2, J1, N9a], [K1, K2, J1], { tractors: [], pairs: 1, singles: 1 }), true);
t.eq('跟牌 拆散主对(3散)→拒', ffo([K1, K2, J1, N9a], [J1, N9a, K1], { tractors: [], pairs: 1, singles: 1 }), false);
t.eq('跟牌 无主对散张可跟', ffo([J1, N9a, P6], [J1, N9a, P6], { tractors: [], pairs: 1, singles: 1 }), true);
const hand5 = [K1, K2, Q1, Q2, P6, J1, N9a];
t.eq('跟牌 打2连拖+单', ffo(hand5, [K1, K2, Q1, Q2, P6], { tractors: [2], pairs: 0, singles: 1 }), true);
t.eq('跟牌 有2连拖却打5散→拒', ffo(hand5, [K1, Q1, P6, J1, N9a], { tractors: [2], pairs: 0, singles: 1 }), false);
t.eq('跟牌 有2连拖却只打一对→拒', ffo(hand5, [K1, K2, P6, J1, N9a], { tractors: [2], pairs: 0, singles: 1 }), false);
const hand6 = [K1, K2, N9a, N9b, P6, J1, P8];
t.eq('跟牌 无同长拖两对退化', ffo(hand6, [K1, K2, N9a, N9b, P6], { tractors: [2], pairs: 0, singles: 1 }), true);
t.eq('跟牌 有两对却打5散→拒', ffo(hand6, [K1, N9a, P6, J1, P8], { tractors: [2], pairs: 0, singles: 1 }), false);
t.eq('跟牌 主牌最大化(有主用尽)', ffo([K1, Q1, fA, fB], [K1, Q1, fA], { tractors: [], pairs: 1, singles: 1 }), true);
t.eq('跟牌 留主牌垫副→拒', ffo([K1, Q1, fA, fB], [K1, fA, fB], { tractors: [], pairs: 1, singles: 1 }), false);
t.eq('跟牌 无主全垫副', ffo([fA, fB, fC, fD], [fA, fB, fC], { tractors: [], pairs: 1, singles: 1 }), true);
// —— 跟甩牌补全:拖分量退化到单张 / 多组拖拉机 / 混合demand(design §5.4.4)——
t.eq('跟牌 拖退化·1对不够必打+其余单', ffo([K1, K2, N9a, P8, P6, id(1, 1, 5)], [K1, K2, N9a, P8, P6], { tractors: [2], pairs: 0, singles: 1 }), true);
t.eq('跟牌 拖退化·有1对却拆散→拒', ffo([K1, K2, N9a, P8, P6, id(1, 1, 5)], [K1, N9a, P8, P6, id(1, 1, 5)], { tractors: [2], pairs: 0, singles: 1 }), false);
t.eq('跟牌 双拖·两组2连拖必打', ffo([K1, K2, Q1, Q2, id(1, 1, 10), id(2, 1, 10), N9a, N9b], [K1, K2, Q1, Q2, id(1, 1, 10), id(2, 1, 10), N9a, N9b], { tractors: [2, 2], pairs: 0, singles: 0 }), true);
t.eq('跟牌 混合·拖+对+单各对位', ffo([K1, K2, Q1, Q2, N9a, N9b, P8, id(1, 1, 5)], [K1, K2, Q1, Q2, N9a, N9b, P8], { tractors: [2], pairs: 1, singles: 1 }), true);
t.eq('跟牌 混合·有对却拆散→拒', ffo([K1, K2, Q1, Q2, N9a, N9b, P8, id(1, 1, 5)], [K1, K2, Q1, Q2, N9a, P8, id(1, 1, 5)], { tractors: [2], pairs: 1, singles: 1 }), false);
// ============ §3 主牌顺序 / 相邻链 ============
// order_cards 结果应为 design §3 从大到小:大王>小王>正7>副7>正2>副2>主A>主K…主5>副牌
const big1 = big(1), small1 = small(1), Z7 = id(1, 1, 7), F7 = id(1, 2, 7), Z2 = id(1, 1, 2), F2 = id(1, 2, 2), zA = id(1, 1, 1), zK = id(1, 1, 13), z5 = id(1, 1, 5);
t.eq('排序 主牌降序=设计顺序', A.order_cards(MF, [z5, F2, Z2, big1, F7, Z7, small1, zA, zK]), [big1, small1, Z7, F7, Z2, F2, zA, zK, z5]);
// is_continuous 相邻链逐段(design §3/§5.3)
const cont = (a, b) => A.is_continuous(A.id_to_code(MF, a), A.id_to_code(MF, b));
t.eq('相邻 大王-小王', cont(big(1), small(1)), true);
t.eq('相邻 小王-正7', cont(small(1), Z7), true);
t.eq('相邻 正7-副7', cont(Z7, F7), true);
t.eq('相邻 副7-正2', cont(F7, Z2), true);
t.eq('相邻 正2-副2', cont(Z2, F2), true);
t.eq('相邻 副2-主A', cont(F2, zA), true);
t.eq('相邻 主A-主K', cont(zA, zK), true);
t.eq('相邻 主8-主6(6/8可连)', cont(id(1, 1, 8), id(1, 1, 6)), true);
t.eq('相邻 主6-主5', cont(id(1, 1, 6), z5), true);
t.eq('相邻 正7-主6 不连(7不入普通序)', cont(Z7, id(1, 1, 6)), false);
t.eq('相邻 主8-正7 不连', cont(id(1, 1, 8), Z7), false);
// 8/6 相邻必须同花色:跨花色的 8 与 6 不成连对(否则 get_chongguan 连对链会被副牌 6 对错误接续)
t.eq('相邻 副♥8-副♥6 同花色 连', cont(id(1, 3, 8), id(1, 3, 6)), true);
t.eq('相邻 副♥8-副♣6 跨花色 不连', cont(id(1, 3, 8), id(1, 2, 6)), false);
t.eq('相邻 主8-副♣6 跨花色 不连', cont(id(1, 1, 8), id(1, 2, 6)), false);
t.eq('相邻 副♠8-副♦6 跨花色 不连', cont(id(1, 4, 8), id(1, 1, 6)), false);
// 顺带确认非 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');
t.eq('常规65 g64过庄', judge(65, 64, false), '过庄/2');
t.eq('常规65 g65升1', judge(65, 65, false), '升1级/2');
t.eq('常规65 g104升1', judge(65, 104, false), '升1级/2');
t.eq('常规65 g105升2', judge(65, 105, false), '升2级/4');
t.eq('常规65 g145升3', judge(65, 145, false), '升3级/6');
process.exit(t.done('arith') ? 0 : 1);