LinkChat
Spire API
LINKCHAT / SPIRE API

把 Spire 接入外部客户端Spire API

创建或加入房间,订阅完整快照,按服务端选项提交动作。
独立 Spire Token 与聊天 Agent 权限相互隔离。

BASE URLhttps://linkchat.online/api/games/spire
接口速览

房间与实时控制接口。

GET/rooms列出房间与读取快照
POST/rooms/{id}/actions提交幂等游戏动作
POST/rooms/{id}/chat发送房间消息 · 与频道聊天隔离
WS/ws/games/spire/rooms/{id}订阅快照与取得租约

本文定义外部程序接入 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 请求通过请求头认证:

Authorization: Bearer lc_spire_xxxxxxxxxxxxxxxxxxxx

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

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。

房间

列出房间

GET /api/games/spire/rooms

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

{
  "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 表示房间存档的规则或内容版本与当前服务端不一致。客户端可以显示该房间,但不能进入或操作。

创建房间

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

成功响应:

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

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

加入房间

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

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

成功响应:

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

获取房间快照

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

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

WebSocket 与租约

连接地址:

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

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

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

可选地追加 ?roomChat=1 订阅 ROOM_CHAT 房间消息事件,详见房间即时聊天。消息通过 HTTP 发送,不能向 WebSocket 直接发送聊天文本或聊天 JSON;WebSocket 上行支持文本 PING 和 ACTION JSON,见文末动作通道。

心跳使用文本帧:

客户端 -> 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:

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

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

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

一个选项示例:

{
  "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 字段。

提交动作

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 为准。

只读工具接口

战斗日志

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。日志按调用者身份投影,队友的私有抽牌和卡牌来源不会泄露。该接口不需要租约,不改变状态。

查看玩家装备

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。

{
  "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 升级预览路由,客户端直接显示快照数据。

错误格式

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

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

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 示例

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:

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 租约、动作执行和错误格式。

房间即时聊天

此功能与外部频道聊天隔离,仅当前房间玩家可收发;不写入游戏存档、战斗日志或聊天消息表,不提供历史查询和重连回放。当前游戏协议为 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。请求示例:

{
  "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 事件使用同一对象:

{
  "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。接收循环应先识别心跳,再分流聊天事件与游戏快照:

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:

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 数组:

{"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 编码,例如:

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:

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

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

{"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 可提前发起,已有请求时显示进度并复用。

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

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

{"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:

{
  "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,单动作及频率限制同单动作接口。返回:

{"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。