二七王:亮牌改为下发庄家全部固定主牌的具体牌面
规则变更(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>
This commit is contained in:
@@ -22,6 +22,28 @@ function collectCards(node, key, out) {
|
||||
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;
|
||||
@@ -35,6 +57,8 @@ function mayKnow(pj, seat, cid, rpc) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -45,6 +69,8 @@ function run(roomtype, call) {
|
||||
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: () => { } };
|
||||
@@ -74,7 +100,11 @@ function run(roomtype, call) {
|
||||
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();
|
||||
// 亮牌快照在埋牌后才固定;先算出期望集合,再审计这一包(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);
|
||||
@@ -122,9 +152,21 @@ function run(roomtype, call) {
|
||||
}
|
||||
|
||||
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);
|
||||
console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定`);
|
||||
// 覆盖下限(续):随机手牌下庄家未必达到亮牌门槛。若一局都没触发,
|
||||
// 上面「无越权泄露」对亮牌这条路径就是【碰巧没走到】而不是【验证过安全】——必须钉住。
|
||||
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);
|
||||
|
||||
@@ -20,27 +20,73 @@ function fill(req, n) {
|
||||
}
|
||||
const mkHand = ids => ({ flower: 1, banker: 0, cards: ids.map(x => ({ id: x, playround: -1, dealowner: 1 })) });
|
||||
const lp = ids => P.get_liangpai(mkHand(fill(ids, 28)));
|
||||
t.eq('亮牌 3王', lp([big(1), small(1), small(2)]), { wang: 3 });
|
||||
t.eq('亮牌 4王+6个7(固定主10)', lp([big(1), big(2), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)]), { zhu: 10, zhupair: 5, zhutuo: 1, wang: 4, qi: 6 });
|
||||
t.eq('亮牌 6个2', lp([id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2), id(1, 3, 2), id(2, 3, 2)]), { er: 6 });
|
||||
t.eq('亮牌 2王2个7(不达标)', lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7)]), null);
|
||||
t.eq('亮牌 6个7(仅qi)', lp([id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)]), { qi: 6 });
|
||||
t.eq('亮牌 固定主9不达标→null', lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 1, 2), id(2, 1, 2), id(1, 2, 2)]), null);
|
||||
t.eq('亮牌 固定主10边界(仅zhu)', lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2)]), { zhu: 10, zhupair: 4, zhutuo: 1 });
|
||||
// 亮牌返回 { cards: [固定主牌id...] }(按主牌序排好);不达标返回 null。
|
||||
// 断言用「排序后的 id 集合」比较,避免把排序细节写死进用例。
|
||||
const sortIds = a => a.slice().sort((x, y) => x - y);
|
||||
const lpCards = ids => { const r = lp(ids); return r === null ? null : sortIds(r.cards); };
|
||||
|
||||
// —— 四条门槛各自能触发,且亮出的都是【全部固定主牌】——
|
||||
{
|
||||
const h = [big(1), small(1), small(2)]; // 3 王
|
||||
t.eq('亮牌 3王 → 亮出这3张王', lpCards(h), sortIds(h));
|
||||
}
|
||||
{
|
||||
const h = [id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2), id(1, 3, 2), id(2, 3, 2)]; // 6 个 2
|
||||
t.eq('亮牌 6个2 → 亮出这6张2', lpCards(h), sortIds(h));
|
||||
}
|
||||
{
|
||||
const h = [id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)]; // 6 个 7
|
||||
t.eq('亮牌 6个7 → 亮出这6张7', lpCards(h), sortIds(h));
|
||||
}
|
||||
{
|
||||
const h = [big(1), small(1), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7),
|
||||
id(1, 1, 2), id(2, 1, 2), id(1, 2, 2), id(2, 2, 2)]; // 固定主 10(各档单看都不达标)
|
||||
t.eq('亮牌 固定主10边界 → 亮出全部10张', lpCards(h), sortIds(h));
|
||||
}
|
||||
// —— 不达标 → null ——
|
||||
t.eq('亮牌 2王2个7 不达标→null', lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7)]), null);
|
||||
t.eq('亮牌 固定主9 不达标→null',
|
||||
lp([big(1), small(1), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 1, 2), id(2, 1, 2), id(1, 2, 2)]), null);
|
||||
|
||||
// —— 关键:亮出的只有【固定主牌】,主花色普通牌【不能】混进来 ——
|
||||
// 手牌里塞满主花色(♦)普通牌 A/K/Q/J/10/9/8/6/5,它们是主牌但不是固定主牌
|
||||
{
|
||||
const guding = [big(1), small(1), small(2)]; // 3 王 → 达标
|
||||
const zhuNormal = [id(1, 1, 1), id(1, 1, 13), id(1, 1, 12), id(1, 1, 11), id(1, 1, 10)]; // ♦A/K/Q/J/10
|
||||
const r = lp(guding.concat(zhuNormal));
|
||||
t.eq('亮牌 只含固定主牌 张数=3', r.cards.length, 3);
|
||||
t.eq('亮牌 主花色普通牌未混入', sortIds(r.cards), sortIds(guding));
|
||||
}
|
||||
// —— 亮出内容与「被哪条门槛触发」无关:4王+6个7 应亮出全部 10 张固定主 ——
|
||||
{
|
||||
const h = [big(1), big(2), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)];
|
||||
t.eq('亮牌 同时满足多档 → 仍只亮同一份固定主牌', lpCards(h), sortIds(h));
|
||||
}
|
||||
// —— 排序:按本局主牌序从大到小(大王 > 小王 > 正7 > 副7 > 正2 > 副2)——
|
||||
{
|
||||
const A = require('../class.arith.js');
|
||||
const h = [big(1), small(1), id(1, 1, 7), id(1, 2, 7), id(1, 1, 2), id(1, 2, 2),
|
||||
id(2, 1, 7), id(2, 2, 7), id(2, 1, 2), id(2, 2, 2)]; // 固定主 10,主花色=♦(1)
|
||||
const r = lp(h);
|
||||
t.eq('亮牌 按主牌序排好', r.cards, A.order_cards(1, r.cards.slice()));
|
||||
t.eq('亮牌 首张为大王', r.cards[0], big(1));
|
||||
}
|
||||
|
||||
// design §8.2:亮牌依据是【庄家埋牌后】的静态手牌快照,全程固定、不随出牌缩水
|
||||
// (闲家断线重连时 get_deskinfo 会再取一次;若按当前剩余手牌重算,亮牌会缩水甚至变 null)
|
||||
const LIANG28 = fill([big(1), big(2), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)], 28);
|
||||
const EXPECT_LIANG = { zhu: 10, zhupair: 5, zhutuo: 1, wang: 4, qi: 6 };
|
||||
t.eq('亮牌 埋牌后快照(出牌开始)', P.get_liangpai(mkHand(LIANG28)), EXPECT_LIANG);
|
||||
const EXPECT_LIANG = sortIds([big(1), big(2), small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)]);
|
||||
t.eq('亮牌 埋牌后快照(出牌开始)', sortIds(P.get_liangpai(mkHand(LIANG28)).cards), EXPECT_LIANG);
|
||||
const played = mkHand(LIANG28); // 同一手牌,已打出2大王+1对♦7
|
||||
for (const c of played.cards){
|
||||
if (c.id === big(1) || c.id === big(2) || c.id === id(1, 1, 7) || c.id === id(2, 1, 7)){ c.playround = 1; }
|
||||
}
|
||||
t.eq('亮牌 打出4张后重连仍按快照', P.get_liangpai(played), EXPECT_LIANG);
|
||||
t.eq('亮牌 打出4张后重连仍按快照', sortIds(P.get_liangpai(played).cards), EXPECT_LIANG);
|
||||
// 埋下的牌(playround==0)不算在快照里
|
||||
const buried = mkHand(LIANG28);
|
||||
for (const c of buried.cards){ if (c.id === big(1) || c.id === big(2)){ c.playround = 0; } }
|
||||
t.eq('亮牌 已埋的牌不计入快照', P.get_liangpai(buried), { qi: 6 });
|
||||
t.eq('亮牌 已埋的牌不计入快照', sortIds(P.get_liangpai(buried).cards),
|
||||
sortIds([small(1), small(2), id(1, 1, 7), id(2, 1, 7), id(1, 2, 7), id(2, 2, 7), id(1, 3, 7), id(2, 3, 7)]));
|
||||
|
||||
// ============ §7/§8 结算 get_paiju_account 集成 ============
|
||||
// 受控 o_paiju/o_desk/o_room:控制庄家算奖手牌(dealowner=1)与闲1捡分(playowner=1,score)
|
||||
|
||||
Reference in New Issue
Block a user