20 KiB
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 + 不改状态)
- 座位鉴权(防越权):操作者座位由连接身份反查(连接绑定的 playerid→座位,如平台
check_player以fromid/conmode绑定),绝不信任包体seat字段;包内 seat 只做一致性校验,不等即拒。否则 A 玩家发{seat: B}就能替 B 操作。 - 阶段/状态门(防非当前阶段包):校验当前游戏阶段/状态允许该操作——非出牌阶段发出牌、非掷骰窗口发掷骰、无响应窗口发碰/过 → 拒绝。门控读
gameState.phase/pendingResponse.waiting/ 是否轮到该座位(currentPlayer === seat)。 - 操作可用性校验(防"当前不允许的操作")——核心闸:校验该操作确实在该座位当前权威
availableActions[seat]里(没可碰的牌却发碰、没有胡机会却发胡 → 拒绝)。关键:
availableActions[seat](听牌/胡牌检测/操作枚举产出的权威合法操作集)既是自动操作模块的决策选项来源,也是校验真人请求的准入白名单——同一份权威数据兼任"决策依据"与"准入校验",统一了数据源唯一(§4)、自动操作职责边界(§5)、自动操作复用真人链路(§7)三条线。 - 幂等 / 去重 / 时序(连点的根治):不是限流,而是状态机的单调推进——一个操作被处理后立即推进状态并更新
availableActions,使重复包因落在新availableActions之外而天然非法;响应窗口对同座位重复响应去重(如addPlayerResponse记录后再收同座位响应即忽略);对已结束窗口/轮次的迟到包丢弃。 - 参数 / 数据合法性:牌
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/ |
- 只改服务端
shared/下文件。 - 改完运行根目录的同步脚本(如
sync-shared.ps1)同步到前端。 - 禁止直接编辑前端
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 查看导航与一页纸模型。