二七王:补齐客户端牌id入参校验(重复/越界/非数组)

check_cards_inhand 此前只逐个校验牌id是否在手上,不查重复、不查
类型与范围,且在牌已出/已埋时 return 空值而非 false,造成两个可
被利用的缺陷:

1. 重复牌id 可伪造牌型:do_playcard([方块K,方块K]) 实测返回
   result=true、cardtype=201(同一张牌被判成一对),可进而伪造
   拖拉机与甩牌分量,本轮结算时该分牌的 score 还会被重复累加。
2. 埋牌传 8 个相同 id 实测只埋下 1 张,庄家带 35 张进入出牌阶段,
   整局牌数错乱。
3. 越界 id 或非数组入参会在解引用处抛异常,中断 DoPack。

新增 check_cards_valid 作为唯一入参校验入口(非空数组、元素为本
局牌表内的整数牌id、互不重复),由 check_cards_inhand 先行调用;
mod.maipai/mod.chupai 在读取 cards.length 之前先过该校验。按
engineering §03「显式失败优于隐式兜底」直接拒绝,不做类型兜底。

新增 test_input.js 28 项正/反/边界用例回归上述三点。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 04:10:15 +08:00
co-authored by Claude Opus 5
parent 391dffc04a
commit a4a8eb8408
3 changed files with 139 additions and 5 deletions
+95
View File
@@ -0,0 +1,95 @@
// 客户端入参校验:重复牌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
};
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);
process.exit(t.done('input') ? 0 : 1);