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

20 KiB
Raw Blame History

04 · 开发规范与红线

本篇汇总所有必须遵守的工程纪律。每一条都对应过真实事故或返工,是代码审查的清单来源。改任何代码前,按相关条目自检。

命名说明:本篇提到的模块/类/文件名(如 GameStateManager、RoomAdapter 等)多为示例、可自定,红线约束的是做法而非具体名字;只有平台接缝上的名字(包字段、export/import 钩子、平台 API、data.success)是契约。详见 README「命名约定」。


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 加载的同名全局解析。
// ✅ 标准形态: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)。

审查信号:看到 || []、|| 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)只是体验层,不承担正确性。

五层验证栈(进业务前依次过闸,任一不过即 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 查看导航与一页纸模型。