# LinkChat Spire 外部 API

本文定义外部程序接入 LinkChat Spire 的 HTTP 与 WebSocket 契约。生产地址示例使用 `https://linkchat.online`；自建部署请替换主机名。

> **开放状态**：房间、资料库、快照、动作、队友检查、升级预览和房间即时聊天接口支持独立 Spire Token。现有网页和 TUI 继续使用 LinkChat 会话 Cookie；外部客户端按本文使用 Bearer Token。

当前发布组合：CLI `0.11.0`、Spire 扩展 `0.7.1`、后端扩展 `0.1.0a2.dev41`（2026-10-01）。包版本与下列协议、规则和内容标识分别管理。

当前游戏协议版本如下：

| 字段 | 当前值 | 含义 |
| --- | --- | --- |
| `protocol` | `8` | 客户端与服务端交换的投影结构版本 |
| `rules` | `spire-tui-4` | 规则和存档兼容版本 |
| `content` | `0.1.0-alpha.2.dev17` | 卡牌、遗物、事件等内容版本 |
| WebSocket 子协议 | `linkchat-game-v8` | 实时连接协议 |

## 认证

Spire 使用独立于 Agent API 的 **Spire Token**。令牌前缀为 `lc_spire_`，完整令牌只在创建时显示一次。用户在 LinkChat 个人资料的“Spire 接入”中选择控制权限和有效期，然后创建令牌；令牌属于账号，不限定频道。服务端只保存令牌哈希。

HTTP 请求通过请求头认证：

```http
Authorization: Bearer lc_spire_xxxxxxxxxxxxxxxxxxxx
```

WebSocket 握手同时携带 Authorization 请求头和子协议：

```http
Authorization: Bearer lc_spire_xxxxxxxxxxxxxxxxxxxx
Sec-WebSocket-Protocol: linkchat-game-v8
```

不要把令牌放进 URL、查询参数、日志或客户端可公开下载的代码。浏览器原生 `WebSocket` API 不能设置 `Authorization` 请求头；浏览器应用应由自己的后端代理连接，或继续使用 LinkChat 网页 Cookie 会话。

每个 Spire Token 都绑定以下信息：

- 一个 LinkChat 账号；在游戏中始终以该账号身份行动，不能指定或模拟其他玩家。
- `canControl` 权限。为 `false` 时只能列出房间、读取自己的房间快照和战斗日志、检查队友装备及请求升级预览；为 `true` 时还可创建、加入房间、提交动作和发送房间消息。
- 到期时间和撤销状态。到期或撤销后立即失去访问权限。

Spire Token 只能用于下列路径：

- `/api/games/spire/*`
- `/ws/games/spire/*`

它不能调用 `/api/v1/*`、外部频道聊天、账户、管理、SSH 登录或令牌管理接口。房间即时聊天属于 Spire 游戏接口，不授予外部频道的消息读写权限。现有 `lc_agent_...` Agent Token 也不能调用 Spire API。

令牌管理接口只接受账号会话，不能使用 Spire Token 自行创建新令牌：

- `GET /api/spire-tokens`：列出当前账号创建的令牌，不返回完整密钥。
- `POST /api/spire-tokens`：创建令牌，请求字段为 `name`、`canControl`、`expiresInDays`。协议 8 起令牌不再限定频道：`channelIds` 已废弃，传入时忽略，响应中恒为空数组。
- `DELETE /api/spire-tokens/{tokenId}`：撤销令牌；已有 WebSocket 会在下次广播或心跳时失效。

## 快速接入流程

1. 用 `GET /api/games/spire/rooms` 查找可继续或可加入的房间，或创建新房间。
2. 合作模式下，其他账号调用 `POST /rooms/{roomId}/join` 加入；创建者已经在房间内。
3. 连接房间 WebSocket，接收首个完整快照并保存其中的 `leaseId`。需要房间聊天时，在连接 URL 添加 `?roomChat=1`，并检查快照中的 `features.roomChat`。
4. 只从最新快照的 `options[].action` 选择动作；需要选牌时按 `choice` 的要求补充 `choice` 或 `cards`。
5. 为每次逻辑动作生成新的 `actionId`，与当前 `leaseId` 一起提交。
6. 持续接收 WebSocket 完整快照；订阅聊天后须先将 `ROOM_CHAT` 事件分流，不用它覆盖游戏状态。以小于 45 秒的间隔发送文本 `PING`，建议每 30 秒发送一次。

除只读工具接口外，客户端不应自行推导可执行动作。服务端内容会继续增加，`options[].action` 是可用动作的权威来源。

## 接口总览

| 方法 | 路径 | 用途 | 需要 `canControl` |
| --- | --- | --- | --- |
| `GET` | `/api/games/spire/catalog` | 资料库分类、角色与进阶元数据 | 否 |
| `GET` | `/api/games/spire/catalog/{category}` | 按小类搜索并分页列出条目 | 否 |
| `GET` | `/api/games/spire/catalog/{category}/{id}` | 条目详情、升级或进阶预览 | 否 |
| `GET` | `/api/games/spire/rooms` | 列出全站可见的未结束房间 | 否 |
| `POST` | `/api/games/spire/rooms` | 创建房间 | 是 |
| `POST` | `/api/games/spire/rooms/{roomId}/join` | 加入或恢复加入合作房间 | 是 |
| `POST` | `/api/games/spire/rooms/{roomId}/abandon` | 房主按 revision 放弃存档，无需租约 | 是 |
| `GET` | `/api/games/spire/rooms/{roomId}` | 获取当前玩家可见的完整快照 | 否 |
| `GET` | `/api/games/spire/rooms/{roomId}/logs` | 分页读取当前战斗的结构化日志 | 否 |
| `GET` | `/api/games/spire/rooms/{roomId}/players/{playerId}/inspection` | 分页查看房间玩家的公开装备 | 否 |
| `POST` | `/api/games/spire/rooms/{roomId}/actions` | 提交游戏动作 | 是 |
| `POST` | `/api/games/spire/rooms/{roomId}/actions/batch` | 顺序提交最多 8 个动作 | 是 |
| `POST` | `/api/games/spire/rooms/{roomId}/chat` | 发送房间临时消息 | 是 |
| `WS` | `/ws/games/spire/rooms/{roomId}` | 接收快照并取得动作租约 | 是 |

所有请求和响应使用 UTF-8 JSON。路径中的 `roomId`、请求中的 `requestId`、`actionId`、`messageId` 和 `leaseId` 均为 UUID 字符串；批量请求的 `batchId` 也使用 UUID。

## 房间

### 列出房间

```http
GET /api/games/spire/rooms
```

房间不属于任何频道，所有账号共用一个大厅。返回可见的未结束房间，按更新时间倒序排列，最多 50 个。尚未开始（`phase` 为 `lobby`）且房主控制连接在线的合作房间对所有账号可见；已开始的合作房间只对其成员可见；房主始终可见自己的合作存档。单人房间只对房主可见。协议 7 的 `channelId` 查询参数已废弃，传入时忽略。响应增加 `features.lobby/catalog/hostRecovery`；房间增加 `ownerId`、`ownerName`、`act`（从 1 起）、`hostOnline`、`revision`，玩家增加 `character`。

```json
{
  "protocol": 8,
  "phases": ["lobby", "map", "combat", "reward", "rest", "shop", "event", "treasure", "actComplete", "ending", "victory", "defeat", "abandoned"],
  "rooms": [
    {
      "id": "a02abf14-6b79-42e0-93bd-a59c442aa315",
      "mode": "coop",
      "phase": "combat",
      "paused": false,
      "floor": 7,
      "ascension": 10,
      "players": [{"id": 12, "name": "Alice"}],
      "compatible": true
    }
  ]
}
```

`compatible=false` 表示房间存档的规则或内容版本与当前服务端不一致。客户端可以显示该房间，但不能进入或操作。

### 创建房间

```http
POST /api/games/spire/rooms
Content-Type: application/json

{
  "requestId": "a02abf14-6b79-42e0-93bd-a59c442aa315",
  "seed": "EXTERNAL-DEMO-001",
  "mode": "coop",
  "ascension": 0
}
```

字段说明：

| 字段 | 必需 | 说明 |
| --- | --- | --- |
| `requestId` | 是 | 本次创建的幂等 UUID，同时成为 `roomId` |
| `seed` | 否 | 最多 64 个字符；空字符串表示服务端生成种子 |
| `mode` | 否 | `solo` 或 `coop`，默认 `coop` |
| `ascension` | 否 | 严格整数 `0..10`，默认 `0` |
| `character` | 否 | `ironclad/silent/defect/regent/necrobinder`，默认 `ironclad` |
| `replaceRoomId`、`replaceRevision` | 否 | 单人替换旧存档时必须同时提供旧房间 UUID 和当前版本；不匹配返回 409 |

成功响应：

```json
{"id":"a02abf14-6b79-42e0-93bd-a59c442aa315"}
```

网络失败后必须使用完全相同的请求体和 `requestId` 重试。新版创建的房间严格校验完整创建参数，同一 ID 参数变化返回 `409`；历史房间保留创建者、模式、进阶的兼容检查。单人模式下，同一账号已有未结束房间时返回该房间（每个账号只有一个单人存档）。显式提供替换参数时，旧存档结束与新存档创建在同一事务内完成，失败不丢失旧存档。每个账号最多同时有 3 个自己创建的未结束合作房间。请求中的 `channelId` 已废弃，传入时忽略。

### 加入房间

```http
POST /api/games/spire/rooms/a02abf14-6b79-42e0-93bd-a59c442aa315/join
```

请求体可为空，或为 `{"character":"silent"}`。角色参数仅用于新成员，原成员恢复时沿用存档角色。非房主加入或连接 WebSocket 前必须已有房主在线，否则返回 `409 host_offline` 或关闭 WebSocket（1008）。只有 `lobby` 阶段可以加入新玩家，合作房间最多四人；已开始房间仅原成员可继续。单人房间不能被其他账号发现或加入。

成功响应：

```json
{"id":"a02abf14-6b79-42e0-93bd-a59c442aa315"}
```

### 获取房间快照

```http
GET /api/games/spire/rooms/{roomId}
```

调用者必须已经在房间中。响应与 WebSocket 的完整快照结构相同，但不含 `leaseId`；它适合恢复显示和只读接入，不能单独用于提交动作。

## WebSocket 与租约

连接地址：

```text
wss://linkchat.online/ws/games/spire/rooms/{roomId}
```

连接成功后服务端发送当前玩家的完整快照，其中 `leaseId` 是该账号在该房间的当前控制租约。一个账号在一个房间只能有一个控制连接；新连接会替换旧连接，旧连接以关闭码 `4001` 结束。客户端必须停止使用旧连接取得的 `leaseId`。

服务端在房间状态改变时发送新的完整快照，而不是 JSON Patch。客户端应按 `revision` 覆盖本地状态；不能把自己推测出的结果当成最终状态。

可选地追加 `?roomChat=1` 订阅 `ROOM_CHAT` 房间消息事件，详见[房间即时聊天](#room-chat)。消息通过 HTTP 发送，不能向 WebSocket 直接发送聊天文本或聊天 JSON；WebSocket 上行支持文本 `PING` 和 `ACTION` JSON，见文末动作通道。

心跳使用文本帧：

```text
客户端 -> PING
服务端 -> PONG
```

客户端以小于 45 秒的间隔发送一次 `PING`，建议间隔 30 秒。发送超限消息、认证失效、协议错误或超过 45 秒无心跳时，服务端会关闭连接。控制连接离开后游戏会暂停；原成员重新连接到齐后自动继续；新建多人房间仍需准备。

常见关闭码：

| 关闭码 | 含义 |
| --- | --- |
| `1008` | 认证、令牌权限、房间成员身份、Origin、子协议或消息格式不符合要求 |
| `4001` | 同账号的新控制连接已替换当前连接 |

服务端当前会校验浏览器连接的 `Origin` 与 `Host`。开放 Spire Token 时，无 `Origin` 的服务端客户端可正常连接；带 `Origin` 的连接仍必须满足同源要求。

## 快照

快照是按当前玩家裁剪的投影，主要字段如下：

| 字段 | 说明 |
| --- | --- |
| `protocol`、`rules`、`content` | 协议、规则和内容兼容版本 |
| `roomId`、`mode` | 房间标识和 `solo`/`coop` 模式；协议 8 删除了 `channelId` |
| `revision` | 状态版本；每次成功改变状态后递增 |
| `phaseId` | 当前阶段实例 ID，用于拒绝来自旧界面的动作 |
| `phase` | 当前阶段，取值见房间列表的 `phases` |
| `paused`、`floor`、`turn`、`seed` | 暂停状态、楼层、回合和规范化种子 |
| `ascension` | 本局升阶等级，范围 A0–A10 |
| `online` | 当前有控制连接的玩家 ID 数组 |
| `leaseId` | 仅 WebSocket 快照存在；提交动作和发送房间消息所需的当前租约 |
| `upgradePreviews` | 按来源和卡牌 ID 索引的当前实例升级预览，见“卡牌升级预览” |
| `partyRequirement` | `minimum`、`online`、`canReady`；新局准备人数要求，存档在原成员控制连接到齐后自动恢复 |
| `features.roomChat` | 为 `true` 表示支持房间即时聊天 |
| `endTurn` | `canEndTurn`、`canUndoEndTurn` 和不可操作原因 |
| `players` | 所有玩家的公开状态，不含其手牌和抽牌顺序 |
| `enemies` | 敌人、意图和公开状态 |
| `self` | 当前玩家的手牌、牌组、弃牌、消耗牌、遗物、药水等奖励信息 |
| `options` | 当前可执行选项；每项含 `id`、`label`、`action` 和可选的 `choice` |
| `act`、`mapView` | 旅程初始化后各阶段可用的幕信息与公开地图；`act.index` 从 0 开始，`act.row` 为幕内层数，顶层 `floor` 为累计楼层 |
| `pendingChoice` | 待完成的卡牌选择及最少、最多数量 |
| `pendingDecision` | 待完成的结构化决定及最少、最多数量 |
| `event`、`eventProgress` | 当前事件及其公开进度 |
| `log`、`battleLog` | 最近 20 条当前玩家可见的结构化战斗日志及分页元数据 |

自 Spire 后端 `0.1.0a2.dev26` 起，多人战斗中，普通出牌、药水及回合开始触发的选牌按玩家独立等待：有待完成选择的玩家先完成自己的选择，其他已就绪玩家仍可出牌、使用药水或结束回合。快照按接收者投影 `pendingChoice` / `pendingDecision`，客户端应以自己的 `options` 与 `endTurn` 为准；有未完成的选择时不会推进敌方回合。候选牌可能因其他动作变化，客户端应移除已不在最新选项中的本地勾选；失效选择会被拒绝。战斗结束或选择者倒下时会取消不再适用的等待。

后端 `0.1.0a2.dev35` 起，事件、先古馈赠、商店（含领主阳伞入店效果）、营地和奖励产生的个人选择也按玩家独立等待。自己的未完成任务阻止自己继续发起其他游戏动作，其他就绪玩家仍可操作。共享事件先完成投票，再分派个人效果；全员相关任务和奖励完成后才推进阶段。当前协议为 8，扩展 API 为 4，ACTION / SYNC / choose / decide 请求字段不变。

快照声明 `features.independentChoices=true`，并新增可选 `choiceStatus:[{id:玩家ID,status:"choosing"|"ready"|"done"}]`；不包含队友候选牌或选择内容。`pendingChoice`、`pendingDecision` 和 `options` 只投影接收者自己的选择；`pendingDecision.options` 仅包含本人候选的 id/label 及可选卡牌展示数据。无本人选择时不得使用队友的选择替代。旧客户端可忽略新增字段。卡牌可带 `starCost`（非负整数或 `"X"`），用于显示 `[能量,辉星]`。


营地新增 `rest/option:mend`（愈合），随后通过已有 `decide` 选择存活队友，回复该队友最大生命的 30%。取消不消耗行动；小帐篷仍限制同一选项只能使用一次。

SL 支持战斗、事件、营地、商店和宝箱。事件节点触发战斗或嵌套奖励后仍恢复整个事件的初始存档点；普通战斗维持恢复战斗开局。多人仍仅房主可触发，暂停、终局及已离开的节点不可重置。没有历史存档点的旧节点不会伪造初始状态。SL 提升 phaseId/revision、更新 token，并废弃旧选择；入店时已产生的选择会连同恢复日志一起重建。

回退开关 `SPIRE_INDEPENDENT_CHOICES=0` 停止创建新的非战斗并行任务；已有并行恢复日志继续按其记录的调度模式完成。部署时须保留兼容读取能力，不可直接回退 dev34 或覆盖数据库。

`self.draw` 是供检查的稳定排序视图，不代表未来抽牌顺序。公开路线通过 `mapView` 返回；服务端不会返回 RNG 内部状态、队友手牌或真实抽牌顺序。

### 选牌费用展示（可选字段）

卡牌候选的 `options[]` 和 `pendingDecision.options[]` 可带 `displayCards` 数组及 `displayPrefix` 字符串。每张展示牌包含 `id/name/cost/description`，可带 `starCost`；`cost` 为非负整数、`"X"` 或 `"—"`，`starCost` 为非负整数或 `"X"`。成组选择会包含组内每张牌。`displayPrefix` 保留“领取”等前缀，默认为空。

可选布尔标记 `freeEnergy/freeStars` 表示当前展示为零的对应资源因免费效果而免费；客户端可将该数字 `0` 显示为绿色。天然零费不附加免费标记。战斗选牌按当前实例和持有者计算费用；选中后免费、加入手牌或整场免费等效果只在副本上预览。X 与不可打出标记保持实际规则，费用增加后不再为零时不标为免费。牌组操作、永久奖励和组合选择显示非战斗实例费用。

展示数据只属于接收者，不包含原始候选实例、费用修改记录或随机流。`label` 仍包含完整纯文本费用，旧客户端可直接使用；没有展示字段时新客户端也继续使用 `label`。候选排序、选择身份、动作结构、规则及存档版本保持不变。提交时继续复制 `options[].action`，不要将展示牌作为 `play` 动作发送。

### 目标伤害预览

战斗快照的 `self.hand[]` 攻击牌，以及战斗牌堆的升级预览，可以包含可选的 `damagePreview`：

```json
{"basis":"currentStateBeforeBlock","byTarget":{"e0":{"damage":12,"hits":1},"e1":{"damage":18,"hits":1}}}
```

`byTarget` 的键为当前存活敌人的 ID。`damage` 是按当前状态计算、尚未扣除格挡的首段攻击伤害，复用实际攻击的动态基础值与伤害修正公式，包括完美打击、力量、活力、虚弱、易伤和适用的遗物效果；`hits` 是本次攻击的次数，可能为 0。它不是预计生命损失或整张牌的连锁结算总伤害；不提前执行出牌前触发、随机目标、抽牌、消耗触发、击杀追加攻击或重放，后续状态变化可能改变伤害。随机攻击的数值表示命中该敌人时的伤害，次数并不保证全部命中该敌人。

客户端切换或悬停目标时读取对应项，收到新快照后重新显示。缺少字段时沿用普通说明；非攻击牌不提供此字段。预览不修改房间状态、不推进 revision、不消耗 RNG，不泄露真实抽牌顺序。当前协议版本为 7；非攻击牌不需要提供伤害预览。

一个选项示例：

```json
{
  "id": "enemy-3",
  "label": "打击 [1] 造成 6 点伤害",
  "action": {
    "type": "play",
    "card": "card-17",
    "target": "enemy-3",
    "phaseId": 18
  },
  "choice": null
}
```

`action` 的具体字段会随阶段和内容变化。客户端应原样复制选中项的 `action`，不要重建它。若 `choice` 指示需要从 `deck`、`deckUpgrade` 等来源选牌，应根据 `pendingChoice`/界面选择补入服务端要求的 `choice` 或 `cards` 字段。

## 提交动作

```http
POST /api/games/spire/rooms/{roomId}/actions
Content-Type: application/json

{
  "actionId": "88c0df8e-28f2-4e3b-b424-8d1f4898c91f",
  "leaseId": "8fe2f945-ee94-4577-82ee-df91e79774d7",
  "action": {
    "type": "ready"
  }
}
```

- `actionId`：每个逻辑动作新建一个 UUID。去重作用域为 `roomId + token 所属账号 + actionId`。
- `leaseId`：最新 WebSocket 快照中的值。重新连接后必须换成新租约。
- `action`：从最新快照 `options[].action` 复制；除 `pause`、`ready`、`abandon` 外必须携带当前 `phaseId`，包括 `character`。
- `action` 按服务端 Python `json.dumps` 默认格式序列化后最多 8192 个 ASCII 字符（非 ASCII 字符会转义，包含默认分隔空格），并非原始 HTTP 请求体长度。

相同 `actionId` 和完全相同的 `action` 可安全重试，不会重复执行，响应为当前投影。相同 `actionId` 携带不同动作返回 `409`。成功响应是动作执行后的完整玩家投影，但不含 `leaseId`；继续使用 WebSocket 保存的当前租约。操作者不会收到同一动作的额外 WS 快照，其他成员仍正常收到广播。

每个租约每秒最多提交 12 次动作。开放实现不能把 Agent API 的每分钟写入限流直接套在游戏动作上，否则正常操作会被错误限制。

除动作端点外，每个 Spire Token 每分钟最多发起 120 次 HTTP 请求；动作端点只使用每租约每秒 12 次的游戏限流。

常见动作类型包括 `ready`、`character`、`vote`、`play`、`end`、`undoEnd`、`potion`、`reward`、`buy`、`rest`、`event`、`choose`、`decide`、`continue` 和 `abandon`。该列表不是稳定枚举；是否可执行仍以最新 `options` 为准。

## 只读工具接口

### 战斗日志

```http
GET /api/games/spire/rooms/{roomId}/logs?battleId=3&after=100&limit=100
```

读取当前战斗的结构化日志。`limit` 范围为 1 到 200，默认 100；`before` 与 `after` 不能同时使用。传入的 `battleId` 与当前战斗不符时返回 `409 stale_phase`。响应包含 `battleId`、`total`、`first`、`last`、`incomplete`、`entries`、`before` 和 `after`。日志按调用者身份投影，队友的私有抽牌和卡牌来源不会泄露。该接口不需要租约，不改变状态。

### 查看玩家装备

```http
GET /api/games/spire/rooms/{roomId}/players/{playerId}/inspection?page=0&size=40
```

调用者必须已加入房间。`page` 从 0 开始，`size` 范围为 1 到 200，默认 40。越界页返回空 `rows`；负数 `page` 或超出范围的 `size` 返回 `400 invalid_request`。当前响应的 `pageSize` 固定为默认值 40，即使请求了其他 `size`；客户端分页应使用自己请求的 `size`。该接口不需要 WebSocket 租约，不增加 `revision`，也不消耗 RNG。

```json
{
  "player": 27,
  "name": "Bob",
  "character": "silent",
  "hp": 52,
  "maxHp": 70,
  "gold": 121,
  "total": 16,
  "pageSize": 40,
  "page": 0,
  "rows": [
    {
      "id": "card-22",
      "pile": "deck",
      "key": "StrikeSilent",
      "upgraded": false,
      "name": "打击",
      "cost": 1,
      "description": "造成伤害。"
    }
  ]
}
```

`rows[].pile` 为 `deck`、`relics`、`potions` 或 `status`；不同类型的行字段不同。响应不会暴露队友手牌、抽牌顺序或私有选择。

### 卡牌升级预览

完整快照的 `upgradePreviews[source][cardId]` 提供 `{card,before,after}`，source 为 `hand/deck/draw/discard/exhaust/reward/shop`。缺少某张牌的条目表示该来源下没有可用升级预览。同一卡牌在手牌与永久牌组中的费用、效果可能不同，必须按来源读取。

收到完整快照时替换预览缓存。预览不执行升级、不消耗 RNG；实际升级通过动作提交。协议 7 删除了逐牌 HTTP 升级预览路由，客户端直接显示快照数据。

## 错误格式

开放接口统一返回机器可处理的错误体：

```json
{
  "error": {
    "code": "stale_phase",
    "message": "阶段已经变化，请刷新快照。",
    "retryable": false
  },
  "requestId": "025582fc-f509-4700-b73f-47000c59a684"
}
```

HTTP 响应同时包含：

```http
X-Request-ID: 025582fc-f509-4700-b73f-47000c59a684
Cache-Control: no-store
```

当前错误码：

| HTTP | `error.code` | 处理方式 |
| --- | --- | --- |
| `400` | `invalid_request` | 修正查询参数或请求语义 |
| `401` | `invalid_spire_token` | 检查令牌是否正确、到期或已撤销 |
| `403` | `control_forbidden` | 只读令牌尝试创建、加入、连接控制 WS、提交动作或发送房间消息 |
| `403` | `not_participant` | 当前账号尚未加入房间 |
| `404` | `room_not_found` | 房间不存在或因单人房间隐私而不可见 |
| `404` | `player_not_found` | 玩家不在该房间 |
| `409` | `stale_phase` | 获取最新快照后，用新 `actionId` 重新决策 |
| `409` | `stale_lease` | 重新连接 WebSocket，使用新 `leaseId` |
| `409` | `invalid_action` | 丢弃本地动作，从最新 `options` 重新选择 |
| `409` | `idempotency_conflict` | 同一 `actionId` 被用于不同动作，或当前连接内同一 `messageId` 被用于不同消息内容；改正客户端逻辑 |
| `409` | `incompatible_room` | 使用与存档相容的服务端版本 |
| `413` | `action_too_large` | 缩小动作 JSON |
| `422` | `validation_error` | 按接口模型修正字段、类型或额外字段 |
| `429` | `rate_limited` | 按 `Retry-After` 等待 |
| `503` | `spire_unavailable` | 仅在 `retryable=true` 时指数退避重试 |

Bearer Token 请求使用上述统一错误体。Cookie 会话请求为兼容现有网页和 TUI，仍返回 `{"message":"..."}`。

## 重试与并发

- 创建房间重试：保留同一个 `requestId` 和完全相同的请求参数。
- 动作重试：保留同一个 `actionId`、`leaseId` 和完全相同的 `action`。
- 收到 `stale_phase`、`stale_lease` 或 `invalid_action`：不要盲目重复原动作；先取得最新快照，再用新的 `actionId` 决策。
- 收到 `429`：遵循 `Retry-After`。游戏动作的限制以租约为单位。
- 收到可重试的 `5xx`：使用带抖动的指数退避。创建和动作已有幂等键，重试时不得生成新键。
- 多人共享阶段允许不同玩家并发提交普通动作；服务端按房间串行落库，并用 `revision` 防止覆盖。

## curl 示例

```bash
export LINKCHAT_SPIRE_TOKEN='lc_spire_请替换'
export LINKCHAT_BASE_URL='https://linkchat.online'
export ROOM_ID="$(python -c 'import uuid; print(uuid.uuid4())')"

curl --fail-with-body \
  "$LINKCHAT_BASE_URL/api/games/spire/rooms" \
  -H "Authorization: Bearer $LINKCHAT_SPIRE_TOKEN"

curl --fail-with-body \
  "$LINKCHAT_BASE_URL/api/games/spire/rooms" \
  -H "Authorization: Bearer $LINKCHAT_SPIRE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d "{\"requestId\":\"$ROOM_ID\",\"mode\":\"coop\",\"seed\":\"EXTERNAL-DEMO-001\"}"

curl --fail-with-body \
  "$LINKCHAT_BASE_URL/api/games/spire/rooms/$ROOM_ID" \
  -H "Authorization: Bearer $LINKCHAT_SPIRE_TOKEN"
```

`curl` 适合 HTTP 调试；取得 `leaseId` 并操作游戏仍需 WebSocket 客户端。

## Python 最小控制示例

以下示例需要 `httpx` 和 `websockets`：

```python
import asyncio
import json
import os
import uuid

import httpx
import websockets

BASE = os.getenv("LINKCHAT_BASE_URL", "https://linkchat.online").rstrip("/")
TOKEN = os.environ["LINKCHAT_SPIRE_TOKEN"]
HEADERS = {"Authorization": f"Bearer {TOKEN}"}


async def main():
    async with httpx.AsyncClient(base_url=BASE, headers=HEADERS) as http:
        room_id = str(uuid.uuid4())
        response = await http.post(
            "/api/games/spire/rooms",
            json={
                "requestId": room_id,
                "mode": "solo",
                "seed": "PYTHON-DEMO-001",
            },
        )
        response.raise_for_status()
        room_id = response.json()["id"]

        ws_url = BASE.replace("https://", "wss://").replace("http://", "ws://")
        async with websockets.connect(
            f"{ws_url}/ws/games/spire/rooms/{room_id}",
            subprotocols=["linkchat-game-v8"],
            additional_headers=HEADERS,
        ) as ws:
            snapshot = json.loads(await ws.recv())
            lease_id = snapshot["leaseId"]

            assert snapshot["protocol"] == 8
            # Pick one explicit action offered by this snapshot.
            action = next(
                row["action"] for row in snapshot["options"]
                if row.get("action") and row["action"]["type"] not in ("abandon", "pause")
            )
            action = {**action, "phaseId": snapshot["phaseId"]}
            action_id = str(uuid.uuid4())
            await ws.send(json.dumps({
                "type": "ACTION", "actionId": action_id,
                "leaseId": lease_id, "action": action,
            }))
            while True:
                message = await ws.recv()
                if message == "PONG":
                    continue
                event = json.loads(message)
                if event.get("type") == "ACTION_ERROR":
                    raise RuntimeError(event["error"])
                if event.get("type") == "ACTION_RESULT" and event["actionId"] == action_id:
                    snapshot = event["state"]
                    break
            # Closing this demo's controller pauses its room.


asyncio.run(main())
```

示例使用支持 `additional_headers` 的 websockets 客户端。生产客户端应让接收循环与心跳循环持续运行，先分流 ACTION_RESULT/ACTION_ERROR，再按 revision 应用确认中的 state 或独立游戏快照；若开启房间聊天，必须先分流 `ROOM_CHAT`，聊天事件不能覆盖快照。上面的基础示例未开启房间聊天。

## 协议 8 与版本检查

协议 8 让 Spire 与频道解绑，是明确的协议切换，不提供协议 7 回退：

- 控制连接必须发送 `Sec-WebSocket-Protocol: linkchat-game-v8`；旧子协议在握手阶段被拒绝。
- 快照和房间列表的 `protocol` 必须为 `8`；快照不再包含 `channelId`。CLI 宿主扩展 API 为 `4`。
- 房间、资料库和令牌都不再按频道鉴权：房间列表是全站共用的大厅，Spire Token 属于账号。请求中残留的 `channelId` / `channelIds` 被忽略。
- 升级到协议 8 的后端启动时会清空协议 7 的全部房间存档（含回执、存档点和战斗日志）；Wongo 与胜利积分等账号进度保留。

协议 7 相对协议 6 的变化（仍然有效）：

- 客户端宿主中 `connect().send()` 与 `connect().reconnect()` 为必需能力。
- 删除动作 `actions=1` 协商、`features.actionTransport/actionBatch/responseOnly` 探测字段、HTTP `responseOnly` 开关以及客户端 HTTP 动作回退。
- 删除 `POST /rooms/{id}/previews/upgrade`，统一读取快照 `upgradePreviews`。
- `roomChat=1` 保留为聊天订阅选项；HTTP 单动作、批量、回执核对、日志和装备查询仍是有效接口。
- 本次不改变 `rules` 或 `content`，不升级或重算游戏存档。

- 客户端必须检查 `protocol`。无法识别的协议版本应停止操作并提示升级。
- `rules` 或 `content` 改变时，服务端不会静默重算旧局；不兼容存档会被拒绝加载。
- 客户端可以忽略未知响应字段，但不能假设 `options[].action` 的字段集合固定。
- 客户端不能依赖动作类型的完整枚举、选项顺序或中文 `label` 文案来判断规则。
- 本接口不提供原作存档导入、原作联网或 RNG 内部状态。

## 实现状态

- 独立 `spire_token` 表只保存安全哈希、账号、`canControl`、有效期与撤销状态；协议 7 的频道授权表不再读取。
- 个人资料提供创建、列表和撤销界面，完整令牌只显示一次。
- `/api/games/spire/*` 同时支持 Cookie 和 Spire Bearer Token；WebSocket 支持 Authorization 请求头。
- HTTP、WebSocket 握手、广播和心跳都会重新校验令牌、登录状态与房间成员身份。
- Bearer 请求返回请求 ID、`Cache-Control: no-store` 和稳定机器错误码。
- OpenAPI 使用 `SpireToken` Bearer 安全方案描述 HTTP 接口。
- 集成测试覆盖创建与撤销、跨频道联机、只读权限、过期、WebSocket 租约、动作执行和错误格式。


<a id="room-chat"></a>

## 房间即时聊天

此功能与外部频道聊天隔离，仅当前房间玩家可收发；不写入游戏存档、战斗日志或聊天消息表，不提供历史查询和重连回放。当前游戏协议为 8，快照中的 `features.roomChat: true` 表示支持聊天。

客户端通过 `/ws/games/spire/rooms/{roomId}?roomChat=1` 显式订阅聊天事件，认证方式和 `linkchat-game-v8` 子协议不变。未订阅聊天时不发送 ROOM_CHAT；游戏快照、动作确认与 PONG 不受影响。客户端应先按 `type === "ROOM_CHAT"` 分流聊天事件，再校验游戏快照；聊天不会推进 `revision` 或 `phaseId`。

发送接口：`POST /api/games/spire/rooms/{roomId}/chat`，需要房间成员身份、当前连接的 `leaseId`，Spire Token 还需要 `canControl`。请求示例：

```json
{
  "messageId": "8ff3e207-f23c-4556-96cb-b99837b92240",
  "leaseId": "7c2f5634-78e1-4e6d-8a48-b7d7d2c18f30",
  "content": "等我一下，先不要结束回合"
}
```

示例 UUID 仅用于展示格式；实际调用时生成新的 `messageId`，并使用当前房间 WebSocket 快照中的 `leaseId`。

`content` 原始输入为 1–200 个 Unicode 码点的纯文本；先将空白（包括换行与制表符）归并为空格，再拒绝空白内容、剩余 C0/C1 控制符及 U+202A–U+202E、U+2066–U+2069 双向文本控制符。作者身份由服务端确定，不能通过请求自报。每个当前玩家租约每 10 秒最多发送 5 条，超过返回 429；此额度与游戏动作额度分开，Spire Token 还受每分钟 120 次非动作 HTTP 请求额度限制。

成功响应与 WebSocket 事件使用同一对象：

```json
{
  "type": "ROOM_CHAT",
  "id": "8ff3e207-f23c-4556-96cb-b99837b92240",
  "roomId": "a02abf14-6b79-42e0-93bd-a59c442aa315",
  "userId": 12,
  "name": "Alice",
  "content": "等我一下，先不要结束回合",
  "createdAt": "2026-09-24T03:00:00+00:00"
}
```

当前连接内消息 ID 的去重回执保留 60 秒，相同 ID 与内容返回同一确认且不重复广播；相同 ID 改换内容返回 409。回执随连接释放，仅用于短期重试，不是消息历史。广播前重新检查每位接收者的访问权限。

TUI 用 T 键输入，Enter 发送、Esc 取消。无边框暗灰色消息区最多显示最新 8 条（包含本人），每条从首次收到起独立显示 60 秒；HTTP 确认与 WebSocket 推送按作者和消息 ID 去重。断线保留草稿，恢复后由玩家发送，不自动重发。


### 接收与发送示例

连接地址为 `wss://linkchat.online/ws/games/spire/rooms/{roomId}?roomChat=1`。接收循环应先识别心跳，再分流聊天事件与游戏快照：

```python
raw = await ws.recv()
if raw == "PONG":
    pass
else:
    event = json.loads(raw)
    if event.get("type") == "ROOM_CHAT":
        # 按 (roomId, userId, id) 去重；首次收到后显示 60 秒。
        show_room_message(event)
    else:
        # 这里才执行 protocol、revision 等游戏快照校验。
        accept_snapshot(event)
```

上例的 `show_room_message` 和 `accept_snapshot` 由客户端实现，仍需独立运行心跳。发送走 HTTP，不发送到 WebSocket：

```bash
curl -sS -X POST "https://linkchat.online/api/games/spire/rooms/$ROOM_ID/chat" \
  -H "Authorization: Bearer $SPIRE_TOKEN" \
  -H 'Content-Type: application/json' \
  --data '{"messageId":"8ff3e207-f23c-4556-96cb-b99837b92240","leaseId":"7c2f5634-78e1-4e6d-8a48-b7d7d2c18f30","content":"等我一下"}'
```

设置 `ROOM_ID`、`SPIRE_TOKEN` 并替换请求中的 UUID；当前控制连接必须保持打开。HTTP 确认也可用于展示本人消息，但须与 WebSocket 回推去重。5xx 或网络中断后的短期手动重试复用同一消息 ID 和内容；重连后换成新租约，旧连接的去重回执不跨连接保留，不能把重新发送当成跨连接幂等保证。


## 频道成员游戏状态摘要

登录会话可调用 `GET /api/channels/{channel_id}/presence`，调用者必须属于该频道。原有 `onlineCount`、`onlineUserIds` 保持兼容；启用支持此功能的 Spire 后端时，新增可选 `spire` 数组：

```json
{"onlineCount":1,"onlineUserIds":[7],"spire":[{"userId":7,"characterName":"铁甲战士","phase":"combat","act":2,"floor":6,"paused":false}]}
```

- `act` 从 1 开始；`floor` 是当前幕内的层数，而非累计楼层。
- 仅返回仍持有有效游戏连接及凭据的当前频道成员；房间不属于频道，成员在任何房间里游戏都会出现。有效游戏连接也计为在线；只有存档不算正在游戏，退出连接后不再返回摘要。
- `characterName` 是服务器当前角色名称，未知时为 `null`；`phase=lobby` 表示等待开始，`paused` 表示暂停。停留在结算画面时仍可返回对应阶段。
- 不返回单人／多人模式、游戏人数、房间 ID、种子、手牌或存档。单人房间也仅公开上述摘要，房间读取权限不变。
- 该接口使用普通登录会话，不接受 Spire Token；玩家自身的游戏连接可以来自有效 Spire Token。
- 游戏模块未启用或暂时不可用时省略 `spire`；正常启用但无人连接时为 `[]`。

终端 `/members` 使用此摘要，每位成员一行；打开成员页期间每 30 秒刷新，也可按 Ctrl+R 刷新。此变更不修改游戏协议版本或存档结构。


## 资料库（只读）

以下接口只需登录会话或任意有效 Spire Token，不要求游戏控制权限，也不需要频道。

- `GET /api/games/spire/catalog`：内容版本、能力声明、分类数量、五角色初始配置、A0–A10 说明。
- `GET /api/games/spire/catalog/{category}`：`category` 为 `cards/monsters/relics/potions/characters`。支持 `q`、`group`、`offset`（默认 0）、`limit`（1–100，默认 50）；返回 `items/total/groups/offset/limit/version`。
- `GET /api/games/spire/catalog/{category}/{id}`：返回名称、`lines` 与关联 `links`。卡牌支持 `upgraded=true`，怪物支持 `ascension=0..10`；怪物为单人基础生命、全部招式、初始状态与部分条件说明，实际动作顺序由战斗状态决定。

`category` 是大类；响应的 `groups` 是该大类全部小类名称，不受当前搜索词、分页或 `group` 筛选影响。卡牌按角色/卡池分组，遗物和药水按角色/通用/特殊分组，怪物按遭遇类型分组。将返回的名称原样作为 `group` 传回；省略或传空字符串表示全部。`q` 和 `group` 最多 100 个字符，`offset` 必须非负。

中文查询参数必须进行 URL 编码，例如：

```bash
curl -sS --get 'https://linkchat.online/api/games/spire/catalog/cards' \
  -H "Authorization: Bearer $SPIRE_TOKEN" \
  --data-urlencode 'group=铁甲战士' \
  --data-urlencode 'q=打击' \
  --data-urlencode 'limit=40'
```

列表条目至少包含 `id/name/group`，部分大类还提供 `kind/rarity`；详情中的 `links` 为 `{category,id,name}` 数组，用于打开角色关联卡牌或遗物。CLI 外层以列表选择大类，内部 Tab / Shift+Tab 切换小类；角色详情用上下键选择关联条目，Enter 查看，Esc 返回。

仅查询当前实现内容，不创建房间、不更新存档，不推进任何真实房间的随机流。

## 场景 SL

快照增加 `features.sl=true` 和 `sl={available,token,hostOnly,canReset}`。`token` 是当前节点及阶段的随机 UUID，不是节点编号。客户端打开聊天时记录此 token，提交前检查仍与当前一致。

通过现有 `POST /rooms/{id}/chat` 发送 `content:"/sl"`，同时携带 `messageId`、当前控制连接的 `leaseId` 和 `scopeToken`。普通聊天无需 `scopeToken`。SL 请求仍经过令牌权限、玩家身份和当前租约校验。

战斗、事件、火堆首次进入时持久化完整初始状态（含随机流）。单人立即恢复；多人只有房主可直接恢复，无需投票；其他成员请求返回 `403 host_only`。`sl.canReset` 表示当前玩家是否有恢复权限。响应和房间广播为 `ROOM_CHAT`，带 `command:"sl"`、`restored`；同时广播完整游戏快照。系统反馈同样只在当前房间展示 60 秒。

存档点保存在独立 `extension_spire_checkpoint` 表，回执在 `extension_spire_reset_receipt`，均与房间更新同事务提交。新节点清除全部旧存档点；阶段变化和暂停轮换 token。事件内战斗与事件保留独立存档点。成功 SL 轮换该节点所有 token，递增 revision/phaseId，因此旧游戏动作也无法再次执行。相同 messageId/token 的重试返回原回执，不重复恢复；旧 token 的新请求返回 `409 stale_checkpoint`。

奖励阶段、地图、终局或没有初始存档点时返回 `409 sl_unavailable`。旧存档更新时若已在场景中，不将中途状态冒充初始状态，需进入下一场景。恢复随机状态意味着相同操作结果相同，不重新随机。


### 多人开始人数限制

合作房间至少需要两名不同玩家在线才能准备、开始或恢复游戏；同一账号的多个连接只算一人。人数不足的 `ready` 请求返回 `409 insufficient_players`，不更新存档。快照增加 `partyRequirement={minimum,online,canReady}`；人数不足时不提供准备/继续选项。创建等待房间和选择角色不受此限制，单人模式仍允许一人开始及恢复。


### 放弃当前存档

`POST /api/games/spire/rooms/{id}/abandon`，请求体 `{"revision":8}`。
仅房主且具有游戏控制权限可调用，无需建立游戏连接。revision 必须匹配当前存档，否则返回 `409 stale_revision`，应刷新并重新确认。成功返回 `{"id":"房间 ID"}`，已放弃的存档可安全重试。非房主返回 `403 host_only`。放弃立即结束整局，不再进行成员投票。

在线多人房主也可通过带租约的 `actions` 提交 `{"type":"abandon"}`；单人界面在继续存档下方提供独立入口。终局不能再次放弃。恢复存档不需提交 ready；服务端在原成员控制连接全部到齐时自动恢复。

## 低延迟动作通道

规则始终在服务端结算。协议版本为 7，必须使用子协议 `linkchat-game-v8`。所有控制连接默认支持 ACTION，无需能力探测或查询参数；CLI 只使用 WS 提交游戏动作，HTTP 单动作和批量接口保留给外部程序。

连接 `/ws/games/spire/rooms/{roomId}?roomChat=1` 后，除了文本 `PING`，还可发送动作 JSON：

```json
{
  "type": "ACTION",
  "actionId": "11111111-1111-4111-8111-111111111111",
  "leaseId": "当前连接取得的 UUID",
  "action": {"type": "play", "card": "卡牌实例 ID", "target": "目标 ID", "phaseId": 12}
}
```

`action` 字段与 HTTP 动作完全相同，具体字段以动作章节为准。成功返回一次完整快照确认，不再额外向操作者广播同一动作快照：

```json
{"type":"ACTION_RESULT","actionId":"原动作 UUID","state":{"revision":13,"leaseId":"当前租约","其他字段":"完整玩家快照"},"timing":{"rules":1.2,"commit":0.8,"total":4.1},"stopQueue":false}
```

失败返回 `{"type":"ACTION_ERROR","actionId":"原动作 UUID","error":{"status":409,"code":"invalid_action","message":"原因","retryable":false},"timing":{}}`。无有效动作 ID 的格式错误可能返回 `actionId:null`。认证失效会关闭连接。每次动作执行及每次下行发送均重新检查授权。

客户端可在前一动作确认前发送下一动作，并设置 `afterActionId` 为紧邻前序动作 UUID。服务端按同一连接的接收顺序执行；前序失败、阶段或回合变化、出现待选项、暂停或终局时，后续依赖动作返回 `409 queue_stopped`，不会执行。成功确认的 `stopQueue=true` 表示应停止继续发送该链。重复已提交动作只返回当前快照，且停止自动延续链。没有 `afterActionId` 的动作是独立请求。

CLI 最多保留 8 个待确认动作，连续出牌/药水可流水发送，结束回合为队列末端；界面仅标记待确认，不推算游戏结果。客户端仍须处理其他玩家引发的完整快照广播，并按 revision 更新。

完整 WS 消息上限为解压后的 4 MiB，服务端完整下行 JSON 也按 UTF-8 字节检查；超限关闭 `1009`，客户端停止重试。动作对象序列化上限仍为 8 KiB；每玩家每秒最多 12 个新动作。心跳仍使用文本 PING/PONG。慢连接下行队列最多 32 条，溢出关闭码为 `1013`；客户端应重连取得最新快照。旧子协议无法建立控制连接。

### HTTP 单动作与断线核对

`POST /api/games/spire/rooms/{id}/actions` 返回完整快照，只广播给其他成员。已移除 `responseOnly` 开关和自身重复广播分支。

HTTP `?receiptOnly=true` 只查询已有成功回执，不执行新动作；无回执返回 `409 action_not_committed`。**查不到回执不代表取消动作**：其他连接或此前排队的动作可能仍在执行。CLI 不使用此路径恢复队列。

### WebSocket 有序核对

后端在快照声明 `features.actionSync=true`。CLI 超时（10 秒）、未知结果错误、断线后重新取得租约与首帧时自动核对；R 可提前发起，已有请求时显示进度并复用。

```json
{"type":"SYNC","requestId":"请求 UUID","leaseId":"当前租约 UUID","actionIds":["待确认动作 UUID"]}
```

`actionIds` 最多 8 个，可为空。服务端在该连接此前动作处理完后，于房间锁内重新验证当前租约并查询回执：

```json
{"type":"SYNC_RESULT","requestId":"请求 UUID","leaseId":"当前租约 UUID","results":[{"actionId":"动作 UUID","status":"committed"}],"state":{"其他字段":"最新完整快照（含 leaseId）"}}
```

每个状态为 `committed` 或 `not_committed`。取得新租约后旧连接不可再提交；动作与核对均在房间锁内重验租约。核对期间冻结新动作但继续登记回执，收到对应请求与租约的完整结果后先应用状态再释放队列。未提交动作只提示重新选择，绝不自动重打。核对 10 秒超时后调用 `connect().reconnect()`，保留原动作 ID，在新租约首帧后继续核对。不支持 actionSync 的后端须升级。

临时发送故障或超时关闭 `1011`，拥塞关闭 `1013`，授权失败关闭 `1008`，租约替换关闭 `4001`。宿主永久性 4xx 握手错误停止重连（408、429 除外）；网络错误、5xx 继续退避，429 尊重有效 Retry-After。退避基准为 1、2、4、8、15 秒并加抖动，收到有效状态且连接稳定 30 秒后才重置。

HTTP 普通重试仍可用原 actionId 重发；这可能执行尚未提交的动作，因此不应将整条不确定队列直接逐一重发。WS 5xx 或连接中断属于结果未知，不能据此判断事务一定失败。

### HTTP 批量动作

`POST /api/games/spire/rooms/{id}/actions/batch`：

```json
{
  "batchId":"22222222-2222-4222-8222-222222222222",
  "leaseId":"当前租约 UUID",
  "actions":[
    {"actionId":"33333333-3333-4333-8333-333333333333","action":{"type":"play","card":"卡牌实例 ID","phaseId":12}}
  ]
}
```

一次 1–8 个动作，actionId 不得重复，规范化后的动作数组上限 64 KiB，单动作及频率限制同单动作接口。返回：

```json
{"batchId":"原批次 UUID","results":[{"actionId":"已完成动作 UUID","revision":13,"duplicate":false}],"stopped":null,"state":{"其他字段":"最终完整快照"},"timing":{"total":5.4}}
```

动作按顺序执行，遇到首个无效动作停止，成功前缀和批次回执在同一事务提交；内部保存或提交异常则整批回滚。无效动作记录为 `stopped:{index,actionId,error}`（索引从 0 开始）；阶段、回合、待选择等边界或已存在动作回执会截断尾部，记录为 `stopped:{index,reason:"decision_boundary"}`。因此 HTTP 200 不代表所有步骤均执行，必须检查 results 和 stopped。首步租约等校验失败也可返回空 results 和 stopped，并保存该批次结果。

同 batchId、同有序动作列表重试，返回原 results/stopped 和当前快照，不会继续执行原来未执行的尾部；允许重连后更换 leaseId。同 batchId 更换动作列表返回 `409 idempotency_conflict`。批量接口只向其他成员广播，操作者使用响应更新。建议只批量发送当前状态下已确定的连续操作，在需要选择目标、奖励或下一阶段时重新读取快照。

### 压缩与耗时指标

部署配置为 JSON HTTP 响应启用 gzip（超过 1024 字节且客户端支持时），为 Uvicorn WebSocket 启用 permessage-deflate；CLI 同时支持 WS 压缩协商。两者独立生效，协议 8 客户端即使不支持压缩也可连接。生产发布需要配套更新后端、CLI 与 Spire 扩展。

HTTP 动作成功响应包含 `Server-Timing`，涵盖 lock/load/progress/rules/save/commit/projection/serialize/total 中实际发生的阶段（毫秒）。WS 确认和批量 JSON 的 timing 为构建响应时的阶段数据，不包含后续网络发送；total 从动作处理器开始计时，不包含其前的认证依赖或客户端 RTT。服务端日志另记录 WS serialize_ms、queue_ms、send_ms、未压缩 bytes，便于区分结算、数据库、排队和发送耗时。

CLI 将动作入队至确认处理完成的 totalMs、服务端 serverMs、actionId、outcome 发布到 Node diagnostics_channel `linkchat.spire.action`；设置 `LINKCHAT_SPIRE_TIMING_FILE` 可追加 JSONL。内存保留最近 256 条样本并支持成功样本 p50/p95 汇总。指标不记录令牌或游戏状态。该客户端指标包含网络及排队耗时，不能直接视为纯 RTT。
