本文定义外部程序接入 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 会在下次广播或心跳时失效。
快速接入流程
- 用
GET /api/games/spire/rooms查找可继续或可加入的房间,或创建新房间。 - 合作模式下,其他账号调用
POST /rooms/{roomId}/join加入;创建者已经在房间内。 - 连接房间 WebSocket,接收首个完整快照并保存其中的
leaseId。需要房间聊天时,在连接 URL 添加?roomChat=1,并检查快照中的features.roomChat。 - 只从最新快照的
options[].action选择动作;需要选牌时按choice的要求补充choice或cards。 - 为每次逻辑动作生成新的
actionId,与当前leaseId一起提交。 - 持续接收 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按服务端 Pythonjson.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探测字段、HTTPresponseOnly开关以及客户端 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 使用
SpireTokenBearer 安全方案描述 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。