二七王:服务端下发面四处缺陷修复(增量路径与重连路径对齐)
前端一致性测试(同一局真包:逐包增量回放 vs 一次性 deskinfo 重连)暴露的四处
服务端缺陷,均属「同一份数据两条路径给的不一样 / 有一条根本没给」:
① chupai1/2/3 补发 playproc(本轮进行态)
与 deskinfo.PushCards 共用新增的 get_playproc() 快照函数,结构取值完全一致。
playproc 是全程复用的活对象(new_playround 就地重置),故快照做逐字段深拷贝,
避免已发出的包被后续出牌回改。可见性:全部字段由桌面公开信息推出,
与既有 PushCards.playproc 一样三家无差别下发。
② deskinfo ChooseMain(step2)/BuryCards(step3) 补发 curmultiple
与 shangzhuang 推送同源同值;此刻一张牌未出、捡分恒 0,必为 +3(大光)。
原先重连重建为 0,顶部「抓分」角标掉档。
③ maipai 补发 seatlist
与 chupai/PushCards 同一张表、同一道查牌模式门控;埋牌完成时为初始化空表。
④ bottomcards / burycards 收敛到单一权威源(SSOT)
- burycards:maipai 原样回显了客户端请求包里 cards 的顺序,改取 get_burycard()
权威快照,与 PushCards.burycards 同一函数。请求包只承载「意图」,其顺序不可信。
- bottomcards:get_bottomcards 按「调用时的 flower」排序,导致上庄推送(flower=-1)
与重连 BuryCards(flower 已定) 两种顺序。底牌是选主【之前】就翻给玩家看的、
发牌结束即固定的快照,故改为发牌时算一次并冻结,get_bottomcards 返回其副本
(order_cards 是原地排序,交出本体会被调用方就地重排)。三个调用点无需改动。
协议文档同步 7 处(新增 playproc/seatlist/curmultiple 字段说明与两处排序口径)。
新增 test_rpc.js「下发面一致性」用例组 21 checks,含 28 轮真实牌局逐包逐座位比对
(249 次、0 不一致)与深拷贝守卫;每条均已用「退回修复必转红」反向验证。
既有 649 checks 未改一条断言,全绿(17 脚本 / 670 checks)。未涉及 shared/。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -165,6 +165,135 @@ const cmZhuang = E.get_deskinfo(e.o_room, 0);
|
||||
t.eq('重连 ChooseMain/BuryCards 侧仍用bottomcards命名未被误改',
|
||||
cmZhuang.PushCards.burycards !== undefined && cmZhuang.PushCards.bottomcards === undefined, true);
|
||||
|
||||
// ===================== 下发面一致性:增量路径(实时推送) 必须与 全量路径(重连 deskinfo) 给出同一份数据 =====================
|
||||
// 背景:前端一致性测试(同一局真包,逐包增量回放 vs 一次性 deskinfo 重建)暴露出 4 处服务端下发面缺陷。
|
||||
// 这些都不是"算错了",而是"同一份数据两条路径给的不一样 / 有一条根本没给",前端只能自己补——
|
||||
// 而自己补就等于把对局推进逻辑搬到前端(前端红线「数据驱动、前端无对局状态机」)。以下逐条钉死。
|
||||
const JS = x => JSON.stringify(x);
|
||||
const AR = require('../class.arith.js'); // 本文件下方另有同模块的 A,此处在其声明之前,故另起名
|
||||
|
||||
// ---- ① bottomcards:上庄推送 / ChooseMain(step2) / BuryCards(step3) 三处必须【同序】----
|
||||
// 曾经的缺陷:get_bottomcards 每次按"当时的 paiju.flower"重排,而底牌是在【选主之前】翻给庄家看的,
|
||||
// 于是 step2(flower=-1) 与 step3(flower 已定) 给出两种顺序——主花色的那张牌被顶到队首。
|
||||
// 现按发牌时冻结的快照下发。反面守卫:主牌花色刻意选成底牌里真实存在的花色,一旦退回"按当时 flower 排"必红。
|
||||
function driveBottom(flowerPick) {
|
||||
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 }));
|
||||
const sz = c.sent.filter(m => m.rpc === 'shangzhuang').find(m => m.data.cards !== undefined).data; // 庄家包
|
||||
const step2 = E.get_deskinfo(c.o_room, 0).ChooseMain;
|
||||
mod.xuanzhu(pack(0, { flower: flowerPick(pj) }));
|
||||
const step3 = E.get_deskinfo(c.o_room, 0).BuryCards;
|
||||
return { pj, c, sz, step2, step3 };
|
||||
}
|
||||
// 主牌花色必须挑一个【会真的改变底牌排序】的:否则本组用例是空转的——
|
||||
// 退回"按当时 flower 重排"也照样绿(实测:随手挑底牌里第一张的花色就是空转,它本来就排在队首)。
|
||||
let btReorders = false;
|
||||
const bt = driveBottom(pj => {
|
||||
const snap = P.get_bottomcards(pj);
|
||||
for (let f = 1; f <= 4; f++) {
|
||||
if (JS(AR.order_cards(f, snap.concat())) !== JS(snap)) { btReorders = true; return f; }
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
// 先钉住前提:所选花色确实会让"按主牌花色排"与"冻结快照"不同序,下面三条才是有效守卫
|
||||
t.eq('底牌 用例前提:所选主牌花色会改变排序(否则本组用例空转)', btReorders, true);
|
||||
t.eq('底牌 上庄推送 8 张', bt.sz.bottomcards.length, 8);
|
||||
t.eq('底牌 上庄推送 == 重连 ChooseMain(step2) 同序', JS(bt.step2.bottomcards), JS(bt.sz.bottomcards));
|
||||
t.eq('底牌 上庄推送 == 重连 BuryCards(step3) 同序(选主后不得重排)', JS(bt.step3.bottomcards), JS(bt.sz.bottomcards));
|
||||
// 快照必须是【副本】:order_cards 是原地排序,调用方就地重排不得污染冻结的快照
|
||||
const bcCopy = P.get_bottomcards(bt.pj); bcCopy.reverse();
|
||||
t.eq('底牌 get_bottomcards 返回副本(调用方重排不污染快照)', JS(P.get_bottomcards(bt.pj)), JS(bt.sz.bottomcards));
|
||||
|
||||
// ---- ② curmultiple:ChooseMain(step2) / BuryCards(step3) 必须与 shangzhuang 同源同值 ----
|
||||
// 此刻一张牌未出、捡分恒为 0 → 必为 +3(大光)。曾经 deskinfo 不带此字段,重连后前端角标掉回 0。
|
||||
t.eq('抓分倍数 上庄推送 curmultiple=3(捡分恒0→大光)', bt.sz.curmultiple, 3);
|
||||
t.eq('抓分倍数 重连 ChooseMain.curmultiple 与上庄同值', bt.step2.curmultiple, bt.sz.curmultiple);
|
||||
t.eq('抓分倍数 重连 BuryCards.curmultiple 与上庄同值', bt.step3.curmultiple, bt.sz.curmultiple);
|
||||
|
||||
// ---- ③ maipai.seatlist:与 PushCards.seatlist 同表同门控 ----
|
||||
// 曾经的缺陷:maipai 不带 seatlist,「埋牌完成 → 庄家首出」这段窗口增量路径为空、重连路径却有表。
|
||||
const mpZhuang3 = mpAll.find(m => m.data.cards !== undefined);
|
||||
const mpXian3 = mpAll.find(m => m.data.cards === undefined);
|
||||
t.eq('埋牌 可查牌 maipai 带 seatlist(庄闲同表)',
|
||||
JS(mpZhuang3.data.seatlist), JS(E.get_deskinfo(e.o_room, 0).PushCards.seatlist));
|
||||
t.eq('埋牌 可查牌 maipai 闲家同一张表(seatlist 三家公开、不裁剪)',
|
||||
JS(mpXian3.data.seatlist), JS(mpZhuang3.data.seatlist));
|
||||
t.eq('埋牌 seatlist 埋牌完成时为初始化空表', JS(mpZhuang3.data.seatlist),
|
||||
JS([0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]])));
|
||||
// 反面:不查牌模式一律不下发(与 chupai / PushCards 同一道门控,design §9)
|
||||
t.eq('埋牌 不查牌 maipai 无 seatlist',
|
||||
en.sent.filter(m => m.rpc === 'maipai').every(m => m.data.seatlist === undefined), true);
|
||||
|
||||
// ---- ④ burycards:maipai 必须发服务端权威快照,不得回显客户端提交的 cards 原序 ----
|
||||
// 曾经的缺陷:msg.data.burycards = cards(请求包原样回显),而 PushCards.burycards 走 get_burycard()
|
||||
// 按主牌花色排序 —— 同一副埋牌底牌两条路径两种顺序(SSOT)。
|
||||
const bu = (() => {
|
||||
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 }));
|
||||
// 刻意打乱提交顺序:手牌是从大到小的,取 8 张后【反转】成从小到大提交
|
||||
const raw = P.get_seat_cards(pj, 0).slice(0, 8).reverse();
|
||||
mod.maipai(pack(0, { cards: raw }));
|
||||
const mp = c.sent.filter(m => m.rpc === 'maipai').find(m => m.data.burycards !== undefined);
|
||||
return { pj, c, raw, mp };
|
||||
})();
|
||||
t.eq('埋牌底牌 maipai == 重连 PushCards 同序(SSOT)',
|
||||
JS(bu.mp.data.burycards), JS(E.get_deskinfo(bu.c.o_room, 0).PushCards.burycards));
|
||||
t.eq('埋牌底牌 maipai 不回显客户端提交的原序', JS(bu.mp.data.burycards) === JS(bu.raw), false);
|
||||
t.eq('埋牌底牌 maipai 与提交的是同一批牌(只是顺序不同)',
|
||||
JS(bu.mp.data.burycards.concat().sort((x, y) => x - y)), JS(bu.raw.concat().sort((x, y) => x - y)));
|
||||
|
||||
// ---- ⑤ chupai1/2/3.playproc:结构与取值必须与同一时刻的 PushCards.playproc 完全一致 ----
|
||||
// 没有它,前端在非重连时拿不到「本轮谁先手/谁最大/三家各出了什么/第几轮」,只能自己累积推导。
|
||||
// 用真实牌局打满 28 轮,每出一手就拿三家的下发包与三家的重连快照逐一比对。
|
||||
const pp = (() => {
|
||||
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 }));
|
||||
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
|
||||
// 深拷贝守卫需要【活引用】:setup 的 SendPack 会 JSON 深拷贝每个包,用 c.sent 验不出活引用问题。
|
||||
// 故在其之外再挂一层,原样留住服务端交出来的那个 playproc 对象。
|
||||
const live = [];
|
||||
const innerSend = mod.app.SendPack;
|
||||
mod.app.SendPack = m => { if (/^chupai[123]$/.test(m.rpc)) live.push(m.data.playproc); innerSend(m); };
|
||||
let cmp = 0, mism = 0, missing = 0, rounds = 0, guard = 0, jiesuanHasPlayproc = null, firstSnap = null;
|
||||
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;
|
||||
c.sent.length = 0;
|
||||
mod.chupai(pack(seat, { cards: pick }));
|
||||
const cps = c.sent.filter(m => /^chupai[123]$/.test(m.rpc));
|
||||
const js = c.sent.filter(m => m.rpc === 'jiesuan');
|
||||
if (js.length) jiesuanHasPlayproc = js.some(m => m.data.playproc !== undefined);
|
||||
if (!cps.length) continue;
|
||||
rounds++;
|
||||
if (cps.some(m => m.data.playproc === undefined)) missing++;
|
||||
if (firstSnap === null) firstSnap = JS(live[0]); // 第 1 包发出【当时】的样子
|
||||
if (pj.step !== 5) continue; // 已进结算,无"当前进行态"可比
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const rec = E.get_deskinfo(c.o_room, s).PushCards.playproc;
|
||||
cmp++;
|
||||
if (JS(cps[s].data.playproc) !== JS(rec)) mism++;
|
||||
}
|
||||
}
|
||||
mod.app.SendPack = innerSend;
|
||||
return { pj, cmp, mism, missing, rounds, jiesuanHasPlayproc, firstSnap, firstRef: live[0] };
|
||||
})();
|
||||
t.eq('本轮进行态 28轮打完(用例真的走到底)', pp.pj.playproc.round, 28);
|
||||
t.eq('本轮进行态 chupai1/2/3 恒带 playproc', [pp.rounds > 80, pp.missing], [true, 0]);
|
||||
t.eq('本轮进行态 增量下发 == 同时刻重连快照(逐包逐座位)', [pp.cmp > 200, pp.mism], [true, 0]);
|
||||
t.eq('本轮进行态 chupai3 转 jiesuan 时不带 playproc', pp.jiesuanHasPlayproc, false);
|
||||
// 深拷贝守卫:playproc 是全程复用的活对象(new_playround 就地重置同一个对象)。
|
||||
// 若下发挂的是活引用,打完整局后"第一包里的第 1 轮进行态"会变成最后一轮的样子。
|
||||
t.eq('本轮进行态 下发的是深拷贝快照(已发出的包不被后续出牌回改)', JS(pp.firstRef), pp.firstSnap);
|
||||
t.eq('本轮进行态 第1包就是第1轮、cards 定长3', [pp.firstRef.round, pp.firstRef.cards.length], [1, 3]);
|
||||
|
||||
// ===================== §5.4 甩牌:chupai1 需下发分量构成 shuai(cardtype 表达不了甩牌)=====================
|
||||
// 主花色=♦(1)。庄家(0) 甩「主♦K对 + 主♦5单张」;两闲家手上一张主牌都没有 → 甩牌合法
|
||||
function mkShuai(extraToSeat1) {
|
||||
|
||||
Reference in New Issue
Block a user