二七王:前端语义化发包
route 显式写 erqiwang——RpcHelper.sendGameRpc 预设 route="room", 用它包会被路由到平台房间模块、到不了子游戏 mod.js 且不报错。 只做形状校验(牌 id 范围/去重/张数),规则合法性由服务端裁定; 不合形状即抛错、不发残缺包。测试断言发包字段集合不含任何结论字段。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
///////////////////////////////////////////////////////////////
|
||||
////////// EQW_Rpc: 语义化发包(一操作一方法)/////////////////
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 前端 04 §1:业务不直接拼包,走两层封装——
|
||||
// 业务 → EQW_Rpc.xxx(意图) → RpcHelper(注入平台字段)→ 引擎发送
|
||||
//
|
||||
// 【route 必须是 erqiwang】RpcHelper.sendGameRpc 预设 route="room"(平台房间模块),
|
||||
// 用它包会被路由到平台、永远到不了子游戏 mod.js,而且不会有任何报错。
|
||||
//
|
||||
// 【请求包只带意图,不带结论】(前端 04 §1 / 服务端 04 §8)
|
||||
// 可以带:操作类型、目标标识(牌 id / 花色 / 提示类型)、座位号(仅供一致性校验)。
|
||||
// 禁止带:分数、番数、判定结果、阶段推进指令、nextSeat、handCards。
|
||||
// 前端 shared/ 的计算只用于本地提示与预校验,【结果不回传】。
|
||||
// 一句话:前端能自己推出来的东西,就是玩家能改的东西——这是防作弊的根本。
|
||||
//
|
||||
// 本文件只做【形状】校验(牌 id 范围/去重/张数),不做【规则】校验
|
||||
// (这手牌能不能出、叫分够不够低由服务端裁定)。形状不合法就 fail-fast 抛错,
|
||||
// 不发出残缺包——服务端会按 PARAM 拒绝,但让错误暴露在最近处更省事。
|
||||
var EQW_Rpc = EQW_Rpc || {
|
||||
|
||||
APP: 'youle',
|
||||
ROUTE: 'erqiwang',
|
||||
|
||||
//—— 内部:取自己的座位 ——
|
||||
_seat: function () {
|
||||
return EQW_GameState.room.mySeat;
|
||||
},
|
||||
|
||||
//—— 内部:发送 ——
|
||||
_send: function (rpc, data) {
|
||||
var pack = { seat: this._seat() };
|
||||
var key;
|
||||
for (key in data) {
|
||||
if (data.hasOwnProperty(key)) {
|
||||
pack[key] = data[key];
|
||||
}
|
||||
}
|
||||
RpcHelper.sendRpc(this.APP, this.ROUTE, rpc, pack);
|
||||
},
|
||||
|
||||
//—— 内部:牌 id 列表形状校验(协议 §0.2)——
|
||||
//必须是非空数组,元素是 0–107 的整数,互不重复
|
||||
_checkCards: function (cards, expectCount) {
|
||||
if (Object.prototype.toString.call(cards) !== '[object Array]' || cards.length === 0) {
|
||||
throw new Error('[EQW_Rpc] cards 必须是非空数组');
|
||||
}
|
||||
if (typeof expectCount === 'number' && cards.length !== expectCount) {
|
||||
throw new Error('[EQW_Rpc] cards 张数应为 ' + expectCount + ',实际 ' + cards.length);
|
||||
}
|
||||
var seen = {};
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
var c = cards[i];
|
||||
if (typeof c !== 'number' || c !== Math.floor(c) || c < 0 || c > 107) {
|
||||
throw new Error('[EQW_Rpc] 非法牌 id: ' + c + '(须为 0–107 的整数)');
|
||||
}
|
||||
if (seen[c]) { throw new Error('[EQW_Rpc] 牌 id 重复: ' + c); }
|
||||
seen[c] = true;
|
||||
}
|
||||
},
|
||||
|
||||
//—— 叫分(call: 5–70 的 5 的倍数;0 = 不叫)——
|
||||
jiaofen: function (call) {
|
||||
if (typeof call !== 'number' || call !== Math.floor(call)) {
|
||||
throw new Error('[EQW_Rpc] call 必须是整数');
|
||||
}
|
||||
if (call !== 0 && (call < 5 || call > 70 || call % 5 !== 0)) {
|
||||
throw new Error('[EQW_Rpc] 非法叫分: ' + call + '(须为 0 或 5–70 的 5 的倍数)');
|
||||
}
|
||||
this._send('jiaofen', { call: call });
|
||||
},
|
||||
|
||||
//—— 投降(仅 70 分坐庄、选主阶段;合法性由服务端裁定)——
|
||||
touxiang: function () {
|
||||
this._send('touxiang', {});
|
||||
},
|
||||
|
||||
//—— 选主(flower: 1方块 2梅花 3红心 4黑桃)——
|
||||
xuanzhu: function (flower) {
|
||||
if (typeof flower !== 'number' || flower < 1 || flower > 4) {
|
||||
throw new Error('[EQW_Rpc] 非法花色: ' + flower + '(须为 1–4)');
|
||||
}
|
||||
this._send('xuanzhu', { flower: flower });
|
||||
},
|
||||
|
||||
//—— 埋牌(恒 8 张)——
|
||||
maipai: function (cards) {
|
||||
this._checkCards(cards, 8);
|
||||
this._send('maipai', { cards: cards });
|
||||
},
|
||||
|
||||
//—— 出牌 ——
|
||||
chupai: function (cards) {
|
||||
this._checkCards(cards);
|
||||
this._send('chupai', { cards: cards });
|
||||
},
|
||||
|
||||
//—— 明牌(查看他家未出主牌)——
|
||||
mingpai: function () {
|
||||
this._send('mingpai', {});
|
||||
},
|
||||
|
||||
//—— 出牌提示(tip: 1踩 2没分 3有分)——
|
||||
tishi: function (tip) {
|
||||
if (tip !== 1 && tip !== 2 && tip !== 3) {
|
||||
throw new Error('[EQW_Rpc] 非法提示类型: ' + tip + '(须为 1踩 2没分 3有分)');
|
||||
}
|
||||
this._send('tishi', { tip: tip });
|
||||
},
|
||||
|
||||
//—— 准备 ——
|
||||
zhunbei: function () {
|
||||
this._send('zhunbei', {});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
// 语义化发包:route 正确、只带意图、参数形状校验
|
||||
const { load, throws } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/01_SubGame/codes/state/GameState.js');
|
||||
|
||||
// 桩掉 RpcHelper,捕获发出的包
|
||||
const sent = [];
|
||||
global.RpcHelper = {
|
||||
sendRpc: function (app, route, rpc, data) { sent.push({ app, route, rpc, data }); }
|
||||
};
|
||||
|
||||
load('client/js/01_SubGame/codes/net/Rpc.js');
|
||||
|
||||
EQW_GameState.room.mySeat = 2;
|
||||
|
||||
// ---- route 必须是 erqiwang,不是 room ----
|
||||
sent.length = 0;
|
||||
EQW_Rpc.jiaofen(60);
|
||||
t.eq('发了一个包', sent.length, 1);
|
||||
t.eq('app', sent[0].app, 'youle');
|
||||
t.eq('route 是 erqiwang', sent[0].route, 'erqiwang');
|
||||
t.eq('rpc', sent[0].rpc, 'jiaofen');
|
||||
t.eq('业务字段', sent[0].data, { seat: 2, call: 60 });
|
||||
|
||||
// ---- seat 自动从 GameState 带上 ----
|
||||
EQW_GameState.room.mySeat = 0;
|
||||
sent.length = 0;
|
||||
EQW_Rpc.zhunbei();
|
||||
t.eq('seat 自动带上', sent[0].data.seat, 0);
|
||||
EQW_GameState.room.mySeat = 2;
|
||||
|
||||
// ---- 各方法的字段集合 ----
|
||||
const call = (fn) => { sent.length = 0; fn(); return sent[0]; };
|
||||
|
||||
t.eq('不叫', call(() => EQW_Rpc.jiaofen(0)).data, { seat: 2, call: 0 });
|
||||
t.eq('投降', call(() => EQW_Rpc.touxiang()).data, { seat: 2 });
|
||||
t.eq('选主', call(() => EQW_Rpc.xuanzhu(3)).data, { seat: 2, flower: 3 });
|
||||
t.eq('埋牌', call(() => EQW_Rpc.maipai([1,2,3,4,5,6,7,8])).data, { seat: 2, cards: [1,2,3,4,5,6,7,8] });
|
||||
t.eq('出牌', call(() => EQW_Rpc.chupai([10,11])).data, { seat: 2, cards: [10,11] });
|
||||
t.eq('明牌', call(() => EQW_Rpc.mingpai()).data, { seat: 2 });
|
||||
t.eq('提示', call(() => EQW_Rpc.tishi(1)).data, { seat: 2, tip: 1 });
|
||||
t.eq('准备', call(() => EQW_Rpc.zhunbei()).data, { seat: 2 });
|
||||
|
||||
// ---- 【核心】只带意图:所有发包的字段集合不含任何结论字段 ----
|
||||
const FORBIDDEN = ['score', 'isWin', 'phase', 'nextSeat', 'nextseat', 'handCards',
|
||||
'grade', 'multiple', 'result', 'banker', 'curmultiple', 'upgrade'];
|
||||
sent.length = 0;
|
||||
EQW_Rpc.jiaofen(60); EQW_Rpc.touxiang(); EQW_Rpc.xuanzhu(1);
|
||||
EQW_Rpc.maipai([1,2,3,4,5,6,7,8]); EQW_Rpc.chupai([9]);
|
||||
EQW_Rpc.mingpai(); EQW_Rpc.tishi(2); EQW_Rpc.zhunbei();
|
||||
|
||||
const leaked = [];
|
||||
sent.forEach(p => Object.keys(p.data).forEach(k => {
|
||||
if (FORBIDDEN.indexOf(k) >= 0) { leaked.push(p.rpc + '.' + k); }
|
||||
}));
|
||||
t.eq('发包不含任何结论字段', leaked, []);
|
||||
|
||||
// ---- 参数形状校验:非法即抛错,不发残缺包 ----
|
||||
sent.length = 0;
|
||||
t.eq('牌 id 非数组抛错', throws(() => EQW_Rpc.chupai('5')), true);
|
||||
t.eq('空数组抛错', throws(() => EQW_Rpc.chupai([])), true);
|
||||
t.eq('字符串牌 id 抛错', throws(() => EQW_Rpc.chupai(['5'])), true);
|
||||
t.eq('小数牌 id 抛错', throws(() => EQW_Rpc.chupai([1.5])), true);
|
||||
t.eq('越界牌 id 抛错', throws(() => EQW_Rpc.chupai([108])), true);
|
||||
t.eq('负数牌 id 抛错', throws(() => EQW_Rpc.chupai([-1])), true);
|
||||
t.eq('重复牌 id 抛错', throws(() => EQW_Rpc.chupai([5, 5])), true);
|
||||
t.eq('埋牌非 8 张抛错', throws(() => EQW_Rpc.maipai([1,2,3])), true);
|
||||
t.eq('叫分越界抛错', throws(() => EQW_Rpc.jiaofen(71)), true);
|
||||
t.eq('叫分非 5 的倍数抛错', throws(() => EQW_Rpc.jiaofen(7)), true);
|
||||
t.eq('花色越界抛错', throws(() => EQW_Rpc.xuanzhu(5)), true);
|
||||
t.eq('提示类型越界抛错', throws(() => EQW_Rpc.tishi(4)), true);
|
||||
t.eq('校验失败时一个包都没发', sent.length, 0);
|
||||
|
||||
// ---- 合法边界值不抛 ----
|
||||
t.eq('牌 id 0 合法', throws(() => EQW_Rpc.chupai([0])), false);
|
||||
t.eq('牌 id 107 合法', throws(() => EQW_Rpc.chupai([107])), false);
|
||||
t.eq('叫分 5 合法', throws(() => EQW_Rpc.jiaofen(5)), false);
|
||||
t.eq('叫分 70 合法', throws(() => EQW_Rpc.jiaofen(70)), false);
|
||||
|
||||
process.exit(t.done('rpc_send') ? 0 : 1);
|
||||
Reference in New Issue
Block a user