Files
youle_framework/docs/server/development-guide/04-开发规范与红线.md
T
2026-08-19 08:14:48 +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)。
  • 前端不是数据源:客户端只提供"意图"(想做什么、对哪个目标),永远不是任何业务数据的权威来源。凡进入服务端状态的值——分数、牌面、阶段、控制权、结算——只能由服务端自己算出或从 o_desk.data.* 读出,绝不采信包体里前端算好的同名字段(见 §8)。对应地,阶段/控制权/availableActions/倒计时锚点由服务端唯一维护并显式下发,前端不推导(见 03 §1.4、前端 05 §6)。

审查信号:看到 || []、|| 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 是否与权威牌局一致 —— 全按服务端权威数据校验,不信客户端传的牌面。

只接受「意图」入参:客户端回传的结论一律忽略

第 5 层的前提是入参里根本不该出现结论。请求包只表达"我想做什么"(操作类型 + 目标标识),不表达"结果是什么"(前端侧规范见 客户端 04 §1「请求包只带意图」):

  • 协议层不定义结论入参:score/isWin/multiplier/nextSeat/phase/result/handCards 之类字段不出现在请求包定义里——定义了它,就是留了一个可被伪造的洞。
  • 收到也忽略:即便非正规客户端硬塞这些字段,handler 一律不读、不落地,全部按服务端权威数据重算。审查信号:handler 里出现 pack.data.score、pack.data.isWin、pack.data.phase 这类读取。
  • 包内 seat 只作一致性校验:身份由连接反查(第 1 层),包内座位不等即拒,不作身份依据。
  • 前端 shared/ 的计算结果不是输入:前端跑 shared/ 只为提示与预校验,其结论不回传、服务端也不采信;服务端自己跑同一份 shared/ 得出权威结果(见 §9)。

设计形态:统一裁决网关,默认拒绝(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/ 属于子游戏,不是平台代码:它存放本玩法自身的规则、算法、常量、数据结构(本项目如胡牌检测 WinDetectionFactory、精牌 JingAlgorithm、计分 ScoreCalculation、牌型/比精,以及 constants/ 下各类常量)。平台既不提供也不感知 shared/——它完全落在子游戏可编辑范围内,是你玩法逻辑的一部分。
  • 为什么要"共享":同一份玩法逻辑需要在两处运行——服务端(Node)做权威裁定,前端(浏览器)做即时表现/预校验。若两端各写一份,必随规则演化而分叉、算出不一致结果。因此把这类"前后端必须完全一致"的纯逻辑收进 shared/,两端跑逐字相同的代码。
  • 它与"数据权威"的关系:shared/ 是逻辑同源(两端同一套算法),不改变数据权威(结果仍以服务端为准,见 §4);前端算出的只是表现/预判,最终以服务端 shared/ 算的为准。

同步流程:服务端权威源 → 前端只读副本

前后端各持一份 shared/,必须经同步流程单向更新,禁止直接编辑前端副本:

角色 路径
权威源(服务端,唯一可改) server/<游戏容器目录>/<你的游戏>/shared/
前端副本(只读,脚本生成) client/js/01_SubGame/codes/shared/
  1. 只改服务端 shared/ 下文件。
  2. 改完运行根目录的同步脚本(如 sync-shared.ps1)同步到前端。
  3. 禁止直接编辑前端 codes/shared/(同步脚本会覆盖)。

判别一段逻辑该不该进 shared/:它是不是"前后端必须算出完全相同结果"的玩法逻辑?是(胡牌/听牌/比精/牌型/计分/规则常量)→ 放 shared/;只是服务端流程编排或只是前端表现 → 各自放自己那侧,不进 shared/。


10. 硬编码常量准则

需提取为常量(满足任一):会随规则变化、跨模块复用、裸值无法自解释。典型:分数数值、状态/类型字符串标识、配置阈值、跨模块共享的 key 名。常量按语义归入 shared/constants/ 下对应文件(分数类、规则配置类、类型枚举类)。

不必提取(避免过度设计):单函数内一次性临时值(循环初值 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、禁兜底、下游不修补;下发包发全前端界面所需核心数据
前端不是数据源 请求包只接受「意图」(操作类型+目标标识),协议不定义 score/isWin/phase 等结论入参,收到也忽略并按权威数据重算;包内 seat 只作一致性校验
下发可见性 不该看到的不发(他人手牌/牌堆序列/未公开判定/未来结果),按座位差异化裁剪;"下发即泄露",前端不渲染不算防护(03 §1.3)
状态机归属 阶段/控制权/availableActions/倒计时锚点由服务端唯一维护并显式下发,前端不推导;状态变更必须有包承载(03 §1.4)
职责 一职能一模块,调用不重造
隔离 状态挂 o_desk.data.*,禁全局;房间+seat 作 key;定时器随房清理
自动操作 复用真人链路,对前端透明
请求验证 客户端不可信;每个操作请求过座位鉴权/阶段门/操作可用性/幂等去重/参数五层校验,统一网关默认拒绝、失败 success:false 不改状态;出牌回合门尤其不可漏(防死代码)
Shared 只改服务端 shared/,跑同步脚本
测试 失败裁定归属、禁掩盖;正式代码不迁就测试
Git 一事一提交、中文信息、及时提交

至此,从框架运作(01)、子游戏接入(02)、收发协议(03)到工程红线(04),构成一套完整的服务端子游戏开发指导。回到 README 查看导航与一页纸模型。