diff --git a/server/games/erqiwang/docs/compliance/01-design合规逐节核对.md b/server/games/erqiwang/docs/compliance/01-design合规逐节核对.md index 0d12a38..3b62e2c 100644 --- a/server/games/erqiwang/docs/compliance/01-design合规逐节核对.md +++ b/server/games/erqiwang/docs/compliance/01-design合规逐节核对.md @@ -217,6 +217,39 @@ F1~F4 修复后,对 **design.md 维度**又做了一次独立取证复核。 --- +## 第七轮核对(反方向:代码 → design) + +> 前六轮都是「design → 代码」方向:拿规则去找代码有没有实现。本轮换**反方向**——从代码出发,把服务端每一个规则决策与常量拉出来,逐个追问「design 里有没有明文依据」。找的是两类此前查不到的东西:**代码做了 design 没授权的事**,以及 **design 有歧义时代码替规则做的选择**。 +> +> **结论:没有发现与 design 冲突的行为。** 但列出 6 项「design 未明文规定、由代码自行决定」的项——它们不是缺陷,但也从未被规则设计者确认过,建议逐条拍板后写回 design。 + +| # | 代码的决定 | design 依据 | 性质 | +| --- | --- | --- | --- | +| R1 | 四个阶段的倒计时秒数 **15 / 20 / 25 / 30**(`class.desk.js`) | §11 只说「四个阶段都会给出一个倒计时秒数」,**未规定具体值** | 实现选择,需确认 | +| R2 | **§5.4.4 拖拉机分量的降级阶梯不要求「最长优先」**:甩牌里有 3 连对分量而闲家只有 2 连对时,代码允许出任意 3 对,不强制先凑出那个 2 连对 | §5.4.4 第 1 条明文写「没有同等连对数的拖拉机,退而求其次用**同等张数的主对子**顶替」——支持代码;但该节开头又说「沿用 §5.2 强制层级同一原则」,而 §5.2 层级 2 要求「能凑多长就必须先凑多长」。**两处措辞可作两种解读** | 歧义,代码按显式的第 1 条实现 | +| R3 | 服务端把提交的一组主牌按「**连续对子最大化合并成拖拉机**」分解(`decompose_trump`) | §5.4.3 只说甩牌可自由混搭,**未规定服务端如何分解**。该选择同时影响最大性判定、跟牌分量需求、扣底倍数(合并对甩牌方更有利:`AAKKQQ` 记 3 连对 ×6 而非 2 连对 ×4) | 实现选择,需确认 | +| R4 | 两个**同级**的副 7 对(♥7对 + ♣7对)**不构成连对** | §5.3 的链只列一次「副7」,同级即非相邻——代码按此。但 design 未直说 | 解读,合理 | +| R5 | `call` 用 `parseInt` 宽松解析:`"65"`(字符串)与 `65.5`(小数截断为 65)都会被接受;而 `cards` 是严格校验(字符串数字/小数一律拒) | design 不管入参类型;协议 §0.2 只对 `cards` 定了严格约束。**两者标准不一致**,但截断后语义正确、不改变规则结果 | 一致性瑕疵,非规则违反 | +| R6 | `get_chongguan` 对 `cards.length < 28` 静默返回 0 奖 | 隐式兜底(违反工程总则「显式失败优于隐式兜底」)。三个调用点的快照恒为 28 或 36,**当前不可触发** | 代码风格,非缺陷 | + +### 本轮新增取证:下发面泄露审计 + +design §4(暗牌只有庄家可见 / 70 分亮 3 秒)、§9(查牌模式)、§11(结束亮底牌)与 server 红线「**发全 ≠ 发多,按可见性下发**」此前**没有任何测试**——各处门控是逐条手工核对的,从未系统验证过「有没有哪个包把不该看的牌送到了某个座位」。 + +本轮把它做成可执行审计(`test/test_leak.js`):跑完整局(可查牌 / 不查牌 × 叫 65 / 70 / 5 共 6 局),对每一个「服务器 → 某座位」的下发面(含 6 个 RPC 的逐座位包 + 各阶段重连快照 + 明牌应答)做深度扫描,取出其中出现的**所有牌 id**,逐个判定该座位此刻是否有权知道这张牌。 + +**结果:1814 个下发面 / 18706 次「座位×牌」可见性判定,0 泄露。** + +变异检验(注入真实泄露确认审计有效): + +| 注入的泄露 | 结果 | +| --- | --- | +| 非 70 分也把 8 张暗牌发给闲家(这正是第三轮修过的历史缺陷) | ✅ 抓住 | +| 重连时把庄家埋的底牌发给闲家 | ✅ 抓住 | +| 出牌包把出牌者剩余手牌发给所有人 | ✅ 抓住 | + +--- + ## 结论摘要 > **注**:下表是**首轮核对**的初始发现快照(保留以记录起点)。其中 §4.4、§5.4、§6.3、§7、§8.1-8.4、§9、§10、§11 等标 🟥/🟧/🟨 的项**后续均已修复并验证**——当前逐节状态以本文档后面的分节详情及全套单测(270 项全绿)为准。 diff --git a/server/games/erqiwang/test/README.md b/server/games/erqiwang/test/README.md index 580dca4..d975190 100644 --- a/server/games/erqiwang/test/README.md +++ b/server/games/erqiwang/test/README.md @@ -44,6 +44,7 @@ node server/games/erqiwang/test/test_arith.js # 单独跑某一组 - `test_calc.js`:**§7 算子全量对拍**。参考实现逐字转写自 §7.1/§7.2/§7.3 的判定表,与 `get_base_bycall` / `get_qvalue` / `get_upgrade` 比对 **2 种算子模式 × 14 个叫分档 × grade 0~260 = 7308 格**(每格比 base/Q/判定倍率/最终子数四项),另按 design 的 17 张表逐格抽查 141 个"写死的最终子数"。断言按(模式,叫分档)与表名分组,失败能直接定位到档位。 - `test_followdiff.js`:**§5.2 跟牌强制层级穷举差分**。参考裁定按 §5.2 原文写(缺门/不足/够牌 × 单张/对子/N 连对 + 最长拖拉机优先的档案比较),对每手牌**枚举全部 C 张出牌组合**逐个比对"参考允许 vs `can_followcard` 允许",任何一侧多放行或多拒绝都会被抓到(约 11 万组)。 +- `test_leak.js`:**下发面泄露审计**(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11)。跑 6 局(可查牌/不查牌 × 叫 65/70/5),对每一个「服务器 → 某座位」的下发面——含各 RPC 的逐座位包、各阶段重连快照、明牌应答——深度扫描出其中所有牌 id,逐个判定该座位此刻**是否有权知道这张牌**(自己的牌 / 已打出 / 庄家的暗牌与底牌 / 70 分亮 3 秒 / 结束亮底牌 / §9 明牌)。约 1800 个下发面、1.8 万次判定。此前各处门控只有逐条手工核对,没有系统验证。 - `test_fuzz.js`:**整局模糊 + 每墩胜者差分**。`test_endgame.js` 的驱动器只会出单张,所以对子/拖拉机/甩牌/甩错在「整局链路」里从未跑过——单测逐个覆盖了 `can_playcard`/`can_followcard`,但它们与 `do_playcard`/`mod.chupai`/结算的**集成**是空白。本文件用带牌型的对局补上(20 局约 480 墩,四种房间选项组合轮换),并对**每一墩**用独立参考实现重算"谁最大"与服务端比对(不是抽查,每墩都比),另独立重算闲家捡分、扣底倍数、算奖与捡分子数的分配。提交时还会把牌按**最坏顺序**摆(`[K,K,Q,Q]→[K,Q,K,Q]`,把每个对子拆开),压服务端判定的入参顺序无关性。 三组都做过**变异检验**(在副本上注入缺陷确认会转红):关掉 `follow_tractor_cover_ok` → `test_followdiff` 三种拖拉机首出全红;大光倍率改 3→4 / 常规 55 档 base 改 4→5 / 爬坡 40·35 档 Q 改 20→40 → `test_calc` 精确指出档位与 grade;牌的归属写给非胜者、单张毙牌不再压过副牌、算奖分配去掉 `×X`、跟牌牌面值改回未排序入参 → `test_fuzz` 全部转红。 diff --git a/server/games/erqiwang/test/test_leak.js b/server/games/erqiwang/test/test_leak.js new file mode 100644 index 0000000..e1b2545 --- /dev/null +++ b/server/games/erqiwang/test/test_leak.js @@ -0,0 +1,130 @@ +// 下发面泄露审计(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', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards']; +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);