diff --git a/client/js/01_SubGame/codes/net/Rpc.js b/client/js/01_SubGame/codes/net/Rpc.js new file mode 100644 index 0000000..34094d2 --- /dev/null +++ b/client/js/01_SubGame/codes/net/Rpc.js @@ -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', {}); + } +}; diff --git a/client/tests/test_rpc_send.js b/client/tests/test_rpc_send.js new file mode 100644 index 0000000..50a7253 --- /dev/null +++ b/client/tests/test_rpc_send.js @@ -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);