Files
erqiwang_youle/docs/server/development-guide/03-数据收发与通信协议.md
T
2026-08-11 22:17:54 +08:00

256 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 03 · 数据收发与通信协议
本篇是**日常手册**:数据包长什么样、收包要做哪些固定步骤、三种发包方式怎么选、为什么"主动推送"是唯一可靠的下发通道,以及本项目最重要的一条——**成败标志只认 `data.success`**。
> **命名说明**:本篇代码里的业务 `rpc` 名(`playCard` 等)与 handler/类/函数名(`BroadcastManager`、`deepCopy` 等)均为**示例、可自定**;只有平台接缝上的名字(包四字段、平台 API `sendpack_toseat`/`sendpack_toother`、成败字段 `data.success`)是契约。详见 [README「命名约定」](./README.md)。
---
## 1. 数据包结构
所有前后端数据包统一四字段:
```js
{
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.2。)
### 1.1 `route` / `routename` 从哪来、在哪定义、怎么匹配
`route` 决定包被投到**哪个模块**,是前后端必须对齐的字符串。它的权威定义与匹配链如下(均可在代码验证):
- **服务端:`routename` 的唯一定义点**是 `mod.js` 里 `cls_mod.new(模块名, 路由名, 所属应用)` 的**第二个参数**:
```js
// server/games2/<你的游戏>/mod.js —— 第二参 "<你的游戏>" 即 routename(route 要用的值)
var mod_<你的游戏> = global.mod_<你的游戏>
|| cls_mod.new("mod_<你的游戏>", "<你的游戏>", youle_app);
```
`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` 必须与它逐字相同,平台据此把包投到你的模块**;改名要两端同步改。
### 1.2 发包必须自带前端界面所需的全部核心数据
**前端以服务端数据为权威——只做界面展示与交互、不做权威计算**(见 [前端 05 开发规范与红线](../../client/development-guide/05-开发规范与红线.md))。推论落到服务端:**每个下发包都必须携带前端渲染/刷新该界面所需的全部核心数据**,让前端「据包写入数据 → 直接刷新出界面」,而**不需要前端自行推算、补全或兜底**。
- **界面要用的字段都要发全**:凡界面要显示、或前端 set/refresh 要用到的核心字段(出了什么牌、轮到谁、各家剩余张数、手牌/副露、分数/比分、倒计时锚点、庄家/座位、各类状态标志……)都要放进 `data`,不能让前端"猜"或本地推算权威结果。
- **漏发是服务端的缺陷,不许前端补**:前端遵循数据权威原则——权威字段缺失应显式报错/留空、不用 `|| 0`/`|| []` 兜造(见 client 05)。所以服务端漏发 = 前端界面缺数据,**修在服务端发包处,不在前端补洞**。
- **落实「一包多信息」(见 §1)**:本包要让前端**仅凭本包 + 已有本地数据**把相关界面刷对,别把一个界面状态拆成几个包拼(中途丢一个就停在自相矛盾的中间态)。
- **差异化但要发全**:每个座位只发它**该看到**的核心数据(手牌只发本人,见 §3),但"该看到的"必须发全、发准。
- **重连/中途加入发完整快照**:`get_deskinfo` 必须给出该座恢复整个界面所需的**全量**核心数据,让前端 `Reconnect` 据快照一次重画(见 §6.4、02)。
> 一句话:**服务端权威不仅是"服务端算",也是"服务端把界面要用的核心数据发全"——前端只据包渲染,不推算、不兜底。**
---
## 2. 收包处理的固定步骤(在 handler 里)
`mod` 上的 RPC 方法只是**薄入口**(`return XxxHandler.handleXxx(pack)`,见 02 §2.3);下面这套"安检流程"发生在**它委托到的 handler 内部**,**不可省略、不可简化**:
```js
XxxHandler.handlePlayCard = function(pack) { // 由 mod_<游戏>.playCard 委托进来
try {
// 1) 提取并校验参数:必填字段 + 数值型字段类型(本项目用 ValidationHelper.extractAndValidateParams)
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) 调试记录(若框架提供 o_desk.debug.save_receivepack)——便于复盘
// 5) 业务校验 + 执行业务:委托权威模块(handler 不内联规则,见 04)
// 6) 构建响应 + 【主动推送】:组包 → 逐座位 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 下发——**这两个连接字段不需要你手工设置**,只要座位上有在线玩家即可。
### 差异化广播:棋牌最常用
牌类游戏里"同一动作、各家可见不同",所以广播时为每个座位**深拷贝一份基础数据再定制**:
```js
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) // 公共信息
};
// 敏感信息只发本人:本人给真实手牌,他人给 []
msg.data.handCards = (seat === actionSeat) ? hands[seat] : [];
o_room.method.sendpack_toseat(msg, seat);
}
```
> 敏感信息(手牌、暗牌、未公开的判定)**只发给该看到的人**,这是服务器权威的一部分。
---
## 4. 主动推送是唯一可靠的下发通道
**前端收包的唯一通道是服务端主动推送**(`sendpack_toseat / toother` → 底层 `SendPack`)。
而 RPC 方法的 **`return` 值不可靠下发前端**:框架虽会对 `DoPack` 的返回值做一次回发,但它只回当前请求连接、不按座位定向,浏览器/友乐链路下不能当作状态推送通道(机制见 01 §4)。
**推论**:凡需要让前端看到的结果(包括操作的成功/失败),都必须放进**主动推送的 `data`** 里。
---
## 5. 成败标志协议(本项目最重要的一条)
> 这条覆盖并修正了早期文档里"用 HTTP 风格 `status` 码判成败"的写法。**以本协议为准。**
### 规则
1. **成败唯一权威字段是 `data.success`(boolean)**。前端一律 `if (!data.success)` 判断操作成败。
2. **禁止用 `data.status`(如 `status === 200`)或 `data.code` 判成败**。`status`/`code` 只能作展示/日志用的细分信息,不参与成败裁定。
3. **主动推送必须自带 `success`**:因为 `return` 不下发前端,凡有成败语义的**推送包**,其 `data` 必须显式带 `success: true/false`,不能只放 `status`。
4. **禁止双轨/兼容兜底**:前端不得写 `status !== 200 && !success` 之类的 `status` 兜底;新增/改动的推送一律补齐 `success`,前端一律只认 `success`。
### 正反例
```js
// ❌ 推送只带 status、少了 success → 前端读 data.success 恒 undefined,误判失败
data: { status: 200, hosting: true }
// ✅ 成败语义放 success,status 仅作细分
data: { success: true, status: 200, hosting: true }
// 前端:只认 success,status/code 仅用于展示或日志
if (!data.success) { /* 失败处理 */ return; }
```
> 典型事故:托管状态推送 `{ status: 200, ... }` 不带 `success`,前端读 `data.success` 恒为 `undefined`,于是"已进入托管却报失败"。根因就是违反了本协议第 3 条。
---
## 6. 端到端收发全链路(前后端对照)
本节把**一个包从前端出发、到服务端处理、再推回前端**的完整链路摊开,标出**每一步在谁的哪个文件/函数**,供前端与服务端开发共同对照。下面的锚点里,**平台/契约名**(包字段、`packet_face.ReceivePack`、`Utl.sendData`、`Game_Modify.*`、`sendpack_toseat`、`data.success`)是真实且固定的;**子游戏侧命名**(`RpcSender`、`SubGameHooks`、收包分发器、各 handler)是本项目示例、可自定(见 [README「命名约定」](./README.md))。
### 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` + 前端在分发表加同名处理器**。任一端单方面改,另一端必按旧结构解析出错。前端侧的收发细节以 [前端 04 网络对接与启动编排](../../client/development-guide/04-网络对接与启动编排.md) 为权威。
---
## 7. 服务端代替玩家操作时的透明性
服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),**必须复用真人操作的同一套数据包与广播链路**:产生的包结构、`rpc`、`data` 与真人操作**完全一致**,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 [04 §7 自动操作复用真人链路](./04-开发规范与红线.md#7-服务端自动操作复用真人链路) 与 [§8 操作请求合法性验证](./04-开发规范与红线.md#8-操作请求合法性验证不可信客户端)。)
---
## 8. 小结
- 包结构恒为 `{app, route, rpc, data}`;收包先 `check_player`,失败静默 `return`。
- **下发包必须发全前端界面所需的核心数据**(§1.2):前端以服务端为权威、不自算,漏发修在服务端发包处、前端不补洞。
- 三种发包方式按"谁该看到什么"选;差异化广播逐座位定制,敏感信息只发本人。
- **主动推送是唯一可靠下发通道**,`return` 不算。
- **成败只认 `data.success`**,推送必自带 `success`,禁止 `status`/`code` 判成败与兼容兜底。
- **端到端一条链**(§6):前端 `Utl.sendData` → 服务端 `packet_face.ReceivePack`→三层路由→handler→`sendpack_toseat` → 前端 `Game_Modify._ReceiveData`→按 `rpc` 分发;`rpc` 是前后端共同契约,改一端必核对另一端。
- 改下发结构必同步核对前端 `StartWar`/`Reconnect`/对应 `rpc` 解析。
下一篇 [04-开发规范与红线](./04-开发规范与红线.md) 汇总所有必须遵守的工程纪律。