二七王:I-3/I-2/M-2 服务端补齐阶段·控制权·结算重连下发面
I-3 阶段与控制权改由服务端唯一维护并逐包下发: - fapai / jiaofen / shangzhuang / xuanzhu / maipai / jiesuan 各补 step(取自 o_paiju.step) - shangzhuang / xuanzhu 补 nextseat(控制权 = o_paiju.banker,选主/投降/埋牌均只由庄家做) - deskinfo 的 ChooseMain / BuryCards 各补 seat(控制权,与增量推送 nextseat 同源同值) - 各下发点的真实值先用探针跑真实一局逐个核实;顺带发现 shangzhuang.data.seat 是 最后一个叫分者而非控制权(本局实测 2 vs 庄家 0),故控制权另起名 nextseat - get_paiju_account 统一把 step 置 6:解散结算此前无人推进 step,包里给不出诚实的阶段值 I-2 结算阶段的重连包补发抠底明细与大局结算: - get_paiju_account 末尾一并冻结 tmp_jiesuan_bottom / tmp_jiesuan_account - get_deskinfo case 6 的 Balance 据此补 bottom / account,与 jiesuan 推送同源同值、 门控一致(投降/解散无 bottom、非末局无 account);新局开始时三份快照一并清除 - 修复「结算面板开着时断线重连/硬刷新 → 抠底明细与末局大结算空白」 M-2 seatlist 不再以活引用挂进下发包: - 新增 get_seatlist() 深拷贝快照,maipai / chupai / deskinfo.PushCards 三处改用它 测试:test_rpc 新增 I-3/I-2/M-2 三节(131→166 checks)、test_desk 新增 fapai 阶段与控制权用例(6→10);全部新增守卫已逐条做「退回修复必转红」反向验证(15/15)。 test_leak 的可见性规则同步收紧到「本局结算(step6)后埋牌底牌才全场公开」, 并修掉一处把阶段写死成 step5 的报错文案;整局泄露审计仍全绿。 协议文档同步补充上述字段说明。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,4 +18,29 @@ t.eq('轮庄 庄2赢连庄', nextFirstseat(2, 0), 2);
|
||||
t.eq('轮庄 庄2闲赢下家(回0)', nextFirstseat(2, 1), 0);
|
||||
t.eq('轮庄 庄0闲赢下家1', nextFirstseat(0, 1), 1);
|
||||
|
||||
// ============ I-3 发牌包必须自带阶段 step 与控制权 seat ============
|
||||
// 红线「状态机唯一在服务端」:阶段与「轮到谁」必须由服务端在包里显式给出。
|
||||
// 曾经 fapai 只有 seat 没有 step,前端只能按 rpc 名硬编码 step=1——那就是把阶段推导
|
||||
// 搬到了前端。反向验证:把 class.desk.js 里的 msg.data.step 删掉,本组第一条必转红。
|
||||
function firstFapai() {
|
||||
const sent = [];
|
||||
const o_room = {
|
||||
seatlist: [{ conmode: 0, fromid: 0 }, { conmode: 0, fromid: 1 }, { conmode: 0, fromid: 2 }],
|
||||
asetcount: 6, method: { sendpack_toother: () => { } }
|
||||
};
|
||||
const prevApp = global.youle_erqiwang.app;
|
||||
global.youle_erqiwang.app = { SendPack: m => sent.push(JSON.parse(JSON.stringify(m))) };
|
||||
const desk = D.new(o_room);
|
||||
D.do_new_paiju(desk, 1); // 首个叫分者 = 1 号位(刻意不用 0,避免"恒等于 0"的假绿)
|
||||
global.youle_erqiwang.app = prevApp;
|
||||
return { desk, pj: desk.paiju_list[desk.paiju_list.length - 1], fapai: sent.filter(m => m.rpc === 'fapai') };
|
||||
}
|
||||
const fp = firstFapai();
|
||||
t.eq('发牌 fapai 带 step,且 = 牌局的真实阶段(1 叫分)', fp.fapai.map(m => m.data.step), [1, 1, 1]);
|
||||
t.eq('发牌 fapai.step 与 paiju.step 同源(不是写死的常量)', fp.fapai[0].data.step, fp.pj.step);
|
||||
t.eq('发牌 fapai 控制权 seat = 当前等待叫分者(firstseat=1)',
|
||||
fp.fapai.map(m => m.data.seat), [1, 1, 1]);
|
||||
t.eq('发牌 fapai.seat 与 get_callgrade_seat() 同源',
|
||||
fp.fapai[0].data.seat, fp.pj.method.get_callgrade_seat());
|
||||
|
||||
process.exit(t.done('desk') ? 0 : 1);
|
||||
|
||||
@@ -55,7 +55,11 @@ function mayKnow(pj, seat, cid, rpc) {
|
||||
if (c.dealowner === seat + 1) return true; // 自己的牌
|
||||
if (seat === pj.banker && (c.dealowner === 0 || c.dealowner === seat + 1)) return true; // 庄家可见底牌/埋牌底牌
|
||||
if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0) return true; // §4 70分亮3秒
|
||||
if (rpc === 'jiesuan' && c.playround === 0) return true; // §11 结束亮埋牌底牌
|
||||
// §11 结束亮埋牌底牌:本局一旦结算(step6),埋牌底牌就对全场公开——jiesuan 广播本就
|
||||
// 三家都发(sendpack_toother(msg,-1) 的 bottom.cards)。重连包在 step6 给出的 Balance.bottom
|
||||
// 是【同一份数据的另一条投递路径】,可见性必须同判,否则会把「重连要看到与广播一样的结算面板」
|
||||
// 误判成泄露。注意这里限定 step===6,不是无条件放行 playround===0
|
||||
if (c.playround === 0 && (rpc === 'jiesuan' || (rpc === 'deskinfo' && pj.step === 6))) return true;
|
||||
if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验
|
||||
// §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包里下发)
|
||||
if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && curLiangpai.has(cid)) return true;
|
||||
@@ -124,7 +128,9 @@ function run(roomtype, call) {
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const di = EX.new().get_deskinfo(o_room, s);
|
||||
const out = new Set(); collectCards(di, null, out); pkts++;
|
||||
for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step5) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); }
|
||||
// 阶段取【实际的】pj.step:最后一手打完后 step 已转 6,这里拿到的是结算快照,
|
||||
// 写死 "step5" 会让报错信息指错阶段(曾据此误判过)
|
||||
for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step${pj.step}) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); }
|
||||
}
|
||||
if (pj.method.have_baofu()) {
|
||||
sent.length = 0;
|
||||
|
||||
@@ -614,4 +614,160 @@ mod.chupai(pack(0, { cards: [P.get_seat_cards(gd.pj, 0)[0]] }));
|
||||
global.min_ontimeout = realTO;
|
||||
t.eq('§11 叫分→选主→埋牌→出牌 全程未注册任何定时器', timerCount, 0);
|
||||
|
||||
// ===================== I-3 阶段 step 与控制权 必须由服务端在包里显式给出 =====================
|
||||
// server 红线:「阶段、控制权(轮到谁)… 必须由服务端唯一维护并在相关下发包里显式给出;
|
||||
// 任何状态变更都要有包承载」。曾经的缺陷:增量推送里【一个 step 都没有】,选主/埋牌阶段
|
||||
// 也没有「轮到谁」,前端只能按 rpc 名硬编码 step、并自己套「选主/埋牌 = 庄家」这条规则——
|
||||
// 那就是前端持有了一小段对局状态机(服务端将来插一个中间阶段,前端不会红、只会静默画错)。
|
||||
//
|
||||
// 本组逐包钉死两件事:①每个包的 step == 同时刻 paiju.step(不是常量,是权威值);
|
||||
// ②每个包的「轮到谁」== 服务端各 RPC 校验所用的同一个权威来源。
|
||||
// 反向验证:删掉 mod.js/class.desk.js 里任意一处 msg.data.step / nextseat,本组必转红。
|
||||
const sp = (() => {
|
||||
const pj = P.new({ paiju_list: [] }, 0);
|
||||
const c = setup("00000", pj);
|
||||
const grab = rpc => c.sent.filter(m => m.rpc === rpc);
|
||||
const out = {};
|
||||
mod.jiaofen(pack(0, { call: 65 }));
|
||||
out.jiaofen1 = grab('jiaofen')[0]; out.after_jf1 = { step: pj.step, next: pj.method.get_callgrade_seat() };
|
||||
mod.jiaofen(pack(1, { call: 0 }));
|
||||
out.jiaofen2 = grab('jiaofen')[1]; out.after_jf2 = { step: pj.step, next: pj.method.get_callgrade_seat() };
|
||||
out.deskinfo1 = E.get_deskinfo(c.o_room, 1);
|
||||
mod.jiaofen(pack(2, { call: 0 })); // → 0 号位上庄
|
||||
out.shangzhuang = grab('shangzhuang')[0]; out.after_sz = { step: pj.step, banker: pj.banker };
|
||||
out.deskinfo2 = E.get_deskinfo(c.o_room, 1); // 闲家视角(控制权与视角无关)
|
||||
mod.xuanzhu(pack(0, { flower: 1 }));
|
||||
out.xuanzhu = grab('xuanzhu')[0]; out.after_xz = { step: pj.step, banker: pj.banker };
|
||||
out.deskinfo3 = E.get_deskinfo(c.o_room, 2);
|
||||
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
|
||||
out.maipai = grab('maipai')[0]; out.after_mp = { step: pj.step, curr: pj.playproc.currseat };
|
||||
out.deskinfo5 = E.get_deskinfo(c.o_room, 1);
|
||||
return { pj, c, out };
|
||||
})();
|
||||
const O = sp.out;
|
||||
// ① 阶段 step:每个包都有,且等于同时刻牌局的真实阶段
|
||||
t.eq('阶段 jiaofen 带 step,且 == 当时的 paiju.step(仍在叫分=1)', [O.jiaofen1.data.step, O.after_jf1.step], [1, 1]);
|
||||
t.eq('阶段 jiaofen(第二包) step 仍为 1', O.jiaofen2.data.step, O.after_jf2.step);
|
||||
t.eq('阶段 shangzhuang 带 step,且 == paiju.step(选主/投降=2)', [O.shangzhuang.data.step, O.after_sz.step], [2, 2]);
|
||||
t.eq('阶段 xuanzhu 带 step,且 == paiju.step(埋牌=3)', [O.xuanzhu.data.step, O.after_xz.step], [3, 3]);
|
||||
t.eq('阶段 maipai 带 step,且 == paiju.step(出牌=5)', [O.maipai.data.step, O.after_mp.step], [5, 5]);
|
||||
// 与重连包 deskinfo.step 同值:增量与全量两条路径必须给同一个阶段
|
||||
t.eq('阶段 jiaofen.step == 同时刻 deskinfo.step', O.jiaofen2.data.step, O.deskinfo1.step);
|
||||
t.eq('阶段 shangzhuang.step == 同时刻 deskinfo.step', O.shangzhuang.data.step, O.deskinfo2.step);
|
||||
t.eq('阶段 xuanzhu.step == 同时刻 deskinfo.step', O.xuanzhu.data.step, O.deskinfo3.step);
|
||||
t.eq('阶段 maipai.step == 同时刻 deskinfo.step', O.maipai.data.step, O.deskinfo5.step);
|
||||
// ② 控制权:每个包的「轮到谁」== 服务端自己校验时用的权威来源
|
||||
t.eq('控制权 jiaofen.nextseat == get_callgrade_seat()(下一个叫分者)', O.jiaofen1.data.nextseat, O.after_jf1.next);
|
||||
t.eq('控制权 jiaofen 不叫后跳过该家', O.jiaofen2.data.nextseat, O.after_jf2.next);
|
||||
t.eq('控制权 shangzhuang.nextseat == banker(选主/投降只由庄家做)', O.shangzhuang.data.nextseat, O.after_sz.banker);
|
||||
t.eq('控制权 xuanzhu.nextseat == banker(埋牌只由庄家做)', O.xuanzhu.data.nextseat, O.after_xz.banker);
|
||||
t.eq('控制权 maipai.seat == playproc.currseat(即将首出者)', O.maipai.data.seat, O.after_mp.curr);
|
||||
// 反面:shangzhuang 的 data.seat 是【最后一个叫分者】,不是控制权——两者必须能区分开,
|
||||
// 否则"直接读 seat 当控制权"的写法也能蒙混过关(本局:最后叫分者=2、庄家=0)
|
||||
t.eq('控制权 shangzhuang.seat(最后叫分者) 与 nextseat(庄家) 确实不同 → 用例非空转',
|
||||
O.shangzhuang.data.seat !== O.shangzhuang.data.nextseat, true);
|
||||
// 重连包:ChooseMain / BuryCards 也必须显式给控制权,且与增量推送同值
|
||||
t.eq('控制权 重连 ChooseMain.seat == shangzhuang.nextseat', O.deskinfo2.ChooseMain.seat, O.shangzhuang.data.nextseat);
|
||||
t.eq('控制权 重连 BuryCards.seat == xuanzhu.nextseat', O.deskinfo3.BuryCards.seat, O.xuanzhu.data.nextseat);
|
||||
t.eq('控制权 重连 PushCards 由 playproc.currseat 给出 == maipai.seat',
|
||||
O.deskinfo5.PushCards.playproc.currseat, O.maipai.data.seat);
|
||||
|
||||
// ===================== I-2 结算阶段(step6)的重连包必须与 jiesuan 推送给同一份结算数据 =====================
|
||||
// 曾经的缺陷:deskinfo 的 Balance 只给 readystate + aset,而 jiesuan 推送给的是
|
||||
// chupai + bottom + aset(+ 末局 account)——结算面板还开着时断线重连/硬刷新,
|
||||
// 抠底明细与末局大结算全部空白(同一份数据两条路径给的不一样)。
|
||||
function driveToSettle(roomtype, hookDesk) {
|
||||
const pj = P.new({ paiju_list: [] }, 0);
|
||||
const c = setup(roomtype, pj);
|
||||
if (hookDesk) { hookDesk(c.o_desk, pj); }
|
||||
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
|
||||
mod.xuanzhu(pack(0, { flower: 1 }));
|
||||
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
|
||||
let guard = 0;
|
||||
while (pj.step === 5 && ++guard < 400) {
|
||||
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
|
||||
let pick = null;
|
||||
if (seat === pj.playproc.start) { pick = [hand[hand.length - 1]]; }
|
||||
else { for (const x of hand) { if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; } } }
|
||||
if (!pick) { break; }
|
||||
mod.chupai(pack(seat, { cards: pick }));
|
||||
}
|
||||
return { pj, c, jiesuan: c.sent.filter(m => m.rpc === 'jiesuan')[0] };
|
||||
}
|
||||
const sv = driveToSettle("00000");
|
||||
t.eq('结算 用例真的打到 step6', sv.pj.step, 6);
|
||||
t.eq('结算 jiesuan 带 step=6(阶段同样由包承载)', sv.jiesuan.data.step, 6);
|
||||
const bal = [0, 1, 2].map(s => E.get_deskinfo(sv.c.o_room, s).Balance);
|
||||
t.eq('结算 重连 Balance.aset == jiesuan.aset(三家)',
|
||||
bal.map(b => JS(b.aset)), [0, 1, 2].map(() => JS(sv.jiesuan.data.aset)));
|
||||
t.eq('结算 重连 Balance.bottom == jiesuan.bottom(抠底明细,三家)',
|
||||
bal.map(b => JS(b.bottom)), [0, 1, 2].map(() => JS(sv.jiesuan.data.bottom)));
|
||||
// 用例非空转:抠底分组必须真的有内容(cards 恒 8 张),否则上面两边"同为 undefined"也会绿
|
||||
t.eq('结算 抠底分组真的有内容(8张埋牌底牌) → 用例非空转', bal[0].bottom.cards.length, 8);
|
||||
t.eq('结算 重连 deskinfo.step == 6', E.get_deskinfo(sv.c.o_room, 0).step, 6);
|
||||
// 已按下准备的座位不再重发结算数据(原有门控不变)
|
||||
sv.c.o_desk.prepare[1] = 1;
|
||||
const balReady = E.get_deskinfo(sv.c.o_room, 1).Balance;
|
||||
t.eq('结算 已准备的座位不带 aset/bottom/account(门控不变)',
|
||||
[balReady.aset, balReady.bottom, balReady.account], [undefined, undefined, undefined]);
|
||||
// 末局:account 也必须能从重连包拿到。用桩 get_desk_account 造出 account(真实实现要写战绩,
|
||||
// 与本用例无关),验证的是「get_paiju_account 冻结了它 + get_deskinfo 原样给出」这条链路
|
||||
const svLast = driveToSettle("00000", (o_desk) => {
|
||||
o_desk.method.get_desk_account = m => { m.data.account = [{ score: 1 }, { score: 2 }, { score: 3 }]; return m; };
|
||||
});
|
||||
svLast.pj.idx = 99; // 末局判定:idx >= asetcount(6)
|
||||
// idx 改在结算之前才有效,故这里重新走一遍结算路径:直接再调一次 get_paiju_account
|
||||
const lastMsg = P.get_paiju_account(svLast.pj, 0, { data: {} });
|
||||
t.eq('结算 末局 jiesuan 带 account', Array.isArray(lastMsg.data.account), true);
|
||||
t.eq('结算 末局 重连 Balance.account == jiesuan.account',
|
||||
JS(E.get_deskinfo(svLast.c.o_room, 0).Balance.account), JS(lastMsg.data.account));
|
||||
// 反面:非末局不带 account(与 jiesuan 推送的取舍一致,不凭空造分组)
|
||||
t.eq('结算 非末局 jiesuan 无 account', sv.jiesuan.data.account, undefined);
|
||||
t.eq('结算 非末局 重连 Balance 无 account', bal[0].account, undefined);
|
||||
|
||||
// ===================== M-2 seatlist 必须以深拷贝下发(不把活对象挂进下发包)=====================
|
||||
// paiju.seatlist 是一张全程就地改写的活表(do_playcard 每手都在改缺门/无对/报副)。
|
||||
// 同一份数组曾同时挂在 maipai 的三个逐座位包、chupai1/2/3 与重连包 PushCards 上——
|
||||
// 平台真实链路立刻序列化看不出来,但这是隐患,与 playproc / pushlist / bottomcards 同一条标准。
|
||||
const EMPTY_TABLE = JS([0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]));
|
||||
const sl = (() => {
|
||||
const pj = P.new({ paiju_list: [] }, 0);
|
||||
const c = setup("00000", pj);
|
||||
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
|
||||
mod.xuanzhu(pack(0, { flower: 1 }));
|
||||
// 深拷贝守卫需要【活引用】:setup 的 SendPack 会 JSON 深拷贝每个包,用 c.sent 验不出问题
|
||||
const inner = mod.app.SendPack;
|
||||
let mpLive = null, cpLive = null;
|
||||
mod.app.SendPack = m => {
|
||||
if (m.rpc === 'maipai' && mpLive === null) { mpLive = m.data.seatlist; }
|
||||
if (/^chupai[123]$/.test(m.rpc) && cpLive === null) { cpLive = m.data.seatlist; }
|
||||
inner(m);
|
||||
};
|
||||
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
|
||||
// 重连快照也取一份活引用(此刻还是初始空表)
|
||||
const diLive = E.get_deskinfo(c.o_room, 1).PushCards.seatlist;
|
||||
let guard = 0;
|
||||
while (pj.step === 5 && ++guard < 400) {
|
||||
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
|
||||
let pick = null;
|
||||
if (seat === pj.playproc.start) { pick = [hand[hand.length - 1]]; }
|
||||
else { for (const x of hand) { if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; } } }
|
||||
if (!pick) { break; }
|
||||
mod.chupai(pack(seat, { cards: pick }));
|
||||
}
|
||||
mod.app.SendPack = inner;
|
||||
return { pj, c, mpLive, cpLive, diLive };
|
||||
})();
|
||||
// 用例非空转:整局打完后这张活表必须真的变过,否则"没被回改"是碰巧
|
||||
t.eq('座位牌况 整局打完后 paiju.seatlist 确实变过 → 用例非空转', JS(sl.pj.seatlist) !== EMPTY_TABLE, true);
|
||||
t.eq('座位牌况 maipai 下发的是深拷贝(已发出的包不被后续出牌回改)', JS(sl.mpLive), EMPTY_TABLE);
|
||||
t.eq('座位牌况 chupai1 下发的是深拷贝(打完整局后仍是发出当时的样子)',
|
||||
JS(sl.cpLive) !== JS(sl.pj.seatlist), true);
|
||||
t.eq('座位牌况 重连 PushCards 下发的是深拷贝', JS(sl.diLive), EMPTY_TABLE);
|
||||
// 正面:深拷贝的内容必须与当时的活表逐格相等(拷贝不能拷错/拷浅)
|
||||
const slNow = P.get_seatlist(sl.pj);
|
||||
t.eq('座位牌况 get_seatlist 内容与活表逐格相等', JS(slNow), JS(sl.pj.seatlist));
|
||||
slNow[0][0][0] = 9;
|
||||
t.eq('座位牌况 get_seatlist 是深拷贝(改副本不影响本体)', sl.pj.seatlist[0][0][0] !== 9, true);
|
||||
|
||||
process.exit(t.done('rpc') ? 0 : 1);
|
||||
|
||||
Reference in New Issue
Block a user