Files
joywayerandClaude Opus 5 ce4c972b7b 二七王:修复跟牌判定依赖客户端入参顺序(可翻转本轮胜负与结算)
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>
2026-08-23 12:18:04 +08:00

140 lines
11 KiB
JavaScript
Raw Permalink 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.
// §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);