二七王:出牌顺序收敛为唯一口径,重连包补发 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user