# 掼蛋卡牌游戏 — 设计规格

**日期：** 2026-05-28
**引擎：** Godot 4.x
**语言：** GDScript

---

## 一、分层架构

```
UI 层 (Godot) → 业务逻辑层 (纯 GDScript) → 数据层 (纯 GDScript)
                     ↓
                 网络层 (Godot ENet，后期)
```

| 层 | 职责 | 技术选型 | 说明 |
|---|------|----------|------|
| **UI 层** | 场景渲染、手牌布局、拖拽出牌、按钮交互、动画、音效 | Godot Control 节点 / Tween | 唯一依赖 Godot API 的层，纯"表现" |
| **业务逻辑层** | 牌型识别、比较大小、出牌合法性、回合流转、得分计算、AI 决策 | 纯 GDScript（零 Godot 依赖） | 核心层，不引用任何 Godot 场景/节点类 |
| **数据层** | 牌、牌堆、手牌、回合状态、玩家数据、配置表 | 纯 GDScript 数据结构 | 不可变数据结构优先，便于状态回滚 |
| **网络层** | 联机消息格式、同步协议、断线重连 | Godot ENet | 后期开发，前置预留接口 |

**交互规则：**
- UI 层通过信号/回调调用逻辑层（如 `logic.play_cards(player, cards)`）
- 逻辑层返回结果后，UI 层负责渲染
- 逻辑层完全不感知 UI、网络或 AI 的存在（依赖倒置）

**异常码定义：**

- 逻辑层所有操作统一返回 `Result` 类型：`{ok: bool, error_code: int, data: Variant}`
- 错误码在 `constants.gd` 中定义枚举：`ERR_INVALID_CARDS`、`ERR_NOT_YOUR_TURN`、`ERR_CANNOT_PASS`、`ERR_CARD_NOT_FOUND`、`ERR_HAND_EMPTY` 等
- UI 层根据 `error_code` 做对应提示文案，支持多语言

**线程安全约束：**

- `core/` 和数据层禁止写全局变量，所有状态通过参数传入、返回值传出
- 避免联机/异步场景下的数据竞争，逻辑层保持无状态函数风格
- 若需缓存计算结果（如 AI 评分），使用局部闭包而非全局状态

**逻辑纯函数约束：**

- 核心判定函数（牌型识别、比较大小、合法性校验）必须为纯函数：相同输入必须返回相同输出，无副作用
- 禁止在核心函数中操作文件、网络、定时器等外部资源
- 便于单元测试、并行 AI 计算、状态回滚验证

**逻辑层的异步执行方案：**

- AI 计算（尤其 L2/L3 枚举所有合法出牌组合）是 CPU 密集型操作，同步调用会卡死 UI 线程
- 利用 Godot 4.x `WorkerThreadPool` 将 AI 决策放入后台线程计算，通过信号或 `await` 将结果回传给 UI 层
- `move_generator` 的枚举计算同样异步化，`game_controller` 负责协调异步调用链

**多线程 AI 的数据竞争防护：**

- Godot 4 的 `WorkerThreadPool` 执行 GDScript 时，**严禁子线程直接读取或修改主线程的 `GameState`**（GDScript 的 Dictionary/Array 非线程安全，会导致崩溃或脏数据）
- AI 线程只能接收**序列化后的纯数据快照**，在子线程完成计算后，仅将生成的 `Action` 序列传回主线程
- 快照格式不可使用 JSON 字符串（频繁编解码导致内存抖动和 CPU 耗时反向超标），应使用**原生包装数组（PackedInt32Array / PackedByteArray）**，利用位掩码（Bitmask）传递手牌和已出牌状态
- 主线程的 `game_controller` 统一调用 `Action.apply()` 修改状态，确保同一时刻只有主线程操作数据

**状态回滚策略 — 命令模式 + Action 重放：**

- GDScript 中深拷贝（`duplicate(true)`）大型 `GameState` 性能极差，不可行
- 采用 **命令模式（Command Pattern）**：每次操作生成 `Action`，执行 `Action.apply(GameState)` 修改状态
- 回滚时从初始状态重放 Action 日志到目标点，而非存储完整快照
- 若需中间快照（如 AI 推演分支），仅对变化的局部数据（Hand、PlayedZone）做浅拷贝，不全局深拷贝

**全局事件总线（EventBus）：**

- 在 `autoload/` 中增加 `event_bus.gd`（全局信号中心），用于广播游戏事件
- 事件类型：`PLAYER_PLAYED_CARDS`、`BOMB_DETONATED`、`TRIBUTE_TRIGGERED`、`ROUND_END`、`GAME_OVER` 等
- UI 组件之间通过订阅 EventBus 信号解耦，避免网状硬编码依赖
- `core/` 层不依赖 EventBus，仅 `game/` 胶水层和 `ui/` 层使用

**EventBus 内存泄漏防护：**

- UI 节点（如动态生成的 `card_node.tscn`）若连接全局 EventBus 信号但在销毁时未断开，会导致严重内存泄漏和野指针崩溃
- 在 UI 基类中重写 `_exit_tree()`，自动遍历并断开所有与 EventBus 的连接
- 或利用 Godot 4 的 `Callable` 弱引用特性（`bind_weak()`）管理信号订阅，节点销毁时自动失效

**配置数据的 Resource 化：**

- 静态配置（卡牌属性、音效映射、AI 权重参数、牌型分值表）优先定义为继承自 `Resource` 的自定义类
- 示例：`class_name CardConfig extends Resource`、`class_name AIParams extends Resource`
- 优势：可直接利用 Godot 编辑器 Inspector 面板可视化调参；底层 C++ 序列化的性能和安全性远超手写 JSON 解析
- `config_loader.gd` 保留用于运行时动态配置（如服务器下发的活动参数），静态配置一律用 `.tres` Resource 文件

**状态不可变性统一规范：**

- GDScript 天生偏可变，需明确不可变性边界：
  - **绝对不可变对象：** `Card`、`PlayedCards`、`Action`（创建后其字段不可修改）
  - **允许局部修改：** `Round`（回合内状态可变）、`GameState`（通过 Action.apply 修改）
  - **禁止共享引用：** 所有 getter 返回副本或只读包装（`duplicate(false)`），禁止返回内部 Array/Dictionary 的原始引用
  - `Array` 和 `Dictionary` 类型的成员变量外部访问时一律浅拷贝

**资源生命周期管理：**

- 场景退出时必须执行清理，防止残留线程、孤儿节点和隐性内存增长：
  - **线程取消：** `_exit_tree()` 中取消所有 `WorkerThreadPool` 未完成任务，`await` 超时后强制丢弃
  - **Replay 中止：** 场景切换时中断回放录制/播放，保存当前进度
  - **节点对象池上限：** `ObjectPool` 设置 `MAX_POOL_SIZE`，超出部分直接 `queue_free()`
  - **Tween 清理：** `_exit_tree()` 中 `stop_all()` 并 `kill()` 所有活跃 Tween
  - **信号断开：** 调用 `disconnect_all()` + 从 EventBus 取消订阅

**日志系统分级：**

- 除 Action 游戏日志外，需另建调试日志系统（`log_manager.gd`，autoload）：
  - `DEBUG`：详细调试信息，Release 默认关闭
  - `INFO`：关键流程节点（发牌、进贡、出牌、升级）
  - `WARN`：异常但可恢复（AI 超时降级、心跳超时、状态不一致）
  - `ERROR`：致命错误（Action 失败、状态机非法转移）
  - `NETWORK`：联机消息日志
  - `AI_TRACE`：AI 决策明细日志
  - `PERF`：性能基准日志
- **安全约束：** `INFO` 及以上禁止打印完整手牌内容；联机模式 `DEBUG` 默认关闭；日志支持写入文件 + 按大小轮转

**多人异步事件顺序约束：**

- 常见冲突场景：动画未播完时收到下一 Action、Pass 与清场同时发生、接风与结算同时触发
- 解决方案：
  - **Event Queue（事件队列）：** `game_controller` 维护优先级事件队列，按时间戳串行消费
  - **UI Transition Lock：** 动画/过渡期间锁住输入，动画结束回调释放锁
  - **强制事件串行化：** 同一帧内只处理一个状态变更，防止 UI 状态错乱
  - 事件优先级：`FATAL_ERROR > GAME_OVER > PLAYER_ACTION > ANIMATION > IDLE`

**配置热更新边界：**

- 配置分为三层，每层有不同的更新约束：

| 层级 | 说明 | 更新时机 | 示例 |
|------|------|----------|------|
| Immutable Match Config | 对局开始后不可变 | 仅在房间创建时 | 牌数、逢人配规则、炸弹规则 |
| Runtime Tweak Config | 可运行时调整 | 对局中或局间 | AI 难度、音效音量、UI 动画速度 |
| Client Cosmetic Config | 纯本地配置 | 随时 | 卡牌皮肤、背景主题、语言 |

- `RuleConfig`（不可变）在联机房间中由房主下发，参与状态哈希；对局中修改需重开房间

---

## 二、核心数据模型

### 卡牌

掼蛋使用两副牌（108张），含大小王。

```
Card:       整数 ID 0-107（两副牌），位运算高效比较大小
            card_id: 0-107 全局唯一 ID，用于日志追踪、网络同步和 UI 渲染
            card_value: 花色 + 点数编码，用于规则比对（忽略唯一 ID）
            Suit: 4 花色 (♠ ♥ ♣ ♦) + Joker
            Rank: 2-14 (2=2, ..., 10=10, J=11, Q=12, K=13, A=14) + 小王=15 + 大王=16
            original_id: 0-53，标记该牌来自第几副牌的第几张，区分两副牌中相同点数花色的牌
Deck:       108 张 Card 列表 → 洗牌、发牌
Hand:       玩家手牌，按 Rank→Suit 排序
```

**card_id 与 card_value 区分：**
- `card_id`（0-107）：全局唯一，日志/同步/渲染时使用
- `card_value`（Suit + Rank 编码）：规则比对时使用，两副牌中相同花色点数的牌共享同一 `card_value`
- 牌型识别、比较大小等核心函数统一按 `card_value` 比较，忽略 `card_id`

### 出牌

```
PlayedCards: 带类型的联合
    {type: TYPE_SINGLE | TYPE_PAIR | TYPE_TRIPLE | ... | TYPE_ROCKET,
     cards: [...],
     primary_rank: 主牌点数,
     is_pure_bomb: bool}  # 炸弹是否为纯炸（无逢人配参与）
```

牌型识别是纯函数：输入 `[Card]` → 输出 `PlayedCards | null`

**多合一牌型歧义与推荐优先级：**

当手牌同时拥有多张逢人配时，同一组牌可能对应多种牌型。例如手牌 `4、5、6` + 两张逢人配：
- 可构成顺子：`4、5、6、逢(7)、逢(8)`
- 可构成连对：`4、4、5、5、6`（逢人配转写为 4 和 5 的对子）
- 可构成三带二：`4、4、4、5、6`（逢人配转写为三同张）

`hand_evaluator.gd` 需定义**牌型推荐优先级枚举**，当同一组牌存在多种合法解释时，按优先级返回默认牌型：

| 优先级 | 牌型 | 说明 |
|--------|------|------|
| 1 | 火箭（四王） | 最高 |
| 2 | 炸弹（含纯炸 > 混炸） | 优先组成更大炸弹 |
| 3 | 同花顺 | |
| 4 | 钢板（三连对） | |
| 5 | 连对 | |
| 6 | 顺子 | |
| 7 | 三带二 | |
| 8 | 三不带 | |
| 9 | 对子 | |
| 10 | 单张 | 最低 |

UI 层在检测到歧义牌型时，允许玩家通过按钮切换可选分支（"以顺子出" / "以连对出"）。

**同花顺配牌花色转写：** 逢人配（红桃级牌）参与拼凑非红桃同花顺时，其自身花色强制转写为目标花色，结算时仅作为目标花色的点数存在，丧失原本的红桃属性。

### 游戏状态

```
Round:      当前回合状态（谁出牌、出的什么、本轮出牌历史）
GameState:  完整一局状态（4个玩家、牌堆、得分、升级、贡牌信息）
            round_seq: 局次编号，支持多局连胜/积分统计
            current_rank: 当前打的级牌等级（2-A），决定逢人配（红桃级牌为万能牌）
```

**2v2 队伍模型（Team）：**

- 掼蛋为 2v2 对战：1、3 号位为一队（Team A），2、4 号位为一队（Team B）
- 算分、升级、进贡、还贡、AI 配合逻辑全部强依赖队伍关系
- `GameState` 引入 `Team` 结构：`{team_id, players: [Player], score, current_level}`
- 每局胜利条件：一队中两人都先出完即为"双下"（大胜），一人先出完为"头游"（小胜）

**局间流转状态机：**

掼蛋完整循环不仅是出牌，还包含复杂的局间阶段。`GameState` 的状态机：

```
INIT → DEAL → TRIBUTE_PHASE → PLAY_PHASE → LEVEL_UP_PHASE → DEAL（下一局）/ GAME_OVER
```

| 阶段 | 说明 | 关键校验 |
|------|------|----------|
| `INIT` | 初始化，确定级牌、洗牌 | — |
| `DEAL` | 发牌 27 张/人 | 两副牌 108 张全部分发 |
| `TRIBUTE_PHASE` | 进贡 / 还贡 / 抗贡 | 双下进贡分配逻辑、抗贡条件（对家也是进贡方则互抵） |
| `PLAY_PHASE` | 出牌循环，直到三人出完 | 牌型合法性、回合轮转、过牌规则、三人连续 Pass 触发清场（CLEARED） |
| `LEVEL_UP_PHASE` | 根据名次判定升级 | 头游队升 1 级，双下游队向胜队进贡；双下则胜队升 3 级 |

**级牌与逢人配动态判定：**

- `GameState.current_rank` 维护当前打的级牌等级
- 红桃花色 + 当前级牌点数 = 逢人配（万能牌），在牌型评估时动态赋予百搭属性
- `rule_engine` / `hand_evaluator` 在识别牌型时，必须结合 `current_rank` 判定是否为逢人配

**CLEARED 状态（清场规则）：**

- 掼蛋规则中，若连续三人弃牌（Pass），最后出牌者获得自由出牌权，即该轮"清场"
- `Round` 状态机需增加 `CLEARED` 子状态标识
- UI 层配合：播放"桌面旧牌收起"动画 + 音效，明确提示玩家当前可出任意合法牌型
- 清场后当前玩家开始新一轮出牌，本轮历史出牌记录归档至出牌历史侧边栏

**接风规则（Catch-Up）：**

- 掼蛋核心规则：当某玩家打出最后一手牌，且全场 Pass（该玩家成为头游），下一轮的自由出牌权（接风权）转移给其 **对家（队友）**，而非下家
- `round.gd` 在检测到玩家手牌清空（`HAND_EMPTY`）且其余人 Pass 时，强制执行接风拦截：下轮 `active_player` 直接指定为当前出完牌玩家的 `teammate`
- 接风后队友获得自由出牌权（等同于 CLEARED 状态），可出任意牌型
- 若接风者同时也是最后一名有空牌的玩家，则本局结束

**逢人配"纯度"降级规则：**

- 含逢人配的炸弹（如 3 张真牌 + 1 张逢人配）在比较时，必须 **小于** 同等点数的纯炸（4 张真牌）
- 逢人配 **不能** 参与组成天王炸（四王 / 火箭）
- `PlayedCards.is_pure_bomb` 标记记录炸弹纯度；`rule_engine` 在比较炸弹时，纯度作为高于点数的第一优先级：
  1. 先比类型（火箭 > 普通炸弹）
  2. 再比纯度（纯炸 > 混炸）
  3. 再比点数（Rank 大的 > Rank 小的）
  4. 最后比张数（炸弹张数多的 > 少的）

**进贡/还贡具体边界校验：**

- 进贡阶段合法性校验：
  - 进贡必须出手牌中 **最大的牌**（受当前级牌/逢人配排除规则约束，依具体规则变体而定）
  - 不能进贡逢人配（红桃级牌），不能进贡当前级牌
  - 双下进贡：末游向头游进贡、次末游向头游队友进贡
- 还贡阶段合法性校验：
  - 还贡必须是 **单张**
  - 还贡不能是逢人配
  - 还贡点数不能大于 10（依规则变体可配置）
- 抗贡条件：双方均为进贡方（互为进贡对象）时互抵，既不进也不还

**大王抗贡的唯一性校验：**

- 掼蛋规定"双大王抗贡"，两副牌共 2 张大王
- 抗贡必须由 **同一个人** 同时抓到 2 张大王（`Rank.16`），才触发全场抗贡
- 若队友各执一张大王，不触发抗贡
- `tribute_phase` 校验逻辑：遍历单人手牌，检查单人 `hand` 中 `Rank.16` 数量 ≥ 2

**进贡最大牌的"同点数次级判定"：**

- 当玩家需要进贡最大牌，但手牌中最大点数有两张（如两张不同花色的 K），且都不属于级牌
- 次级判定规则：
  1. 优先进贡非红桃的牌（避免将逢人配进贡出去）
  2. 若点数花色均相同（两副牌中同一张牌），系统随机选择或提供 UI 让玩家勾选
  3. 逢人配（红桃级牌）绝对不可进贡，已在主规则中排除

### Action 日志

```
Action:     {player, action_type, cards, timestamp}
action_type: PLAY | PASS | TRIBUTE_GIVE | TRIBUTE_RETURN | RESHUFFLE | GAME_END
```

状态变更生成 `Action` 记录，构成不可变日志。这是联机同步和观战回放的基础。

**贡牌/还贡专用 Action：** 使用独立类型 `TRIBUTE_GIVE` 和 `TRIBUTE_RETURN`，避免与普通出牌混同导致逻辑混乱和校验冲突。

**逢人配来源标记：** 每张用于配牌的逢人配 Card 附带 `wild_source: GRADE_CARD | SYSTEM_ALLOC` 标记，支持规则变体（是否允许级牌做配牌、系统自动配牌上限等）。

### RuleConfig（规则变体配置化）

掼蛋在不同地区存在规则差异，所有核心规则必须数据驱动，不得硬编码在逻辑中。

```
RuleConfig: {
    same_suit_straight_beats_bomb: bool,  # 同花顺是否大于普通炸弹
    double_down_levels: int,              # 双下升几级（默认 3）
    can_tribute_wild: bool,               # 是否允许红桃级牌进贡
    tribute_return_max_rank: int,         # 还贡点数上限（默认 10，0=不限）
    straight_extends_to_ace: bool,        # 顺子是否可延伸到 A
    bomb_compare_priority: enum,          # 炸弹比较优先级：BOMB_BY_RANK 点数优先 / BOMB_BY_COUNT 张数优先
    wild_count: int,                      # 逢人配数量（默认 2）
    team_formation: enum,                  # 队伍构成：FIXED_1_3 固定 1/3 号位 / RANDOM 随机
}
```

**使用约束：**
- `RuleConfig` 是 Immutable Match Config，对局开始后不可修改
- 联机时由房主下发统一 `RuleConfig`，参与 `GameState` 状态哈希校验
- 核心规则函数（`rule_engine`、`hand_evaluator`、`game_state`）的参数中必须包含 `RuleConfig` 引用
- 单机模式下可通过菜单选择预设规则（"标准" / "淮安" / "安徽" 等）

---

## 三、AI 架构

| 层级 | 名称 | 适用场景 | 逻辑 |
|------|------|----------|------|
| **L1** | 基础 AI | 训练模式"简单"难度 | 能出就出最少的牌，规则合规即可 |
| **L2** | 规则 AI | 训练模式"中等"、单机普通 | 启发式评分：保留炸弹、拆牌优先短顺、配牌合理使用 |
| **L3** | 策略 AI | 训练模式"困难"、单机困难 | 记忆出牌、推理队友意图、控制炸弹节奏 |

**技术细节：**

- AI 与玩家调用同一个 `logic.play_cards()` 接口
- L2/L3 使用启发式评分：每个合法出牌组合 → 计算策略评分 → 选最优
- L3 加入对战历史分析，通过复盘 `Action` 日志做状态推演
- 训练模式的"牌型提示"展示 L2 AI 的最优解

**AI 安全与性能约束：**

- **防死循环机制：** AI 决策若超过 3 秒未返回，自动降级到 L1 基础 AI，确保对局不卡死
- **可禁用提示开关：** 训练模式的牌型提示可通过配置关闭，避免干扰正式对局或高阶玩家
- **统一决策耗时上限：** 所有 AI 层级共用 `MAX_AI_DECISION_MS` 配置项，防止卡顿主线程；复杂计算使用分帧/线程池处理

**队伍配合意识（Teammate Awareness）：**

- L2/L3 AI 的启发式评分函数必须引入队友和对手状态权重：
  - **队友剩余牌数**：队友剩 1-2 张时优先出小单张/对子"送牌"助攻
  - **队友头游判定**：队友已出完时切换到保级策略
  - **对手压制权重**：对手剩 1-2 张时提升炸弹/大牌的出牌优先级，避免对手跑掉
- 评分公式示例：`score = hand_quality * 0.4 + teammate_context * 0.3 + opponent_threat * 0.3`

**宏观局势与目标管理：**

- AI 需根据当前积分差和升级规则，动态切换策略模式：
  - **保守策略**：本局只需保一人头游即可升级时，优先保护已有优势，减少激进出牌
  - **激进策略**：落后方必须抓双下才能反超时，主动拆炸弹、保留大牌组合冲击双下
- 策略模式切换由 `GameState` 的积分差和 `current_rank` 自动计算

**记牌与算牌维度（L3 专用）：**

- L3 维护"场外信息池"（Hidden Information Pool），追踪三类信息：
  1. **记大牌**：王、级牌、A 的已出数量，推算剩余威胁
  2. **记断门**：某花色已出完 13 张时，推断对手不可能持有该花色的同花顺/炸弹
  3. **记剩余牌数**：结合已出牌总数，推算对手手中最多几张炸弹，判断压制可能性
- 信息池在每轮出牌后增量更新，不依赖回放全量重算

**L3 手牌推断（Hand Inference）：**

- 仅有"记牌池"不足以支撑 L3 高级决策，还需根据对手行为概率化推测其手牌分布
- 在 `utils/probability.gd` 中增加贝叶斯推断模块：
  - 对手进贡了一张红桃A → 推断对手手中缺大牌 → 后续可大胆用中等牌型压制
  - 对手多轮不出某花色 → 推定该花色可能为断门或预留炸弹
  - 还贡牌型（小单张）→ 推断对手在清杂牌准备冲刺
- 推断结果以概率分布形式存入 Hidden Information Pool，供评分函数加权使用

**伪随机数生成器（PRNG）种子注入：**

- `deck.gd` 的洗牌算法（Fisher-Yates）必须支持外部注入随机种子（Seed）
- AI 决策中涉及随机选择的部分（如多个同等评分出牌）同样使用可复现的 PRNG
- 目的：跑 GUT 自动化测试时，可复现完全相同的发牌序列和 AI 决策路径，实现确定性测试
- 正式对局使用系统真随机（`randf()`），测试用固定种子

**AI 可观测性与调试工具：**

- AI 决策对玩家和开发者不透明，需提供可观测性支持：
  - **AI Decision Trace：** 决策树日志，记录每一轮 AI 考虑的所有合法出牌组合及其评分明细
  - **评分明细展示：** 调试模式下显示"为什么出这手牌"面板：`手牌质量: 0.7 | 队友权重: 0.8 | 对手威胁: 0.5 = 总分 2.0`
  - **AI HeatMap：** 手牌上颜色覆盖显示关键牌/危险牌权重（红色=炸弹候选，蓝色=顺子候选，黄色=配牌候选）
  - **Replay + AI Debug Overlay：** 回放模式下叠加 AI 决策过程，可逐帧回溯 AI 思路
- 调试模式通过配置开关控制（`enable_ai_debug`），正式对局关闭

**AI 与规则引擎的隔离边界：**

- AI 推演必须在沙盒环境中完成，防止隐性修改真实状态：
  - AI **禁止直接修改**任何 `GameState` 或其子对象的字段
  - AI **不允许缓存**任何可变引用（如 `GameState.players` 数组的引用）
  - AI 推演时必须基于 `GameState` 的只读快照（`PackedInt32Array`），在沙盒中操作临时状态
  - AI 唯一输出为 `Action` 对象或 `null`（弃牌），由 `game_controller` 统一 apply

---

## 四、项目文件结构

```
game-cards/
├── project.godot
├── addons/                   # 第三方插件（按需）
├── assets/
│   ├── cards/                # 52 张卡面图 + 2 王牌 + 牌背
│   ├── ui/                   # UI 贴图、按钮、桌布背景
│   ├── audio/                # 音效、BGM
│   └── fonts/                # 字体
│
├── localization/             # 多语言文本
│   ├── zh_cn.csv             # 简体中文
│   └── en.csv                # 英文
│
├── save/                     # 存档目录（运行时生成）
│   ├── settings.cfg          # 用户设置
│   └── records/              # 对局记录
│
├── tools/                    # 工具脚本
│   ├── card_generator.gd    # 批量生成卡牌资源
│   └── config_converter.gd  # 配置文件格式转换
│
├── src/
│   ├── core/                 # ★ 业务逻辑层（零 Godot 依赖）
│   │   ├── card.gd           # Card 类型、牌组定义、排序比较
│   │   ├── deck.gd           # 洗牌、发牌
│   │   ├── hand.gd           # 手牌管理、排序
│   │   ├── hand_evaluator.gd # 牌型识别：判断给定牌组属于何种牌型
│   │   ├── move_generator.gd # 合法出牌生成：枚举手牌所有合法出牌组合（含剪枝优化）
│   │   ├── rule_engine.gd    # 出牌合法性校验 + 牌型比较大小
│   │   ├── round.gd          # 回合流转状态机
│   │   ├── game_state.gd     # 全局游戏状态（计分、升级、贡牌、队伍）
│   │   ├── actions.gd        # Action 日志定义 + apply/rollback
│   │   ├── config_loader.gd  # 配置表解析（JSON/CSV，AI 参数、关卡等）
│   │   └── constants.gd      # 枚举、配置常量
│   │
│   ├── utils/                # 通用工具库
│   │   ├── combinatorics.gd  # 组合数学（C(n,m)、排列枚举、剪枝辅助）
│   │   ├── shuffle.gd        # 洗牌算法（Fisher-Yates）
│   │   └── probability.gd    # 概率计算辅助
│   │
│   ├── ai/                   # AI 模块
│   │   ├── base_ai.gd        # AI 基类接口
│   │   ├── l1_basic_ai.gd    # 简单 AI
│   │   ├── l2_rule_ai.gd     # 规则 AI（带启发式评分）
│   │   └── l3_strategy_ai.gd # 策略 AI
│   │
│   ├── game/                 # 游戏控制器（胶水层）
│   │   ├── game_controller.gd    # 单机对战的流程控制
│   │   ├── training_controller.gd # 训练模式控制
│   │   └── replay_recorder.gd     # 回放录制
│   │
│   ├── network/              # 联机层（后期开发）
│   │   ├── network_interface.gd   # 抽象接口
│   │   ├── client.gd              # 客户端同步
│   │   └── server.gd              # 服务器端
│   │
│   ├── ui/                   # UI 场景和脚本
│   │   ├── scenes/
│   │   │   ├── main_menu.tscn
│   │   │   ├── game_table.tscn     # 主牌桌
│   │   │   ├── training_room.tscn  # 训练室
│   │   │   ├── lobby.tscn          # 大厅（联机）
│   │   │   └── spectator.tscn      # 观战
│   │   └── components/             # 可复用 UI 组件
│   │       ├── card_node.tscn      # 单张卡牌控件
│   │       ├── hand_area.tscn      # 手牌区域
│   │       ├── played_zone.tscn    # 出牌展示区
│   │       ├── card_type_hint.tscn # 牌型提示浮窗（训练模式）
│   │       ├── play_history_sidebar.tscn # 出牌历史侧边栏（可折叠）
│   │       └── scoreboard.tscn     # 记分板
│   │
│   └── autoload/             # Godot Autoload 单例
│       ├── config.gd          # 全局配置
│       ├── event_bus.gd       # 全局事件总线（信号中心），解耦 UI 组件通信
│       ├── audio_manager.gd   # 音频管理
│       └── scene_manager.gd   # 场景切换
│
└── tests/                    # 测试（逻辑层）
    ├── test_cards.gd
    ├── test_deck.gd
    ├── test_hand_evaluator.gd
    ├── test_move_generator.gd
    ├── test_rule_engine.gd
    ├── test_game_state.gd
    ├── test_tribute.gd        # 进贡/还贡/抗贡边界
    ├── test_state_recovery.gd  # 断线重连状态恢复
    └── test_ai.gd
```

**关键原则：**
- `core/` 目录下任何文件不引用 `res://src/ui/`、`res://src/game/` 或 Godot 节点类
- `game/` 是桥接层，串联 core + UI（或 core + network）
- test 只测 core 层，不依赖场景

**rule_engine 拆分理由：**
- 带有逢人配（百搭）时，合法出牌组合数量呈指数级爆炸，`rule_engine.gd` 职责过重
- 拆分为三个模块：
  - `hand_evaluator.gd`：判断给定牌组属于何种牌型（纯判定，轻量）
  - `rule_engine.gd`：出牌合法性校验 + 比较大小（规则比对）
  - `move_generator.gd`：生成当前手牌所有合法出牌组合（含剪枝优化，供 AI 专用）

**move_generator 剪枝策略（防组合爆炸）：**

逢人配 + 双副牌导致合法出牌组合数呈指数级失控，必须硬性剪枝：

- **等价状态去重：** 相同牌型、相同点数的出牌只保留一种（如两张不同的黑桃A组成的对子视为等价）
- **对称配牌剪枝：** 多张逢人配可互换位置时，只保留一种枚举（如两张逢人配当 `7` 和 `8` 与当 `8` 和 `7` 等价）
- **Beam Search 上限：** L2/L3 评分阶段保留前 `BEAM_WIDTH`（默认 50）个最优候选，丢弃低分分支
- **最大枚举节点数：** 单次 `move_generator` 枚举上限 `MAX_ENUM_NODES`（默认 10000），超限后自动降级为启发式搜索
- **降级策略：** 枚举超限时切换为贪心出牌（按牌型优先级选择最大牌组合），保证回合不超时

### 新增文件

```
├── src/core/
│   ├── rule_config.gd       # RuleConfig 资源类（定义规则变体）
│   └── ...
├── src/
│   ├── statistics/           # 数值统计模块
│   │   └── stats_tracker.gd  # 胜率、双下率、炸弹率等统计
│   │
│   ├── debug/                # 调试与可观测性
│   │   ├── ai_trace.gd       # AI 决策日志
│   │   └── debug_overlay.gd  # 调试叠加层
│   │
│   ├── logging/              # 日志系统
│   │   └── log_manager.gd    # 分级日志管理
```

---

## 五、测试策略

| 测试类型 | 范围 | 工具 | 内容 |
|----------|------|------|------|
| **单元测试** | `core/` 所有模块 | GUT (Godot Unit Test) | 牌型识别、比较大小、得分计算、洗牌发牌 |
| **集成测试** | `core/` + `ai/` | GUT | AI 对不同局面的决策、完整一局流程 |
| **回归测试** | `core/` 规则引擎 | GUT | 固定测试套件，每次修改规则引擎必须全过 |
| **恶意输入测试** | `core/` 所有入口 | GUT | 空牌、重复牌、越界牌、非法牌型组合 |
| **性能基准** | `core/` + `ai/` | GUT + 计时 | 牌型识别 <1ms、AI决策 <3s、状态序列化 <50ms |
| **UI 测试** | `ui/` 场景 | 手动 + 截图对比 | 手牌渲染、动画、交互 |

**重点测试用例：**
- 掼蛋所有牌型识别覆盖（单张、对子、三不带、三带二、顺子、钢板、连对、同花顺、炸弹、火箭）
- 配牌（逢人配）边界情况 — 最高/最低配、多配同时可选
- 炸弹升级场景（四炸 → 五炸 → … → 火箭）
- 逢人配被炸弹压制的优先级
- CI 每次提交跑 `gut` 命令行，全绿才算合格

**回归测试集要求：** 维护一份固定的 `tests/regression/` 目录，包含 50+ 覆盖各种边界的牌型场景，每次修改规则引擎必须全部通过。

**恶意输入测试覆盖：**
- 传入空数组、重复卡片 ID、越界 ID（<0 或 >107）
- 不符合任何牌型的非法卡牌组合
- 同一张牌在牌组中出现超过 4 次（两副牌最多每种 4 张）

**性能基准阈值：**
- 单次牌型识别 < 1ms
- `move_generator` 最坏情况（手牌 15 张含 2 张逢人配）：枚举所有合法组合 < 16ms（保证异步处理不超时）
- AI 决策（L2）< 2s，（L3）< 5s，超时自动降级
- 完整状态序列化/反序列化 < 50ms

**进贡/还贡/抗贡边界测试用例：**
- 双下进贡分配逻辑：谁向谁进贡、进几张、大小牌选择
- 抗贡条件判定：双方均为进贡方时互抵（不进不还）的精确条件
- 还贡牌型合法性：还贡必须是单张、不能还逢人配、不能还级牌
- 进贡阶段 Action 日志完整性

**断线重连状态恢复测试：**
- 基于 Action 日志重放恢复 `GameState` 的集成测试
- 确保重连后双方状态绝对一致：已出的牌、剩余牌数、当前分数、谁在回合中
- 测试中断场景：进贡中掉线、出牌中掉线、结算中掉线

**测试数据生成器：**

- **随机牌局生成器（Fuzz）：** `tools/fuzz_dealer.gd`，自动生成 10000+ 局随机发牌，喂入 AI 对打，自动发现崩溃/卡死/非法状态
- **特殊边界牌局生成器：** `tools/edge_case_generator.gd`，预设边界场景（全炸弹手、全逢人配、空手回溯等）
- **Monte Carlo 对局生成：** 随机发牌 × 多 AI 难度 × 多规则配置的批量对局，输出胜率统计和性能分布
- **Fuzz 测试集成至 CI：** 每次提交跑 500 局随机对局，发现异常自动标记

---

## 六、开发顺序

1. **训练模式** — 核心逻辑 + L1/L2 AI + 牌型提示 UI
2. **单机对战** — 完整四人局 + L3 AI + 完整 UI
3. **联机** — Action 同步协议 + 匹配 + 房间
4. **观战** — Action 流回放 + 观战 UI

**各阶段接口冻结点：**

| 阶段 | 冻结接口 | 说明 |
|------|----------|------|
| 训练模式完成 | Card、Deck、Hand、PlayedCards、rule_engine | 核心数据结构和牌型判定不再变更 |
| 单机对战完成 | GameState、Round、Action、AI 基类接口 | 状态管理和 AI 接口定型 |
| 联机开始 | NetworkInterface 抽象层 | 联机协议可独立迭代不影响上层 |

每个阶段冻结后，后续重构只影响当前阶段模块，不回溯修改已冻结接口。

**训练模式优先完成：**
- 牌型校验全覆盖（所有牌型 + 边界 + 逢人配），全部回归测试通过
- 再做 AI 集成与 UI 开发，避免"功能在前，正确性在后"导致返工

**单机模式全流程验证：**
- 必须先跑通"发牌 → 进贡 → 还贡 → 出牌循环 → 升级判定 → 结算"完整链路
- 验证多局连胜、级牌升级、炸弹升级等长期状态正确性

---

## 七、约束与边界

**单局最大时长限制：** 配置项 `MAX_GAME_DURATION_SEC`，超时判定为强制结束/平局，防止一方故意拖延。

**防重复出牌校验：** 同一套牌（相同 `original_id` 组合）不能在同一轮连续打出，尤其针对"过牌后再出同一套"的边界。

**出牌区最大显示数量：** 中央出牌区每次最多显示 4 组（各玩家最新一轮出牌），旧牌自动收起，避免 UI 溢出。

**级牌、主牌、逢人配优先级判定流程：**
1. 判定当前级牌等级（从 `GameState.level` 读取）
2. 主牌 = 级牌 + 两张逢人配（系统配）
3. 逢人配可用于补齐任意牌型（顺子、钢板、连对等），但必须在炸弹压制规则下低于同点数炸弹
4. 逢人配不参与火箭（大小王）判定

**终端状态分支：**
- `GAME_END_NORMAL`：一方升至A并获胜
- `GAME_END_SURRENDER`：一方投降
- `GAME_END_TIMEOUT`：超时强制结束
- `GAME_END_DRAW`：平局（双方同分且均未升级）
- `GAME_END_DISCONNECT`：联机中一方掉线超时

**断线托管机制：** 联机模式下，离线玩家自动交由 AI（默认 L2）托管，并在 UI 显示托管标识和倒计时；重连后恢复玩家控制。

**异常恢复策略：**

- 不可恢复的异常（Fatal Error）需分级处理：
  - **Action apply 失败：** 日志记录 + 拒绝该 Action + 请求服务端全量状态同步
  - **非法状态进入：** 自动回退到最近合法快照（每 N 轮自动保存快照），从快照重放
  - **Action 日志损坏：** 标记损坏段 + 请求全量同步 + 客户端重建 UI
  - **回放中断：** 保存进度 + 允许从中断点续播
  - **网络包缺失：** 基于 `action_seq` 检测空洞 + 发送补发请求 + 超时后请求全量同步
- **崩溃日志导出：** 发生 Fatal Error 时自动导出崩溃栈 + 最近 100 条 Action 日志到 `save/crash/`

**多人托管切换边界：**

- 托管玩家重连时的冲突场景需要明确定义：
  - **所有权转移时机：** 托管状态下，AI 已产生但未 apply 的 Action 在玩家重连瞬间丢弃；正在执行的 AI 计算可等结果返回，但标记为"AI 建议"而非强制执行
  - **AI 行为不可撤销：** 已 apply 的 AI Action 不可回退，玩家重连后从当前状态继续
  - **重连保护期：** `RECONNECT_GRACE_PERIOD`（默认 2s），重连后 2 秒内禁用托管自动出牌，给玩家恢复操作时间
  - 托管模式下 UI 显示闪烁边框 + "AI Auto-Play" 标签

---

## 八、UI 交互规范

**手牌布局规则：**
- 自动对齐：手牌按 Rank→Suit 排序后均匀分布，中心对齐
- 重叠防遮挡：手牌扇形/横向排列，重叠率 50%-65%，被选中牌抬高 20px
- 长牌适配：手牌 >20 张时自动缩小间距和卡牌尺寸，确保全部可见

**27 张手牌的"智能理牌/折叠"：**

- 起手 27 张牌（进贡后可达 28 张），在移动端或小屏幕上极其拥挤，拖拽框选极易误触
- **按牌型分类视图：** 手牌区分为单张区、对子区、三同张区、炸弹区、顺子区，玩家可切换视图
- **自动折叠：** 连续的顺子、钢板、三同张自动折叠显示为一组，点击展开
- **手动标记：** 玩家可手动将多张牌标记为一组，组内牌共享高亮颜色
- 折叠/展开状态持久化，保存到用户设置

**出牌历史侧边栏：**

- 中央出牌区收起旧牌后，玩家算牌极为困难
- 在 UI 边缘增加可折叠的"出牌历史侧边栏"，以紧凑文本或微缩图形式记录最近 3-5 轮各家的出牌明细
- 显示格式：`[轮次] 玩家1: 过 | 玩家2: 对2 | 玩家3: 对K（压）| 玩家4: 过`
- 辅助玩家手动算牌、也为 L3 AI 的记牌模块提供快速回溯入口

**滑动框选防误触（V-Filtering 重叠度阈值）：**

- 移动端 27 张牌极度紧凑，框选时极易连带选中目标牌左右两侧的边缘牌
- 引入 **滑动轨迹重叠度阈值**：手指滑动的 X/Y 轴轨迹覆盖某张卡牌 **中心区域超过 40%** 时才判定选中
- 仅擦过卡牌边缘（覆盖率 <10%）的不触发悬起/选中
- 阈值可配置（`SELECTION_OVERLAP_THRESHOLD`），桌面端可适当降低

**逢人配智能吸附：**

- 玩家手持逢人配且正在构建不完整牌型时（如已选 `3、4、5、6`），点击"提示"或长按空白区
- UI 自动将手牌中的逢人配牌弹起并闪烁，提示玩家此牌可补齐当前残缺牌型（如当 `7` 完成顺子）
- 发生多歧义时（既可补顺子也可补连对），弹出小型选项菜单供玩家选择

**卡牌 UI 节点轻量化设计：**

- 同屏可能超过 150+ 个卡牌 Control 节点（4 人 × 27 张 + 出牌区 + 历史记录），每个节点挂载独立 Tween/信号监听在低端设备极易掉帧
- **分级渲染策略：**
  - **暗牌（非玩家手牌）**：仅渲染微缩"数量标签（Label）"或单一共享九宫格动态图，不生成独立的卡牌实体节点
  - **明牌（玩家手牌 + 中央出牌区）**：实例化为独立的 `card_node.tscn`，但禁用不必要的关系检测和碰撞
- 对局中动态销毁已出牌的节点，复用节点对象池（Object Pool），避免频繁 `instantiate` / `queue_free`

**出牌二次确认：**
- 玩家点击"出牌"按钮后出现确认提示，显示即将打出的牌型和点数
- 训练模式可选关闭确认，单机/联机默认开启
- 支持"自动出牌"快捷键：选中牌后双击或按 Enter 直接出

**多牌选择交互：**
- 单击选中/取消单张牌
- 长按拖拽框选连续区域
- Ctrl+点击连选多张
- "提示"按钮一键选中 AI 推荐的最优出牌组合

**回合计时与状态屏蔽：**
- 每回合显示倒计时进度条（默认 30s），超时自动过牌
- 等待他人出牌时，手牌区灰显 + 禁用操作
- 出牌结束到下一回合之间显示过渡动画（0.5s），期间屏蔽输入

**输入系统冲突裁定：**

- 同时存在点击、长按、滑动框选、双击、Ctrl 连选等多种输入方式，需定义冲突优先级：
  - **输入状态机：** `InputFSM` 管理空闲（IDLE）→ 轻触（TOUCH_DOWN）→ 拖动（DRAGGING）→ 长按激活（LONG_PRESS）→ 释放（RELEASED）
  - **Gesture Priority：** 滑动优先于长按（手指移动 >5px 进入 DRAGGING，取消长按计时）；双击 < 300ms 且位移 <5px 判定双击；Ctrl 状态与触摸并存时优先解析为 Ctrl 连选
  - **PC / Mobile 分离输入策略：** 桌面端优先键盘快捷键 + 鼠标操作；移动端优先触摸手势；同一事件不触发双份响应

**移动端性能边界：**

- **最低目标机型：** iPhone X（2017） / 骁龙 665 中端 Android，保持 ≥ 30 FPS
- **FPS 目标：** 桌面 ≥ 60 FPS，移动端 ≥ 30 FPS
- **最大内存占用：** 桌面 < 256MB，移动端 < 128MB
- **动态降级策略：** FPS 持续 < 20 时自动关闭动画（Tween）、降低卡牌分辨率、减少出牌历史保留轮数
- **低端机配置开关：** `LOW_PERF_MODE`，关闭粒子特效、阴影、抗锯齿，使用预烘焙卡牌贴图

**跨平台适配：**

- **分辨率适配：** 支持 16:9、21:9、4:3 主流比例；采用 `Control.anchor` + `Control.expand` 响应式布局
- **横竖屏：** 强制横屏（landscape），卡牌游戏竖屏无法容纳 27 张手牌
- **DPI 缩放：** 使用 Godot `Theme` 系统和 `DisplayServer.screen_get_dpi()` 自动调整字体/控件尺寸
- **安全区（Safe Area）：** 适配刘海屏/挖孔屏，使用 `DisplayServer.get_display_safe_area()` 避让
- 平板设备使用特殊 UI 布局（更大卡牌、更宽手牌区域）

---

## 九、数据与序列化

**状态哈希校验：** 每局结束计算 `GameState` 的 SHA-256 哈希，客户端和服务器（或双方客户端）比对，防止联机数据篡改。

**日志裁剪策略：**
- `Action` 日志按局分割，开局清空上局日志
- 单局日志上限 10000 条（约 500 轮出牌），超出自动截断头部
- 完整日志仅在开启回放录制时保留，写入 `save/records/`

**存档格式版本：**
- 所有序列化数据头部包含 `version: int` 字段
- 当前版本 V1，后续升级需提供 `migrate_vN_to_vN+1()` 迁移函数
- 读取存档时校验版本号，不兼容版本提示用户更新

**状态重放的随机数种子绑定：**

- Action 日志用于回滚/重放时，若涉及 `RESHUFFLE`（洗牌）或 AI 决策中的 PRNG 动作，必须强制绑定该局开局的 `seed` 值
- 断线重连重放 Action 时，若随机数种子不一致，AI 会产生与掉线前完全不同的决策分支，导致前后端状态彻底跑飞
- `Action` 日志起始位置记录 `{seed: int, timestamp: int}`，重放时还原相同种子
- `deck.gd` 的洗牌和 AI 的随机选择全部使用可注入种子的 PRNG

**Replay 确定性保证（多平台一致性）：**

仅绑定 seed 不足以保证跨平台回放一致，还须满足严格条件：
- Action 执行顺序绝对固定（按时间戳严格递增，禁止并发 apply）
- Dictionary 遍历不能参与逻辑（GDScript Dictionary 迭代顺序不保证，改用 Array + 排序）
- 所有排序必须使用稳定排序算法（`sort_custom` + 稳定比较器）
- AI 随机源禁止使用系统随机（`randf()` / `randi()`），强制使用种子 PRNG
- 时间戳不能参与规则逻辑（仅用于日志标记和 UI 动画节奏，不用于状态判定）

**数值统计体系：**

- 长期统计数据用于匹配、AI 调优和平衡分析：
  - `stats_tracker.gd` 记录每个玩家的：总对局数、胜率、双下率、被双下率
  - AI / 实战统计：平均出牌时长、炸弹使用率、逢人配使用次数、进贡胜率
  - Replay 元数据：对局时长、回合数、局次、规则配置、参与者信息
- 统计数据存储在 `save/stats/`，格式为结构化 JSON，支持导出分析
- AI 难度统计分离记录（"L1 胜率" vs "L2 胜率"），辅助 AI 调优

---

## 十、网络层（预留）

**同步模式选型：**

| 模式 | 原理 | 适用 |
|------|------|------|
| **状态同步（推荐）** | 服务器计算权威状态，客户端接收后渲染 | 回合制卡牌，延迟容忍度高 |
| 帧同步 | 同步操作指令，各端独立计算 | 实时操作类游戏，此处不适用 |

选择状态同步模型，同步频率为每轮出牌结束时触发一次。

**客户端作弊防护（Server-Authoritative）：**

所有 Action 必须在服务端做权威校验，客户端仅允许发送"意图"：

- **非法 Action 注入防范：** 服务端 `validate_action(game_state, action)` 校验每一项 Action，包括：出牌合法性、回合归属、seq_id 连续性
- **客户端手牌篡改防范：** 客户端不持有完整 GameState，服务端维护权威手牌；客户端只收到自身手牌信息
- **重放攻击防范：** Action 增加 `seq_id`（严格递增）+ `nonce`（随机盐），服务端拒绝重复 seq_id
- **Action_seq 回退检测：** 服务端记录每个连接的最后 `ack_seq`，拒绝 < ack_seq 的 Action
- **客户端伪造 AI 托管结果防范：** 托管由服务端发起，AI 计算在服务端完成；托管期间客户端发送的 Action 一律拒绝
- **防作弊时效：** 出牌合法性只在服务端最终裁定，客户端可以做本地预判以优化 UI 响应，但服务端结果始终覆盖客户端

**联机同步冲突恢复（Authoritative Rollback）：**

客户端预测状态与服务端权威状态不一致时的处理：

- 客户端每次提交 Action 后，服务端回传 `{action_seq, state_hash}`
- 客户端比对自身状态的哈希：若一致则确认；若不一致则触发回滚
- **回滚流程：** 暂停 UI 输入 → 请求从 `action_seq` 开始的全量状态同步 → 重建 UI（手牌重排、出牌区清空重渲染）→ 播放"重同步"过渡动画 → 恢复输入
- UI 层必须支持**状态全量重建**：接收到新的 `GameStateView` 后完全重建手牌、出牌区、计分板，而非增量 patch

**网络协议版本兼容：**

- 存档版本（`data_version`）≠ 联机协议版本（`protocol_version`），两者独立管理
- `protocol_version` 定义在 `NetworkInterface` 中，起手握手阶段交换
- **客户端最低兼容版本：** `MIN_COMPATIBLE_PROTOCOL`，低于此版本的客户端拒绝连接，提示更新
- **热更新后的版本协商：** Lobby 阶段交换支持的 protocol 范围，若房主与客户端版本不兼容则显示提示
- 协议变更需提供 `protocol_migrate_vN_to_vN+1()` 迁移处理

**观战一致性：**

- **手牌可见性：** 非好友房观战默认隐藏所有手牌，仅显示公共出牌区；好友房可经房主授权后显示队友手牌
- **延迟观战：** 观战者看到的画面延迟 N 回合（默认 3 回合 / 可配置），防止实时观战泄露信息给对战者
- **中途加入：** 允许中途加入观战，从加入时刻最近的 `CLEARED` 状态开始重放，不追溯更早回合
- **透视作弊防护：** 观战者持有独立的 `spectator_view` 视野过滤器，与对局玩家网络通道完全隔离
- 观战人数上限：每局最多 50 名观战者，超出拒绝新加入

**联机状态机正式定义：**

完整的房间生命周期状态图，确保 network / game / ui 三层状态同步：

```
                    创建房间
                       ↓
    ┌───────────────── IDLE ──────────────────┐
    │                   ↓ 有人加入              │
    │               WAITING (等待满员)           │
    │                   ↓ 4人齐                │
    │               READY_CHECK                │
    │              ╱            ╲              │
    │         ALL_READY     TIMEOUT            │
    │              ↓            ↓              │
    │         IN_GAME     返回 WAITING         │
    │              ↓                           │
    │  ┌─── PLAYING ───┐                      │
    │  │   ↓ 掉线       │                      │
    │  │  HOSTING      │   (托管中)            │
    │  │   ↓ 重连       │                      │
    │  │  RECONNECTING │                      │
    │  │   ↓           │                      │
    │  └→ PLAYING     ←┘                      │
    │      ↓ 对局结束                          │
    │   FINISHED                              │
    │      ↓                                  │
    │  VOTE_PHASE (投降投票/再开投票)          │
    │      ↓                                  │
    └── IDLE ─────────────────────────────────┘
          ↓ 房主解散房间
       DESTROYED
```

**各状态约束：**
- `PLAYING` → `HOSTING`：心跳超时触发，AI 自动托管，UI 标记托管状态
- `HOSTING` → `RECONNECTING`：客户端发起重连，服务端追帧
- `RECONNECTING` → `PLAYING`：状态同步完成，`RECONNECT_GRACE_PERIOD` 内禁止托管出牌
- `FINISHED` → `VOTE_PHASE`：允许投降投票（联机）/ 下一局投票，对手否决权生效
- `VOTE_PHASE` → `IDLE`：投票通过或超时，房间解锁

**房间与开局条件：**
- `NET_ROOM_CREATED` → `NET_PLAYER_JOINED` × 4 → `NET_ALL_READY` → `NET_GAME_START`
- 4 名玩家全部准备后房主点击开局
- 等待中可设 AI 补位开关

**心跳与异常处理：**
- 客户端每 5s 发送心跳包
- 服务端 15s 未收到心跳判定断线，触发托管
- 重连后追帧：客户端发送最后收到的 `action_seq`，服务端补发缺失的 Action 列表

**房间锁、准备状态：**
- 开局后房间上锁，禁止新玩家加入
- 对局中任意时间仅房主可发起投降投票（需队友同意）
- 对局结束后房间自动解锁，返回等待状态

**断线重连的视野过滤（Vision Filter / Fog of War）：**

- 服务端序列化状态发送给特定客户端时，**必须经过视野过滤器**
- 只打包该玩家有权知道的信息：自身手牌、公共出牌区、已公开的进贡/还贡牌、历史出牌记录
- **绝对禁止**将包含所有玩家手牌的完整内存对象直接下发，防止抓包窥牌
- `NetworkInterface` 中实现 `filter_state_for_player(game_state, player_id): GameStateView` 方法，返回裁剪后的可见状态

**进贡阶段的动态视野变化：**

- 进贡和还贡不是同时发生的：A 进贡给 B 时，B 可见此牌，但 C 和 D 不可见
- 直到还贡结束或出牌阶段该牌被打出，其他玩家才可见该牌的去向
- 服务端的 `filter_state_for_player` 必须动态感知 `TRIBUTE_PHASE` 内的"卡牌所有权临时移交状态"
- 防止联机时外挂通过抓包提前得知他人的进贡牌

**投降机制的对手否决权：**

- 掼蛋涉及"升 3 级（双下）"和"升 1 级"的巨大差异，一方提前投降会损害对手抓双下的收益
- 投降不仅需要队友同意，**还必须发起对手接受投票**
- 流程：发起方 → 队友同意 → 发起对手投票 → 对手两人中至少一人接受 → 投降生效
- 若对手拒绝投降，游戏必须继续，直至产生实际的名次结算
- 单机模式中对 AI 可配置投降阈值（如分差 > 100 分），免除投票流程
