文档红线细化:新增服务端请求合法性验证(§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>
This commit is contained in:
@@ -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-操作请求合法性验证不可信客户端)。)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 | 一事一提交、中文信息、及时提交 |
|
||||
|
||||
Reference in New Issue
Block a user