can_followcard 尾段计算 cardvalue/noflower/nopair 时用的是入参原始数组 followcards(客户端提交顺序),而 get_pairlist/get_tuolaji_list 都按降序 相邻取对。端到端实测:闲家用主拖拉机毙牌且为末轮,降序提交时闲家赢下本轮 (捡分 40、扣底 ×4),把同一手牌打乱成 [51,50,105,104] 后对子漏判、 cardvalue 归 0,变成庄家赢、闲家 0 分大光、不扣底——同一手合法牌仅靠数组 顺序就能翻转胜负、捡分归属与最终结算,同源问题还能抹掉 noflower 缺门标志 污染 §9 下发给全场的牌况表。 改为在 min_ary_deduct 削减 _followcards 之前另存完整排序快照 _sortfollow, 尾段 8 处全部改用它(_followcards 会被削减、不可复用)。 顺带给 get_disbandRoom 加空守卫:平台在 makewar 后立刻置 battlestate=1, 而首局是延迟 1 秒创建的,这段窗口内解散会在 curr_paiju() 的 undefined 上 解引用抛异常、打断平台解散链路;现返回 null 走平台既有的「不带 deskfree」分支。 补 8 条回归(入参顺序无关性 5 条 + 端到端 1 条 + 解散空守卫 2 条), 已用「回退修复 → 用例转红」反验有效性;全套单测 401 项全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
140 lines
11 KiB
JavaScript
140 lines
11 KiB
JavaScript
// §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);
|
||
|
||
const N6 = d => id(d, 2, 6);
|
||
|
||
// ===================== 主牌首出的跟牌(followers 必须跟主牌)=====================
|
||
t.eq('主单 有主必跟主', res(f([zQ(1), oth(9)], [zQ(1)], 1, 1, 101)), true);
|
||
t.eq('主单 有主却垫副→拒', res(f([zQ(1), oth(9)], [oth(9)], 1, 1, 101)), false);
|
||
t.eq('主单 缺主垫副(合法不压)', res(f([oth(9), oth(8)], [oth(9)], 1, 1, 101)), true);
|
||
t.eq('主单 缺主垫副 不压', beat(f([oth(9), oth(8)], [oth(9)], 1, 1, 101)), false);
|
||
t.eq('主对 有主对必出对', res(f([zJ(1), zJ(2), z9(1)], [zJ(1), zJ(2)], 2, 1, 201)), true);
|
||
t.eq('主对 有主对却拆散→拒', res(f([zJ(1), zJ(2), z9(1)], [zJ(1), z9(1)], 2, 1, 201)), false);
|
||
|
||
// ===================== 对子首出·同花色不足两张(必出仅有的1张+补)=====================
|
||
t.eq('对 同花1张必出它+补1', res(f([N9(1), oth(5), oth(6)], [N9(1), oth(5)], 2, 2, 201)), true);
|
||
t.eq('对 同花1张却不出它→拒', res(f([N9(1), oth(5), oth(6)], [oth(5), oth(6)], 2, 2, 201)), false);
|
||
|
||
// ===================== 拖拉机首出·跟牌方各手牌分支 =====================
|
||
t.eq('拖 对子不够·必出1对+补', res(f([J(1), J(2), N9(1), N8(1), N6(1)], [J(1), J(2), N9(1), N8(1)], 4, 2, 302)), true);
|
||
t.eq('拖 对子不够·不出对→拒', res(f([J(1), J(2), N9(1), N8(1), N6(1)], [N9(1), N8(1), N6(1), Q(1)], 4, 2, 302)), false);
|
||
t.eq('拖 同花无对·补同花4张', res(f([K(1), Q(1), J(1), T(1), N9(1)], [K(1), Q(1), J(1), T(1)], 4, 2, 302)), true);
|
||
// 多候选拖拉机(3连对里选1个2连拖)——回归 can_followcard 曾崩溃的 cantype=3xx 分支
|
||
t.eq('拖 多候选·出合法2连拖', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2)], [K(1), K(2), Q(1), Q(2)], 4, 2, 302)), true);
|
||
t.eq('拖 多候选·出不连两对→拒(D1回归)', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2)], [K(1), K(2), J(1), J(2)], 4, 2, 302)), false);
|
||
// 3连对首出(303)
|
||
t.eq('拖 3连对·有3连拖必出', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2), N8(1)], [K(1), K(2), Q(1), Q(2), J(1), J(2)], 6, 2, 303)), true);
|
||
// 3连对首出、跟牌方有富余对子(4对)但含1个3连拖 → 必出该3连拖,拆散→拒
|
||
t.eq('拖 3连对·富余对子中出3连拖', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2), N9(1), N9(2)], [K(1), K(2), Q(1), Q(2), J(1), J(2)], 6, 2, 303)), true);
|
||
t.eq('拖 3连对·拆散3连拖(改出99)→拒', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2), N9(1), N9(2)], [K(1), K(2), Q(1), Q(2), N9(1), N9(2)], 6, 2, 303)), false);
|
||
|
||
// —— 最大覆盖度校验:修复 get_followcard 降级递归 else 分支欠约束 ——
|
||
// 跟牌方该副花色 = AA KK JJ TT 88 66(三组互不相邻的2连对:{A,K} {J,T} {8,6})
|
||
const sA = d => id(d, 2, 1); // 副A(flower2, number1;非固定主)
|
||
const H3 = [sA(1), sA(2), K(1), K(2), J(1), J(2), T(1), T(2), N8(1), N8(2), N6(1), N6(2)];
|
||
// 303:无3连拖、有多组2连拖 → 必出1个2连拖+1散对(档案[2,1]);出三孤立对(档案[1,1,1])应拒
|
||
t.eq('拖 303·三组2连·出2连+散(合法)', res(f(H3, [sA(1), sA(2), K(1), K(2), N8(1), N8(2)], 6, 2, 303)), true);
|
||
t.eq('拖 303·三组2连·出三孤立对AA/JJ/88→拒(else回归)', res(f(H3, [sA(1), sA(2), J(1), J(2), N8(1), N8(2)], 6, 2, 303)), false);
|
||
t.eq('拖 303·三组2连·另组KK/TT/66孤立→拒', res(f(H3, [K(1), K(2), T(1), T(2), N6(1), N6(2)], 6, 2, 303)), false);
|
||
// 304:无4连拖、有三组2连拖 → 必出两个2连拖(档案[2,2]);出1个2连+两散对(档案[2,1,1])应拒
|
||
t.eq('拖 304·三组2连·出两2连拖(合法)', res(f(H3, [sA(1), sA(2), K(1), K(2), J(1), J(2), T(1), T(2)], 8, 2, 304)), true);
|
||
t.eq('拖 304·三组2连·出1个2连+两散对→拒(else回归)', res(f(H3, [sA(1), sA(2), K(1), K(2), J(1), J(2), N8(1), N8(2)], 8, 2, 304)), false);
|
||
|
||
// ===================== 手牌数 == 首出张数:全部必出 =====================
|
||
t.eq('全出 手牌恰1张(单张首出)', res(f([oth(9)], [oth(9)], 1, 2, 101)), true);
|
||
t.eq('全出 手牌恰2张(对子首出)', res(f([oth(9), oth(8)], [oth(9), oth(8)], 2, 2, 201)), true);
|
||
|
||
// ===================== 垫牌:缺门首家副花色,用其他副牌补,不争夺出牌权 =====================
|
||
// (单张垫牌上文已覆盖;此处补对子/拖拉机首家时的副牌垫)
|
||
t.eq('垫 对子缺门出两副散(合法)', res(f([oth(9), oth(8), zK(1)], [oth(9), oth(8)], 2, 2, 201)), true);
|
||
t.eq('垫 对子缺门出两副散 不压', beat(f([oth(9), oth(8), zK(1)], [oth(9), oth(8)], 2, 2, 201)), false);
|
||
t.eq('垫 拖缺门出四副散(合法)', res(f([oth(9), oth(8), oth(6), oth(5), zK(1)], [oth(9), oth(8), oth(6), oth(5)], 4, 2, 302)), true);
|
||
t.eq('垫 拖缺门出四副散 不压', beat(f([oth(9), oth(8), oth(6), oth(5), zK(1)], [oth(9), oth(8), oth(6), oth(5)], 4, 2, 302)), false);
|
||
|
||
// ===================== 混合出牌:有该花色但不够,只能补主牌,等效垫牌不争权 =====================
|
||
// 首家副对子:手中仅1张该副花色 + 主牌(无其他副牌)→ 必出该副 + 补主,混合牌压不过
|
||
t.eq('混 对子·1副+补主(合法)', res(f([N9(1), zK(1), zQ(1)], [N9(1), zK(1)], 2, 2, 201)), true);
|
||
t.eq('混 对子·1副+补主 不压(等效垫)', beat(f([N9(1), zK(1), zQ(1)], [N9(1), zK(1)], 2, 2, 201)), false);
|
||
t.eq('混 对子·丢下必出的副牌只出两主→拒', res(f([N9(1), zK(1), zQ(1)], [zK(1), zQ(1)], 2, 2, 201)), false);
|
||
// 首家副拖拉机:手中2张该副花色散张 + 主牌 → 必出2副 + 补2主,混合压不过
|
||
t.eq('混 拖·2副散+补2主(合法)', res(f([N9(1), N8(1), zK(1), zQ(1), zJ(1)], [N9(1), N8(1), zK(1), zQ(1)], 4, 2, 302)), true);
|
||
t.eq('混 拖·2副散+补2主 不压', beat(f([N9(1), N8(1), zK(1), zQ(1), zJ(1)], [N9(1), N8(1), zK(1), zQ(1)], 4, 2, 302)), false);
|
||
// 首家副拖拉机:手中1对该副花色(数量仍不足) + 主牌 → 有对也压不过(混合非纯主拖)
|
||
t.eq('混 拖·副对(不足)+补2主 不压', beat(f([N9(1), N9(2), zK(1), zQ(1), zJ(1)], [N9(1), N9(2), zK(1), zQ(1)], 4, 2, 302)), false);
|
||
|
||
// ===================== 入参顺序无关性(服务端不得依赖客户端提交的数组顺序)=====================
|
||
// 回归:can_followcard 尾段曾用入参原始顺序的 followcards 算 cardvalue/noflower/nopair,
|
||
// 而 get_pairlist/get_tuolaji_list 按降序相邻取对——客户端只要打乱数组顺序,
|
||
// 就能把本该压过的主拖拉机算成牌面 0(改写本轮胜者/捡分/扣底),或抹掉缺门标志污染 §9 牌况表。
|
||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||
|
||
// 主拖拉机毙牌:有序 vs 乱序,结果必须完全一致且都判为「压过」
|
||
const HT = [zK(1), zK(2), zQ(1), zQ(2), oth(9)];
|
||
const ht1 = f(HT, [zK(1), zK(2), zQ(1), zQ(2)], 4, 2, 302); // 降序
|
||
const ht2 = f(HT, [zK(1), zQ(1), zK(2), zQ(2)], 4, 2, 302); // 乱序(对子被拆开摆放)
|
||
t.eq('序无关 主拖毙牌·有序压过', beat(ht1), true);
|
||
t.eq('序无关 主拖毙牌·乱序同样压过', beat(ht2), true);
|
||
t.eq('序无关 主拖毙牌·两种顺序结果全等', same(ht1, ht2), true);
|
||
|
||
// 主对子毙牌
|
||
const HP = [zK(1), zK(2), oth(9)];
|
||
t.eq('序无关 主对毙牌·两种顺序结果全等',
|
||
same(f(HP, [zK(1), zK(2)], 2, 2, 201), f(HP, [zK(2), zK(1)], 2, 2, 201)), true);
|
||
|
||
// 同花色拖拉机跟牌(非毙牌)的牌面值也不得随顺序变
|
||
const HS = [J(1), J(2), T(1), T(2), N8(1)];
|
||
t.eq('序无关 同花色跟拖·两种顺序结果全等',
|
||
same(f(HS, [J(1), J(2), T(1), T(2)], 4, 2, 302), f(HS, [J(1), T(1), J(2), T(2)], 4, 2, 302)), true);
|
||
|
||
// 混合出牌:3 张该副花色 + 1 主牌。把副牌摆在首尾也必须记为「已缺该花色」
|
||
const HM = [K(1), Q(1), J(1), zK(1)];
|
||
const hm1 = f(HM, [zK(1), K(1), Q(1), J(1)], 4, 2, 302); // 降序:主牌在首
|
||
const hm2 = f(HM, [K(1), zK(1), Q(1), J(1)], 4, 2, 302); // 乱序:首尾都是副牌
|
||
t.eq('序无关 混合出牌·有序记缺门', hm1.noflower, true);
|
||
t.eq('序无关 混合出牌·乱序同样记缺门', hm2.noflower, true);
|
||
t.eq('序无关 混合出牌·两种顺序结果全等', same(hm1, hm2), true);
|
||
|
||
process.exit(t.done('follow') ? 0 : 1);
|