diff --git a/server/games/erqiwang/test/test_leak.js b/server/games/erqiwang/test/test_leak.js index 8738dfa..dbc63c3 100644 --- a/server/games/erqiwang/test/test_leak.js +++ b/server/games/erqiwang/test/test_leak.js @@ -1,9 +1,17 @@ // 下发面泄露审计(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11) -// 跑完整局,对每一个「服务器 → 某座位」的包做深度扫描,取出其中出现的所有牌 id, -// 逐个判定「此刻该座位是否有权知道这张牌」。依据 design §4(底牌只有庄家可见 / 70分亮3秒)、 -// §9(查牌模式)、§11(结束亮埋牌底牌)与 server 红线「发全 ≠ 发多,按可见性下发」。 +// 跑完整局,对每一个「服务器 → 某座位」的包做两层扫描: +// ① 牌 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; @@ -11,16 +19,33 @@ const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; s 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」的整数(0~107)。 +// ========================================================================== +// ①层:牌 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, key, out) { +function collectCards(node, path, key, out) { if (node === null || node === undefined) return; - if (Array.isArray(node)) { node.forEach(x => collectCards(x, key, out)); return; } - if (typeof node === 'number') { if (CARD_FIELDS.indexOf(key) >= 0 && node >= 0 && node <= 107) out.add(node); return; } - if (typeof node === 'object') { for (const k in node) collectCards(node[k], CARD_FIELDS.indexOf(k) >= 0 ? k : key, out); } + 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, @@ -43,30 +68,294 @@ 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; -let ctxRef = null; -// 判定:seat 此刻是否有权知道 cid -function mayKnow(pj, seat, cid, rpc) { +// 判定: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 || c.dealowner === seat + 1)) return true; // 庄家可见底牌/埋牌底牌 - if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0) return true; // §4 70分亮3秒 - // §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 (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 / 重连包里下发) - if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && curLiangpai.has(cid)) return true; + // §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包的 liangpai 分组里下发) + if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && pathIs(path, 'liangpai.cards') && curLiangpai.has(cid)) return true; return false; } -function run(roomtype, call) { +// ========================================================================== +// ②层:统计量必须能由「公开账本」独立重算出来 +// ========================================================================== +// 账本里只有【三家都看得见】的东西: +// 庄家 / 庄家叫分(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', @@ -75,49 +364,100 @@ function run(roomtype, call) { }; 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(); - ctxRef = { pj, sent }; 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 cid of out) { + collectCards(m.data, '', null, out); + for (const seat of targets) for (const e of out) { scanned++; - if (!mayKnow(pj, seat, cid, m.rpc)) { - leaks.push(`rpc=${m.rpc} → seat${seat} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); + 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(); - // 重连快照也要审计(每个座位各取一次) - 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(step${pj.step}) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); } - } + 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]]; @@ -125,13 +465,7 @@ function run(roomtype, call) { if (!pick) break; mod.chupai(pk(seat, { cards: pick })); audit(); if (guard % 7 === 0) { // 中途穿插重连与明牌 - 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++; - // 阶段取【实际的】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})`); } - } + auditDeskinfo(); if (pj.method.have_baofu()) { sent.length = 0; mod.mingpai(pk(0)); @@ -154,6 +488,19 @@ function run(roomtype, call) { } } 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; } @@ -166,13 +513,28 @@ 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); -console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定 / 亮牌达标 可查牌${liangpaiRounds}局·不查牌${liangpaiRoundsNoCheck}局`); +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);