Files
joywayerandClaude Opus 5 08c387e609 二七王:test_leak 门禁强化——统计量重算、豁免按字段路径收窄、覆盖投降/解散
泄露缺陷功能正常、玩家看不出、只有抓包的人知道,所以门禁的【盲区】比一次具体泄露更值得投入。
本轮补掉四处盲区:

① 统计量层(新增):原来只扫牌 id,seatlist 的计数、playproc.maxcard 的牌编码、curmultiple
   一个都扫不到。新增「公开账本独立重算」——只喂三家都看得见的信息(已广播的 chupai*.cards、
   chupai1 的 count/flower/cardtype、庄家/叫分/主花色,账本里没有任何人的手牌),
   算出的 maxseat/maxcard/缺门无对表/curmultiple/捡分 必须与包内值逐项相等;
   再钉死 seatlist 恒 5 项、playproc 恒 10 个键,防止有人新加一项统计而门禁照样全绿。
   报副格(design §9 的有意公开)单独做门控断言:没人报无主之前必须还是 [-1,-1]。
   反向验证 5 组(塞第 6 项统计 / 提前填报副格 / 按真实手牌点亮已有缺门格 /
   maxcard 混入余主数 / curmultiple 混入余主数)全部转红。

② 埋牌底牌豁免:从「按 rpc 整包放行」收窄为「按字段路径 bottom.cards + step===6 + result!==3」。
   为此 collectCards 改为同时记录牌 id 的字段路径。对照实证:构造「给解散路径也带上 bottom」,
   新规则转红、旧规则静默放行。

③ 投降 / 解散两条结算路径此前零覆盖(9 局全是正常打完),各补 2 局并加覆盖下限断言。
   审计未发现新泄露;唯一需放行的是「算奖明细 aset.seatlist[].cards」——投降/解散时牌一张没打,
   他家的王/冲关牌会随算奖明细发给三家。这是既有行为(算奖需向三家自证、局已终止),
   写成带理由、带命中计数(awardExempt>=1)的显式豁免,不让它落在扫不到的盲区里。

④ 措辞更正:上一轮把「给重连包补上豁免」记成"收紧",实际净效果是放宽(旧放行面 ⊂ 新放行面),
   注释已改成准确说法。

覆盖量:3141 个下发面 / 38619 次可见性判定 / 103175 次统计量重算比对。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:07:15 +08:00

541 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 下发面泄露审计(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11)
// 跑完整局,对每一个「服务器 → 某座位」的包做两层扫描:
// ① 牌 id 层:取出包里出现的所有牌 id,逐个判定「此刻该座位是否有权知道这张牌」。
// 依据 design §4(底牌只有庄家可见 / 70分亮3秒)、§9(查牌模式)、§11(结束亮埋牌底牌)。
// ② 统计量层:牌 id 之外,包里还有【由手牌算出来的数】——seatlist 的缺门/无对表、
// playproc 的 maxseat/maxcard、curmultiple、捡分。它们不含牌 id,①层一个都扫不到,
// 却同样能泄露("某家某花色还剩几张"这种统计,功能正常、玩家看不出、只有抓包的人知道)。
// 故本层用【公开账本】独立重算这些数:只喂三家都看得见的信息(已广播的 chupai*.cards、
// chupai1 的 count/flower/cardtype、庄家/叫分/主花色),算出来必须与包内值逐项相等。
// 能被公开信息算出来的数 = 不含私密信息;算不出来 = 它一定读了别人的手牌。
// 同时钉死这些结构的【字段集/项数】,防止有人往里加一项新统计而门禁照样全绿。
const R = require('./_rpc.js'); const mod = R.mod;
const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith;
const CFG = global.cls_youle_erqiwang_config;
const D = require('../class.desk.js'), EX = require('../class.export.js');
const t = require('./_assert')();
let seed = 0xBEEF01;
const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; seed >>>= 0; return seed % n; };
global.min_ontimeout = fn => fn();
global.min_random = (a, b) => a + rnd(b - a + 1);
const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) });
const JS = x => JSON.stringify(x);
// ==========================================================================
// ①层:牌 id 的可见性
// ==========================================================================
// 深度收集一个对象里所有「看起来是牌 id」的整数(0~107),并记下它【出现在哪个字段路径上】。
// 为避免把 seat/count/grade 之类误当牌 id,只扫描已知承载牌 id 的字段名。
// 【为什么要记路径】豁免必须按「哪一个字段」给,而不是按「哪一个 rpc」给:
// 同一个包里既有该公开的分组(结算面板的抠底明细/算奖明细),也有绝不该公开的分组,
// 按 rpc 整包放行会把后者一并放掉。路径只由对象键组成、忽略数组下标(如
// aset.seatlist[1].cards → "aset.seatlist.cards")。
const CARD_FIELDS = ['cards', 'bottomcards', 'burycards', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards', 'mustcard'];
function collectCards(node, path, key, out) {
if (node === null || node === undefined) return;
if (Array.isArray(node)) { node.forEach(x => collectCards(x, path, key, out)); return; }
if (typeof node === 'number') { if (CARD_FIELDS.indexOf(key) >= 0 && node >= 0 && node <= 107) out.add(node + '@' + path); return; }
if (typeof node === 'object') {
for (const k in node) {
collectCards(node[k], path ? path + '.' + k : k, CARD_FIELDS.indexOf(k) >= 0 ? k : key, out);
}
}
}
const cidOf = e => parseInt(e.slice(0, e.indexOf('@')), 10);
const pathOf = e => e.slice(e.indexOf('@') + 1);
// 路径尾匹配:deskinfo 会多一层分组前缀(Balance.aset.seatlist.cards vs aset.seatlist.cards)
const pathIs = (path, suffix) => path === suffix || path.endsWith('.' + suffix);
// design §8.2 亮牌:庄家埋牌后手中【固定主牌】达门槛时,这些牌的牌面对两个闲家公开(仅可查牌模式)。
// 【独立于 get_liangpai 重算一遍】——若实现误把非固定主牌(主花色普通牌、副牌)塞进 liangpai,
// 下面的集合里不会有它,审计照样报泄露。这是审计的意义所在,不能图省事直接复用被测实现。
function bankerLiangpaiCards(pj) {
const out = [];
let wang = 0, qi = 0, er = 0;
for (const c of pj.cards) {
// 与 get_seat_cards_award 同口径:庄家手牌 = 发到自己的 + 摸起的底牌,且排除已埋的
if (!(c.dealowner === pj.banker + 1 || c.dealowner === 0)) continue;
if (c.playround === 0) continue;
if (c.number >= 53) { wang++; out.push(c.id); }
else if (c.number === 7) { qi++; out.push(c.id); }
else if (c.number === 2) { er++; out.push(c.id); }
}
if (out.length < 10 && wang < 3 && qi < 6 && er < 6) return new Set(); // 不达标:一张都不该下发
return new Set(out);
}
let curLiangpai = new Set(); // 本局亮牌应公开的牌(埋牌后确定,全程固定)
let curNoCheck = false; // 本局是否「不查牌」
let liangpaiRounds = 0; // 可查牌 + 庄家达标的局数(亮牌真的下发过)
let liangpaiRoundsNoCheck = 0; // 不查牌 + 庄家达标的局数(此时一张都不该发)
let surrenderRuns = 0; // 走过投降结算的局数
let disbandRuns = 0; // 走过中途解散结算的局数
let awardExempt = 0; // 「算奖明细」这条显式豁免被真正用到的次数
const leaks = [];
let pkts = 0, scanned = 0;
// 判定:seat 此刻是否有权知道 cid(path 见 collectCards 的说明)
function mayKnow(pj, seat, cid, rpc, path) {
const c = pj.cards[cid];
if (c.dealowner === -1) return true; // 规则去除的 3/4,不可能出现
if (c.playround > 0) return true; // 已打出,全场可见
if (c.dealowner === seat + 1) return true; // 自己的牌
if (seat === pj.banker && c.dealowner === 0) return true; // 庄家可见底牌(含摸起后又埋下的)
// §4 70分坐庄:上庄推送把 8 张底牌向三家亮 3 秒——只放行 shangzhuang 的 bottomcards 这一处
if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0 && pathIs(path, 'bottomcards')) return true;
// §11 结束亮埋牌底牌:本局【正常结算】后,埋牌底牌随抠底明细对全场公开。
// jiesuan 广播(bottom.cards,sendpack_toother(msg,-1) 三家都发)与结算面板还开着时的
// 重连包(Balance.bottom.cards)是【同一份数据的两条投递路径】,可见性必须同判。
// 门控写成「step===6 且 result!==3」而不是「rpc 是 jiesuan/deskinfo」:
// · 只认字段路径 bottom.cards,不整包放行(同一个包里的其他分组仍严格判定);
// · result 3 = 中途解散(class.paiju.js get_paiju_account 的 case 2,o_paiju.result = 3)。解散是【没打完】,
// 埋牌底牌不该亮。当前解散路径的 tmp_jiesuan_bottom 恒为 null、包里本就没有 bottom,
// 所以这一条现在不改变任何判定;它挡的是【将来】有人给解散路径也带上 bottom——
// 那时门禁必须转红,而不是因为「step 也是 6」被放行。
// 【措辞更正】上一轮把「给重连包补上这条豁免」记成了"收紧"——其实那一步的净效果是
// 【放宽】(在原豁免上加了一个 OR 分支,旧放行面 ⊂ 新放行面),只是放行面被限定在
// step===6 的同一份公开数据上。真正的收紧是这一轮:从「按 rpc 整包放行」改成
// 「按字段路径 bottom.cards + step6 + result!==3 放行」。
if (c.playround === 0 && pathIs(path, 'bottom.cards') && pj.step === 6 && pj.result !== 3) return true;
// 【显式豁免 · 结算算奖明细】aset.seatlist[i].cards(get_paiju_account 的 _cglist[i].cards)
// 是 design §8.1 冲关 / §8.3 傍王的算奖依据牌,三种结算来源都带、且三家都收到。
// · 正常打完:这些牌早已打出(playround>0),本来就公开,本豁免对它无影响。
// · 投降(step2 直接结算)与中途解散:牌【一张没打】(playround===-1),于是他家的
// 王/冲关牌会随算奖明细发给三家。这是既有行为——算奖要向三家自证,牌局此刻已终止、
// 不再有后续出牌,公开这几张牌不影响任何未决的对局信息。
// 写成一条【带路径限定的显式豁免】而不是让它落在扫不到的盲区里:
// 若哪天有人把「未打出的手牌」塞到别的字段上,它不在这条路径下,照样报泄露。
if (pathIs(path, 'aset.seatlist.cards') && pj.step === 6) { awardExempt++; return true; }
if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验
// §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包的 liangpai 分组里下发)
if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && pathIs(path, 'liangpai.cards') && curLiangpai.has(cid)) return true;
return false;
}
// ==========================================================================
// ②层:统计量必须能由「公开账本」独立重算出来
// ==========================================================================
// 账本里只有【三家都看得见】的东西:
// 庄家 / 庄家叫分(shangzhuang 广播)、主花色(xuanzhu 广播)、
// 每一手已摊在桌上的牌(chupai1/2/3 的 data.cards,三家同收)、
// 首家这一手的 张数/花色/牌型(chupai1 的 count/flower/cardtype —— 它就是那几张明牌的描述)。
// 账本【没有】任何人的手牌。凡是能从账本算出来的数,就不可能夹带手牌信息。
let LG = null;
let statChecks = 0;
const statBad = [];
const flowerOf = (fl, id) => { const c = A.id_to_code(fl, id); return c > 1000 ? fl : Math.floor(c / 100); };
// 牌面分值:5→5、10/K→10,其余 0。只由牌 id 推出(牌已摊在桌上,牌面是公开的)
const scoreOf = id => { const n = A.id_to_number(id); return n === 5 ? 5 : ((n === 10 || n === 13) ? 10 : 0); };
// 首家这一手的牌型值 = 这一手【最小一张】的牌编码。
// 【独立推导,不复用 can_playcard】:那段逐张状态机无论走 单张/对子/拖拉机/甩牌 哪个分支,
// cardvalue 最终都停在「最后处理到的那张牌」上;牌已按编码降序排列,最后一张即最小一张。
// 换个式子算出同一个数,才谈得上"独立重算"。
function leadValue(fl, cards) {
const s = A.order_cards(fl, cards.concat());
return A.id_to_code(fl, s[s.length - 1]);
}
// 跟家这一手的牌型值:照 design 的规则从【摊在桌上的牌】重算(can_followcard 里这段同样
// 只吃 _sortfollow / startflower / startcardtype,不碰手牌——本函数是它的独立复述)。
function followValue(fl, cards, startflower, starttype) {
const s = A.order_cards(fl, cards.concat());
const top = A.id_to_code(fl, s[0]);
const flTop = top > 1000 ? fl : Math.floor(top / 100);
const sameLine = (flTop === startflower || flTop === fl);
// 副7 / 副2 同级归一(四门的 7 一样大、四门的 2 一样大)
const norm = c => (c > 7000 && c < 8000) ? 7000 : ((c > 2000 && c < 3000) ? 2000 : c);
if (starttype === 101) { return sameLine ? norm(top) : 0; }
if (starttype > 101 && starttype < 200) { return 0; } // 首家甩单张:跟牌一律压不过
if (starttype === 201) {
if (A.get_pairlist(fl, s).length !== 1) return 0;
return sameLine ? norm(top) : 0;
}
if (starttype > 201 && starttype < 300) { return 0; } // 首家甩多对:同上
if (starttype > 300 && starttype < 400) {
if (A.get_tuolaji_list(fl, A.get_pairlist(fl, s), starttype).length !== 1) return 0;
return sameLine ? top : 0;
}
return 0;
}
// 跟家这一手暴露出的「缺门 / 无对」:完全由摊在桌上的牌与首家牌型推出(design §9 的牌况表)。
// 最大或最小的一张不是首家花色 → 这门没牌了(顺带也没对子)
// 首家出的是对子/拖拉机、跟牌没凑够同样多的对子 → 这门没对子了
function followFlags(fl, cards, startflower, starttype) {
const s = A.order_cards(fl, cards.concat());
let noflower = false, nopair = false;
if (flowerOf(fl, s[0]) !== startflower) { noflower = true; nopair = true; }
if (flowerOf(fl, s[s.length - 1]) !== startflower) { noflower = true; nopair = true; }
if (starttype > 200 && A.get_pairlist(fl, s).length !== starttype % 100) { nopair = true; }
return { noflower, nopair };
}
// 首家甩牌的分量需求:把桌面上那一手直接分解即可(decompose_trump 只吃牌 id),非甩牌为 null
function expectShuaiDemand() {
if (LG.start < 0 || !LG.cards[LG.start]) return null;
const comps = A.decompose_trump(LG.flower, LG.cards[LG.start].concat());
if (comps.length <= 1) return null;
const re = { tractors: [], pairs: 0, singles: 0 };
for (const c of comps) {
if (c.type === 'tractor') { re.tractors.push(c.len); }
else if (c.type === 'pair') { re.pairs++; }
else { re.singles++; }
}
return re;
}
function ledgerNewRound(round, start) {
LG.round = round; LG.start = start; LG.currseat = start;
LG.startcount = -1; LG.startflower = -1; LG.starttype = -1;
LG.maxseat = -1; LG.maxcard = -1;
LG.cards = [null, null, null];
}
function ledgerApplyFollow(seat, cards) {
const fl = LG.flower;
const s = A.order_cards(fl, cards.concat());
LG.cards[seat] = s;
const v = followValue(fl, s, LG.startflower, LG.starttype);
if (v > LG.maxcard) { LG.maxseat = seat; LG.maxcard = v; }
const fg = followFlags(fl, s, LG.startflower, LG.starttype);
const cell = LG.table[seat][LG.startflower - 1];
if (fg.nopair) { cell[1] = 1; }
if (fg.noflower) { cell[0] = 1; cell[1] = 1; }
}
function ledgerEndRound() {
// 本墩被闲家收走 → 墩里的分归闲家(庄家收走则不计入闲家捡分)
let g = 0;
if (LG.maxseat !== LG.banker) {
for (let i = 0; i < 3; i++) { for (const id of (LG.cards[i] || [])) { g += scoreOf(id); } }
}
LG.grade += g;
LG.lastRoundGrade = g;
ledgerNewRound(LG.round + 1, LG.maxseat);
}
// 当前抓分倍数:算法本身只吃 (叫分, 闲家捡分, 级距) 三个数——叫分与捡分都在账本里
function ledgerCurmultiple() {
if (!LG.call || LG.call <= 0) return 0;
return A.get_upgrade(LG.call, LG.grade, A.get_qvalue(LG.call, LG.climb));
}
// 把一个【逻辑包】喂进公开账本(同一个包会逐座位发 3 份,只喂第一份)
function ledgerFeed(rpc, d) {
if (!LG) return;
switch (rpc) {
case 'shangzhuang': LG.banker = d.banker; LG.call = d.grade; break;
case 'xuanzhu': LG.flower = d.flower; break;
case 'maipai': ledgerNewRound(1, d.seat); break;
case 'chupai1': {
const s = A.order_cards(LG.flower, d.cards.concat());
LG.startcount = s.length;
LG.startflower = flowerOf(LG.flower, s[0]);
LG.starttype = d.cardtype;
LG.maxseat = d.seat;
LG.maxcard = leadValue(LG.flower, s);
LG.cards[d.seat] = s;
LG.currseat = (LG.start + 1) % 3;
// 首家自报的 张数/花色 必须与桌面上那几张牌一致(同一份公开数据两处口径)
statChecks++;
if (d.count !== s.length || d.flower !== LG.startflower) {
statBad.push(`chupai1 自报 count/flower=(${d.count},${d.flower}) 与桌面牌重算=(${s.length},${LG.startflower}) 不一致`);
}
// 账本自检:首出者必须就是本轮首家(账本若跟丢了轮次,下面所有比对都不再可信)
statChecks++;
if (d.seat !== LG.start) { statBad.push(`chupai1 首出者=${d.seat} 公开账本认为本轮首家=${LG.start}`); }
break;
}
case 'chupai2': ledgerApplyFollow(d.seat, d.cards); LG.currseat = (LG.start + 2) % 3; break;
case 'chupai3': ledgerApplyFollow(d.seat, d.cards); ledgerEndRound(); break;
case 'jiesuan':
// 收尾墩不发 chupai3,整包换成 jiesuan、把这一手放进 data.chupai(协议 §13/§14)
if (d.chupai && d.chupai.cards) { ledgerApplyFollow(d.chupai.seat, d.chupai.cards); ledgerEndRound(); }
break;
}
}
// playproc 的字段集是【契约】:多一个键就是多一份下发信息,必须显式暴露出来
const PLAYPROC_KEYS = ['cards', 'currseat', 'maxcard', 'maxseat', 'round', 'shuai_demand', 'start', 'startcount', 'startflower', 'starttype'];
function checkPlayproc(tag, pp) {
statChecks++;
const keys = Object.keys(pp).sort();
if (JS(keys) !== JS(PLAYPROC_KEYS)) { statBad.push(`${tag} playproc 字段集变了:${JS(keys)}`); }
const exp = {
round: LG.round, start: LG.start, currseat: LG.currseat,
startcount: LG.startcount, startflower: LG.startflower, starttype: LG.starttype,
maxseat: LG.maxseat, maxcard: LG.maxcard
};
for (const k in exp) {
statChecks++;
if (pp[k] !== exp[k]) { statBad.push(`${tag} playproc.${k}=${pp[k]} 公开账本重算=${exp[k]}`); }
}
for (let s = 0; s < 3; s++) {
statChecks++;
const got = (pp.cards[s] === undefined || pp.cards[s] === null) ? null : pp.cards[s];
if (JS(got) !== JS(LG.cards[s])) { statBad.push(`${tag} playproc.cards[${s}]=${JS(got)} 公开账本重算=${JS(LG.cards[s])}`); }
}
statChecks++;
if (JS(pp.shuai_demand) !== JS(expectShuaiDemand())) {
statBad.push(`${tag} playproc.shuai_demand=${JS(pp.shuai_demand)} 桌面牌重算=${JS(expectShuaiDemand())}`);
}
}
function checkSeatlist(tag, sl) {
statChecks++;
if (!Array.isArray(sl) || sl.length !== 3) { statBad.push(`${tag} seatlist 外层不是定长 3:${JS(sl)}`); return; }
// 报副格(第 5 项)是 design §9 的【有意公开】:一旦有人把主牌打空(报无主),
// 系统就向全体三人公示三家各自的余主数量与余主对数。反过来说——【没人报无主之前
// 一格都不许透露】,必须还是初值 [-1,-1]。这条门控若被放宽,等于提前泄露三家主牌结构。
const anyEmpty = sl.some(one => Array.isArray(one[4]) && one[4][0] === 0);
for (let s = 0; s < 3; s++) {
const one = sl[s];
statChecks++;
if (!Array.isArray(one) || one.length !== 5) {
// 约定恒 5 项:花色 1~4 的 [缺门, 无对] + 报副 [余主数, 余主对数]。
// 多出来的一项必定是一份【新的统计】——而新统计几乎注定是从真实手牌算出来的,
// 公开账本重算不出来。这里直接判死,不给它蒙混过关的机会。
statBad.push(`${tag} seat${s} seatlist 项数不是 5(新增统计?):${JS(one)}`);
continue;
}
for (let f = 0; f < 5; f++) {
statChecks++;
const cell = one[f];
if (!Array.isArray(cell) || cell.length !== 2 || typeof cell[0] !== 'number' || typeof cell[1] !== 'number') {
statBad.push(`${tag} seat${s}[${f}] 形状不是二元数值组:${JS(cell)}`);
}
}
// 前 4 格:只能是公开账本推得出的缺门/无对
for (let f = 0; f < 4; f++) {
const exp = [LG.table[s][f][0], LG.table[s][f][1]];
// 主花色那一格另有一条来源:报无主公示之后,「余主数=0」等价于主花色缺门+无对、
// 「余主对数=0」等价于主花色无对(class.paiju.js do_playcard 的报副刷新)。
// 它推导自【本包自己给出的】报副格,仍属公开信息,不是额外泄露。
if (LG.flower > 0 && f === LG.flower - 1 && Array.isArray(one[4])) {
if (one[4][0] === 0) { exp[0] = 1; exp[1] = 1; }
else if (one[4][0] > 0 && one[4][1] === 0) { exp[1] = 1; }
}
statChecks++;
if (one[f][0] !== exp[0] || one[f][1] !== exp[1]) {
statBad.push(`${tag} seatlist[${s}][${f}]=${JS(one[f])} 公开账本重算=${JS(exp)}`);
}
}
statChecks++;
if (!anyEmpty) {
if (JS(one[4]) !== JS([-1, -1])) { statBad.push(`${tag} 无人报无主却下发了 seat${s} 的余主统计:${JS(one[4])}`); }
} else if (one[4][0] < 0 || one[4][1] < 0) {
statBad.push(`${tag} 已报无主但 seat${s} 的余主统计仍是初值:${JS(one[4])}`);
}
}
}
// 对一个「分组对象」(包的 data,或 deskinfo 的 ChooseMain / BuryCards / PushCards)做统计量审计
function statAuditGroup(tag, g, opts) {
if (!g || typeof g !== 'object' || !LG) return;
opts = opts || {};
if (g.hasOwnProperty('curmultiple')) {
statChecks++;
const exp = ledgerCurmultiple();
if (g.curmultiple !== exp) { statBad.push(`${tag} curmultiple=${g.curmultiple} 公开账本重算=${exp}`); }
}
if (opts.cumulativeGrade && g.hasOwnProperty('grade')) {
statChecks++;
if (g.grade !== LG.grade) { statBad.push(`${tag} grade=${g.grade} 公开账本重算=${LG.grade}`); }
}
// 分组这一层的 seatlist 恒指 design §9 的【牌况表】(maipai / chupai1/2/3 / PushCards);
// 结算包里的 aset.seatlist 是另一回事,嵌在 aset 下、不会走到这里。
// 【不加形状守卫】形状变了就该由 checkSeatlist 判红,而不是被守卫悄悄跳过
if (g.hasOwnProperty('seatlist')) { checkSeatlist(tag, g.seatlist); }
if (g.playproc) { checkPlayproc(tag, g.playproc); }
}
// ==========================================================================
function run(roomtype, call, opts) {
opts = opts || {};
const sent = [];
const o_room = {
roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T',
seatlist: [0, 1, 2].map(i => ({ conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P', avatar: '', gameinfo: {} })),
method: { sendpack_toother: m => sent.push({ to: 'ALL', m: JSON.parse(JSON.stringify(m)) }) }
};
curNoCheck = (String(roomtype).charAt(4) === '1'); // roomtype 位4:1=不查牌
curLiangpai = new Set();
LG = {
climb: CFG.parse(roomtype).climb, banker: -1, call: -1, flower: -1,
round: 0, start: -1, currseat: -1, startcount: -1, startflower: -1, starttype: -1,
maxseat: -1, maxcard: -1, cards: [null, null, null], grade: 0, lastRoundGrade: 0,
table: [0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0]])
};
const desk = D.new(o_room); o_room.o_desk = desk;
global.youle_erqiwang.app = { SendPack: m => sent.push({ to: m.fromid, m: JSON.parse(JSON.stringify(m)) }) };
global.youle_erqiwang.import = { check_player: () => o_room, deduct_roomcard: () => { }, save_grade: () => { } };
mod.import = global.youle_erqiwang.import; mod.app = global.youle_erqiwang.app;
D.do_new_paiju(desk, 0);
const pj = desk.method.curr_paiju();
const audit = () => {
while (sent.length) {
const { to, m } = sent.shift();
pkts++;
// 同一个逻辑包逐座位发 3 份(i=0,1,2),只用第一份推进公开账本;
// 但【每一份都要审计】,这样三家收到的统计量若有差异也会被抓住
if (to === 0 || to === 'ALL') { ledgerFeed(m.rpc, m.data); }
const targets = to === 'ALL' ? [0, 1, 2] : [to];
statAuditGroup(`rpc=${m.rpc}→seat${to}`, m.data);
// chupai3 的 data.grade 是【这一墩】闲家收到的分(>0 才带),与账本的墩内分值同源
if (m.rpc === 'chupai3') {
statChecks++;
const got = m.data.hasOwnProperty('grade') ? m.data.grade : 0;
if (got !== LG.lastRoundGrade) { statBad.push(`chupai3 本墩 grade=${got} 公开账本重算=${LG.lastRoundGrade}`); }
}
// 结算包的闲家总捡分 = 账本累计捡分 + 抠底(扣底只在最后一墩产生)
if (m.rpc === 'jiesuan' && m.data.aset) {
statChecks++;
const bg = (m.data.bottom && m.data.bottom.grade2) ? m.data.bottom.grade2 : 0;
if (m.data.aset.grade !== LG.grade + bg) {
statBad.push(`jiesuan aset.grade=${m.data.aset.grade} 公开账本重算=${LG.grade}+抠底${bg}`);
}
}
const out = new Set();
collectCards(m.data, '', null, out);
for (const seat of targets) for (const e of out) {
scanned++;
const cid = cidOf(e);
if (!mayKnow(pj, seat, cid, m.rpc, pathOf(e))) {
leaks.push(`rpc=${m.rpc} @${pathOf(e)} → seat${seat} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`);
}
}
}
};
// 重连快照的审计(每个座位各取一次)。阶段取【实际的】pj.step:最后一手打完后 step 已转 6,
// 这里拿到的是结算快照,写死 "step5" 会让报错信息指错阶段(曾据此误判过)
const auditDeskinfo = () => {
for (let s = 0; s < 3; s++) {
const di = EX.new().get_deskinfo(o_room, s);
pkts++;
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.ChooseMain`, di.ChooseMain);
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.BuryCards`, di.BuryCards);
statAuditGroup(`deskinfo(step${pj.step})→seat${s}.PushCards`, di.PushCards, { cumulativeGrade: true });
const out = new Set(); collectCards(di, '', null, out);
for (const e of out) {
scanned++;
const cid = cidOf(e);
if (!mayKnow(pj, s, cid, 'deskinfo', pathOf(e))) {
leaks.push(`deskinfo(step${pj.step}) @${pathOf(e)} → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`);
}
}
}
};
audit();
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call })); audit();
if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); }
if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); }
const b = pj.banker;
// ---- 投降结算路径(design §4:70 分坐庄可在选主阶段直接投降)----
// 这条路径此前从未被审计过:9 局全部「正常打完」,mod.touxiang 一次都没走到。
if (opts.surrender) {
if (pj.call !== 70) { leaks.push(`投降用例前置不成立:庄家叫分=${pj.call},非 70 分坐庄`); return pj; }
mod.touxiang(pk(b)); audit();
if (pj.step !== 6 || pj.result !== 2) { leaks.push(`投降用例未真正结算:step=${pj.step} result=${pj.result}`); return pj; }
auditDeskinfo();
surrenderRuns++;
return pj;
}
mod.xuanzhu(pk(b, { flower: 1 + rnd(4) })); audit();
// 亮牌快照在埋牌后才固定;先算出期望集合,再审计这一包(maipai 正是亮牌的下发时机)
mod.maipai(pk(b, { cards: P.get_seat_cards(pj, b).slice(-8) }));
curLiangpai = bankerLiangpaiCards(pj);
if (curLiangpai.size > 0) { if (curNoCheck) { liangpaiRoundsNoCheck++; } else { liangpaiRounds++; } }
audit();
auditDeskinfo();
let guard = 0;
while (pj.step === 5 && ++guard < 400) {
if (opts.disbandAfter && guard > opts.disbandAfter) { break; }
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 c of hand) if (A.can_followcard(pj.flower, hand, [c], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [c]; break; }
if (!pick) break;
mod.chupai(pk(seat, { cards: pick })); audit();
if (guard % 7 === 0) { // 中途穿插重连与明牌
auditDeskinfo();
if (pj.method.have_baofu()) {
sent.length = 0;
mod.mingpai(pk(0));
// §9 明牌:只允许返回「另外两家未出的主牌」,多一张都算越权
for (const { m } of sent) {
if (m.rpc !== 'mingpai' || !m.data.others) continue;
for (const o of m.data.others) {
for (const cid of o.zhucards) {
scanned++;
const c = pj.cards[cid];
const isTrumpCard = c.flower === pj.flower || c.flower === 5 || c.number === 2 || c.number === 7;
if (c.dealowner !== o.seat + 1 && !(o.seat === pj.banker && c.dealowner === 0)) leaks.push(`mingpai 返回了非该座位的牌 ${cid}`);
if (c.playround !== -1) leaks.push(`mingpai 返回了已出/已埋的牌 ${cid}`);
if (!isTrumpCard) leaks.push(`mingpai 返回了非主牌 ${cid}`);
}
}
}
sent.length = 0;
}
}
}
audit();
// ---- 中途解散结算路径(get_disbandRoom)----
// 同样此前零覆盖。平台在解散时取这份 deskfree 下发给三家(前端 Game_Modify.Free),
// 故按「广播给三家」审计;随后的 step6 重连快照(result===3)也一并过一遍。
if (opts.disbandAfter) {
const msg = EX.new().get_disbandRoom(o_room);
if (!msg) { leaks.push('解散用例前置不成立:get_disbandRoom 返回 null'); return pj; }
sent.push({ to: 'ALL', m: JSON.parse(JSON.stringify(msg)) });
audit();
if (pj.step !== 6 || pj.result !== 3) { leaks.push(`解散用例未真正结算:step=${pj.step} result=${pj.result}`); return pj; }
auditDeskinfo();
disbandRuns++;
}
return pj;
}
for (const rt of ['00000', '00001']) for (const call of [65, 70, 5]) run(rt, call);
// 上面 6 局按 0xBEEF01 的牌序,庄家一次都没达到亮牌门槛(§8.2)。亮牌是【有意的信息公开】,
// 必须真的被审计走过,否则「无越权泄露」对这条路径只是碰巧没触发。故换牌序再补几局:
// 可查牌局验「该发的发了且不越权」,不查牌局验「达标也一张都不发」。
seed = 0x12345;
for (const call of [65, 5]) run('00000', call);
seed = 0x777001;
run('00000', 65);
run('00001', 65);
// 投降与解散:两条结算路径此前从未进过本门禁(9 局全是「正常打完」)
seed = 0x24680;
run('00000', 70, { surrender: true });
run('00001', 70, { surrender: true });
run('00000', 65, { disbandAfter: 11 });
run('00001', 60, { disbandAfter: 5 });
t.eq('下发面无越权泄露', [...new Set(leaks)].slice(0, 5), []);
t.eq('统计量均可由公开账本重算', [...new Set(statBad)].slice(0, 5), []);
// 覆盖下限:若收集器失效(扫不到牌),上面的断言会假绿,这里钉住实际扫描量
t.eq('审计覆盖 下发面 ≥ 1000 个', pkts >= 1000, true);
t.eq('审计覆盖 可见性判定 ≥ 10000 次', scanned >= 10000, true);
t.eq('审计覆盖 统计量重算 ≥ 10000 次', statChecks >= 10000, true);
// 覆盖下限(续):随机手牌下庄家未必达到亮牌门槛。若一局都没触发,
// 上面「无越权泄露」对亮牌这条路径就是【碰巧没走到】而不是【验证过安全】——必须钉住。
t.eq('审计覆盖 亮牌路径(可查牌) 至少 1 局', liangpaiRounds >= 1, true);
t.eq('审计覆盖 亮牌路径(不查牌) 至少 1 局', liangpaiRoundsNoCheck >= 1, true);
t.eq('审计覆盖 投降结算路径 至少 1 局', surrenderRuns >= 1, true);
t.eq('审计覆盖 中途解散结算路径 至少 1 局', disbandRuns >= 1, true);
// 「算奖明细」那条显式豁免必须真的被用到,否则它是一条无人走过的死规则,
// 早晚会在无人察觉时开始放行别的东西
t.eq('审计覆盖 算奖明细豁免确实被走到', awardExempt >= 1, true);
console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定 / ${statChecks} 次统计量重算比对`);
console.log(` [统计] 亮牌达标 可查牌${liangpaiRounds}局·不查牌${liangpaiRoundsNoCheck}局 / 投降${surrenderRuns}局 / 解散${disbandRuns}局 / 算奖明细豁免命中 ${awardExempt} 次`);
process.exit(t.done('leak') ? 0 : 1);