Files
erqiwang_youle/server/games/erqiwang/test/test_leak.js
T
joywayerandClaude Opus 5 4aab71e6a3 二七王:实现 S-1 curmultiple 与 S-2 mustcard
S-1 当前抓分倍数(design §7.2.0):
- class.paiju.js 新增 get_curmultiple(),复用 get_qvalue + get_upgrade,
  与结算 aset.upgrade 同源同算法,差别仅在不含扣底(末轮才产生)。
- 随现有包下发、不另开推送:shangzhuang / chupai1-3 / deskinfo.PushCards。
- 三家同值,整表下发不涉及泄露(捡分本就公开);叫分未定时为 0。

S-2 跟牌必出牌(design §5.2):
- class.paiju.js 新增 get_mustcard(seat),内部复用与出牌校验完全相同的
  get_followcard,故建议与校验天然一致。
- 【只发给 nextseat 一家】:它是该玩家自己手牌的子集,整表下发会泄露
  他家手牌结构(server 红线:发全 ≠ 发多)。
- 四种情形不下发:非出牌阶段、该座位是本轮首家、首家甩牌、必出牌为空。
  甩牌一条尤其重要——甩牌跟牌走 flush_follow_ok 的逐分量匹配,与
  get_followcard 不是同一条路径,给建议会给错。

测试(新增 test/test_hint.js,33 checks):
- curmultiple:算法层逐档对齐 design §7.2.0 判定表(含爬坡 Q 分段)、
  三家同值、重连与出牌包一致、叫分未定为 0、与 get_upgrade 绝对值一致。
- mustcard:只有 nextseat 收到、另两家没有、内容确属收包者自己的牌、
  首家无、缺门有富余时无、手牌数恰等于首家张数时整手必出、甩牌不发、
  非出牌阶段不发、重连按 currseat 门控。

过程中修正的两个测试自身问题(均为脚本缺陷,非业务缺陷,业务判定经证据核实正确):
- 「缺门方无必出」原用例给闲2 只发 2 张牌,恰等于首家张数,get_followcard
  正确判定为「整手必出」;改为发 4 张才留出选择余地,并补一条对照用例
  锁住「恰等于张数则整手必出」这个正确行为。
- 「甩牌不发」「非出牌阶段不发」两条原本是假通过——桩的 startcount 仍是 -1,
  前置守卫先返回了 null,根本没测到被测因素。改为先把桩推进到跟牌态、
  每个用例只变动一个因素,并加一条「自证」用例确认推进后确实能算出必出牌。

另:mustcard 补进 test_leak.js 的 CARD_FIELDS 审计白名单(与 burycards 同理,
不加则误发不会被泄露审计发现)。反向验证:改成整表下发后,审计立刻报出
5 张越权牌;把 curmultiple 写死为 1 后,两条断言失败。均已还原、全绿。

协议文档同步 shangzhuang / chupai1-3 / PushCards 的字段说明。
至此 §7.5 服务端待补 5 条全部完成,服务端侧无遗留。

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

131 lines
7.7 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); }
}
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 明牌:单独在下面按内容校验
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)) }) }
};
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();
mod.maipai(pk(b, { cards: P.get_seat_cards(pj, b).slice(-8) })); 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);
t.eq('下发面无越权泄露', [...new Set(leaks)].slice(0, 5), []);
// 覆盖下限:若收集器失效(扫不到牌),上面的断言会假绿,这里钉住实际扫描量
t.eq('审计覆盖 下发面 ≥ 1000 个', pkts >= 1000, true);
t.eq('审计覆盖 可见性判定 ≥ 10000 次', scanned >= 10000, true);
console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定`);
process.exit(t.done('leak') ? 0 : 1);