新规hook

This commit is contained in:
2026-07-06 17:16:05 +08:00
parent 363f97cdbe
commit 047561675b
28 changed files with 510 additions and 1345 deletions
@@ -1,201 +0,0 @@
# 01 · 服务端环境与框架基础
本篇建立**全局认知**:子游戏运行在什么环境里、平台框架由哪些对象构成、一个数据包如何被路由到你的代码、一个房间从生到死经历哪些阶段。读懂这一篇,后面的接入与协议才有坐标。
> 举例以麻将类房卡游戏为主,但本篇所有机制对任意子游戏一致。
---
## 1. 运行环境:一套代码,两个运行时
子游戏代码**同时运行在两套环境**,写每一行都要同时考虑:
| 运行时 | 场景 | 模块加载方式 | 是否有 `require` |
|--------|------|--------------|------------------|
| **Node.js** | 本地、单元测试 | `require()` | 有 |
| **浏览器 / 友乐平台** | 线上部署 | 由 `mod.js` 用 `min_loadJsFile` 统一加载为**全局对象** | **没有** |
由此引出两条硬约束(详见 04):
1. **严格 ES5**:用 `var`/`function`,禁止 `let`/`const`/箭头函数/模板串/`class`/`Promise` 等;线上浏览器/友乐运行时不保证 ES6+。
2. **`require` 只能在文件开头的守卫块内**:
```js
// ✅ 正确:集中在顶部守卫块;运行时按全局名引用
if (typeof require !== 'undefined') {
var GameStateManager = require('./dataStructures/GameStateManager.js');
}
// ... 之后直接用全局名 GameStateManager.xxx() ——浏览器由 mod.js 加载为同名全局
```
浏览器运行时没有 `require`,任何**无守卫的、函数体内的中途 `require`** 都会抛 `ReferenceError: require is not defined`,导致功能崩溃。跨模块统一**按全局名引用**:Node 由守卫块 `var X = require(...)` 得到,浏览器由 `mod.js` 加载的同名全局解析,二者靠共享全局作用域下的同名 `var` 统一。
### 前后端是物理分离的两端
- 客户端跑在用户浏览器,服务端跑在 Node。两端**不是函数调用,而是 WebSocket/HTTP 收发 JSON 包**,全程异步。
- 前端代码不走 npm 构建,**前后端共享代码靠文件复制同步**(见 04 的 shared 同步流程)。
- **服务器权威**:凡房卡、胜负、积分、房间状态等关键数据/操作,一律以服务器为准,前端只负责显示,不能"前端说什么就是什么"。
---
## 2. 平台代码 vs 子游戏代码
```
server/ ← 平台框架(除 games2/<你的游戏>/ 外,全部禁改)
├── packet.js 总收发包入口、应用列表、最终 SendPack
├── applist.js 加载各应用(server / youle / update)
├── class/ 框架基础类
│ ├── class.app.js 应用基类 cls_app(按 route 路由到模块)
│ ├── class.mod.js 模块基类 cls_mod(按 rpc 路由到方法)
│ └── class.desk.js / ... 桌、牌等基础类
├── youle/ 友乐应用(appname = "youle")
│ ├── app.js 创建 youle_app
│ └── server_room/ 房间模块 youle_room(routename = "room")
│ ├── class.room.js 房间对象 o_room(含 seatlist、发包方法)
│ ├── class.player.js 座位/玩家对象(含 conmode/fromid)
│ ├── class.export.js 框架对外服务:check_player / deduct_roomcard / save_grade ...
│ └── class.import.js 框架回调子游戏:makewar_deskwar / get_disbandRoom / player_* ...
└── games2/
└── <你的游戏>/ ← 子游戏(唯一可编辑目录)
├── mod.js 模块入口:创建模块、加载文件、定义 RPC 方法
├── export.js 子游戏对平台暴露的 8 个必需接口
├── import.js 子游戏对平台服务的 4 个封装接口
└── ... 你的玩法业务代码
```
**一句话**:平台提供"网络 + 房间 + 玩家 + 房卡 + 战绩"的地基,子游戏只往 `games2/<你的游戏>/` 里填"玩法"。两边的接缝就是 **export / import**(详见 02)。
---
## 3. 核心对象模型
理解五个对象的层级关系,就理解了框架的数据骨架:
```
app (youle_app) 一个应用,appname = "youle"
└── modlist[] 应用下挂的所有模块
├── youle_room 平台房间模块,routename = "room"
└── mod_<你的游戏> 你的子游戏模块,routename = "<你的游戏>"
o_room 房间对象(平台创建,一桌一个)
├── roomcode / roomtype / asetcount / battlestate ... 平台基础数据
├── seatlist[] 座位列表,长度=满桌人数
│ └── seatlist[seat] = o_player 座位上的玩家对象
│ ├── playerid / nickname / onstate
│ └── conmode / fromid 连接方式与连接ID(发包要用)
├── method.sendpack_toseat(msg, seat) 平台发包方法
├── method.sendpack_toother(msg, seat)
└── o_desk ★ 子游戏的牌桌对象(由你在 makewar 里创建并挂上)
├── o_room 反向引用回房间
└── data.* ★ 你的对局状态全挂这里(房间隔离的落点)
```
要点:
- **`o_room` 由平台创建并持有**;`seatlist[seat]` 是玩家对象,发包所需的 `conmode`/`fromid` 就在它上面。
- **`o_desk` 由子游戏创建**(在 `makewar` 中),并与 `o_room` 建立双向引用:`o_room.o_desk = desk; desk.o_room = o_room;`。
- **对局状态一律挂在 `o_room.o_desk.data.*`**。这是"房间隔离"的物理落点:每桌一份,互不串扰(见 04)。
> 举例:麻将把游戏状态放在 `o_room.o_desk.data.roomAdapter.gameState`,把操作队列放在 `o_room.o_desk.data.operationQueue`。换成斗地主、跑得快也是同一套落点,只是 `data.*` 下的字段不同。
---
## 4. 三层路由:一个包如何到达你的函数
平台把客户端发来的包,按 `app → route → rpc` 三级精确投递到子游戏的某个函数。
```
{ app: "youle", route: "<你的游戏>", rpc: "playCard", data: { ... } }
```
| 级 | 在哪 | 依据 | 动作 |
|----|------|------|------|
| 1 | `packet.js` `packet_face.ReceivePack` | `pack.app` | 在 `applist` 里找到 `appname == pack.app` 的应用,调 `app.ReceivePack(pack)` |
| 2 | `class/class.app.js` `cls_app.ReceivePack` | `pack.route` | 在 `app.modlist` 里找到 `routename == pack.route` 的模块,调 `mod.DoPack(pack)` |
| 3 | `class/class.mod.js` `cls_mod.DoPack` | `pack.rpc` | 若 `mod[pack.rpc]` 存在,调用 `mod[pack.rpc](pack)` |
证据:
- 第 1 级:`server/packet.js` `ReceivePack` 按 `pack.app == applist[i].appname` 分发。
- 第 2 级:`server/class/class.app.js` `ReceivePack` 按 `pack.route == modlist[i].routename` 调 `DoPack`。
- 第 3 级:`server/class/class.mod.js` `DoPack` 按 `_msg.rpc` 调 `_obj_mod[_msg.rpc](_msg)`。
**对开发者的含义**:你在 `mod.js` 里写下 `mod_<游戏>.playCard = function(pack){...}`,前端只要发 `{route:"<你的游戏>", rpc:"playCard", ...}`,框架就会自动调到它——**无需自己写路由分发**,更不要在一个总入口里用 `switch(action)` 做二次路由(一个操作对应一个 RPC 方法)。
### ⚠️ 关于 `DoPack` 的返回值(重要)
`cls_app.ReceivePack` 在拿到 `DoPack` 的返回值后,确实会执行一次 `app.SendPack(repack)`。但这个回发:
- 只回给**当前请求的那条连接**,且不经过"按座位定向"的处理;
- 在浏览器/友乐链路下**不是子游戏向前端推送状态的可靠通道**。
因此本项目的铁律是:**子游戏 RPC handler 一律通过 `o_room.method.sendpack_toseat / sendpack_toother` 主动推送**来下发结果,**不依赖 `return`**。这条直接推导出 03 的"成败标志必须放在主动推送的 `data.success` 里"。
---
## 5. 模块注册与全局暴露
子游戏模块用框架基类创建,并自动注册进应用:
```js
// cls_mod.new(模块名, 路由名, 所属应用)
var mod_<你的游戏> = mod_<你的游戏> || cls_mod.new("mod_<你的游戏>", "<你的游戏>", youle_app);
```
`cls_mod.new` 做了三件事:把模块 push 进 `app.modlist`、在 `app` 上挂 `app[模块名]=mod`、并通过 `OutputMod` 把模块**暴露为全局名**(`global[modname]`)。这套"双运行时全局暴露"是浏览器/友乐能按全局名找到模块的基础。
> 双运行时陷阱:凡"构造函数 + 单例实例"式模块,`module.exports` 之后必须**无条件**把全局名暴露出去(不要塞进 `else` 分支),否则线上浏览器拿不到该全局,报 `xxx is not a function`,而 Node 测试却测不出来。
`export.js` / `import.js` 在各自文件末尾把接口对象挂到 `mod.export` / `mod.import`,`mod.js` 只负责按顺序加载它们(见 02)。
---
## 6. 房间生命周期
一个房间从创建到回收,平台与子游戏分工如下。**理解每个阶段"谁调用谁",是接入的关键**:
```
创建房间
平台调 export.get_needroomcard / get_asetcount / get_needroomcard_joinroom
→ 决定房卡与总局数(此时还没有 o_desk,子游戏不需要知道谁坐在哪)
满桌/房主开战
平台调 export.makewar(o_room, o_game_config)
→ 子游戏创建 o_desk、初始化对局状态、返回开战数据包
→ 平台把开战包下发所有客户端(对应前端 StartWar)
→ o_room.battlestate = 1
对局进行中
客户端发 RPC → mod[rpc](pack) → 子游戏处理 → 主动推送结果
断线重连 / 中途加入
平台调 export.get_deskinfo(o_room, seat)
→ 子游戏返回该座位「当前完整快照」(对应前端 Reconnect)
每小局结算
第一小局结算时:子游戏调 import.deduct_roomcard(o_room) 扣房卡(仅此一次)
大局(整场)结束
子游戏算完最终战绩后调 import.save_grade(o_room, ...)
→ 平台保存战绩并「自动释放房间」,子游戏无需再回收房间本身
解散房间
平台调 export.get_disbandRoom(o_room) 取解散数据包下发
玩家中途进出
平台调 export.player_enter / player_leave 通知子游戏处理
```
**子游戏自己创建的东西,自己负责回收**:定时器、托管/决策状态、各类缓存等,必须能按房间定位,并在小局结束、解散、开新局时清理干净,禁止泄漏到下一局或别的房间(见 04 的房间隔离)。
---
## 7. 小结
- 一套代码两个运行时 → 强制 ES5 + 守卫块 require。
- 对象层级:`app → mod`、`o_room → seatlist[seat](o_player) / o_desk(.data.*)`。
- 三层路由:`app(按app) → route(按route) → rpc(按rpc)`,框架自动投递,你只写 `mod.rpc 方法`。
- `DoPack` 的 `return` 不是可靠下发通道 → **一律主动 `sendpack_toseat` 推送**。
- 房间生命周期由 export/import 接缝串起:`makewar` 开局、`get_deskinfo` 重连、`deduct_roomcard` 首局扣卡、`save_grade` 终局保存并自动回收。
下一篇 [02-子游戏接入与开发流程](./02-子游戏接入与开发流程.md) 讲:这三个文件具体怎么写、8+4 个接口各做什么、一次操作的完整数据流。
</content>
@@ -1,247 +0,0 @@
# 02 · 子游戏接入与开发流程
本篇讲**怎么动手**:一个子游戏由哪三个必需文件构成、`export.js` 的 8 个接口与 `import.js` 的 4 个接口各做什么、`makewar`/重连怎么写,以及一次玩家操作从收包到推送的完整数据流。
> 仍以麻将举例,但"三文件架构 + 8/4 接口 + 主动推送"对任意子游戏一致。
---
## 1. 文件结构
### 必需三件套(固定文件名)
```
server/games2/<你的游戏>/
├── mod.js 模块入口:创建模块、按序加载文件、定义 RPC 方法
├── export.js 对平台暴露的 8 个必需接口(+ 可选接口)
└── import.js 对平台服务的 4 个封装接口
```
### 业务分层(复杂游戏推荐)
按职责拆分,**一个文件一个明确职责**,被依赖者先加载(见 04 的"模块职责边界"):
```
├── game/ 对局编排:状态管理、控制器、对平台的适配器
├── rpc/ 收发包:各 RPC handler、广播、响应构建、序列化
├── rules/ 规则解析与规则引擎
├── shared/ 前后端共享代码(改这里,再用脚本同步到前端)
├── utils/ 日志、错误处理等工具
└── tests/ 单元/集成测试
```
> 举例(麻将):`game/RoomAdapter.js`(房间↔对局适配)、`game/GameController.js`(对局编排)、`rpc/BroadcastManager.js`(差异化广播)、`rpc/handlers/*`(各操作处理)。换个玩法,分层思路不变,文件内容不同。
---
## 2. `mod.js` —— 模块入口
`mod.js` 做三件事:**创建模块 → 按依赖顺序加载文件 → 定义 RPC 方法**。
### 2.1 创建模块
```js
// cls_mod.new(模块名, 路由名, 所属应用)
var mod_<你的游戏> = mod_<你的游戏> || cls_mod.new("mod_<你的游戏>", "<你的游戏>", youle_app);
```
路由名即前端包里的 `route`,要与目录/约定一致。
### 2.2 按依赖顺序加载文件
被依赖的先加载;`export.js`/`import.js` 通常在业务类之后加载(它们会用到业务模块):
```js
// 浏览器/友乐:min_loadJsFile 异步链式加载
min_loadJsFile("games2/<你的游戏>/常量与工具.js", function(){
min_loadJsFile("games2/<你的游戏>/数据结构.js", function(){
min_loadJsFile("games2/<你的游戏>/export.js", function(){
min_loadJsFile("games2/<你的游戏>/import.js", function(){
min_loadJsFile("games2/<你的游戏>/业务与rpc.js", function(){
console.log("模块 [" + mod_<你的游戏>.modname + "] 加载完成");
});});});});});
```
> 推荐分层加载顺序:**常量 → 工具 → 数据结构 → export/import → 收发包层 → 业务层 → 规则引擎**。Node 测试侧用 `require`(写在守卫块内),浏览器侧用 `min_loadJsFile`,两套并存但按全局名统一引用(见 01)。
### 2.3 定义 RPC 方法(一操作一函数,委托给 handler)
每个客户端操作对应一个 RPC 方法。**方法本身只做"接住请求并委托"**,真正逻辑放在 `rpc/handlers/*`,保持 `mod.js` 薄:
```js
mod_<你的游戏>.playCard = function(pack) {
return RpcHandler.handlePlayCard(pack); // 委托到收发包层
};
mod_<你的游戏>.declareHu = function(pack) {
return RpcHandler.handleDeclareHu(pack);
};
```
**新增一个前后端接口** = 在 `mod.js` 加一个 `mod_<游戏>.<rpc> = function(pack){...}` + 前端包里 `rpc: "<rpc>"`。**不要**在受限前端接口文件里新增接口(见 04),也**不要**用 `switch(action)` 二次路由。
---
## 3. `export.js` —— 平台调用子游戏的 8 个必需接口
平台在房间生命周期的各节点回调这 8 个接口。用工厂模式创建,文件末尾挂到 `mod.export`:
```js
var cls_<游戏>_export = cls_<游戏>_export || {
new: function() {
var exp = {};
exp.get_needroomcard = function(roomtype, o_game_config) { /* ... */ };
exp.get_asetcount = function(roomtype, o_game_config) { /* ... */ };
exp.get_needroomcard_joinroom = function(roomtype, o_game_config) { return 0; };
exp.makewar = function(o_room, o_game_config) { /* ... */ };
exp.get_deskinfo = function(o_room, seat) { /* ... */ };
exp.get_disbandRoom = function(o_room) { /* ... */ };
exp.player_enter = function(o_room, seat) { /* ... */ };
exp.player_leave = function(o_room, seat) { /* ... */ };
return exp;
}
};
mod_<你的游戏>.export = cls_<游戏>_export.new(); // 文件末尾自动挂载
```
| 接口 | 何时被调 | 返回 | 职责 |
|------|----------|------|------|
| `get_needroomcard` | 创建房间 | Number | 该房型创建需要的房卡数(由你解析 `roomtype`) |
| `get_asetcount` | 创建房间 | Number | 该房型总局数(小局数量) |
| `get_needroomcard_joinroom` | 他人加入 | Number | 加入需要的房卡数(多数玩法返回 0) |
| `makewar` | 开战 | Object | **创建 `o_desk`、初始化对局、返回开战数据包** |
| `get_deskinfo` | 重连/中途加入 | Object | **返回该座位当前完整快照** |
| `get_disbandRoom` | 解散达成 | Object | 返回解散数据包 |
| `player_enter` | 中途进入 | Object? | 处理新玩家加入 |
| `player_leave` | 中途退出 | Object? | 处理玩家离开(如清理其托管/占位) |
> `roomtype` 是一个由子游戏**自定义、自解析**的房型编码(数组或数字串),各位代表局数/人数/扣卡方式/玩法开关等。它的含义只有你的子游戏知道,平台不解释它。
### 3.1 `makewar` 是接入的核心
`makewar` 必须完成三件事,缺一不可:
```js
exp.makewar = function(o_room, o_game_config) {
// 1) 创建子游戏牌桌对象,并与房间建立【双向引用】
if (!o_room.o_desk) { o_room.o_desk = {}; }
if (!o_room.o_desk.data) { o_room.o_desk.data = {}; }
o_room.o_desk.o_room = o_room; // 反向引用
// 2) 创建对局状态,挂到 o_room.o_desk.data.*(房间隔离的落点)
var gameState = createGameState(o_room, o_game_config);
o_room.o_desk.data.gameState = gameState; // 此后所有业务都从这里读对局态
// 3) 返回开战数据包(通常按座位差异化下发)
return {
success: true,
sendtype: 1, // 差异化标记:平台逐座位下发
seatlist: [ { seat: 0, data: {/* 0号位能看到的 */} }, /* ... */ ]
};
};
```
要点:
- **此时不需要知道"谁"坐在每个位置**,只需知道有几个位置有人——身份由平台管理。
- **对局状态挂 `o_room.o_desk.data.*`**,不要放模块级单例/全局(见 04)。
- 开战包对应前端的 `Game_Modify.StartWar`:**改了 `makewar` 下发结构,必须同步改前端 StartWar 解析**(见 03)。
### 3.2 `get_deskinfo` 处理重连
重连/中途加入时,平台带着 `seat` 来要"当前快照"。你要把该座位此刻该看到的一切(手牌仅本人可见、弃牌区、轮到谁、可用操作、倒计时、比分等)从 `o_room.o_desk.data.*` 组装返回。它对应前端的 `Game_Modify.Reconnect`。
> 关键:重连快照应与正常对局推送**复用同一套状态组装逻辑**,避免两份并行实现随规则演化而分叉(数据权威原则,见 04)。
---
## 4. `import.js` —— 子游戏调用平台的 4 个接口
这 4 个接口是对平台 `youle_room.export` 的薄封装,**逻辑由平台实现,你只管在正确时机调用**:
```js
mod_<你的游戏>.import = (function() {
var imp = {};
imp.check_player = function(agentid, gameid, roomcode, seat, playerid, conmode, fromid) {
return mod_<你的游戏>.app.youle_room.export.check_player(
agentid, gameid, roomcode, seat, playerid, conmode, fromid);
};
imp.deduct_roomcard = function(o_room) {
return mod_<你的游戏>.app.youle_room.export.deduct_roomcard(o_room);
};
imp.save_grade = function(o_room, o_gameinfo1, o_gameinfo2, freeroomflag) {
return mod_<你的游戏>.app.youle_room.export.save_grade(
o_room, o_gameinfo1, o_gameinfo2, freeroomflag);
};
imp.finish_gametask = function(agentid, o_player, taskid, finishamount) {
return mod_<你的游戏>.app.youle_room.export.finish_gametask(
agentid, o_player, taskid, finishamount);
};
return imp;
})();
```
| 接口 | 调用时机(关键) | 作用 |
|------|------------------|------|
| `check_player` | **每个 RPC handler 开头** | 校验玩家位置/连接,返回 `o_room` 或 `null` |
| `deduct_roomcard` | **第一小局结算时(仅一次)** | 扣房卡。注意**不是开战时扣** |
| `save_grade` | **大局完全结束、战绩算好后** | 保存战绩;调用后**平台自动释放房间** |
| `finish_gametask` | 完成游戏内任务时(可选) | 上报任务进度 |
> 时机错误是高频 bug:`deduct_roomcard` 放到开战时会导致重复/错误扣卡;`save_grade` 在结算数据没算完就调用会保存到残缺战绩。
---
## 5. 一次玩家操作的完整数据流
把 01 的路由和本篇的接口串起来,一次"出牌"从收包到下发如下(其它操作同构):
```
① 客户端发包 { app:"youle", route:"<游戏>", rpc:"playCard", data:{ roomcode, seat, ... } }
│ 三层路由(见 01)
▼
② mod.<游戏>.playCard(pack) → 委托 RpcHandler.handlePlayCard(pack)
│
▼
③ handler:参数提取 + 校验
var o_room = mod.import.check_player(...); // 校验玩家;失败直接 return
if (!o_room) return;
var o_desk = o_room.o_desk; if (!o_desk) return;
// 状态/轮次/合法性校验(轮到该座?这张牌在手里?)
│
▼
④ 执行业务(委托给权威业务模块,handler 不内联规则)
var result = OperationManager.handleOperation(o_room, params);
│
▼
⑤ 构建响应(按需补充摸牌、可用操作、倒计时、比分、游戏状态等)
var responseData = ResponseBuilder.buildPlayCardResponse(...);
│
▼
⑥ 差异化广播:为每个座位定制其「可见数据」并主动推送
BroadcastManager.broadcastPlayCard(o_room, responseData);
→ 内部对每个座位组 msg,调用 o_room.method.sendpack_toseat(msg, seat)
→ 前端按既有 playCard 逻辑解析(无需区分触发源)
```
每个环节的纪律:
- **③ 校验必做且失败静默 `return`**:不要给客户端回作弊提示,`check_player` 失败、状态不对、轮次不对都直接 `return`。
- **④ 不在 handler 内重造规则**:胡牌检测、听牌分析、合法操作枚举等核心算法调用权威模块,handler 只编排(见 04)。
- **⑥ 主动推送、自带成败**:下发**唯一靠 `sendpack_toseat/toother` 主动推送**;凡有成败语义的推送,`data` 必须带 `success`(见 03)。
- **服务端代替玩家操作(如 AI 托管)走的是同一条 ④⑤⑥ 链路**,只是触发源从"前端请求"变成"服务端决策",对前端透明(见 04)。
---
## 6. 接入自检清单
新接入或改动子游戏时,逐条核对:
- [ ] `mod.js` 用 `cls_mod.new` 创建,路由名与前端 `route` 一致;文件按依赖顺序加载。
- [ ] `export.js` 实现 8 个必需接口并在末尾挂到 `mod.export`。
- [ ] `makewar` 创建了 `o_desk`、建立 `o_room.o_desk` ↔ `o_desk.o_room` 双向引用、对局态挂 `o_desk.data.*`、返回开战包。
- [ ] `get_deskinfo` 能给出与正常对局一致的当前快照(复用状态组装,不另写一份)。
- [ ] `import.js` 4 接口就位;`deduct_roomcard` 在首局结算调、`save_grade` 在终局调。
- [ ] 每个 RPC handler:先 `check_player` → 取 `o_desk` → 校验 → 委托业务 → 主动推送。
- [ ] 改了下发结构,前端 `StartWar`/`Reconnect`/对应操作解析同步检查。
下一篇 [03-数据收发与通信协议](./03-数据收发与通信协议.md) 详解包结构、发包方式、主动推送与 `success` 成败协议。
</content>
@@ -1,170 +0,0 @@
# 03 · 数据收发与通信协议
本篇是**日常手册**:数据包长什么样、收包要做哪些固定步骤、三种发包方式怎么选、为什么"主动推送"是唯一可靠的下发通道,以及本项目最重要的一条——**成败标志只认 `data.success`**。
---
## 1. 数据包结构
所有前后端数据包统一四字段:
```js
{
app: "youle", // 应用名(游戏固定 "youle")
route: "<你的游戏>", // 路由名 = 模块 routename
rpc: "playCard", // 方法名,决定调到 mod 的哪个函数 / 前端走哪个解析分支
data: { /* 业务数据 */ }
}
```
- `app/route/rpc` 三字段驱动三层路由(见 01)。
- `data` 里**必含平台参数**:`agentid`、`playerid`、`gameid`、`roomcode`、`seat` 等;数值参数收包时要 `parseInt`。
- **一包多信息**:一个响应包应携带本次状态变更的完整信息(出了什么牌、轮到谁、各家剩余、倒计时、比分……),减少往返。
---
## 2. 收包处理的固定步骤
每个 RPC handler 开头都是同一套"安检流程",**不可省略、不可简化**:
```js
mod_<游戏>.playCard = function(pack) {
// 1) 提取参数(数值 parseInt)
var agentid = pack.data.agentid;
var playerid = parseInt(pack.data.playerid);
var gameid = pack.data.gameid;
var roomcode = parseInt(pack.data.roomcode);
var seat = parseInt(pack.data.seat);
// 2) 校验玩家与房间(必做)——失败静默 return
var o_room = mod_<游戏>.import.check_player(
agentid, gameid, roomcode, seat, playerid, pack.conmode, pack.fromid);
if (!o_room) return;
// 3) 取桌对象与对局状态
var o_desk = o_room.o_desk;
if (!o_desk) return;
// 4) 业务校验(游戏阶段?轮到该座?操作合法?)——任一不过即 return
// 5) 执行业务(委托权威模块)
// 6) 构建并【主动推送】结果(见下文)
};
```
- **`check_player` 是强制安检**:它校验座位、连接、身份,返回 `o_room` 或 `null`。返回 `null` 一律直接 `return`。
- **校验失败静默**:不要回 "你作弊了" 之类提示,直接 `return`,避免给作弊者反馈。
- **调试记录**(若框架提供 `o_desk.debug.save_receivepack` 等)在关键节点调用,便于复盘。
---
## 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) // 公共信息
};
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` 码判成败"的写法。**以本协议为准。**
### 规则
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,前端读 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);
```
```js
// 前端:只认 success
if (!data.success) { /* 失败处理 */ return; }
// data.status / data.code 仅用于展示或日志细分
```
> 典型事故:托管状态推送 `{ status: 200, ... }` 不带 `success`,前端读 `data.success` 恒为 `undefined`,于是"已进入托管却报失败"。根因就是违反了本协议第 3 条。
---
## 6. 前后端对接的固定接缝
下发结构变了,对应的前端解析接口必须同步检查(前端平台代码与受限接口文件不可随意改,见 04):
| 场景 | 服务端产出 | 前端接收接口 |
|------|------------|--------------|
| 开战 | `export.makewar` 的返回包 | `Game_Modify.StartWar(makewar 返回值)` |
| 重连/中途加入 | `export.get_deskinfo` 的返回 | `Game_Modify.Reconnect(get_deskinfo 返回值)` |
| 对局操作 | RPC handler 的主动推送(按 `rpc` 区分) | 前端对应 `rpc` 的解析分支 |
**改服务端下发 = 同步核对前端解析**,否则前端按旧结构解析必然出错。
---
## 7. 服务端代替玩家操作时的透明性
服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),**必须复用真人操作的同一套数据包与广播链路**:产生的包结构、`rpc`、`data` 与真人操作**完全一致**,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 04 的对应红线。)
---
## 8. 小结
- 包结构恒为 `{app, route, rpc, data}`;收包先 `check_player`,失败静默 `return`。
- 三种发包方式按"谁该看到什么"选;差异化广播逐座位定制,敏感信息只发本人。
- **主动推送是唯一可靠下发通道**,`return` 不算。
- **成败只认 `data.success`**,推送必自带 `success`,禁止 `status`/`code` 判成败与兼容兜底。
- 改下发结构必同步核对前端 `StartWar`/`Reconnect`/对应 `rpc` 解析。
下一篇 [04-开发规范与红线](./04-开发规范与红线.md) 汇总所有必须遵守的工程纪律。
</content>
@@ -1,168 +0,0 @@
# 04 · 开发规范与红线
本篇汇总所有**必须遵守**的工程纪律。每一条都对应过真实事故或返工,是代码审查的清单来源。改任何代码前,按相关条目自检。
---
## 1. 可编辑范围
### 服务端
- **唯一可编辑目录**:`server/games2/<你的游戏>/`。
- **禁改**:`server/` 其余一切均为平台代码——`server/class/`、`server/config/`、`server/server/`、`server/youle/`、`server/update/`、`server/packet.js`、`server/applist.js`、`server/minhttp.js` 等。
### 前端
- **平台代码禁改**:`client/js/00_Surface/` 下全部文件。
- **受限接口文件**(不可新增对外接口,尽量不改,确需则只在现有接口内部加逻辑):
`client/js/01_SubGame/00_SubGame_Config.js`、`01_SubGame_modify.js`、`02_SubGame_Input.js`。
- **新增前后端交互**一律走 `mod.js` 的 `mod_<游戏>.<rpc>` 机制,**不在受限文件里新增接口**。
---
## 2. 语言标准:严格 ES5
- 全部 JS **必须严格符合 ES5**:用 `var`/`function`、字符串用 `+` 拼接。
- **禁止** ES6+:`let`/`const`、箭头函数、模板字符串、解构、默认参数、展开运算符、`class`、`for...of`、`Promise`/`async`/`await`、对象简写等。
- 原因:线上浏览器/友乐运行时不保证 ES6+ 支持。
---
## 3. 模块加载:require 双运行时守卫
- **所有 `require` 必须写在文件开头的 `if (typeof require !== 'undefined') { ... }` 守卫块内。**
- **禁止函数体内 / 中途 `require`**(含"延迟 require 规避循环依赖"的写法)。浏览器/友乐运行时无 `require`,无守卫的 `require` 会抛 `ReferenceError: require is not defined`。
- 跨模块运行时**按全局名引用**:Node 由守卫块 `var X = require(...)` 得到,浏览器由 `mod.js` 加载的同名全局解析。
```js
// ✅ 正确
if (typeof require !== 'undefined') {
var GameStateManager = require('./dataStructures/GameStateManager.js');
}
function foo() { GameStateManager.doSomething(); } // 直接用全局名
// ❌ 错误:函数体内中途 require —— 浏览器崩溃
function bar() { var GSM = require('./dataStructures/GameStateManager.js'); }
```
### 双运行时全局暴露陷阱
"构造函数 + 单例实例"式模块,`module.exports` 之后必须**无条件**把全局名暴露出去(**不要放进 `else` 分支**),否则线上浏览器拿不到该全局,报 `xxx is not a function`,而 Node 测试测不出来。
---
## 4. 数据权威原则
- **数据源唯一**:同一业务数据只有一个权威来源;上游计算/写入/校验,下游只读取/消费,**不重复推断、不重复拼装**。
- **禁止猜测兜底**:不用默认值掩盖缺失数据。**关键权威字段缺失要显式报错或返回 `null`**,由上层决定是否终止,**禁止** `|| 0`、`|| []`、`|| ''`、双源回退等掩盖。
- **下游不修补**:下游不得为"让流程跑起来"而补写/重算上游本该提供的数据;发现下游在修补,应把责任前移到权威来源。
- **边界**:只有展示层、纯 UI 兼容、非关键日志字段,才可在明确边界内用安全默认值。
> 审查信号:看到 `|| []`、`|| 0`、`|| ''`、三元默认、双源字段,先判断它是不是**权威字段**(影响发牌/庄家/手牌/规则/结算/重连/状态流转)。是 → 改成权威读取;只是展示/日志 → 才可保留默认。
---
## 5. 模块职责边界
- **一个职能只在一个模块实现**,其他模块**只调用、不重造**。
- 需要某能力时,调用对应**权威模块**;权威能力不满足,应在权威模块内扩展,而非在调用方旁路重写。
- **禁止**:在 A 模块内联 B 模块核心算法的"简化版";同一职能在两处各有一份实现并行演化。
- 后果:交叉实现产生双份并行逻辑,必随规则演化分叉、互相矛盾——这是数据权威原则在"代码职责"维度的同一条红线。
### "自动操作"模块只做决策,不重造规则
服务端自动替玩家操作的模块(如 AI 托管)**只负责决策**(出哪张、是否碰/杠/吃/胡/过),**核心算法一律复用权威实现**:胡牌检测、听牌/做牌分析、合法操作枚举、牌型判定等都已在权威模块实现,决策模块只能**读取/调用其结果**,禁止自行重算。
- 判别:回答"能否胡/听什么/有哪些合法操作"——属核心算法,必须复用;回答"在合法选项中选哪个更好"——才属决策。
---
## 6. 房间隔离
服务端同进程并发多张牌桌(多个 `o_room`)。**任何随对局变化的状态都必须以房间为单位隔离**:
- **状态挂房间**:对局数据存 `o_room.o_desk.data.*`,由 `o_room` 携带。**禁止存在模块级单例 / 全局变量 / 静态字段**。
- **不得只用 `seat` 作 key**:座位号仅 0–3,多房并发必碰撞。缓存、定时器表、决策状态、计数器等**必须用 `房间 + seat` 复合维度**(或每房一份实例)。
- **定时器随房生命周期**:所有 `setTimeout`/`setInterval` 必须能按房间定位与清理;小局结束、解散房间、开新局时,**清理该房名下全部定时器与残留状态**,禁止泄漏到下一局或别房。
- 覆盖范围:自动托管/决策表、算法中间态缓存(听牌/胡牌检测)、超时与回合定时器、待响应队列等,任一项跨房共享都会导致 A 房误改 B 房。
> 一句话:一切随对局变化的东西都属于某个房间,必须能用 `o_room` 唯一定位、隔离与回收。
---
## 7. 服务端自动操作复用真人链路
服务端代替玩家执行的操作(AI 托管等):
- **必须复用真人手动操作的同一套数据包链路**:走与真人相同的服务端处理入口与广播下发路径,产生的包结构与真人**完全一致**。
- **对前端透明**:前端只按既有"玩家操作"逻辑解析表现,**禁止为"自动操作"单开一套接收/解析/表现分支**,无需区分触发源。
- **唯一区别在触发源**:由"前端请求"变为"服务端决策",其后数据组织、下发协议、广播路径不变。
---
## 8. Shared 文件同步流程
前后端存在一份共享代码,**必须**经流程修改,禁止直接编辑前端副本:
| 角色 | 路径 |
|------|------|
| 权威源(服务端) | `server/games2/<你的游戏>/shared/` |
| 前端副本(只读) | `client/js/01_SubGame/codes/shared/` |
1. **只改服务端** `shared/` 下文件。
2. 改完运行根目录的同步脚本(如 `sync-shared.ps1`)同步到前端。
3. **禁止直接编辑前端 `codes/shared/`**(同步脚本会覆盖)。
---
## 9. 硬编码常量准则
**需提取为常量**(满足任一):会随规则变化、跨模块复用、裸值无法自解释。典型:分数数值、状态/类型字符串标识、配置阈值、跨模块共享的 key 名。常量按语义归入 `shared/constants/` 下对应文件(分数类、规则配置类、类型枚举类)。
**不必提取**(避免过度设计):单函数内一次性临时值(循环初值 `0`、空数组 `[]`)、框架约定固定串(`require` 路径)、自解释布尔开关、纯展示标点文字。
---
## 10. 测试纪律
- **测试唯一目的是验证业务正确性**。失败是有价值的信号,第一反应是**定位根因**,不是"让测试变绿"。
- **禁止任何掩盖手段**:skip/条件 return、软化断言、放宽阈值、try-catch 吞异常、把硬断言改成"存在才校验"。
- **每个失败必须裁定归属**(基于证据):【业务代码缺陷】还是【测试脚本缺陷】,二选一。手段:临时诊断探针、打印中间态、最小复现;拿证据再下结论,**禁止凭猜测定性**(诊断日志定位后清理)。
- **按归属修复**:业务缺陷 → 修业务、单独提交、写明根因;脚本缺陷 → 修置场/时序,断言保持硬断言。优先级:**确定性构造场景 > 有界重试采样 > 条件跳过**。
- **flaky 同样是缺陷**:要么业务竞态、要么测试非确定性置场,须根治;验收标准是**连跑 ≥5 次全绿**。
### 测试不绑架正式代码
- **正式核心代码禁止存在专为测试服务的逻辑**,更禁止为"让测试通过/兼容测试"而新增或修改正式代码。
- 方向永远是**测试适配正式代码的生产契约**,而非正式代码迁就测试的简化输入/不规范 stub。测试要构造符合生产契约的输入与 stub。
- 违例信号:正式代码注释出现"仅兼容测试 stub""如单元测试传 X"之类,即是违例。
---
## 11. Git 提交规范
- **及时自动提交**:每完成一个可独立成立的逻辑改动(一个修复/功能/重构/一批测试)就**立即提交**,不堆积工作区。
- **无需逐次询问**:完成阶段性改动后主动提交(`push` 按需)。
- **提交信息用中文**,简明说明"做了什么/为什么",一次提交聚焦一件事,结尾保留 `Co-Authored-By` 署名行。
---
## 12. 审查速查表
| 维度 | 红线 |
|------|------|
| 范围 | 只改 `games2/<游戏>/` 与允许的前端范围 |
| 语言 | 纯 ES5;`require` 仅在顶部守卫块 |
| 成败 | 只认 `data.success`,推送必自带,禁 `status` 兜底 |
| 数据 | 权威唯一、缺失报错/返回 null、禁兜底、下游不修补 |
| 职责 | 一职能一模块,调用不重造 |
| 隔离 | 状态挂 `o_desk.data.*`,禁全局;`房间+seat` 作 key;定时器随房清理 |
| 自动操作 | 复用真人链路,对前端透明 |
| Shared | 只改服务端 `shared/`,跑同步脚本 |
| 测试 | 失败裁定归属、禁掩盖;正式代码不迁就测试 |
| Git | 一事一提交、中文信息、及时提交 |
---
至此,从框架运作(01)、子游戏接入(02)、收发协议(03)到工程红线(04),构成一套完整的服务端子游戏开发指导。回到 [README](./README.md) 查看导航与一页纸模型。
</content>
-79
View File
@@ -1,79 +0,0 @@
# 服务端 · 子游戏开发指导文档
> 本套文档是**友乐游戏平台**下「子游戏服务端」开发的通用指导与规范。
> 它讲清楚三件事:**框架怎么运作**、**子游戏怎么接进去**、**开发时必须守哪些规矩**。
>
> 文中以「麻将」一类房卡棋牌作举例,但所有结论都是**框架通用**的,不绑定任何具体玩法。
> 新开发者按本套文档即可理解运作流程、动手接入并写出符合规范的子游戏。
---
## 这套文档写给谁
- **新接手子游戏服务端的开发者**:先读完 01、02 建立全局认知,再按 03、04 动手。
- **正在开发/维护某个子游戏的开发者**:03、04 是日常红线,改任何东西前回查。
- **做代码审查的人**:04 是审查清单的来源。
## 阅读顺序
| 篇 | 文档 | 解决什么问题 |
|----|------|--------------|
| 00 | 本文 README | 这套文档是什么、怎么读、红线速查 |
| 01 | [01-服务端环境与框架基础.md](./01-服务端环境与框架基础.md) | 平台与子游戏的关系、核心对象模型、三层路由、房间生命周期 |
| 02 | [02-子游戏接入与开发流程.md](./02-子游戏接入与开发流程.md) | 三文件架构、export/import 接口、makewar/重连、一次操作的完整数据流 |
| 03 | [03-数据收发与通信协议.md](./03-数据收发与通信协议.md) | 包结构、收发包方式、主动推送、成败标志协议、前后端对接点 |
| 04 | [04-开发规范与红线.md](./04-开发规范与红线.md) | 可编辑范围、ES5/require、数据权威、模块职责、房间隔离、测试纪律 |
建议第一次**从 01 顺序读到 04**;之后把 03/04 当手册随用随查。
---
## 一页纸:核心运作模型
```
客户端数据包 { app, route, rpc, data }
│
▼
packet_face.ReceivePack 按 pack.app 找到「应用」
│
▼
app.ReceivePack 按 pack.route 找到「模块」(子游戏)
│
▼
mod.DoPack 按 pack.rpc 调到「方法」 mod[rpc](pack)
│
▼
子游戏 RPC handler 校验玩家 → 取对局状态 → 执行业务 → 主动推送
│
▼
o_room.method.sendpack_toseat 按座位取连接信息(conmode/fromid) → 下发客户端
```
- **平台框架**负责:网络收发、应用/模块/房间/玩家对象、路由、房卡、战绩、房间生命周期。
- **子游戏**负责:玩法规则、对局状态、每个操作的处理与广播。两者通过 **export / import** 两组接口对接。
---
## 红线速查(详见 04)
> 以下每一条都有过真实事故或返工,**改代码前先对照**。
- **可编辑范围**:服务端只能改 `server/games2/<你的游戏>/`,`server/` 其余皆平台代码,禁改。
- **双运行时**:代码同时跑在 Node 与浏览器/友乐平台。必须严格 **ES5**;`require` 只能写在文件开头 `if (typeof require !== 'undefined') {}` 守卫块内,**禁止函数体内/中途 require**。
- **成败标志唯一是 `data.success`**:前端只认 `if (!data.success)`;**主动推送的 `data` 必须自带 `success`**(mod 的 `return` 值不可靠下发前端);禁止用 `status`/`code` 判成败、禁止 `status` 兼容兜底。
- **数据权威**:同一业务数据只有一个权威来源,下游只读不重算;权威字段缺失要**显式报错/返回 null**,禁止 `|| 0`、`|| []`、`|| ''` 兜底掩盖。
- **模块职责边界**:一个职能只在一个模块实现,其他模块**调用而非重造**。
- **房间隔离**:一切随对局变化的状态都挂 `o_room.o_desk.data.*`;**禁止模块级单例/全局变量存对局态**;缓存/定时器/决策表不得只用 `seat` 作 key,必须 `房间 + seat`;结束/解散/开新局时按房间清理全部定时器与残留状态。
- **服务端自动操作复用真人链路**:服务端代替玩家做的操作(如 AI 托管)必须复用「真人操作」的同一套处理入口与广播链路,对前端透明,不为它单开一套下发分支。
- **服务器权威**:房卡、胜负、积分、状态等关键数据与操作一律以服务器为准,前端数据仅供显示。
- **测试纪律**:测试失败先用证据裁定「业务缺陷 vs 测试脚本缺陷」,禁止 skip/软化断言/吞异常掩盖;**正式代码不得为兼容测试而加逻辑**。
---
## 与既有文档的关系
- 平台级、面向「所有子游戏」的总纲在 `docs/important/server/`(友乐框架收发包规范、子游戏开发要求)。
- 本套文档是其**落地版**:结合本仓库真实代码,给出可直接照做的接入步骤与红线,并对其中已被本项目实践修正的部分(如成败标志由 `status` 收敛为 `success`)以本套为准。
- 各子游戏内部的架构细节(模块划分、算法)仍以各自 `games2/<游戏>/docs/` 为准。
</content>
</invoke>