二七王:夹具补甩牌样本,重连恢复 baozhu

导出脚本原来全出单张,单张一手无所谓顺序,服务端两个顺序口径的分岔
在夹具里永远测不出来。改出牌策略(种子不变):首家优先合法甩牌 → 领
最大的一对 → 最小单张;跟家凑合法跟牌;提交时刻意反转成升序,模拟真实
客户端的点击顺序,把「下发顺序是否被请求顺序污染」暴露出来。
中段 step5 快照锚点从 1 个加到 5 个——开局那三张快照的 pushlist 还是空的,
只有打到一半的快照才真的比对到出牌历史。

重导结果:51 手出牌,一手多张 30 手、合法甩牌 4 手,deskinfo 快照 18 张,
仍覆盖 step 1/2/3/5/6;连跑两次逐字节相同。
该样本在修复前使 test_consistency 5 个比对点转红,修复后全绿。

前端 ResyncHandler 补上 PushCards.baozhu 的映射(服务端已补发),
test_resync 加正反两条断言。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 20:12:24 +08:00
co-authored by Claude Opus 5
parent 4508fe005f
commit dac4a94b38
4 changed files with 15121 additions and 24373 deletions
+128 -16
View File
@@ -159,31 +159,142 @@ snap(3, pj.banker); // 埋牌阶段(step3):庄家视角
mod.maipai(pk(pj.banker, { cards: P.get_seat_cards(pj, pj.banker).slice(-8) }));
snap(5, 0); snap(5, 1); snap(5, 2); // 出牌阶段开局(step5):出牌者 cardsinhand、下一位 mustcard 差异
// 出牌:首家出手上最小单张,跟家取第一张能通过 can_followcard 的单张——
// 与服务端 test/test_endgame.js 的 playAll() 同一套算法,直至打完整副牌进入结算。
// ================= 出牌策略 =================
// 【为什么不再是"全出单张"】:单张一手无所谓顺序,服务端「这一手打出的牌」的两个顺序口径
// (chupai/playproc.cards 与 deskinfo.PushCards.pushlist)在单张局面下永远相等,夹具测不出
// 分岔。甩牌(design §5.4)与领对子会产生"一手多张",才真正压到这条契约。
//
// 策略(确定性,不含随机):
// 首家:优先甩牌(多分量主牌,且服务端 can_playcard 判定合法)→ 其次领最小的一对
// → 否则最小单张
// 跟家:先取 get_followcard 的必出牌,再从可出牌里补足张数,逐个组合试到
// can_followcard(+ 甩牌时的 flush_follow_ok)通过为止
//
// 【提交顺序刻意用升序】:真实客户端提交的是玩家的点击顺序,服务端不得依赖它
//(server 04 §8「前端不是数据源」;class.arith.js can_followcard 里也有同样的告诫)。
// 服务端的权威展示顺序是 order_cards 的【降序】,所以这里一律把选好的一手【反转成升序】
// 再提交,模拟"玩家从小到大点选"。这不是伪造数据——包仍然是服务端真实产出的,
// 只是让请求侧的顺序与权威顺序刻意不同,把"下发顺序是否被请求顺序污染"暴露出来。
const asc = function (cards) {
return A.order_cards(pj.flower, cards.concat()).reverse();
};
// 两名对手未出的主牌(供甩牌最大性判定),与 class.paiju.js do_playcard 的取法一致
function oppZhuList(seat) {
const list = [];
for (let os = 0; os < 3; os++) {
if (os !== seat) { list.push(P.get_seat_zhucards(pj, os)); }
}
return list;
}
// 首家:尝试甩牌——把手上主牌分解成分量,按分量牌力从大到小取前 k 个(k 从多到少),
// 交给服务端 can_playcard 判定;只接受 result===true 的(合法甩牌),
// 不提交会被判"甩错"的组合(那会被服务端收回成单张,反而回到单张局面)。
// 注意 can_playcard 会【就地排序】入参数组,故一律传副本。
function tryShuai(seat, hand) {
const trumps = hand.filter(function (c) { return A.id_to_code(pj.flower, c) > 1000; });
if (trumps.length < 2) { return null; }
const comps = A.decompose_trump(pj.flower, trumps).slice()
.sort(function (a, b) { return b.value - a.value; });
const opp = oppZhuList(seat);
for (let k = Math.min(comps.length, 3); k >= 2; k--) {
let pick = [];
for (let i = 0; i < k; i++) { pick = pick.concat(comps[i].cards); }
if (A.can_playcard(pj.flower, pick.concat(), seat, pj.seatlist, opp).result) {
return pick;
}
}
return null;
}
// 首家:尝试领一对。取【最大的一对】——甩牌合法的前提是两名对手都压不过任一分量
//(§5.4.2 opp_can_beat_flush),大牌先走能更快拔掉对手的主牌,后续局面才会出现合法甩牌。
// 这不是"为了凑测试"的取巧:本夹具的目的就是覆盖甩牌,而甩牌本来就只在这种局面下成立。
// 同一颗种子下:领最小的一对 → 全局只有 1 手合法甩牌;领最大的一对 → 4 手。
function tryLeadPair(seat, hand) {
const pairs = A.get_pairlist(pj.flower, hand.concat());
if (!pairs.length) { return null; }
const pick = pairs[0].concat();
if (A.can_playcard(pj.flower, pick.concat(), seat, pj.seatlist, oppZhuList(seat)).result) {
return pick;
}
return null;
}
// 跟家:这一手是否被服务端接受(与 class.paiju.js do_playcard 的跟牌校验同一组判定)
function followOk(hand, cand) {
const proc = pj.playproc;
if (!A.can_followcard(pj.flower, hand, cand, proc.startcount, proc.startflower, proc.starttype).result) {
return false;
}
if (proc.shuai_demand && !A.flush_follow_ok(pj.flower, hand, cand, proc.shuai_demand)) {
return false;
}
return true;
}
// 跟家:凑出 startcount 张的合法跟牌
function pickFollow(seat, hand) {
const proc = pj.playproc;
const n = proc.startcount;
const get = A.get_followcard(pj.flower, hand.concat(), n, proc.startflower, proc.starttype);
const base = (get && get.mustcard) ? get.mustcard.concat() : [];
if (base.length === n) {
return followOk(hand, base) ? base : null;
}
const need = n - base.length;
if (need < 0) { return null; }
// 候选池:优先服务端给出的"可出的牌",为空则退回整手牌
let pool = (get && get.cancard && get.cancard.length) ? get.cancard.concat() : hand.concat();
pool = pool.filter(function (c) { return base.indexOf(c) < 0; });
let found = null;
const combo = [];
(function walk(start) {
if (found) { return; }
if (combo.length === need) {
const cand = base.concat(combo);
if (followOk(hand, cand)) { found = cand; }
return;
}
for (let i = start; i < pool.length && !found; i++) {
combo.push(pool[i]);
walk(i + 1);
combo.pop();
}
})(0);
return found;
}
// 打到一半时的 step5 快照锚点(按已出手数计)。
// 【为什么要不止一个】:出牌阶段开局那三张 step5 快照的 pushlist 都还是空的,
// 只有"打到一半"的快照才真的比对到出牌历史(一致性测试里唯一压到 pushlist 的点)。
// 一个锚点=一个比对点,太薄;多取几个覆盖不同轮次/不同座位/轮中不同位次。
const MID_SNAP_AT = [4, 9, 16, 25, 34];
let guard = 0;
let midSnapDone = false;
let shuaiHands = 0; // 统计:合法甩牌手数
let multiHands = 0; // 统计:一手多张的手数(含跟牌)
while (pj.step === 5 && guard < 400) {
guard++;
const seat = pj.playproc.currseat;
const hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) {
pick = [hand[hand.length - 1]];
pick = tryShuai(seat, hand);
if (pick) { shuaiHands++; }
if (!pick) { pick = tryLeadPair(seat, hand); }
if (!pick) { pick = [hand[hand.length - 1]]; }
} else {
for (let i = 0; i < hand.length; i++) {
const c = hand[i];
if (A.can_followcard(pj.flower, hand, [c], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) {
pick = [c];
break;
}
}
pick = pickFollow(seat, hand);
}
if (!pick) { break; }
mod.chupai(pk(seat, { cards: pick }));
// 出牌进行到中段时额外拍一张 step5 快照,覆盖"打到一半"的重连场景
if (!midSnapDone && guard === 6 && pj.step === 5) {
midSnapDone = true;
// 找不到合法出牌属脚本缺陷(服务端保证当前座位总有牌可出),显式失败、不静默跳过
if (!pick) {
throw new Error('export_packets: 座位 ' + seat + ' 第 ' + guard + ' 手凑不出合法出牌');
}
if (pick.length > 1) { multiHands++; }
mod.chupai(pk(seat, { cards: asc(pick) }));
// 出牌进行到中段时额外拍 step5 快照,覆盖"打到一半"的重连场景
if (MID_SNAP_AT.indexOf(guard) >= 0 && pj.step === 5) {
snap(5, pj.playproc.currseat);
}
}
@@ -202,4 +313,5 @@ fs.writeFileSync(OUT, JSON.stringify(fixture, null, 2));
console.log('已写入 ' + OUT);
console.log('座位包数: ' + seatPackets.map(function (a) { return a.length; }).join(' / '));
console.log('deskinfo 快照数: ' + deskinfo.length);
console.log('出牌手数: ' + guard + '(其中一手多张 ' + multiHands + ' 手,合法甩牌 ' + shuaiHands + ' 手)');
console.log('paiju.step(结束时)=' + pj.step + ' result=' + pj.result);
+14977 -24355
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -28,7 +28,7 @@ EQW_ResyncHandler.handleDeskinfo({
grade: 25, playproc: { round: 4, currseat: 1, cards: [[9],[],[]] },
seatlist: [[0,0,0,0,[5,2]],[0,0,0,0,[6,1]],[0,0,0,0,[7,3]]],
liangpai: { cards: [52,53] }, pushlist: [[[1],[2],[3]]],
curmultiple: 2, mustcard: [4,5]
curmultiple: 2, mustcard: [4,5], baozhu: 1
}
});
t.eq('总局数', S.room.asetCount, 6);
@@ -46,6 +46,17 @@ t.eq('亮牌', S.table.liangpai, { cards: [52,53] });
t.eq('必出牌', S.my.mustCard, [4,5]);
t.eq('控制权(由 playproc.currseat 给出)', S.turn.seat, 1);
t.eq('重连发全量重画事件', evts.indexOf('EQW_RESYNC_ALL') >= 0, true);
// 报无主:明牌按钮与余主公示的开关(协议 PushCards.baozhu)。
// 增量路径由 chupai1/2/3 的 baozhu 维护,重连路径必须一并恢复,
// 否则重连后按钮凭空消失(两条路径对同一状态给出不同结果)。
t.eq('报无主(PushCards.baozhu)', S.aset.baozhu, 1);
// 反面:不查牌房服务端恒发 0,前端照写不反推
S.reset(); S.room.mySeat = 1;
EQW_ResyncHandler.handleDeskinfo({
count: 6, idx: 2, PlayerInfo: [0,0,0], step: 5, MyCards: [1],
PushCards: { banker: 0, flower: 3, playproc: { round: 1, currseat: 1, cards: [[],[],[]] }, baozhu: 0 }
});
t.eq('不查牌 baozhu 恒 0', S.aset.baozhu, 0);
// ================= deskinfo:step 1 叫分阶段 =================
S.reset(); evts.length = 0;