18 KiB
03 · 数据收发与通信协议
本篇是日常手册:数据包长什么样、收包要做哪些固定步骤、三种发包方式怎么选、为什么"主动推送"是唯一可靠的下发通道,以及本项目最重要的一条——成败标志只认 data.success。
命名说明:本篇代码里的业务
rpc名(playCard等)与 handler/类/函数名(BroadcastManager、deepCopy等)均为示例、可自定;只有平台接缝上的名字(包四字段、平台 APIsendpack_toseat/sendpack_toother、成败字段data.success)是契约。详见 README「命名约定」。
1. 数据包结构
所有前后端数据包统一四字段:
{
app: "youle", // 应用名(游戏固定 "youle")
route: "<你的游戏>", // 路由名 = 服务端 cls_mod.new 第二参 routename(见 §1.1)
rpc: "playCard", // 方法名,决定调到 mod 的哪个函数 / 前端走哪个解析分支(见 02 §2.3)
data: { /* 业务数据 */ }
}
app/route/rpc三字段驱动三层路由(见 01)。data里必含平台参数:agentid、playerid、gameid、roomcode、seat等;数值参数收包时要parseInt。- 一包多信息:一个响应包应携带本次状态变更的完整信息(出了什么牌、轮到谁、各家剩余、倒计时、比分……),减少往返。
1.1 route / routename 从哪来、在哪定义、怎么匹配
route 决定包被投到哪个模块,是前后端必须对齐的字符串。它的权威定义与匹配链如下(均可在代码验证):
-
服务端:
routename的唯一定义点是mod.js里创建模块的cls_mod.new(模块名, 路由名, 所属应用)的第二个参数:// server/games2/<你的游戏>/mod.js var mod_<你的游戏> = global.mod_<你的游戏> || cls_mod.new("mod_<你的游戏>", "<你的游戏>", youle_app); // ▲ 第一参 modname ▲ 第二参 routename(就是 route 要用的值)cls_mod.new把第二参存进mod.routename,并把模块push进app.modlist(server/class/class.mod.js)。 -
它是你自定义的字符串:由你起名,只要在应用内唯一即可;与目录名、与模块名
modname(第一参,用于全局暴露global[modname]、app[modname])都无强制绑定——本项目三者恰好都叫jinxianmahjong只是约定(见 02 §2.1、01 §5)。 -
平台按它匹配模块:收包时
cls_app.ReceivePack用pack.route == modlist[i].routename找到模块,再DoPack进第三层按rpc调方法(server/class/class.app.js,见 01 §4)。 -
前端发包的
route必须与它逐字一致:前端把该值固化为常量(本项目codes/game/network/RpcSender.js里var ROUTE_NAME = 'jinxianmahjong',经Utl.sendData(app, route, rpc, data)发出)。两端字符串不一致 → 平台匹配不到模块,包被静默丢弃(前端也收不到任何响应)。 -
与平台房间模块区分:平台自带的房间模块
routename是"room",处理创建/加入/开战(createRoom、self_join_room、self_makewar等);你的子游戏自定义 RPC(playCard等)走你自己的routename。两者不要混用:平台流程发route:"room",玩法操作发route:"<你的游戏>"。
一句话:
routename在服务端cls_mod.new第二参定义(自定、应用内唯一),前端发包route必须与它逐字相同,平台据此把包投到你的模块;改名要两端同步改。
2. 收包处理的固定步骤(在 handler 里)
mod 上的 RPC 方法只是薄入口(return XxxHandler.handleXxx(pack),见 02 §2.3);下面这套"安检流程"发生在它委托到的 handler 内部,不可省略、不可简化:
XxxHandler.handlePlayCard = function(pack) { // 由 mod_<游戏>.playCard 委托进来
try {
// 1) 提取并校验参数:列出必填字段 + 数值型字段的类型要求
// (本项目用统一工具 ValidationHelper.extractAndValidateParams 做这件事)
// 等价于:agentid/playerid/gameid/roomcode/seat 必填,playerid/roomcode/seat 需为数值
var params = extractAndValidateParams(
pack,
['agentid', 'playerid', 'gameid', 'roomcode', 'seat', 'cardUniqueId'],
{ playerid: 'number', roomcode: 'number', seat: 'number' });
if (!params.success) return { success: false, error: params.error };
var p = params.params;
// 2) 校验玩家与房间(必做)——失败直接 return
// ⚠️ conmode / fromid 取自 pack 顶层(不是 pack.data),是发包定向要用的连接信息
var o_room = mod_<游戏>.import.check_player(
p.agentid, p.gameid, p.roomcode, p.seat, p.playerid, pack.conmode, pack.fromid);
if (!o_room) return { success: false, error: '玩家验证失败' };
// 3) 取桌对象与对局状态
var o_desk = o_room.o_desk;
if (!o_desk) return { success: false, error: '游戏桌不存在' };
// 4) 调试记录(若框架提供)——便于复盘
if (o_desk.debug && o_desk.debug.save_receivepack) {
o_desk.debug.save_receivepack(pack, p.seat, p.playerid);
}
// 5) 业务校验 + 执行业务:委托权威模块(handler 不内联规则,见 04)
// 如:OperationExecutor.executePlayCard(o_room, {...})
// 6) 构建响应 + 【主动推送】:ResponseBuilder 组包 → BroadcastManager 逐座位 sendpack_toseat
// 推送 data 自带 success(见 §5);return 不是下发通道(见 §4)
} catch (e) { /* 记录日志;必要时给该座推送失败包 */ }
};
- 参数提取要校验类型:数值字段(
playerid/roomcode/seat等)必须转成数值再用;缺字段/类型不符即判失败。用统一工具集中做(本项目ValidationHelper.extractAndValidateParams)比每处手写parseInt更不易漏。 check_player是强制安检:校验座位、连接、身份,返回o_room或null;null一律return。它的第 6/7 个参数pack.conmode、pack.fromid来自包顶层(框架收包时注入),是后续定向发包的连接凭据。- 校验失败不回作弊提示:对客户端不下发"你作弊了"之类反馈,直接结束(如需可只给本座推送一个通用失败包),避免给作弊者信息。
return值仅服务端内部用:例中的return {success:false,...}不会下发前端(见 §4);要让前端看到的结果一律走第 6 步主动推送。
3. 三种发包方式
| 方式 | 接口 | 用于 |
|---|---|---|
| 点对点 | o_room.method.sendpack_toseat(msg, seat) |
只发给某个座位(个人状态、定向数据) |
| 广播其他人 | o_room.method.sendpack_toother(msg, seat) |
发给除 seat 外所有人(seat=-1 即全发) |
| 差异化广播 | 逐座位组 msg 后各自 sendpack_toseat |
每个玩家看到的内容不同(如手牌只发本人) |
平台发包内部会从 seatlist[seat] 上取 conmode/fromid 填入包,再交给底层 SendPack 按 TCP/HTTP 下发——这两个连接字段不需要你手工设置,只要座位上有在线玩家即可。
差异化广播:棋牌最常用
牌类游戏里"同一动作、各家可见不同",所以广播时为每个座位深拷贝一份基础数据再定制:
for (var seat = 0; seat < o_room.seatlist.length; seat++) {
if (!o_room.seatlist[seat]) continue; // 空座跳过
var msg = {
app: "youle", route: "<游戏>", rpc: "playCard",
data: deepCopy(baseData) // 公共信息
};
if (seat === actionSeat) {
msg.data.handCards = hands[seat]; // 仅本人可见手牌
} else {
msg.data.handCards = []; // 他人看不到
}
o_room.method.sendpack_toseat(msg, seat);
}
敏感信息(手牌、暗牌、未公开的判定)只发给该看到的人,这是服务器权威的一部分。
4. 主动推送是唯一可靠的下发通道
前端收包的唯一通道是服务端主动推送(sendpack_toseat / toother → 底层 SendPack)。
而 RPC 方法的 return 值不可靠下发前端:框架虽会对 DoPack 的返回值做一次回发,但它只回当前请求连接、不按座位定向,浏览器/友乐链路下不能当作状态推送通道(机制见 01 §4)。
推论:凡需要让前端看到的结果(包括操作的成功/失败),都必须放进主动推送的 data 里。
5. 成败标志协议(本项目最重要的一条)
这条覆盖并修正了早期文档里"用 HTTP 风格
status码判成败"的写法。以本协议为准。
规则
- 成败唯一权威字段是
data.success(boolean)。前端一律if (!data.success)判断操作成败。 - 禁止用
data.status(如status === 200)或data.code判成败。status/code只能作展示/日志用的细分信息,不参与成败裁定。 - 主动推送必须自带
success:因为return不下发前端,凡有成败语义的推送包,其data必须显式带success: true/false,不能只放status。 - 禁止双轨/兼容兜底:前端不得写
status !== 200 && !success之类的status兜底;新增/改动的推送一律补齐success,前端一律只认success。
正反例
// ❌ 错误:推送只带 status,前端读 data.success 恒 undefined → 误判失败
o_room.method.sendpack_toseat({
app:"youle", route:"<游戏>", rpc:"setHostingState",
data: { status: 200, hosting: true } // 少了 success
}, seat);
// ✅ 正确:成败语义放 success,status 仅作细分
o_room.method.sendpack_toseat({
app:"youle", route:"<游戏>", rpc:"setHostingState",
data: { success: true, status: 200, hosting: true }
}, seat);
// 前端:只认 success
if (!data.success) { /* 失败处理 */ return; }
// data.status / data.code 仅用于展示或日志细分
典型事故:托管状态推送
{ status: 200, ... }不带success,前端读data.success恒为undefined,于是"已进入托管却报失败"。根因就是违反了本协议第 3 条。
6. 端到端收发全链路(前后端对照)
本节把一个包从前端出发、到服务端处理、再推回前端的完整链路摊开,标出每一步在谁的哪个文件/函数,供前端与服务端开发共同对照。下面的锚点里,平台/契约名(包字段、packet_face.ReceivePack、Utl.sendData、Game_Modify.*、sendpack_toseat、data.success)是真实且固定的;子游戏侧命名(RpcSender、SubGameHooks、收包分发器、各 handler)是本项目示例、可自定(见 README「命名约定」)。
6.1 方向 A:玩家主动操作(请求 → 处理 → 推送)
前端 client 服务端 server
──────────────── ────────────────
业务/控制器
└ 发包封装 / RpcHelper(自动补 agentid/playerid/roomcode/seat…)
└ Utl.sendData(app, route, rpc, data) ──WS/HTTP──▶ packet_face.ReceivePack (按 app 找应用)
(平台 08_Utl_Output.js) └ cls_app.ReceivePack (按 route 找模块,01 §4)
└ cls_mod.DoPack (按 rpc 找方法)
└ mod[rpc](pack) RPC 薄入口(02 §2.3)
└ XxxHandler.handleXxx(pack)
check_player → 取 o_desk → 校验
→ 委托权威业务模块(04)
→ o_room.method.sendpack_toseat(msg, seat)
(填 conmode/fromid)
Game_Modify._ReceiveData(_msg) ◀──WS/HTTP── → app.SendPack → packet_face.SendPack
(平台 02_SubGame_Input.js,受限文件) (只定向该座位的那条连接,01 §4)
└ 转交子游戏收包分发器(SubGameHooks._ReceiveData)
└ 按 _msg.rpc 查路由表 → 对应 rpc 处理器
└ if(!data.success) 失败处理;否则改数据模型 → 表现(04 篇)
- 前端出口只有一个:所有请求经发包封装/
RpcHelper最终落到平台Utl.sendData(app, route, rpc, data),业务不手拼包(前端 04 §1)。 - 服务端入口只有一个:
packet_face.ReceivePack,随后三层路由app→route→rpc精确投递(01 §4)。 - 服务端出口只有一个:
o_room.method.sendpack_toseat/sendpack_toother——它从seatlist[seat]取conmode/fromid填入包,交app.SendPack定向下发(class.room.js)。return不算下发(§4)。 - 前端收口只有一个:平台
09_Net.js收到主动推送后调Game_Modify._ReceiveData(_msg)(受限文件只转交,不写业务),再由子游戏收包分发器按_msg.rpc分到对应处理器(前端 04 §2)。
6.2 方向 B:服务端主动推送(无前端请求)
超时、AI 托管、他人操作波及本座、每小局/大局结算等,都是服务端主动发起、前端没有对应请求的推送。它复用方向 A 的后半段——同样 sendpack_toseat/toother → 前端 _ReceiveData → 按 rpc 分发:
服务端某处业务(定时器/AI决策/结算)
→ o_room.method.sendpack_toseat(msg, seat) (与真人操作完全相同的出口与包结构)
→ 前端 _ReceiveData → 收包分发器按 rpc → 对应处理器
因此前端无法也无需区分一个推送是"我请求的响应"还是"服务端主动发的"——两者走同一条收口、同一张分发表。服务端替玩家操作必须保持这种一致(§7、04 §7)。
6.3 谁在哪:收 / 发 / 路由一览
| 环节 | 前端(client) | 服务端(server) |
|---|---|---|
| 发包出口 | 发包封装/RpcHelper → Utl.sendData(app,route,rpc,data)(平台 08_Utl_Output.js) |
o_room.method.sendpack_toseat / sendpack_toother → app.SendPack |
| 传输 | WebSocket/HTTP(平台 09_Net.js / 00_minhttp.js) |
平台 packet.js(SendPack_Tcp / SendPack_Http) |
| 收包入口 | 平台 09_Net.js → Game_Modify._ReceiveData(_msg)(受限文件转交) |
packet_face.ReceivePack(packet.js) |
| 路由依据 | 子游戏收包分发器按 _msg.rpc → 处理器 |
三层 app→route→rpc(01 §4),rpc 直取同名 mod 方法 |
| 处理 | 对应 rpc 处理器,先判 data.success |
RPC handler:check_player → 取 o_desk → 校验 → 委托权威业务 |
| 成败标志 | 只认推送 data.success(§5) |
推送 data 必自带 success(§5) |
| 开局 | Game_Modify.StartWar(_msg)(取本座数据,02 篇/前端 04 §3) |
export.makewar 返回包(sendtype:1 + seatlist[] 差异化) |
| 重连/中途加入 | Game_Modify.Reconnect(_deskinfo)(据快照重画) |
export.get_deskinfo 返回该座完整快照 |
6.4 对接接缝:改一端必核对另一端
rpc 是前后端的共同契约字符串:服务端推送用哪个 rpc,前端就必须在收包分发表里注册同名处理器;反之前端请求的 rpc,服务端 mod 上必须有同名方法。因此:
| 场景 | 服务端产出 | 前端接收 |
|---|---|---|
| 开战 | export.makewar 的返回包 |
Game_Modify.StartWar(_msg) → 开局处理 |
| 重连/中途加入 | export.get_deskinfo 的返回 |
Game_Modify.Reconnect(_deskinfo) → 重画 |
| 对局/推送 | RPC handler 或主动推送(按 rpc) |
收包分发表里同名 rpc 的处理器 |
改服务端下发结构 = 同步核对前端对应 rpc 的解析;新增一种推送 = 服务端选定 rpc + 前端在分发表加同名处理器。任一端单方面改,另一端必按旧结构解析出错。前端侧的收发细节以 client/docs/development-guide/04-网络对接与启动编排 为权威。
7. 服务端代替玩家操作时的透明性
服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),必须复用真人操作的同一套数据包与广播链路:产生的包结构、rpc、data 与真人操作完全一致,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 04 的对应红线。)
8. 小结
- 包结构恒为
{app, route, rpc, data};收包先check_player,失败静默return。 - 三种发包方式按"谁该看到什么"选;差异化广播逐座位定制,敏感信息只发本人。
- 主动推送是唯一可靠下发通道,
return不算。 - 成败只认
data.success,推送必自带success,禁止status/code判成败与兼容兜底。 - 端到端一条链(§6):前端
Utl.sendData→ 服务端packet_face.ReceivePack→三层路由→handler→sendpack_toseat→ 前端Game_Modify._ReceiveData→按rpc分发;rpc是前后端共同契约,改一端必核对另一端。 - 改下发结构必同步核对前端
StartWar/Reconnect/对应rpc解析。
下一篇 04-开发规范与红线 汇总所有必须遵守的工程纪律。