Files
youle_framework/docs/server/development-guide/04-开发规范与红线.md
T
joywayerandClaude Opus 4.8 96971f3072 文档红线细化:新增服务端请求合法性验证(§8)+悲观UI/输入-渲染解耦,收紧 shared 与常量准入门槛
- 服务端 04:新增「操作请求合法性验证(不可信客户端)」章节——五层验证栈
  (座位鉴权/阶段门/操作可用性/幂等去重/参数校验)、统一裁决网关默认拒绝、
  availableActions 兼任决策依据与准入白名单、出牌回合门防死代码;后续章节顺延重编号
- 前端 04/05:悲观 UI——点击只发请求包、对局状态表现收包后更新,收包处理器触发源无关,
  AI 托管/他人广播共用同一更新路径;响应/掷骰交互按钮隐藏为受控乐观清除例外
- shared 准入门槛(前后端 04/05 §9/§8):进 shared 的门槛是「前后端都真正用到且必须
  逐字一致」而非「它是玩法逻辑」;前端只展示+悲观UI ⇒ 计算类逻辑不进 shared,默认留服务端
- 硬编码常量准则(服务端 04 §10):魔法字符串默认常量化(单一来源);常量放 shared 亦按
  同一准入门槛判定,仅前后端都用到才进 shared/constants

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 04:12:27 +08:00

234 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.
# 04 · 开发规范与红线
本篇汇总所有**必须遵守**的工程纪律。每一条都对应过真实事故或返工,是代码审查的清单来源。改任何代码前,按相关条目自检。
> **命名说明**:本篇提到的模块/类/文件名(如 `GameStateManager`、`RoomAdapter` 等)多为**示例、可自定**,红线约束的是**做法**而非具体名字;只有平台接缝上的名字(包字段、`export`/`import` 钩子、平台 API、`data.success`)是契约。详见 [README「命名约定」](./README.md)。
---
## 1. 可编辑范围
### 服务端
- **唯一可编辑目录**:子游戏自己的目录 `server/<游戏容器目录>/<你的游戏>/`。容器目录名由接入方自定,**非框架强制**,换任何不与框架冲突的目录皆可(框架按 `modename` 而非目录名定位子游戏,见 01)。
- **禁改**:`server/` 其余一切均为平台代码——`server/class/`、`server/config/`、`server/server/`、`server/youle/`、`server/update/`、`server/packet.js`、`server/applist.js`、`server/minhttp.js` 等。
- **唯一例外**:接入新游戏时需在平台文件 `server/youle/app.js` 里加一行 `min_loadJsFile("<容器目录>/<你的游戏>/mod.js", ...)` 完成注册加载——这是必须触碰平台代码的**唯一游戏接入点**,除此之外不得改动 `server/youle/`。
### 前端
- **平台代码禁改**:`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
// ✅ 标准形态:require 仅在顶部守卫块,函数体内直接用全局名
if (typeof require !== 'undefined') {
var GameStateManager = require('./dataStructures/GameStateManager.js');
}
function foo() { GameStateManager.doSomething(); }
// ❌ 函数体内中途 require → 浏览器崩溃:function bar(){ var GSM = require('...'); }
```
### 双运行时全局暴露陷阱
"构造函数 + 单例实例"式模块,`module.exports` 之后必须**无条件**把全局名暴露出去(**不要放进 `else` 分支**),否则线上浏览器拿不到该全局,报 `xxx is not a function`,而 Node 测试测不出来。
---
## 4. 数据权威原则
- **数据源唯一**:同一业务数据只有一个权威来源;上游计算/写入/校验,下游只读取/消费,**不重复推断、不重复拼装**。
- **禁止猜测兜底**:不用默认值掩盖缺失数据。**关键权威字段缺失要显式报错或返回 `null`**,由上层决定是否终止,**禁止** `|| 0`、`|| []`、`|| ''`、双源回退等掩盖。
- **下游不修补**:下游不得为"让流程跑起来"而补写/重算上游本该提供的数据;发现下游在修补,应把责任前移到权威来源。
- **边界**:只有展示层、纯 UI 兼容、非关键日志字段,才可在明确边界内用安全默认值。
- **下发面要发全**:因前端以服务端为权威、不自算权威结果,服务端**每个下发包必须携带前端界面所需的全部核心数据**(界面要显示、或前端 set-refresh 要用的字段都发全);漏发 = 前端缺数据,**修在服务端发包处、前端不补洞**(详见 [03 §1.2](./03-数据收发与通信协议.md))。
> 审查信号:看到 `|| []`、`|| 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. 操作请求合法性验证(不可信客户端)
> 与 §7 配套:§7 保证"自动操作走真人链路",本节保证"到达的每个请求都合法才被链路执行"。
**客户端完全不可信**:它可能连点、乱序、丢包重发、篡改包体、发送非当前阶段/越权/当前不允许的操作。服务端**必须对每个到达的请求独立判定合法性**,非法即拒绝且**绝不改动任何对局状态**——就当这个包没来过。这是正确性的**根本防线**;前端的乐观清除/防连点(见前端 [04 §5.6](../../client/development-guide/04-网络对接与启动编排.md#56-受控例外响应交互按钮乐观清除--服务端合法性验证))只是体验层,**不承担正确性**。
### 五层验证栈(进业务前依次过闸,任一不过即 `success:false` + 不改状态)
1. **座位鉴权(防越权)**:操作者座位**由连接身份反查**(连接绑定的 playerid→座位,如平台 `check_player` 以 `fromid/conmode` 绑定),**绝不信任包体 `seat` 字段**;包内 seat 只做一致性校验,不等即拒。否则 A 玩家发 `{seat: B}` 就能替 B 操作。
2. **阶段/状态门(防非当前阶段包)**:校验当前游戏阶段/状态允许该操作——非出牌阶段发出牌、非掷骰窗口发掷骰、无响应窗口发碰/过 → 拒绝。门控读 `gameState.phase` / `pendingResponse.waiting` / 是否轮到该座位(`currentPlayer === seat`)。
3. **操作可用性校验(防"当前不允许的操作")——核心闸**:校验该操作**确实在该座位当前权威 `availableActions[seat]` 里**(没可碰的牌却发碰、没有胡机会却发胡 → 拒绝)。
> 关键:`availableActions[seat]`(听牌/胡牌检测/操作枚举产出的**权威合法操作集**)**既是自动操作模块的决策选项来源,也是校验真人请求的准入白名单**——同一份权威数据兼任"决策依据"与"准入校验",统一了数据源唯一(§4)、自动操作职责边界(§5)、自动操作复用真人链路(§7)三条线。
4. **幂等 / 去重 / 时序(连点的根治)**:不是限流,而是**状态机的单调推进**——一个操作被处理后立即推进状态并更新 `availableActions`,使**重复包因落在新 `availableActions` 之外而天然非法**;响应窗口对同座位重复响应去重(如 `addPlayerResponse` 记录后再收同座位响应即忽略);对已结束窗口/轮次的迟到包丢弃。
5. **参数 / 数据合法性**:牌 `uniqueId` 是否真在该玩家手里、`targetCard`/`fromSeat`/`choiceIndex` 是否与权威牌局一致 —— 全按**服务端权威数据**校验,不信客户端传的牌面。
### 设计形态:统一裁决网关,默认拒绝(fail-closed)
把五层收敛成**一道所有操作共用的准入网关**(而非每个 handler 各写一遍散点 if)。网关默认 **deny**,仅当请求显式**命中该座位 `availableActions`** 且通过座位/阶段/参数校验才 **allow**。好处:
- **单点权威**:合法性判定只有一处,各 handler 不会判得不一致(呼应 §5 职责边界);
- **自动操作与真人同闸**:自动操作(AI 托管)本就从 `availableActions` 选,必过同一网关,真正做到 §7 "自动操作 ≈ 服务端模拟一次**合法**真人操作"。
> **反模式警示**:若"是否轮到你/是否允许"的通用时序校验只存在于某个**从未被调用**的方法里(死代码),等于没有验证——尤其**出牌入口最易漏掉回合门**,导致任一玩家可在非自己回合越权出牌。验证网关必须**在统一入口真实生效**,并有测试覆盖。
### 失败返回:安静且明确
- 一律 `success:false` +(可选)`reason` 码,**绝不静默改状态**;失败推送前端只做提示/日志、**不改对局界面**(与前端 `if(!data.success)` 闭环)。
- 最危险的是**"非法包被误当合法处理"(fail-open)**——默认拒绝就是为杜绝它。
### 验收(测试构造非法包,硬断言拒绝)
按 §11 测试纪律补单测:**非自己回合出牌 / 同回合连发两张 / 非当前阶段各操作 / 响应窗口关闭后迟到碰杠 → 断言 `success:false` 且状态不变**;**合法出牌各时机(摸后/碰后/杠后/报定后)→ 断言通过**(防回合门误伤合法出牌)。
> 一句话:**服务端对每个操作请求默认拒绝,仅当命中该座位当前权威 `availableActions` 且通过座位/阶段/参数校验才执行;操作一经消费即推进状态使重复包天然失效。正确性完全由这道网关保证,前端乐观清除只是体验。**
---
## 9. Shared 文件同步流程
### `shared/` 是什么:子游戏自己的游戏逻辑,与平台无关
- **`shared/` 属于子游戏,不是平台代码**:它存放**本玩法自身**、且**前后端都真正会用到**的那部分纯逻辑/常量/数据结构。平台**既不提供也不感知** `shared/`——它完全落在子游戏可编辑范围内,是你玩法逻辑的一部分。
- **为什么要"共享"**:**同一段逻辑需要在两处运行且必须算出完全一致的结果**——服务端(Node)做**权威裁定**,前端(浏览器)确有场景**在本地即时预判/预校验**同一段逻辑。若两端各写一份,必随规则演化而分叉、算出不一致结果。因此把这类"两端都用、且必须逐字一致"的纯逻辑收进 `shared/`,两端跑**逐字相同**的代码。
- **它与"数据权威"的关系**:`shared/` 是**逻辑同源**(两端同一套算法),不改变**数据权威**(结果仍以服务端为准,见 §4);前端算出的只是表现/预判,最终以服务端 `shared/` 算的为准。
### 准入门槛:慎重——默认留服务端,别把 shared 当玩法逻辑的垃圾桶
**进 `shared/` 的唯一理由是"前后端都真正会用到这段逻辑",不是"它是玩法逻辑"。** 判定务必慎重:
- **前端只做展示、不做核心运算**(服务器权威,裁定与结果均由服务端下发,见 §4),叠加**悲观 UI**(可操作项/提示/落子结果由服务端推送、前端不预测,见前端 04 §5)——因此**前端实际会重算的玩法逻辑很少**。
- 绝大多数**计算类逻辑**(发牌、洗牌、胡牌/听牌裁定、计分、AI 决策、随机数等)前端**根本不重算**:结果由服务端算好推给前端展示即可。它们应**只留在服务端、不进 `shared/`**。
- 只有当某段逻辑**确有明确的前端使用场景**——例如需要本地即时预判/高亮可操作项,或纯展示所需的**规则常量、牌型/花色映射、文案枚举**——才把**那一部分**提升进 `shared/`;提升的是"前端确需的最小子集",不是把整套算法一并搬过去。
- **默认放服务端**:拿不准某文件前端到底用不用,就先留在服务端子游戏目录下,等出现真实的前端使用点再提升进 `shared/`。宁可后补,也不要预防性地把一堆前端用不到的文件塞进 `shared/`(徒增只读副本体积、误导读者以为前端在重算)。
### 同步流程:服务端权威源 → 前端只读副本
前后端各持一份 `shared/`,**必须**经同步流程单向更新,禁止直接编辑前端副本:
| 角色 | 路径 |
|------|------|
| 权威源(服务端,唯一可改) | `server/<游戏容器目录>/<你的游戏>/shared/` |
| 前端副本(只读,脚本生成) | `client/js/01_SubGame/codes/shared/` |
1. **只改服务端** `shared/` 下文件。
2. 改完运行根目录的同步脚本(如 `sync-shared.ps1`)同步到前端。
3. **禁止直接编辑前端 `codes/shared/`**(同步脚本会覆盖)。
> 判别一段逻辑该不该进 `shared/`,连问两关:**①前端到底用不用它?**(前端只做展示、悲观 UI 下多数计算不重算——不用 → 只留服务端)**②两端是否必须算出完全一致的结果?**(是 → 才有共享价值)。两关都过(前端确有本地预判/展示用途,且必须与服务端逐字一致,如某些规则常量、牌型映射、本地可高亮的预校验)→ 放 `shared/`;**只要有一关不过**——服务端流程编排、只有服务端裁定的计算(发牌/胡牌/计分/AI)、只是前端表现——都**各自放自己那侧,不进 `shared/`**。
---
## 10. 硬编码常量准则
**需提取为常量**(满足任一):会随规则变化、跨模块复用、裸值无法自解释。典型:分数数值、状态/类型字符串标识、配置阈值、跨模块共享的 key 名。
**魔法字符串尤其要慎重、默认常量化**:出现在**判等/分支/作为 key/跨模块传递**的字符串字面量(状态名、类型/枚举值、rpc 名、字段名、事件名等)几乎都属"魔法字符串"——**同一个值被硬编码在多处**时,拼写漂移、改名遗漏、无法被搜索/校验,是隐性 bug 的高发点。这类一律**提取为单一来源的常量**、各处只引用;同一语义禁止在多处重复写裸串。
**常量定义放哪里,同样要慎重(沿用 §9 的 shared 准入门槛)**:常量并非一律进 `shared/constants/`。判据仍是"**前后端是否都真正用到**"——
- **仅服务端用到**的常量(服务端内部状态机名、只在服务端分支的类型标识、服务端流程 key 等)→ 放在**服务端子游戏目录内**的常量文件,**不进 `shared/`**。
- **前后端都会用到、且必须取值完全一致**的常量(前端展示/预判也要引用的规则常量、牌型/花色映射、双方约定的枚举值等)→ 才归入 `shared/constants/` 下对应文件(分数类、规则配置类、类型枚举类)。
- 默认放服务端一侧,只有出现真实的前端引用点,才把**那一部分**常量提升进 `shared/`。
**不必提取**(避免过度设计):单函数内一次性临时值(循环初值 `0`、空数组 `[]`)、框架约定固定串(`require` 路径)、自解释布尔开关、纯展示标点文字。
---
## 11. 测试纪律
- **测试唯一目的是验证业务正确性**。失败是有价值的信号,第一反应是**定位根因**,不是"让测试变绿"。
- **禁止任何掩盖手段**:skip/条件 return、软化断言、放宽阈值、try-catch 吞异常、把硬断言改成"存在才校验"。
- **每个失败必须裁定归属**(基于证据):【业务代码缺陷】还是【测试脚本缺陷】,二选一。手段:临时诊断探针、打印中间态、最小复现;拿证据再下结论,**禁止凭猜测定性**(诊断日志定位后清理)。
- **按归属修复**:业务缺陷 → 修业务、单独提交、写明根因;脚本缺陷 → 修置场/时序,断言保持硬断言。优先级:**确定性构造场景 > 有界重试采样 > 条件跳过**。
- **flaky 同样是缺陷**:要么业务竞态、要么测试非确定性置场,须根治;验收标准是**连跑 ≥5 次全绿**。
### 测试不绑架正式代码
- **正式核心代码禁止存在专为测试服务的逻辑**,更禁止为"让测试通过/兼容测试"而新增或修改正式代码。
- 方向永远是**测试适配正式代码的生产契约**,而非正式代码迁就测试的简化输入/不规范 stub。测试要构造符合生产契约的输入与 stub。
- 违例信号:正式代码注释出现"仅兼容测试 stub""如单元测试传 X"之类,即是违例。
---
## 12. Git 提交规范
- **及时自动提交**:每完成一个可独立成立的逻辑改动(一个修复/功能/重构/一批测试)就**立即提交**,不堆积工作区。
- **无需逐次询问**:完成阶段性改动后主动提交(`push` 按需)。
- **提交信息用中文**,简明说明"做了什么/为什么",一次提交聚焦一件事,结尾保留 `Co-Authored-By` 署名行。
---
## 13. 审查速查表
| 维度 | 红线 |
|------|------|
| 范围 | 只改子游戏目录 `<容器目录>/<游戏>/` 与允许的前端范围 |
| 语言 | 纯 ES5;`require` 仅在顶部守卫块 |
| 成败 | 只认 `data.success`,推送必自带,禁 `status` 兜底 |
| 数据 | 权威唯一、缺失报错/返回 null、禁兜底、下游不修补;下发包发全前端界面所需核心数据 |
| 职责 | 一职能一模块,调用不重造 |
| 隔离 | 状态挂 `o_desk.data.*`,禁全局;`房间+seat` 作 key;定时器随房清理 |
| 自动操作 | 复用真人链路,对前端透明 |
| 请求验证 | 客户端不可信;每个操作请求过座位鉴权/阶段门/操作可用性/幂等去重/参数五层校验,统一网关默认拒绝、失败 `success:false` 不改状态;出牌回合门尤其不可漏(防死代码) |
| Shared | 只改服务端 `shared/`,跑同步脚本 |
| 测试 | 失败裁定归属、禁掩盖;正式代码不迁就测试 |
| Git | 一事一提交、中文信息、及时提交 |
---
至此,从框架运作(01)、子游戏接入(02)、收发协议(03)到工程红线(04),构成一套完整的服务端子游戏开发指导。回到 [README](./README.md) 查看导航与一页纸模型。