Files
erqiwang_youle/server/games/erqiwang/test/test_arith.js
T
joywayerandClaude Opus 5 916066ba96 二七王:修正 design 全量重核发现的 3 项不一致(亮牌快照/报无主下发面/甩牌张数上限)
- §8.2 亮牌改用埋牌后 28 张静态快照 get_seat_cards_award,不再用
  get_seat_cards(只含未出的牌)——否则闲家重连时 get_deskinfo 按庄家
  当前剩余手牌重算,亮牌统计缩水甚至变 null。
- §9 出牌包由只带出牌者一家的 info 改为整表下发三家 seatlist,与重连包
  PushCards.seatlist 同名同构;报无主后另两家的主牌数/对子数不再滞后两次出牌。
- §5.4.3 甩牌"无组合数量限制":can_playcard 去掉规则外的 14 张硬上限,
  改为结构性上界 28(单人手牌上限,牌型编码在此范围内不溢出)。

协议文档同步改写 chupai 三处 info 条目与 liangpai 口径;合规文档补记第四轮
核对(流程层无缺环,规则层 3 项已收口)。全套单测 368 项通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 19:33:06 +08:00

184 lines
14 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);
// ============ §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);