文档:新增「前端数据驱动架构」规范(前端无对局状态机 + 防作弊四条)

前端的阶段/状态/显示一律以服务器为准,前端不得自建状态机、不得本地推导或推进流程;
由此推出请求包只带意图、服务端按可见性下发两条防作弊约束。

- client 05 §6 改为「数据驱动架构:服务端状态的投影」,新增 6.1 前端无对局状态机、
  6.2 视图=f(服务端快照)(丢弃 this.data 仅凭最近快照重画须一致)、6.3 本地 UI 态白名单
  (并澄清与 04 §5.6 乐观清除例外的关系);原条目归入 6.4
- client 04 §1 新增「请求包只带意图,不带结论」;§5.3 补「不得据本地推断补齐未下发状态」
- server 03 新增 §1.3 按可见性下发(下发即泄露)、§1.4 状态机唯一在服务端并随包下发
- server 04 §4 补「前端不是数据源」;§8 新增「只接受意图入参,客户端回传结论一律忽略」
- 两份 README 红线速查与审查速查表同步补条目

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 20:02:55 +08:00
co-authored by Claude Opus 5
parent 681be60e20
commit b255730c1b
6 changed files with 102 additions and 5 deletions
@@ -55,6 +55,28 @@
> 一句话:**服务端权威不仅是"服务端算",也是"服务端把界面要用的核心数据发全"——前端只据包渲染,不推算、不兜底。**
### 1.3 发全 ≠ 发多:按可见性下发(防作弊)
§1.2 要求"该看到的发全",本节是它的另一半:**不该看到的一律不发**。前端是不可信环境——**下发即泄露**:包到了客户端就能被抓包/改内存看到,"前端拿到但不渲染"等于零防护。
- **按座位裁剪可见面**:他人手牌、牌堆剩余序列与顺序、未公开的判定结果(他人是否听牌/能否胡、暗牌内容、未到揭示时机的底牌与亮牌明细)——**对不该看到的座位不放进 `data`**,用差异化下发(`sendtype:1` + `seatlist[]` 逐座位组包,见 §3)实现。
- **只发"公开面"的统计量**:他人的信息若界面确实要显示,只发**已公开的派生量**(如剩余张数、已亮出的花色/数量),不发原始牌面。
- **不发未来**:尚未发生或尚未公开的权威结果(下一张要摸的牌、预先算好的胜负、待揭晓的底牌)不提前下发,哪怕前端"只是缓存"。
- **判别**:问一句 **"这个字段落到一个改过的客户端手里,玩家会不会因此获得优势?"** 会 → 不发(或只发到该看到的座位)。
- **违例信号**:`deepCopy(baseData)` 后**没有**逐座位裁剪敏感字段就全员广播;把全量 `handCards`/`cardPool` 塞进公共包;重连快照 `get_deskinfo` 直接回整张桌的内部状态。
> §1.2 与 §1.3 合起来才是完整的下发面:**该看到的一个不少(否则前端缺数据),不该看到的一个不多(否则等于送作弊入口)。**
### 1.4 阶段与状态机唯一在服务端,并随包下发
前端**不持有对局状态机**(前端侧规范见 [客户端 05 §6](../../client/development-guide/05-开发规范与红线.md)):阶段、轮到谁、该座位可用操作、倒计时基准,前端一概不推导。因此这些字段**必须由服务端唯一维护、并在每个相关下发包里明确给出**:
- **阶段/状态标志**(当前 `phase`、是否在响应窗口、是否已结束等)——不让前端按"收到了什么包"去反推阶段。
- **控制权**(下一个该谁操作,如 `nextControlSeat`/`currentPlayer`)——显式字段,不让前端按座位顺序自己算。
- **该座位可用操作**(`availableActions[seat]`)——按座位下发;它同时是服务端准入白名单(见 [04 §8](./04-开发规范与红线.md#8-操作请求合法性验证不可信客户端)),**同一份权威数据既驱动前端按钮显示、又校验请求合法性**,天然不会两边判得不一致。
- **倒计时锚点与时长**——前端只做本地插值显示,**超时的裁定与后续推进仍在服务端**,不接受前端上报"我超时了"。
- **状态变更必须有包**:任何阶段推进都要有一个下发包承载;**没有包的状态变化 = 前端不可能正确显示**,只能靠前端猜——那正是本节要杜绝的。
---
## 2. 收包处理的固定步骤(在 handler 里)
@@ -124,7 +146,7 @@ for (var seat = 0; seat < o_room.seatlist.length; seat++) {
}
```
> 敏感信息(手牌、暗牌、未公开的判定)**只发给该看到的人**,这是服务器权威的一部分。
> 敏感信息(手牌、暗牌、未公开的判定)**只发给该看到的人**,这是服务器权威的一部分。裁剪清单与判别标准见 [§1.3 按可见性下发](#13-发全--发多按可见性下发防作弊)——**下发即泄露,前端"拿到但不渲染"不算防护**。
---
@@ -246,6 +268,8 @@ if (!data.success) { /* 失败处理 */ return; }
- 包结构恒为 `{app, route, rpc, data}`;收包先 `check_player`,失败静默 `return`。
- **下发包必须发全前端界面所需的核心数据**(§1.2):前端以服务端为权威、不自算,漏发修在服务端发包处、前端不补洞。
- **不该看到的一律不发**(§1.3):下发即泄露,按座位裁剪可见面;"该看到的一个不少、不该看到的一个不多"。
- **阶段/控制权/可用操作/倒计时锚点由服务端唯一维护并显式下发**(§1.4):前端不持有状态机、不反推阶段;状态变更必须有包承载。
- 三种发包方式按"谁该看到什么"选;差异化广播逐座位定制,敏感信息只发本人。
- **主动推送是唯一可靠下发通道**,`return` 不算。
- **成败只认 `data.success`**,推送必自带 `success`,禁止 `status`/`code` 判成败与兼容兜底。
@@ -59,6 +59,7 @@ function foo() { GameStateManager.doSomething(); }
- **下游不修补**:下游不得为"让流程跑起来"而补写/重算上游本该提供的数据;发现下游在修补,应把责任前移到权威来源。
- **边界**:只有展示层、纯 UI 兼容、非关键日志字段,才可在明确边界内用安全默认值。
- **下发面要发全**:因前端以服务端为权威、不自算权威结果,服务端**每个下发包必须携带前端界面所需的全部核心数据**(界面要显示、或前端 set-refresh 要用的字段都发全);漏发 = 前端缺数据,**修在服务端发包处、前端不补洞**(详见 [03 §1.2](./03-数据收发与通信协议.md))。
- **前端不是数据源**:客户端只提供"意图"(想做什么、对哪个目标),**永远不是任何业务数据的权威来源**。凡进入服务端状态的值——分数、牌面、阶段、控制权、结算——只能由服务端自己算出或从 `o_desk.data.*` 读出,**绝不采信包体里前端算好的同名字段**(见 §8)。对应地,阶段/控制权/`availableActions`/倒计时锚点由服务端唯一维护并显式下发,前端不推导(见 [03 §1.4](./03-数据收发与通信协议.md)、前端 [05 §6](../../client/development-guide/05-开发规范与红线.md))。
> 审查信号:看到 `|| []`、`|| 0`、`|| ''`、三元默认、双源字段,先判断它是不是**权威字段**(影响发牌/庄家/手牌/规则/结算/重连/状态流转)。是 → 改成权威读取;只是展示/日志 → 才可保留默认。
@@ -117,6 +118,15 @@ function foo() { GameStateManager.doSomething(); }
4. **幂等 / 去重 / 时序(连点的根治)**:不是限流,而是**状态机的单调推进**——一个操作被处理后立即推进状态并更新 `availableActions`,使**重复包因落在新 `availableActions` 之外而天然非法**;响应窗口对同座位重复响应去重(如 `addPlayerResponse` 记录后再收同座位响应即忽略);对已结束窗口/轮次的迟到包丢弃。
5. **参数 / 数据合法性**:牌 `uniqueId` 是否真在该玩家手里、`targetCard`/`fromSeat`/`choiceIndex` 是否与权威牌局一致 —— 全按**服务端权威数据**校验,不信客户端传的牌面。
### 只接受「意图」入参:客户端回传的结论一律忽略
第 5 层的前提是**入参里根本不该出现结论**。请求包只表达"我想做什么"(操作类型 + 目标标识),**不表达"结果是什么"**(前端侧规范见 [客户端 04 §1「请求包只带意图」](../../client/development-guide/04-网络对接与启动编排.md)):
- **协议层不定义结论入参**:`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**。好处:
@@ -204,6 +214,9 @@ function foo() { GameStateManager.doSomething(); }
| 语言 | 纯 ES5;`require` 仅在顶部守卫块 |
| 成败 | 只认 `data.success`,推送必自带,禁 `status` 兜底 |
| 数据 | 权威唯一、缺失报错/返回 null、禁兜底、下游不修补;下发包发全前端界面所需核心数据 |
| 前端不是数据源 | 请求包只接受「意图」(操作类型+目标标识),协议不定义 `score`/`isWin`/`phase` 等结论入参,收到也忽略并按权威数据重算;包内 `seat` 只作一致性校验 |
| 下发可见性 | 不该看到的不发(他人手牌/牌堆序列/未公开判定/未来结果),按座位差异化裁剪;"下发即泄露",前端不渲染不算防护(03 §1.3) |
| 状态机归属 | 阶段/控制权/`availableActions`/倒计时锚点由服务端唯一维护并显式下发,前端不推导;状态变更必须有包承载(03 §1.4) |
| 职责 | 一职能一模块,调用不重造 |
| 隔离 | 状态挂 `o_desk.data.*`,禁全局;`房间+seat` 作 key;定时器随房清理 |
| 自动操作 | 复用真人链路,对前端透明 |
+5 -2
View File
@@ -21,8 +21,8 @@
| 00 | 本文 README | 这套文档是什么、怎么读、红线速查 |
| 01 | [01-服务端环境与框架基础.md](./01-服务端环境与框架基础.md) | 平台与子游戏的关系、核心对象模型、三层路由、房间生命周期 |
| 02 | [02-子游戏接入与开发流程.md](./02-子游戏接入与开发流程.md) | 三文件架构、export/import 接口、makewar/重连、一次操作的完整数据流 |
| 03 | [03-数据收发与通信协议.md](./03-数据收发与通信协议.md) | 包结构、收发包方式、主动推送、成败标志协议、前后端对接点 |
| 04 | [04-开发规范与红线.md](./04-开发规范与红线.md) | 可编辑范围、ES5/require、数据权威、模块职责、房间隔离、测试纪律 |
| 03 | [03-数据收发与通信协议.md](./03-数据收发与通信协议.md) | 包结构、收发包方式、**下发面发全 / 按可见性裁剪 / 状态机唯一在服务端**、主动推送、成败标志协议、前后端对接点 |
| 04 | [04-开发规范与红线.md](./04-开发规范与红线.md) | 可编辑范围、ES5/require、数据权威(含**前端不是数据源**)、模块职责、房间隔离、请求合法性验证、测试纪律 |
建议第一次**从 01 顺序读到 04**;之后把 03/04 当手册随用随查。
@@ -85,6 +85,9 @@ o_room.method.sendpack_toseat 按座位取连接信息(conmode/fromid) → 下
- **房间隔离**:一切随对局变化的状态都挂 `o_room.o_desk.data.*`;**禁止模块级单例/全局变量存对局态**;缓存/定时器/决策表不得只用 `seat` 作 key,必须 `房间 + seat`;结束/解散/开新局时按房间清理全部定时器与残留状态。
- **服务端自动操作复用真人链路**:服务端代替玩家做的操作(如 AI 托管)必须复用「真人操作」的同一套处理入口与广播链路,对前端透明,不为它单开一套下发分支。
- **服务器权威 + 发全下发面**:房卡、胜负、积分、状态等关键数据与操作一律以服务器为准、前端只作显示不自算;因此**服务端每个下发包必须携带前端界面所需的全部核心数据**,漏发修在服务端发包处、前端不补(详见 03 §1.2 / 04 §4)。
- **状态机唯一在服务端**:前端是**数据驱动**的、不持有对局状态机(见前端 05 §6),所以阶段、控制权(轮到谁)、该座位 `availableActions`、倒计时锚点**必须由服务端唯一维护并在相关下发包里显式给出**;**任何状态变更都要有包承载**——没有包的状态变化等于逼前端去猜。超时裁定在服务端,不接受前端上报"我超时了"(详见 03 §1.4)。
- **前端不是数据源,只收「意图」**:请求包只接受「操作类型 + 目标标识」;协议**不定义** `score`/`isWin`/`phase`/`nextSeat`/`handCards` 之类**结论入参**,即便客户端硬塞也一律**不读、不落地**,全部按服务端权威数据重算;包内 `seat` 只作一致性校验、身份由连接反查(详见 04 §8)。
- **发全 ≠ 发多,按可见性下发**:他人手牌、牌堆剩余序列、未公开判定、尚未揭晓的结果**不发给不该看到的座位**(差异化下发逐座位裁剪)。**下发即泄露**——包到了客户端就能被抓包看到,"前端拿到但不渲染"不算防护(详见 03 §1.3)。
- **测试纪律**:测试失败先用证据裁定「业务缺陷 vs 测试脚本缺陷」,禁止 skip/软化断言/吞异常掩盖;**正式代码不得为兼容测试而加逻辑**。
---