二七王: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:
2026-08-27 21:30:56 +08:00
co-authored by Claude Opus 5
parent cfa0409d94
commit 5d981a722e
8 changed files with 313 additions and 14 deletions
+156
View File
@@ -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);