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>
118 lines
6.2 KiB
JavaScript
118 lines
6.2 KiB
JavaScript
// 客户端入参校验:重复牌id / 越界id / 非数组 / 非整数(check_cards_valid + check_cards_inhand)
|
||
// 回归两个真实缺陷:① 重复id 被 can_playcard 当成对子/拖拉机;② 埋牌传重复id 只埋下 1 张
|
||
require('./_shim');
|
||
const { mod, setup, make108, id } = require('./_rpc.js');
|
||
const P = require('../class.paiju.js');
|
||
const t = require('./_assert')();
|
||
|
||
// 造一个最小可用牌局:108 张牌全发给指定座位,主花色=1
|
||
function newPaiju(step, banker, owner) {
|
||
const paiju = {
|
||
cards: make108(), step, banker, call: 65, flower: 1,
|
||
seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]],
|
||
playproc: {}, callproc: [], idx: 1
|
||
};
|
||
for (let i = 0; i < 108; i++) paiju.cards[i].dealowner = owner + 1;
|
||
paiju.method = {
|
||
check_cards_valid: c => P.check_cards_valid(paiju, c),
|
||
check_cards_inhand: (c, s) => P.check_cards_inhand(paiju, c, s),
|
||
do_burycard: c => P.do_burycard(paiju, c),
|
||
do_playcard: c => P.do_playcard(paiju, c),
|
||
get_seat_cards: s => P.get_seat_cards(paiju, s),
|
||
get_seat_zhucards: s => P.get_seat_zhucards(paiju, s),
|
||
have_baofu: () => (paiju.seatlist[0][4][0] == 0 || paiju.seatlist[1][4][0] == 0 || paiju.seatlist[2][4][0] == 0),
|
||
get_liangpai: () => null,
|
||
get_curmultiple: () => P.get_curmultiple(paiju),
|
||
get_mustcard: s => P.get_mustcard(paiju, s)
|
||
};
|
||
return paiju;
|
||
}
|
||
|
||
// ============ L1 check_cards_valid:正 / 反 / 边界 ============
|
||
const pj = newPaiju(5, 0, 0);
|
||
const V = c => P.check_cards_valid(pj, c);
|
||
|
||
t.eq('valid 正 单张', V([0]), true);
|
||
t.eq('valid 正 多张不重复', V([0, 1, 2, 53]), true);
|
||
t.eq('valid 边界 最大牌id 107', V([107]), true);
|
||
t.eq('valid 边界 最小牌id 0', V([0]), true);
|
||
|
||
t.eq('valid 反 undefined', V(undefined), false);
|
||
t.eq('valid 反 null', V(null), false);
|
||
t.eq('valid 反 字符串', V('012'), false);
|
||
t.eq('valid 反 对象', V({ 0: 1, length: 1 }), false);
|
||
t.eq('valid 反 空数组', V([]), false);
|
||
t.eq('valid 反 重复id', V([5, 5]), false);
|
||
t.eq('valid 反 多张中含重复', V([1, 2, 3, 2]), false);
|
||
t.eq('valid 反 越界上界108', V([108]), false);
|
||
t.eq('valid 反 负数id', V([-1]), false);
|
||
t.eq('valid 反 小数id', V([1.5]), false);
|
||
t.eq('valid 反 数字字符串', V(['5']), false);
|
||
|
||
// ============ L1 check_cards_inhand ============
|
||
t.eq('inhand 正 在手上', P.check_cards_inhand(pj, [0, 1], 0), true);
|
||
t.eq('inhand 反 不在手上(别人的)', P.check_cards_inhand(pj, [0], 1), false);
|
||
t.eq('inhand 反 重复id(经 valid 拦截)', P.check_cards_inhand(pj, [0, 0], 0), false);
|
||
t.eq('inhand 反 越界id(经 valid 拦截)', P.check_cards_inhand(pj, [999], 0), false);
|
||
pj.cards[3].playround = 1; // 已打出
|
||
t.eq('inhand 反 已打出的牌', P.check_cards_inhand(pj, [3], 0), false);
|
||
pj.cards[4].playround = 0; // 已埋
|
||
t.eq('inhand 反 已埋下的牌', P.check_cards_inhand(pj, [4], 0), false);
|
||
|
||
// ============ L2 出牌:重复id 不得被当成一对 ============
|
||
const pj2 = newPaiju(5, 0, 0);
|
||
P.new_playround(pj2, 1, 0);
|
||
const cid = id(1, 1, 13); // 方块K
|
||
t.eq('出牌 反 重复id 被 mod 层拦截(valid)', P.check_cards_valid(pj2, [cid, cid]), false);
|
||
|
||
// ============ L3 mod.maipai:8 个重复id 必须被拒、不得进入出牌阶段 ============
|
||
const pj3 = newPaiju(3, 0, 0);
|
||
const s3 = setup('00000', pj3);
|
||
const dup = id(1, 1, 13);
|
||
mod.maipai({ data: { seat: 0, cards: [dup, dup, dup, dup, dup, dup, dup, dup] }, conmode: 0, fromid: 0 });
|
||
t.eq('埋牌 反 8个重复id 被拒(step仍为3)', pj3.step, 3);
|
||
t.eq('埋牌 反 8个重复id 未埋下任何牌', pj3.cards.filter(c => c.playround === 0).length, 0);
|
||
|
||
// 对照:8 张不同的牌可以正常埋下
|
||
const pj4 = newPaiju(3, 0, 0);
|
||
setup('00000', pj4);
|
||
mod.maipai({ data: { seat: 0, cards: [0, 1, 2, 3, 4, 5, 6, 7] }, conmode: 0, fromid: 0 });
|
||
t.eq('埋牌 正 8张不同牌 埋牌成功(step进5)', pj4.step, 5);
|
||
t.eq('埋牌 正 实际埋下8张', pj4.cards.filter(c => c.playround === 0).length, 8);
|
||
|
||
// ============ L3 mod.chupai:越界/非数组入参不得抛异常 ============
|
||
const pj5 = newPaiju(5, 0, 0);
|
||
P.new_playround(pj5, 1, 0);
|
||
const s5 = setup('00000', pj5);
|
||
let threw = null;
|
||
try {
|
||
mod.chupai({ data: { seat: 0, cards: [999] }, conmode: 0, fromid: 0 });
|
||
mod.chupai({ data: { seat: 0, cards: undefined }, conmode: 0, fromid: 0 });
|
||
mod.chupai({ data: { seat: 0, cards: 'x' }, conmode: 0, fromid: 0 });
|
||
mod.chupai({ data: { seat: 0, cards: [dup, dup] }, conmode: 0, fromid: 0 });
|
||
} catch (e) { threw = String(e); }
|
||
t.eq('出牌 反 非法入参不抛异常', threw, null);
|
||
t.eq('出牌 反 非法入参不产生任何出牌', pj5.cards.filter(c => c.playround > 0).length, 0);
|
||
|
||
|
||
|
||
// ============ design §9:报无主后立即为全体三人刷新主牌统计 ============
|
||
// 造 step5 牌局:seat0 手上只剩 1 张主牌(打掉即报无主),seat1/seat2 各持若干主牌
|
||
const pj6 = newPaiju(5, 0, 0);
|
||
for (let i = 0; i < 108; i++) pj6.cards[i].dealowner = -1; // 先全部清空
|
||
const give = (cid, seat) => { pj6.cards[cid].dealowner = seat + 1; pj6.cards[cid].playround = -1; };
|
||
give(id(1, 1, 5), 0); // seat0:主♦5(唯一主牌)
|
||
give(id(1, 3, 9), 0); // seat0:副♥9(保证还有牌)
|
||
give(id(1, 1, 13), 1); give(id(2, 1, 13), 1); give(id(1, 1, 12), 1); // seat1:主♦K对 + 主♦Q
|
||
give(id(1, 1, 7), 2); // seat2:正7(固定主牌)
|
||
P.new_playround(pj6, 1, 0);
|
||
t.eq('报无主前 三家统计均为初始 [-1,-1]', pj6.seatlist.map(s => s[4]), [[-1, -1], [-1, -1], [-1, -1]]);
|
||
P.do_playcard(pj6, [id(1, 1, 5)]); // seat0 打出唯一主牌 → 报无主
|
||
t.eq('报无主 seat0 主牌清空 [0,0]', pj6.seatlist[0][4], [0, 0]);
|
||
t.eq('报无主 立即刷新 seat1 [3张,1对]', pj6.seatlist[1][4], [3, 1]);
|
||
t.eq('报无主 立即刷新 seat2 [1张,0对]', pj6.seatlist[2][4], [1, 0]);
|
||
t.eq('报无主 seat0 主花色标志置1', pj6.seatlist[0][pj6.flower - 1], [1, 1]);
|
||
t.eq('报无主 seat2 无主对→无对标志置1', pj6.seatlist[2][pj6.flower - 1][1], 1);
|
||
|
||
process.exit(t.done('input') ? 0 : 1);
|