diff --git a/docs/client/development-guide/04-网络对接与启动编排.md b/docs/client/development-guide/04-网络对接与启动编排.md index d42a277..77bb462 100644 --- a/docs/client/development-guide/04-网络对接与启动编排.md +++ b/docs/client/development-guide/04-网络对接与启动编排.md @@ -116,7 +116,80 @@ function handleXxx(data) { --- -## 5. 启动编排 +## 5. 输入—渲染解耦:发包只请求,收包才表现 + +> 专业名:**服务端权威的悲观 UI 更新(Server-Authoritative Pessimistic Rendering)** / 单向数据流下的**输入-渲染解耦**。 + +**核心时序原则:用户点击只负责发出请求包,绝不直接改动任何对局状态界面;一切随对局状态变化的表现,只在收到服务端下发的结果/推送包后才更新。** + +发包与表现是两条独立通道:**点击发「命令」,收包应「事件」,UI 只订阅事件**。界面永远是服务端已确认状态的投影,不做「点击即更新」的乐观预测。 + +### 5.1 点击回调的职责边界 + +点击回调**只做两件事**:①组织并发送请求包(走 §1 语义化发包封装);②(可选)纯本地物理反馈。**不得**在点击回调里改动任何对局状态界面。 + +| 归类 | 例子 | 点击时可否做 | +|------|------|--------------| +| 纯本地物理反馈(不碰领域状态) | 按钮按下高亮/缩放/音效 | ✅ 可即时 | +| **响应/掷骰交互按钮的隐藏**(本玩家点击的碰/杠/过/胡按钮、手动掷骰按钮) | 点击发包即隐藏该按钮(兼作防连点) | ⚠️ **受控例外**允许乐观清除——**前提是服务端合法性验证 + 收包侧兜底**(见 5.6) | +| 其余对局状态表现(领域状态) | 各类提示(等待听牌/报定等待/掷骰提示文字)、当前控制权高亮、启停倒计时、落牌进牌河、阶段/托管图标、他人手牌/副露 | ❌ **禁止**乐观,一律等收包 | + +判别标准:**这个变化服务端要不要确认?** 要 → 收包后更新(除 5.6 例外的交互按钮);纯本地物理反馈、服务端根本不关心 → 可即时。 + +### 5.2 为什么必须如此(与 AI 托管同源) + +真人操作与 AI 托管/他人操作**共用同一后半段**:`服务端处理 → 下发结果包 → 前端更新`。 + +``` +真人: 点击 → 发请求包 →┐ + ├→ 服务端处理 → 下发结果包 → 收包处理器(唯一更新点) +AI 托管:服务端 AI 决策 →┘ +``` + +把界面更新一律挂在「**收包**」这个节点,则无论操作由真人点击还是服务端 AI 自动触发,前端表现都自动一致、**无需区分触发源**(渲染透明)。反之若挂在「点击」节点:AI 托管时**根本没有点击动作**,服务端自动发包后对应更新代码永不触发 → 界面卡死、提示不消失、按钮不刷新。 + +这正是根目录 CLAUDE.md「AI 托管数据一致性 / 对前端透明」在**前端时序维度**的必然要求,也与「前端不推测、当前玩家由服务端权威字段(如 `nextControlSeat`)给出」一脉相承。 + +### 5.3 收包处理器必须「触发源无关」 + +同一收包处理器既服务真人操作回包、也服务 AI 托管与他人操作广播。因此: + +- **不得假设「本座刚点过」**:所需数据一律从**包内权威字段**读取,**禁止**依赖「点击时暂存的本地变量」。 +- **先判 `data.success`**(§4),再据包字段渲染;无对应点击也能正确渲染。 + +### 5.4 适用范围 + +凡「随对局状态变化」的表现均适用,包括但不限于:交互提示(手动掷骰提示文字 / 请出牌 / 等待其他玩家听牌 / 报定等待)、当前控制权高亮(按包内 `nextControlSeat`)、倒计时(按包内剩余时间启停)、手牌/牌河/亮牌、他人副露、阶段与托管图标。 + +> **例外**:本玩家点击的**响应/掷骰交互按钮**(碰/杠/过/胡按钮、手动掷骰按钮)的**隐藏**允许乐观清除(见 5.6);但这些按钮的**显示**,以及一切**提示**(含掷骰提示文字、等待听牌、报定等待),仍严格收包驱动。 + +### 5.5 验收标准 + +每处改动须验**两条路径表现一致**: + +1. **真人手动操作**:点击后界面在收到回包时才更新(点击瞬间不抢先变化)。 +2. **服务端自动发包**(AI 托管 / 他人操作广播):无任何点击,界面同样在收到推送包后正确更新,且与真人路径**表现完全一致**。 + +两条都验过且一致,方为合规。 + +### 5.6 受控例外:响应交互按钮乐观清除 + 服务端合法性验证 + +对**本玩家点击触发的响应交互按钮**(碰/杠/过/胡 操作按钮、手动掷骰按钮),**允许**在点击发包时**乐观隐藏**该按钮——它同时充当防连点(按钮没了就点不了第二次)与即时反馈。这是对 5.1 悲观 UI 的**受控例外**,成立必须**同时满足**下列三条,缺一不可: + +1. **服务端合法性验证兜底(正确性根本)**:正确性**绝不依赖**前端乐观隐藏。服务端对每个操作请求做完整合法性验证(座位鉴权 / 阶段门 / 操作可用性 / 幂等去重),默认拒绝非法/重复/越权/乱序包并回 `success:false` 且**不改状态**。即便乐观隐藏被绕过、或非正规客户端狂发包,服务端也正确拒绝。详见服务端 [04 §8 操作请求合法性验证](../../server/development-guide/04-开发规范与红线.md#8-操作请求合法性验证不可信客户端)。 +2. **收包侧兜底同样成立(AI 托管一致)**:因 AI 托管/他人操作**无点击**、不触发乐观隐藏,该按钮的清除**必须**在收包侧同样能发生——碰/杠/吃后操作者收非空 `availableActions` 覆盖刷新、胡后收包隐藏操作按钮、掷骰结果收包 `hideManualDiceUI`、「过」由收包据服务端下发的空 `availableActions` 清(或"进入托管即隐藏交互"覆盖)。**不得因加了乐观清除就删除收包兜底**。 +3. **仅限交互按钮、不外扩**:例外只覆盖"玩家自己点击的响应/掷骰交互按钮"。其余一切表现(等待听牌/报定等待/掷骰提示文字等**提示**、当前控制权高亮、倒计时、落牌进牌河、阶段/托管图标、他人手牌/副露)**仍严格收包驱动**。 + +一句话:**乐观清除是即时反馈 + 防连点,收包侧与服务端验证才是权威——三者并存,不是用乐观清除替代收包/服务端。** + +**例外项验收**(5.5 两条路径一致仍成立,只是真人侧多了"乐观隐藏"一步): +- 真人:点击 → 按钮立即隐藏(乐观);服务端验证通过走正常流程,若拒绝(`success:false`)则按钮由收包纠正(重显或按服务端权威保持隐藏)。 +- AI 托管:无点击 → 按钮由收包侧隐藏,表现与真人一致(**重点验「过」不残留**)。 +- 连点/非法包:服务端拒绝,状态不变。 + +--- + +## 6. 启动编排 一局前端的启动由一段**启动编排**一次性完成(经 `Game_Modify.appStart` 触发): @@ -132,7 +205,7 @@ function handleXxx(data) { --- -## 6. controllers 与 managers 职责 +## 7. controllers 与 managers 职责 | 类别 | 角色 | 典型成员 | |------|------|----------| @@ -157,7 +230,7 @@ function handleXxx(data) { --- -## 7. 本篇 DO / DON'T +## 8. 本篇 DO / DON'T | DO ✅ | DON'T ❌ | |------|---------| @@ -165,6 +238,8 @@ function handleXxx(data) { | 收包统一进收包分发器,一 rpc 一处理器 | 在 `Game_Modify.*` 或散点里直接处理推送 | | 受限入口只解包转交,业务在新架构 handler | 在受限文件堆业务逻辑 | | 成败只认 `data.success` | 用 `status`/`code` 判成败、写 status 兜底 | +| 点击只发请求包,对局状态表现等收包后更新(响应/掷骰交互按钮乐观清除为 5.6 受控例外) | 提示/控制权/倒计时/落牌等点击即更新(乐观预测)——AI 托管无点击时界面卡死 | +| 收包处理器触发源无关,数据从包字段读 | 依赖「点击时暂存的本地变量」渲染 | | 重连/重画复用同一路径,据数据重建界面 | 重连单写一套与正常对局不同的渲染 | | 业务在 controllers/managers,各司其职 | 处理器交叉重造别人的能力 | diff --git a/docs/client/development-guide/05-开发规范与红线.md b/docs/client/development-guide/05-开发规范与红线.md index e2022ed..afdaa23 100644 --- a/docs/client/development-guide/05-开发规范与红线.md +++ b/docs/client/development-guide/05-开发规范与红线.md @@ -74,6 +74,7 @@ - **服务端权威**:所有核心运算与胜负裁定以服务端为准、数据以服务端为权威;前端只做界面展示与玩家交互,本地数据仅为「渲染副本」,不做权威计算。 - **组件数据自持 + set-refresh**:组件(及其下每个「零件 UI」)的自有数据集中在 `this.data`,**不散落**各处;每个 UI 都有 `setXxx`(只写数据)/`refreshXxx`(只据数据画界面)成对方法,组件另有总 `refresh()` 据 `this.data` 重建整块界面(见 02)。 +- **更新时机——发包只请求、收包才表现(悲观 UI / 输入-渲染解耦)**:用户点击**只发请求包**,**绝不**在点击时改动任何对局状态界面(提示显隐、按钮增删、当前控制权、倒计时启停、落牌、阶段/托管图标);这些表现一律在**收到服务端结果/推送包后**更新。点击回调只做「发包 +(可选)纯本地物理反馈(按下高亮/音效、防连点 `disable`)」。收包处理器**触发源无关**——数据从包字段读、不依赖「点击时暂存的本地变量」,故真人操作与 **AI 托管/他人广播共用同一更新路径、对前端透明**(无点击时也正确更新);把更新挂在「点击」而非「收包」会导致 AI 托管时界面卡死。详见 [04 §5](./04-网络对接与启动编排.md#5-输入渲染解耦发包只请求收包才表现)。**受控例外**:本玩家点击的**响应/掷骰交互按钮**(碰/杠/过/胡按钮、手动掷骰按钮)的**隐藏**允许乐观清除(兼作防连点+即时反馈),前提是**服务端合法性验证 + 收包侧兜底(AI 托管一致)**,见 [04 §5.6](./04-网络对接与启动编排.md#56-受控例外响应交互按钮乐观清除--服务端合法性验证) 与服务端 [04 §8](../../server/development-guide/04-开发规范与红线.md#8-操作请求合法性验证不可信客户端);其余提示/控制权/倒计时/落牌仍严格收包驱动。 - **收包节奏**:**先 `setXxx` 写数据 →(必要时 `refresh` 刷静态界面)→ 再播动画 → 动画回调里只刷新界面**;动画的开始/结束/出错等生命周期回调里**绝不设置核心数据**,动画期间**不改数据**。 - **重画随时可用(断线重连 + 硬刷新都要处理)**:重连/页面重载的本质是**恢复数据 → 调各 UI 组件 `setXxx`/`refreshXxx` 恢复数据与界面状态**;任何时候调 `refresh` 都能据 `this.data` 重建正确界面(断线重连、硬刷新/网页重载、切 app 复用**同一条**重画路径,不为重连单写一套渲染,详见 04 §3.3)。 - **动画是体验层**:开发阶段可**先不做动画**只保证静态界面正确;即使动画缺失/卡住/播错,数据、逻辑与界面仍正确、互不影响。 @@ -90,10 +91,10 @@ ## 8. shared 同步 -- **`shared/` 是子游戏自己的游戏逻辑,与平台无关**:存放本玩法**前后端必须算出完全一致**的纯逻辑(胡牌/听牌/比精/牌型/计分/规则常量等),前端用于即时表现与预校验,服务端做权威裁定。它不是平台代码,平台既不提供也不感知。 +- **`shared/` 只放"前后端都真正用到"的那部分逻辑,务必慎重**:进 `shared/` 的门槛不是"它是玩法逻辑",而是"**前端确实会用到、且两端必须算出完全一致的结果**"。因为**前端只做展示、不做核心运算**(服务端权威,见 §5)叠加**悲观 UI**(可操作项/提示/结果由服务端推送、前端不预测,见 §5),**前端实际会重算的逻辑很少**——发牌/胡牌裁定/计分/AI 等计算类逻辑前端根本不算(服务端算好推来展示即可),它们**不进 `shared/`**。只有本地即时预判/高亮所需的校验、或纯展示所需的规则常量/牌型映射等,才把**那一部分**提升进 `shared/`。**默认留服务端**,拿不准就别放。它不是平台代码,平台既不提供也不感知。 - `01_SubGame/codes/shared/` 是服务端 `server/<游戏容器目录>/<游戏>/shared/` 的**同步副本**,前端**只读**(脚本生成)。 - 改共享算法只改服务端权威源,再运行同步脚本覆盖前端副本;**禁止直接编辑前端 `codes/shared/`**。 -- 逻辑同源不改变数据权威:前端 `shared/` 算的是表现/预判,最终以服务端为准(数据优先、表现延后,见 §6)。详见服务端 [04 §8 shared 文件同步流程](../../server/development-guide/04-开发规范与红线.md#8-shared-文件同步流程)。 +- 逻辑同源不改变数据权威:前端 `shared/` 算的是表现/预判,最终以服务端为准(数据优先、表现延后,见 §6)。详见服务端 [04 §9 shared 文件同步流程](../../server/development-guide/04-开发规范与红线.md#9-shared-文件同步流程)。 --- @@ -125,6 +126,7 @@ | 常量 | 精灵/群组/图层/图片/声音/Spine/坐标/动画/事件全进常量;UI 代码禁硬编码 id 与裸值 | | 组件 | 继承 `BaseComponent`;事件 `addEventListener`;`destroy` 清动态精灵/定时器 | | 数据 | 服务端权威(核心运算/裁定在服务端);组件自有数据集中 `this.data`;每个 UI/零件有 set/refresh 对、组件有总 `refresh` | +| 更新时机 | 点击只发请求包,对局状态表现等**收包后**更新(悲观 UI);收包处理器触发源无关、数据从包字段读;AI 托管/他人广播共用同一更新路径。**受控例外**:响应/掷骰交互按钮隐藏可乐观清除(防连点+即时反馈),前提是服务端合法性验证+收包兜底(04 §5.6、服务端 04 §8) | | 节奏 | 先写数据后表现;动画回调只刷界面、**不写核心数据**;重画可随时据数据还原界面 | | 成败 | 只认 `data.success`,禁 `status` 兜底 | | 收发包 | 发走语义化发包封装、收走收包分发器;业务不进 `Game_Modify` | diff --git a/docs/client/development-guide/README.md b/docs/client/development-guide/README.md index 169e987..d544fae 100644 --- a/docs/client/development-guide/README.md +++ b/docs/client/development-guide/README.md @@ -66,6 +66,7 @@ BaseComponent / UIManager(ui,组件化与场景) ← gameabc-framework( - **组件数据自持 + set-refresh**:组件自有数据集中 `this.data`;每个 UI/零件有 `setXxx`(只写数据)/`refreshXxx`(只据数据画)对,组件有总 `refresh()` 可据数据重建界面。 - **UI 组件专职自己的界面**:每个界面的数据与渲染只由其对应 UI 组件实现;别的模块要改/刷该界面一律**调该组件的公开接口**(`setXxx`/`refreshXxx`/语义方法),**禁止**在别处重复实现重叠或类似的界面数据/渲染逻辑。 - **显隐走 `showXxx`/`hideXxx`**:UI 组件/零件的精灵、群组显隐必须由组件暴露的 `showXxx`/`hideXxx` 接口控制;**禁止**别处直接用其精灵 ID/群组 ID 去 `SpriteManager.show/hide` 控显隐。 +- **发包只请求、收包才表现(悲观 UI / 输入-渲染解耦)**:用户点击**只发请求包**,对局状态界面(提示/按钮/控制权/倒计时/落牌/阶段)一律**收到服务端结果或推送包后**才更新,点击时不做乐观预测;收包处理器**触发源无关**(数据从包字段读,不依赖点击时的本地变量),故真人操作与 **AI 托管/他人广播共用同一更新路径、对前端透明**——挂在「点击」而非「收包」会导致 AI 托管时界面卡死(详见 04 §5)。**受控例外**:本玩家点击的**响应/掷骰交互按钮**(碰/杠/过/胡、手动掷骰按钮)的隐藏允许乐观清除(兼作防连点+即时反馈),前提是**服务端合法性验证 + 收包侧兜底(AI 托管一致)**,见 04 §5.6 与服务端 04 §8;其余提示/控制权/倒计时/落牌仍严格收包驱动。 - **数据优先、表现延后**:收到推送先 `setXxx` 写数据、再播动画;动画的开始/结束/出错回调里**只刷界面、不写核心数据**。即使动画缺失/卡住/出错,数据、逻辑、界面仍正确、互不影响。 - **重连即重画(断线重连 + 硬刷新都要处理)**:重连/页面重载的本质是**恢复数据 → 调各 UI 组件 set-refresh 恢复数据与界面状态**,复用同一条重画路径,不为重连单写一套渲染(详见 04 §3.3、05 §6)。 - **成败只认 `data.success`**:前端一律 `if (!data.success)` 判成败,**不**用 `status`/`code`,**不**写 `status` 兼容兜底。 diff --git a/docs/server/development-guide/03-数据收发与通信协议.md b/docs/server/development-guide/03-数据收发与通信协议.md index 5e5dbc4..809809a 100644 --- a/docs/server/development-guide/03-数据收发与通信协议.md +++ b/docs/server/development-guide/03-数据收发与通信协议.md @@ -238,7 +238,7 @@ if (!data.success) { /* 失败处理 */ return; } ## 7. 服务端代替玩家操作时的透明性 -服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),**必须复用真人操作的同一套数据包与广播链路**:产生的包结构、`rpc`、`data` 与真人操作**完全一致**,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 04 的对应红线。) +服务端自动替玩家执行的操作(如 AI 托管出牌/碰杠胡),**必须复用真人操作的同一套数据包与广播链路**:产生的包结构、`rpc`、`data` 与真人操作**完全一致**,前端无需也禁止为它单开一套接收/解析分支。唯一区别只在触发源(前端请求 → 服务端决策),其后的数据组织、协议、广播路径不变。(详见 [04 §7 自动操作复用真人链路](./04-开发规范与红线.md#7-服务端自动操作复用真人链路) 与 [§8 操作请求合法性验证](./04-开发规范与红线.md#8-操作请求合法性验证不可信客户端)。) --- diff --git a/docs/server/development-guide/04-开发规范与红线.md b/docs/server/development-guide/04-开发规范与红线.md index c819a6f..a98c07d 100644 --- a/docs/server/development-guide/04-开发规范与红线.md +++ b/docs/server/development-guide/04-开发规范与红线.md @@ -102,14 +102,60 @@ function foo() { GameStateManager.doSomething(); } --- -## 8. Shared 文件同步流程 +## 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/` 属于子游戏,不是平台代码**:它存放**本玩法自身**的规则、算法、常量、数据结构(本项目如胡牌检测 `WinDetectionFactory`、精牌 `JingAlgorithm`、计分 `ScoreCalculation`、牌型/比精,以及 `constants/` 下各类常量)。平台**既不提供也不感知** `shared/`——它完全落在子游戏可编辑范围内,是你玩法逻辑的一部分。 -- **为什么要"共享"**:**同一份玩法逻辑需要在两处运行**——服务端(Node)做**权威裁定**,前端(浏览器)做**即时表现/预校验**。若两端各写一份,必随规则演化而分叉、算出不一致结果。因此把这类"前后端必须完全一致"的纯逻辑收进 `shared/`,两端跑**逐字相同**的代码。 +- **`shared/` 属于子游戏,不是平台代码**:它存放**本玩法自身**、且**前后端都真正会用到**的那部分纯逻辑/常量/数据结构。平台**既不提供也不感知** `shared/`——它完全落在子游戏可编辑范围内,是你玩法逻辑的一部分。 +- **为什么要"共享"**:**同一段逻辑需要在两处运行且必须算出完全一致的结果**——服务端(Node)做**权威裁定**,前端(浏览器)确有场景**在本地即时预判/预校验**同一段逻辑。若两端各写一份,必随规则演化而分叉、算出不一致结果。因此把这类"两端都用、且必须逐字一致"的纯逻辑收进 `shared/`,两端跑**逐字相同**的代码。 - **它与"数据权威"的关系**:`shared/` 是**逻辑同源**(两端同一套算法),不改变**数据权威**(结果仍以服务端为准,见 §4);前端算出的只是表现/预判,最终以服务端 `shared/` 算的为准。 +### 准入门槛:慎重——默认留服务端,别把 shared 当玩法逻辑的垃圾桶 + +**进 `shared/` 的唯一理由是"前后端都真正会用到这段逻辑",不是"它是玩法逻辑"。** 判定务必慎重: + +- **前端只做展示、不做核心运算**(服务器权威,裁定与结果均由服务端下发,见 §4),叠加**悲观 UI**(可操作项/提示/落子结果由服务端推送、前端不预测,见前端 04 §5)——因此**前端实际会重算的玩法逻辑很少**。 +- 绝大多数**计算类逻辑**(发牌、洗牌、胡牌/听牌裁定、计分、AI 决策、随机数等)前端**根本不重算**:结果由服务端算好推给前端展示即可。它们应**只留在服务端、不进 `shared/`**。 +- 只有当某段逻辑**确有明确的前端使用场景**——例如需要本地即时预判/高亮可操作项,或纯展示所需的**规则常量、牌型/花色映射、文案枚举**——才把**那一部分**提升进 `shared/`;提升的是"前端确需的最小子集",不是把整套算法一并搬过去。 +- **默认放服务端**:拿不准某文件前端到底用不用,就先留在服务端子游戏目录下,等出现真实的前端使用点再提升进 `shared/`。宁可后补,也不要预防性地把一堆前端用不到的文件塞进 `shared/`(徒增只读副本体积、误导读者以为前端在重算)。 + ### 同步流程:服务端权威源 → 前端只读副本 前后端各持一份 `shared/`,**必须**经同步流程单向更新,禁止直接编辑前端副本: @@ -123,19 +169,26 @@ function foo() { GameStateManager.doSomething(); } 2. 改完运行根目录的同步脚本(如 `sync-shared.ps1`)同步到前端。 3. **禁止直接编辑前端 `codes/shared/`**(同步脚本会覆盖)。 -> 判别一段逻辑该不该进 `shared/`:**它是不是"前后端必须算出完全相同结果"的玩法逻辑**?是(胡牌/听牌/比精/牌型/计分/规则常量)→ 放 `shared/`;只是服务端流程编排或只是前端表现 → 各自放自己那侧,不进 `shared/`。 +> 判别一段逻辑该不该进 `shared/`,连问两关:**①前端到底用不用它?**(前端只做展示、悲观 UI 下多数计算不重算——不用 → 只留服务端)**②两端是否必须算出完全一致的结果?**(是 → 才有共享价值)。两关都过(前端确有本地预判/展示用途,且必须与服务端逐字一致,如某些规则常量、牌型映射、本地可高亮的预校验)→ 放 `shared/`;**只要有一关不过**——服务端流程编排、只有服务端裁定的计算(发牌/胡牌/计分/AI)、只是前端表现——都**各自放自己那侧,不进 `shared/`**。 --- -## 9. 硬编码常量准则 +## 10. 硬编码常量准则 -**需提取为常量**(满足任一):会随规则变化、跨模块复用、裸值无法自解释。典型:分数数值、状态/类型字符串标识、配置阈值、跨模块共享的 key 名。常量按语义归入 `shared/constants/` 下对应文件(分数类、规则配置类、类型枚举类)。 +**需提取为常量**(满足任一):会随规则变化、跨模块复用、裸值无法自解释。典型:分数数值、状态/类型字符串标识、配置阈值、跨模块共享的 key 名。 + +**魔法字符串尤其要慎重、默认常量化**:出现在**判等/分支/作为 key/跨模块传递**的字符串字面量(状态名、类型/枚举值、rpc 名、字段名、事件名等)几乎都属"魔法字符串"——**同一个值被硬编码在多处**时,拼写漂移、改名遗漏、无法被搜索/校验,是隐性 bug 的高发点。这类一律**提取为单一来源的常量**、各处只引用;同一语义禁止在多处重复写裸串。 + +**常量定义放哪里,同样要慎重(沿用 §9 的 shared 准入门槛)**:常量并非一律进 `shared/constants/`。判据仍是"**前后端是否都真正用到**"—— +- **仅服务端用到**的常量(服务端内部状态机名、只在服务端分支的类型标识、服务端流程 key 等)→ 放在**服务端子游戏目录内**的常量文件,**不进 `shared/`**。 +- **前后端都会用到、且必须取值完全一致**的常量(前端展示/预判也要引用的规则常量、牌型/花色映射、双方约定的枚举值等)→ 才归入 `shared/constants/` 下对应文件(分数类、规则配置类、类型枚举类)。 +- 默认放服务端一侧,只有出现真实的前端引用点,才把**那一部分**常量提升进 `shared/`。 **不必提取**(避免过度设计):单函数内一次性临时值(循环初值 `0`、空数组 `[]`)、框架约定固定串(`require` 路径)、自解释布尔开关、纯展示标点文字。 --- -## 10. 测试纪律 +## 11. 测试纪律 - **测试唯一目的是验证业务正确性**。失败是有价值的信号,第一反应是**定位根因**,不是"让测试变绿"。 - **禁止任何掩盖手段**:skip/条件 return、软化断言、放宽阈值、try-catch 吞异常、把硬断言改成"存在才校验"。 @@ -151,7 +204,7 @@ function foo() { GameStateManager.doSomething(); } --- -## 11. Git 提交规范 +## 12. Git 提交规范 - **及时自动提交**:每完成一个可独立成立的逻辑改动(一个修复/功能/重构/一批测试)就**立即提交**,不堆积工作区。 - **无需逐次询问**:完成阶段性改动后主动提交(`push` 按需)。 @@ -159,7 +212,7 @@ function foo() { GameStateManager.doSomething(); } --- -## 12. 审查速查表 +## 13. 审查速查表 | 维度 | 红线 | |------|------| @@ -170,6 +223,7 @@ function foo() { GameStateManager.doSomething(); } | 职责 | 一职能一模块,调用不重造 | | 隔离 | 状态挂 `o_desk.data.*`,禁全局;`房间+seat` 作 key;定时器随房清理 | | 自动操作 | 复用真人链路,对前端透明 | +| 请求验证 | 客户端不可信;每个操作请求过座位鉴权/阶段门/操作可用性/幂等去重/参数五层校验,统一网关默认拒绝、失败 `success:false` 不改状态;出牌回合门尤其不可漏(防死代码) | | Shared | 只改服务端 `shared/`,跑同步脚本 | | 测试 | 失败裁定归属、禁掩盖;正式代码不迁就测试 | | Git | 一事一提交、中文信息、及时提交 |