Files
youle_framework/docs/server/development-guide/03-数据收发与通信协议.md
T
joywayerandClaude Opus 4.8 f2f618b965 降耗②·铺开:按试点尺度精简其余 12 篇编号正文
沿用 client 02 的保守尺度(规范条款/表格/关键代码/标题/链接全保留,
只压缩冗长代码示例、重复解说、演进历史、✅/❌ 成对代码块)逐篇精简
client 01/03/04/05/06、server 01/02/03/04、engineering 01/02/03。

- 12 篇合计 78,171 → 74,726 字符(省 3,445,~4.4%);红线密集篇(client
  05、server 04)极保守、几乎不动,符合"不丢规范优先于省字数"。
- 已核验:51 个跨文档链接目标全部存在、3 个锚点全部命中真实标题;
  server 03 §6、server 04 §8/§10/§11 等被引用小节标题逐字未改;README 未动。
- 每篇均随附规范保留清单逐条自查(并行子代理完成、逐份复核)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 08:52:16 +08:00

19 KiB
Raw Blame History

03 · 数据收发与通信协议

本篇是日常手册:数据包长什么样、收包要做哪些固定步骤、三种发包方式怎么选、为什么"主动推送"是唯一可靠的下发通道,以及本项目最重要的一条——成败标志只认 data.success。

命名说明:本篇代码里的业务 rpc 名(playCard 等)与 handler/类/函数名(BroadcastManager、deepCopy 等)均为示例、可自定;只有平台接缝上的名字(包四字段、平台 API sendpack_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.2。)

1.1 route / routename 从哪来、在哪定义、怎么匹配

route 决定包被投到哪个模块,是前后端必须对齐的字符串。它的权威定义与匹配链如下(均可在代码验证):

  • 服务端:routename 的唯一定义点是 mod.js 里 cls_mod.new(模块名, 路由名, 所属应用) 的第二个参数:

    // 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 开发规范与红线)。推论落到服务端:每个下发包都必须携带前端渲染/刷新该界面所需的全部核心数据,让前端「据包写入数据 → 直接刷新出界面」,而不需要前端自行推算、补全或兜底。

  • 界面要用的字段都要发全:凡界面要显示、或前端 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 内部,不可省略、不可简化:

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 下发——这两个连接字段不需要你手工设置,只要座位上有在线玩家即可。

差异化广播:棋牌最常用

牌类游戏里"同一动作、各家可见不同",所以广播时为每个座位深拷贝一份基础数据再定制:

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。

正反例

// ❌ 推送只带 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「命名约定」)。

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 网络对接与启动编排 为权威。


7. 服务端代替玩家操作时的透明性

服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),必须复用真人操作的同一套数据包与广播链路:产生的包结构、rpc、data 与真人操作完全一致,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 04 的对应红线。)


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-开发规范与红线 汇总所有必须遵守的工程纪律。