Files
erqiwang_youle/server/games/erqiwang/test/test_leak.js
T
joywayerandClaude Opus 5 b98f58effc 二七王:亮牌改为下发庄家全部固定主牌的具体牌面
规则变更(design §8.2):原先「只亮数量/结构,不亮具体是哪几张牌」,现改为
向两个闲家亮出【庄家手中全部固定主牌的具体牌面】。

- 固定主牌 = 双王 + 全部花色的 2 + 全部花色的 7,【不含】主花色普通牌
  A/K/Q/J/10/9/8/6/5。注意这与旧实现收集的「所有主牌」范围不同。
- 门槛不变(固定主牌总数 ≥10 / 王 ≥3 / 7 ≥6 / 2 ≥6,任一满足)。
- 不限叫分;仅可查牌模式下发;亮出的是固定的一份,不随被哪条门槛触发而增减。
- 协议 liangpai 由 {zhu,zhupair,zhutuo,wang,qi,er} 数量结构改为 {cards:[牌id...]},
  数量交由前端从 cards 自行计算。

服务端 get_liangpai() 重写(class.paiju.js);design §8.2 整节重写,术语表、
易混对照表、§9 相应更新;协议 §0.0b 与 maipai / PushCards 两处字段说明同步。

测试:
- test_paiju 亮牌用例按新规则重写(54 checks)。新增两条针对性用例:
  「只含固定主牌·主花色普通牌未混入」与「同时满足多档仍只亮同一份」——
  前者正是这次范围变更最容易写错的地方。
- test_leak 增加亮牌可见性规则。关键点:期望集合【独立于 get_liangpai 重算】,
  而不是复用被测实现——否则实现若误塞非固定主牌,审计会跟着放行、抓不住。
  反向验证证实了这一点:故意让 get_liangpai 混入主花色普通牌后,审计确实报警。

发现并修复一处假绿:原 6 局随机牌序下庄家一次都没达到亮牌门槛,即
「下发面无越权泄露」对亮牌这条路径根本没走到。现追加换牌序的 4 局(保留
原 6 局覆盖不动),并加两条覆盖断言钉住「可查牌达标 ≥1 局」「不查牌达标
≥1 局」,防止以后再退化成碰巧没触发。反向验证:去掉不查牌门控后审计立即
报出 5 张越权牌。

待定:T-34 亮牌的展示时机(出牌前一次性弹出 / 随时可查)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 23:46:06 +08:00

173 lines
11 KiB
JavaScript
Raw 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,
// 逐个判定「此刻该座位是否有权知道这张牌」。依据 design §4(底牌只有庄家可见 / 70分亮3秒)、
// §9(查牌模式)、§11(结束亮埋牌底牌)与 server 红线「发全 ≠ 发多,按可见性下发」。
const R = require('./_rpc.js'); const mod = R.mod;
const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith;
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 || {}) });
// 深度收集一个对象里所有「看起来是牌 id」的整数(0~107)。
// 为避免把 seat/count/grade 之类误当牌 id,只扫描已知承载牌 id 的字段名。
const CARD_FIELDS = ['cards', 'bottomcards', 'burycards', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards', 'mustcard'];
function collectCards(node, 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); }
}
// 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; // 不查牌 + 庄家达标的局数(此时一张都不该发)
const leaks = [];
let pkts = 0, scanned = 0;
let ctxRef = null;
// 判定:seat 此刻是否有权知道 cid
function mayKnow(pj, seat, cid, rpc) {
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秒
if (rpc === 'jiesuan' && c.playround === 0) return true; // §11 结束亮埋牌底牌
if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验
// §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包里下发)
if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && curLiangpai.has(cid)) return true;
return false;
}
function run(roomtype, call) {
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();
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++;
const targets = to === 'ALL' ? [0, 1, 2] : [to];
const out = new Set();
collectCards(m.data, null, out);
for (const seat of targets) for (const cid 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})`);
}
}
}
};
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;
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})`); }
}
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 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) { // 中途穿插重连与明牌
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})`); }
}
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();
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);
t.eq('下发面无越权泄露', [...new Set(leaks)].slice(0, 5), []);
// 覆盖下限:若收集器失效(扫不到牌),上面的断言会假绿,这里钉住实际扫描量
t.eq('审计覆盖 下发面 ≥ 1000 个', pkts >= 1000, true);
t.eq('审计覆盖 可见性判定 ≥ 10000 次', scanned >= 10000, true);
// 覆盖下限(续):随机手牌下庄家未必达到亮牌门槛。若一局都没触发,
// 上面「无越权泄露」对亮牌这条路径就是【碰巧没走到】而不是【验证过安全】——必须钉住。
t.eq('审计覆盖 亮牌路径(可查牌) 至少 1 局', liangpaiRounds >= 1, true);
t.eq('审计覆盖 亮牌路径(不查牌) 至少 1 局', liangpaiRoundsNoCheck >= 1, true);
console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定 / 亮牌达标 可查牌${liangpaiRounds}局·不查牌${liangpaiRoundsNoCheck}局`);
process.exit(t.done('leak') ? 0 : 1);