二七王:出牌顺序收敛为唯一口径,重连包补发 baozhu

同一份数据「这一手打出的牌」此前有两个顺序口径:chupai*/playproc.cards
回显客户端请求原序,重连包 pushlist 则从 playround 反查后自己再排一次序。
一手多张(甩牌/对子/拖拉机)时两者必然分岔;同编码的一对牌更是排序都
区分不了,靠"两处各自排序"永远收敛不了。

改为单一写入处:
- 新增 order_playcards(),do_playcard 入口归一化一次(顺带隔离
  can_playcard 的原地排序,不再就地改写 pack.data.cards)
- 新增 paiju.playhistory,出牌当场归档;新增 get_pushlist() 只做深拷贝
- class.export.js 删掉整段反查+重排,改为直读归档
- do_playcard 三个分支都给 re.cards,mod.js 去掉回落到入参的兜底

顺带修复重连包漏发 baozhu:它是余主公示与明牌按钮的开关,此前只在
chupai 包里给,报无主后重连按钮会凭空消失。现与 chupai 同源同门控下发。

协议文档新增 §0.6「一手出牌」的顺序口径,并补 PushCards.baozhu 字段。
测试:pushlist 一节改为真实链路驱动(一手多张 + 乱序提交),断言升级为
「重连每一手与当时的 chupai.cards 逐元素相等」;三处修复均已退回验证转红。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 20:12:03 +08:00
co-authored by Claude Opus 5
parent 5689ceab6f
commit 4508fe005f
8 changed files with 233 additions and 89 deletions
+3 -2
View File
@@ -122,7 +122,7 @@ function mkFollow() {
const pj = {
idx: 1, step: 5, banker: 0, call: 65, flower: 1, callproc: [],
seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]],
playproc: {}, cards: make108()
playproc: {}, playhistory: [], cards: make108()
};
for (let i = 0; i < 108; i++) pj.cards[i].dealowner = -1;
const give = (cid, s) => { pj.cards[cid].dealowner = s + 1; pj.cards[cid].playround = -1; };
@@ -141,7 +141,8 @@ function mkFollow() {
get_liangpai: () => null,
have_baofu: () => false,
get_curmultiple: () => P.get_curmultiple(pj),
get_mustcard: s => P.get_mustcard(pj, s)
get_mustcard: s => P.get_mustcard(pj, s),
get_pushlist: () => P.get_pushlist(pj)
};
P.new_playround(pj, 1, 0);
return pj;
+1 -1
View File
@@ -10,7 +10,7 @@ function newPaiju(step, banker, owner) {
const paiju = {
cards: make108(), step, banker, call: 65, flower: 1,
seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]],
playproc: {}, callproc: [], idx: 1
playproc: {}, playhistory: [], callproc: [], idx: 1
};
for (let i = 0; i < 108; i++) paiju.cards[i].dealowner = owner + 1;
paiju.method = {
+3 -3
View File
@@ -128,7 +128,7 @@ function mkBottomPaiju(maxseat, winCards, bottomScoreCards, flower) {
for (let i = 0; i < 108; i++) cards[i] = { id: i, playround: -1, score: 0, dealowner: 1, playowner: -1 };
for (const [cid, sc] of bottomScoreCards) { cards[cid].playround = 0; cards[cid].score = sc; }
const pc = [[], [], []]; pc[maxseat] = winCards;
const o = { banker: 0, flower, cards, playproc: { maxseat, cards: pc } };
const o = { banker: 0, flower, cards, playproc: { maxseat, cards: pc }, playhistory: [] };
o.method = { get_burycard: () => P.get_burycard(o), get_grade_incard: c => P.get_grade_incard(o, c) };
return o;
}
@@ -154,7 +154,7 @@ function mkFirstPlay(flower, dealMap) {
for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: 99, playround: -1, playowner: -1, score: 0, flower: A.id_to_flower(i), number: A.id_to_number(i) };
for (const [cid, owner] of dealMap) cards[cid].dealowner = owner;
const sl = () => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]];
const o = { step: 5, banker: 0, flower, cards, seatlist: [sl(), sl(), sl()], playproc: {} };
const o = { step: 5, banker: 0, flower, cards, seatlist: [sl(), sl(), sl()], playproc: {}, playhistory: [] };
o.method = { have_baofu: () => o.seatlist.some(s => s[4][0] == 0) };
P.new_playround(o, 1, 0);
return o;
@@ -193,7 +193,7 @@ function mkTrick(deal, scores) {
for (const [cid, seat] of deal) cards[cid].dealowner = seat + 1;
for (const [cid, s] of scores) cards[cid].score = s;
const sl = () => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]];
const o = { step: 5, banker: 0, flower: 1, cards, seatlist: [sl(), sl(), sl()], playproc: {} };
const o = { step: 5, banker: 0, flower: 1, cards, seatlist: [sl(), sl(), sl()], playproc: {}, playhistory: [] };
o.method = { have_baofu: () => o.seatlist.some(s => s[4][0] == 0) };
P.new_playround(o, 1, 0); // 第 1 轮由庄家(0)首出
return o;
+118 -46
View File
@@ -331,7 +331,7 @@ function mkShuai(extraToSeat1) {
const pj = {
idx: 1, step: 5, banker: 0, call: 65, flower: 1, callproc: [],
seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]],
playproc: {}, cards: make108()
playproc: {}, playhistory: [], cards: make108()
};
for (let i = 0; i < 108; i++) pj.cards[i].dealowner = -1; // 先全部清空
const give = (cid, s) => { pj.cards[cid].dealowner = s + 1; pj.cards[cid].playround = -1; };
@@ -372,57 +372,129 @@ t.eq('甩错 shuaicuo=1', shPk && shPk.data.shuaicuo, 1);
t.eq('甩错 只打出最小一张(主♦5)', shPk && shPk.data.cards, [id(1, 1, 5)]);
t.eq('甩错 无 shuai 分量', shPk && shPk.data.shuai, undefined);
// ===================== 重连 PushCards.pushlist:结构恒 3 座位 + 按本局主牌花色排序 =====================
// 造 5 轮已出牌的牌局(每轮每家 1 张),外加 seat0 在第 1 轮同时出「主5 + 副♥A」用于验证排序
function mkPushlist() {
const pj = {
idx: 1, step: 5, banker: 0, call: 65, flower: 1, callproc: [],
seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]],
playproc: { round: 5, start: 0, currseat: 0, cards: [[], [], []] },
cards: make108()
};
for (let i = 0; i < 108; i++) pj.cards[i].dealowner = (i % 3) + 1;
// 每轮每家一张
let next = 0;
for (let r = 1; r <= 5; r++) for (let s = 0; s < 3; s++) {
for (let i = next; i < 108; i++) {
if (pj.cards[i].dealowner === s + 1 && pj.cards[i].playround === -1) { pj.cards[i].playround = r; break; }
// ===================== 重连 PushCards.pushlist:与 chupai 下发的 data.cards 同一份数据 =====================
// 【契约】「这一手打出的牌」只有一个顺序口径:do_playcard 用 order_playcards 归一一次,
// 之后 chupai1/2/3 的 data.cards、playproc.cards、playhistory→PushCards.pushlist 全都读它。
// 因此重连包里的每一手,必须与当时那个 chupai 包的 cards【逐元素完全相等】。
//
// 旧实现在这里分岔:pushlist 是从 cards[i].playround/dealowner 反查后自己再排一次序,
// 与 chupai 回显的「客户端提交原序」是两个写入处——一手多张(甩牌/对子)时必然不同,
// 同编码的一对牌更是排序都区分不了。故本用例必须【一手多张 + 乱序提交】才抓得住。
//
// 【乱序提交】asc() 把选好的一手反转成升序再发,模拟真实客户端的点击顺序;
// 服务端不得回显它,一律给权威降序。
function ascSubmit(pj, cards) { return AR.order_cards(pj.flower, cards.concat()).reverse(); }
function drivePushlist(roomtype, hands) {
const c = driveToPlay(roomtype);
const pj = c.pj;
const played = []; // [{round, seat, cards(下发的)}...]
for (let h = 0; h < hands; h++) {
if (pj.step !== 5) break;
const seat = pj.playproc.currseat;
const round = pj.playproc.round;
const hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) {
// 首家优先领一对(制造"一手多张";对子两张同编码,排序无法区分,正是分岔点)
const pairs = AR.get_pairlist(pj.flower, hand.concat());
for (const pr of pairs) {
if (AR.can_playcard(pj.flower, pr.concat(), seat, pj.seatlist,
[0, 1, 2].filter(s => s !== seat).map(s => P.get_seat_zhucards(pj, s))).result) {
pick = pr.concat(); break;
}
}
if (!pick) { pick = [hand[hand.length - 1]]; }
} else {
const n = pj.playproc.startcount;
const get = AR.get_followcard(pj.flower, hand.concat(), n, pj.playproc.startflower, pj.playproc.starttype);
const base = (get && get.mustcard) ? get.mustcard.concat() : [];
if (base.length === n) { pick = base; }
else {
const pool = (get && get.cancard && get.cancard.length ? get.cancard : hand).filter(x => base.indexOf(x) < 0);
const need = n - base.length;
const combo = [];
(function walk(st) {
if (pick) return;
if (combo.length === need) {
const cand = base.concat(combo);
if (AR.can_followcard(pj.flower, hand, cand, n, pj.playproc.startflower, pj.playproc.starttype).result
&& (!pj.playproc.shuai_demand || AR.flush_follow_ok(pj.flower, hand, cand, pj.playproc.shuai_demand))) {
pick = cand;
}
return;
}
for (let i = st; i < pool.length && !pick; i++) { combo.push(pool[i]); walk(i + 1); combo.pop(); }
})(0);
}
}
if (!pick) { break; }
const before = c.sent.length;
mod.chupai(pack(seat, { cards: ascSubmit(pj, pick) }));
const cp = c.sent.slice(before).filter(m => /^chupai[123]$/.test(m.rpc))[0];
if (cp) { played.push({ round, seat, cards: cp.data.cards, submitted: ascSubmit(pj, pick) }); }
}
// seat0 第1轮补出:主5(flower1) 与 副♥A(flower3),主牌必须排在副牌前
const zhu5 = id(1, 1, 5), fuA = id(1, 3, 1);
for (const c of [zhu5, fuA]) { pj.cards[c].dealowner = 1; pj.cards[c].playround = 1; }
// 当前轮(第5轮)桌面上的牌:两种查牌模式下都必须能恢复(design §9 边界说明)
const cur = id(2, 1, 10);
pj.cards[cur].dealowner = 1; pj.cards[cur].playround = 5;
pj.playproc.cards[0] = [cur];
pj.method = {
get_seat_cards: s => P.get_seat_cards(pj, s),
get_jian_grade: () => P.get_jian_grade(pj),
get_burycard: () => P.get_burycard(pj),
get_liangpai: () => null,
get_curmultiple: () => P.get_curmultiple(pj),
get_mustcard: s => P.get_mustcard(pj, s)
};
return { pj, zhu5, fuA, cur };
return { c, pj, played };
}
const pl = mkPushlist();
const plRoom = setup("00000", pl.pj).o_room;
const di = E.get_deskinfo(plRoom, 1).PushCards.pushlist;
t.eq('重连 pushlist 轮数=5', di.length, 5);
t.eq('重连 pushlist 每轮恒3个座位', di.map(r => r.length), [3, 3, 3, 3, 3]);
const plv = drivePushlist("00000", 12);
t.eq('pushlist 用例真的出过一手多张', plv.played.some(x => x.cards.length > 1), true);
const di = E.get_deskinfo(plv.c.o_room, 1).PushCards.pushlist;
t.eq('重连 pushlist 每轮恒3个座位', di.every(r => r.length === 3), true);
t.eq('重连 pushlist 不含 undefined', di.some(r => r.some(x => x === undefined)), false);
// 第1轮 seat0 的牌:主5 必须排在副♥A 之前(按 paiju.flower=1 排序,而非泄漏的大王花色5)
t.eq('重连 pushlist 按本局主牌花色排序(主5在副A前)',
di[0][0].indexOf(pl.zhu5) < di[0][0].indexOf(pl.fuA), true);
// 核心:逐手比对「重连归档」与「当时的 chupai 包」——两条路径同一份数据,必须逐元素相等
let plMismatch = [];
for (const h of plv.played) {
const arc = di[h.round - 1] && di[h.round - 1][h.seat];
if (JS(arc) !== JS(h.cards)) { plMismatch.push({ round: h.round, seat: h.seat, arc, pkt: h.cards }); }
}
t.eq('重连 pushlist 每一手都与当时的 chupai.cards 逐元素相等', plMismatch, []);
// 反面:服务端不得回显客户端的提交顺序(提交是升序,下发必须是权威降序)
const plMulti = plv.played.filter(x => x.cards.length > 1);
t.eq('chupai.cards 不回显客户端提交原序(一手多张时)',
plMulti.some(x => JS(x.cards) === JS(x.submitted) && JS(x.cards) !== JS(AR.order_cards(plv.pj.flower, x.cards.concat()))), false);
t.eq('chupai.cards 是本局主牌花色的权威降序',
plMulti.every(x => JS(x.cards) === JS(AR.order_cards(plv.pj.flower, x.cards.concat()))), true);
// 客户端提交的数组不被就地改写(can_playcard 是原地排序,do_playcard 必须先拷贝)
const inplace = driveToPlay("00000");
const inHand = P.get_seat_cards(inplace.pj, inplace.pj.playproc.currseat);
const inArg = [inHand[inHand.length - 1], inHand[inHand.length - 2]];
const inArgCopy = inArg.concat();
mod.chupai(pack(inplace.pj.playproc.currseat, { cards: inArg }));
t.eq('chupai 不就地改写 pack.data.cards', inArg, inArgCopy);
// §9 不查牌模式:出牌历史属于「查牌」,一律不下发;但当前轮桌面上的牌必须照常恢复
const pl2 = mkPushlist();
const pc2 = E.get_deskinfo(setup("00001", pl2.pj).o_room, 1).PushCards;
const plv2 = drivePushlist("00001", 2);
const pc2 = E.get_deskinfo(plv2.c.o_room, 1).PushCards;
t.eq('重连 不查牌 无出牌历史 pushlist', pc2.pushlist, undefined);
t.eq('重连 不查牌 当前轮桌面牌仍恢复(playproc.cards)', pc2.playproc.cards[0], [pl2.cur]);
const pc1 = E.get_deskinfo(plRoom, 1).PushCards;
t.eq('重连 不查牌 当前轮桌面牌仍恢复(playproc.cards)',
JS(pc2.playproc.cards[plv2.played[0].seat]), JS(plv2.played[0].cards));
const pc1 = E.get_deskinfo(plv.c.o_room, 1).PushCards;
t.eq('重连 可查牌 有出牌历史 pushlist', Array.isArray(pc1.pushlist), true);
t.eq('重连 可查牌 当前轮桌面牌也在', pc1.playproc.cards[0], [pl.cur]);
// ===================== 重连 PushCards.baozhu:与 chupai 的 baozhu 同源同门控 =====================
// baozhu 是「余主公示 + 明牌按钮」的开关(design §9)。它只在 chupai 包里给、重连包不给的话,
// 重连后按钮凭空消失——状态变更没有包承载(server 红线:发全下发面)。
function mkBaozhuRoom(roomtype) {
const o_desk_stub = { paiju_list: [] };
const pj = P.new(o_desk_stub, 0);
pj.banker = 0; pj.call = 65; pj.flower = 1; pj.step = 5;
P.new_playround(pj, 1, 0);
const lead = id(1, 3, 9);
pj.cards[lead].dealowner = 1; pj.cards[lead].playround = -1;
for (const cc of P.get_seat_zhucards(pj, 1)) { pj.cards[cc].playround = 1; }
pj.seatlist[1][4][0] = 0; // seat1 报无主 → have_baofu()=true
const c = setup(roomtype, pj);
mod.chupai(pack(0, { cards: [lead] }));
return c;
}
const bzOn = mkBaozhuRoom("00000");
t.eq('重连 可查牌 PushCards 带 baozhu,且与 chupai 同值',
E.get_deskinfo(bzOn.o_room, 1).PushCards.baozhu,
bzOn.sent.filter(m => /^chupai[123]$/.test(m.rpc))[0].data.baozhu);
t.eq('重连 可查牌 已报无主 → baozhu=1', E.get_deskinfo(bzOn.o_room, 1).PushCards.baozhu, 1);
const bzOff = mkBaozhuRoom("00001");
t.eq('重连 不查牌 baozhu 恒 0', E.get_deskinfo(bzOff.o_room, 1).PushCards.baozhu, 0);
// ===================== §7.3 下发面 multiple 必须随「爬坡」开关取值(与结算 aset.multiple 同源)=====================
// 叫 45 分:常规算子基础子数 6(design §7.1「50 及以下统一 6」),爬坡 7(design §7.3.1)
@@ -506,7 +578,7 @@ t.eq('叫分 反 已不叫者再叫 未产生庄家', bd.banker, -1);
// 桩要"完整到能走完成功路径":否则一旦校验被改松,这里会**抛异常打断整个文件**,
// 后面的断言全不执行——看到的是崩溃而不是某条断言转红,定位与信号都变差。
const mkBury = (step, banker) => ({
step, banker, cards: make108(), playproc: { currseat: banker },
step, banker, cards: make108(), playproc: { currseat: banker }, playhistory: [],
method: {
check_cards_valid: c => P.check_cards_valid({ cards: make108() }, c),
check_cards_inhand: () => true,