世界模型
World Model 是一局游戏里由内核保管的那部分世界数据:世界记录、角色和角色的属性定义、资料库、维度的当前值。这一页说明它们各自的规则:名字怎样找到角色,维度的值什么时候能读、什么时候更新,资料库条目归谁所有。
世界作者主要需要“别名”一节。其余部分面向写插件的人。世界包里的文件怎么写,见世界数据。
插件读取 World Model 的四个入口:
| 入口 | 内容 |
|---|---|
ctx.world.worldRecord | 世界记录 |
ctx.world.characterSchema | 角色的类型和属性定义 |
ctx.world.characters | 角色 |
ctx.world.dimensions | 维度的当前值 |
ctx.world.seats 和它们放在一起,是会话的座位(谁在玩、控制哪些角色),只读。它是会话状态,不属于 World Model,见座位。
这些入口是只读的,写入要通过 proposal(一份“我想改什么”的申请,由内核校验后统一保存)。
- 角色、角色 schema 和维度:
ctx.world可以读取已完成上游的合法提案,并随读取叠加当前 runtime 自己的合法提案。这些修改尚未持久化,最终仍需提交。 - 维度的公共输入、内置查询和叙事摘要使用执行开始时冻结的底本。它们与用于计算的
ctx.world投影视图不同,见冻结读取与维护。
字段和各部分归谁管见插件契约。
读取与提交。 可见的上游在每次 runtime 调用开始时取定,同层并行的兄弟互不可见。角色和 schema 是共享领域数据,不按插件过滤。上游按提交顺序叠加,递归子调用先于父调用,并保留各自的运行身份。只接受可提交的结果:带输出的成功结果,或带缓冲写入的 skipped guard;失败、挂起、无输出的成功结果和无效 effects 不贡献写入。重试与 detached 作业携带的种子结果及其后代不重放,它们已经由源执行提交或丢弃。
只有成功访问属性才记录读取依赖。只拿到 ctx.world 不算读取;宿主构造、校验、复制和缓存视图也不算。作者主动展开、序列化或使用平台的结构化克隆功能复制 ctx.world,会访问其中的可枚举属性。
| 读取 | 记录的上游 |
|---|---|
characters(包括 find、过滤和空结果判断) | 当前视图中所有 character.upsert 的生产者 |
characterSchema | 当前视图中所有 character.schema.set 的生产者 |
dimensions、worldRecord、seats、dimensionProviderPluginId | 不记录读取依赖 |
这是按整个属性记录的保守依赖。后写覆盖前写也保留前面的生产者;自己的写入不能抹去已发生的读取。只读角色不会隐式记录 schema 的生产者,反过来也一样。已提交数据和自己的缓冲不产生跨运行依赖,重复读取会去重。
函数、跳过模型的 guard、agent 工具,以及直接、导入和嵌套服务都使用同一次调用的记录器,与插件数据和操作输入的依赖合并。让模型继续运行的 guard 不把自己的依赖交给 agent。函数视图保持冻结;工具和服务拿到独立快照,局部修改不影响调用方或另一调用。调用结束或取消后,视图不能继续读取。
提交时,生产者被丢弃,读过它的辅助单元连同写入、操作和待保存的挂起项一起丢弃。story 或 setup 单元(包括含有嵌套 story/setup 的单元)保留,每个丢失的生产者只记一条 read-dependency.kept trace。它们自己的提案被拒仍按原规则失败,见事务。此机制跟踪丢弃,不保证 Hook 改写后值仍等价,也不检查执行外的读基线。
嵌套执行仍只看自己的上游,不继承父执行的上游;子 agent 仍只读已提交世界。父调用读取角色来构造子 agent 简报时,依赖记在父调用上。恢复执行从当前已提交世界和自己的已保存缓冲开始,不携带历史生产者身份。数据库和会话格式不变;测试此保证时应放弃并重建旧的待恢复挂起项,它们不会被补录读取来源。
角色与角色 schema
世界包可以在 world.yaml 里声明 characterSchema,创建会话时它被复制进会话。
- 它包含
types(角色类型)和attributes(属性)。 player是保留类型,表示“可以由玩家扮演的角色”,一局游戏可以有多个。其他类型由 schema 声明。玩家此刻扮演哪一个,由座位决定。- 角色的
fields按当前的 schema 校验。 - 修改 schema 的 proposal 是
character.schema.set,内容是{types, attributes},版本由内核递增。
几条边界:
- 角色的创建和更新只通过角色 proposal 和角色工具。
- 角色面板读会话的
session.characters,属性的显示读会话的characterSchema。 - 世界的角色记录可以带插件的数据(例如扮演说明),写在插件声明的字段里。导入时内核把它交给接收的插件,键是角色的
id;它不会变成角色的fields,也不留在角色记录里。 - 内核的角色记录不会复制到任何插件的数据里。
- 维度的结构不会自动变成角色属性。角色、背包、好感和时间各由自己的插件维护。
AI 生成世界时的校验:
- 新建时,接受补充的角色之前,按生成的
characterSchema校验整组角色:角色类型和属性约束。 - 修改时,按合并后的 schema 校验全部角色,不能只检查新增的。
别名与按名字解析
故事里同一个人有很多叫法:名字、昵称、头衔、另一种文字写的名字。所以角色在 name 之外还有可选的 aliases:一个有顺序的字符串列表,按展示用的写法保存。没有别名时没有这个字段。
interface CharacterRecord {
id: string;
name: string;
aliases?: readonly string[]; // 守灯人伊索德 → ["伊索德", "守灯人"]
type: string;
// description, fields, version, createdAt, updatedAt
}一个名字只指一个人。
- 一局游戏里,任何别名都不能是另一个角色的名字或别名。角色的名字也不能是另一个角色的别名。
- 世界导入、执行中的读取和最终保存用的是同一份校验(
validateWorldModel)。违反时报错,并写明另一个角色是谁,不会悄悄选一个。 - 两个角色同名(类型不同)仍然允许。按这个名字查找时,结果是“有歧义”。
把名字变成角色只有一条规则:@covel/plugin-handlers-utils 的 resolveCharacter(characters, query, options?)(@covel/shared 也导出同名函数)。内置的角色工具和内置插件都用它。结果有三种:
| 结果 | 含义 |
|---|---|
found | 依次按 ID 原样、名字、别名匹配到唯一角色,matchedBy 说明是哪一步 |
ambiguous | 这个名字或别名属于不止一个角色,candidates 列出它们;调用方必须报告,不能自行选择 |
missing | 没有角色叫这个名字;closest 按相似程度列出最多 5 个相近的角色,供错误信息提示,不用于匹配 |
比较名字和别名之前,先用 characterNameKey 把写法统一:
- 算作相同的差别:
- 字母大小写;
- 全角与半角(NFKC);
- 首尾空白和连续空白;
- 包在名字外面的引号与书名号(
"…"、「…」、《…》); - 译名中间的间隔号(
・、·、•视为同一个); - 撇号的不同写法;
- 英文开头的
the。
- 不算相同的差别:头衔、敬称和称谓(
Dr.、Sister、先生、小姐、老)。Dr. Park和Mr. Park、“陈先生”和“陈小姐”可以是两个人,去掉称谓会把他们并成一个。只指一个人的头衔请写成别名。 - 没有模糊匹配:不比较编辑距离,也不比较读音。拼错一个字母不会匹配成功,只会出现在
closest里,让模型自己改正。
{ partial: true } 只给读取用。没有精确匹配时,如果恰好只有一个角色的名字或别名包含查询词(或被它包含),就返回这个角色。例如 Mina Park 找到 Dr. Mina Park。
WARNING
写入不能用 partial。“陈”今天只匹配陈远山;故事里来了第二个姓陈的人之后,就会写到错的人身上。
别名从哪里来:
- 世界作者在
characters/characters.json里写aliases(见角色文件)。语言文件可以整列替换。 - 故事揭示新名字时,模型通过
sync-characters.updates[]的aliases参数追加。sync-characters.creates[]也接受aliases。 character.upsertproposal 的aliases:- 带
expectedVersion时,追加到已有别名之后。同一回合两次追加都保留,和fields的合并方式一样。 - 不带时,它就是完整的列表。
- 和角色名字相同的别名、重复的别名在写入时去掉。
- 带
expectedVersion的 proposal 还可以带removeAliases:先从已有别名里去掉这些(按统一后的写法比较,没有的略过),再追加aliases。模型挂错的别名靠它撤销。否则按“一个名字只指一个人”,真正叫这个名字的角色永远拿不到它。别名冲突的报错里会提示这一点。
- 带
模型看到的写法是名字后面跟一个括号:- 守灯人伊索德 (aka 伊索德, 守灯人) [npc] | …(characterLabel)。characterSheetSegments、{{ characters.npcs }}、list-characters 和角色追踪的名册都只在有别名时多出这一段;没有别名的角色一个字符也不多。内置的四个世界里,每个角色因此多 5 到 9 个 token。
玩家看到的:角色面板里显示别名,只读。游玩界面不提供编辑。
玩家的名字不能是世界里已有角色的名字或别名(按 characterNameKey 比较)。原因:世界常把常见的名写成别名(Tomas、June)。玩家起了这样的名字,保存时会被拒绝;和某个 NPC 全名相同虽然不被拒绝,之后按这个名字的每次查找都会是 ambiguous。所以建角的三个环节都执行同一条规则:
- 建角表单带校验器
player-name(char-creator用registerFormValidator注册)。提交已被占用的名字会得到form_rejected,issues指向characterName字段,文字用会话的内容语言。答案没有保存,玩家改名后再提交同一张表单。- 表单会发给客户端并留在会话消息里,所以它不带任何角色的名字或别名。它带的是这些名字按表单加盐后的 SHA-256 摘要(放在
validation.data)。 - 摘要只防止直接读出名单,防不住拿着已经猜到的名字去验证。
- 表单会发给客户端并留在会话消息里,所以它不带任何角色的名字或别名。它带的是这些名字按表单加盐后的 SHA-256 摘要(放在
player-init的 guard 再查一次,因为表单发出之后世界里的角色可能变了。名字仍然冲突时,它不产生character.upsert,而是让 runtime 重新发出表单,notice说明这个名字已有角色使用,其余答案保留。开局不会因此反复失败。sync-characters.creates[]创建type: "player"时,名字是其他角色的名字或别名就报错,并说明要向玩家另要一个名字。它不会把那个 NPC 当作“已经存在的角色”返回。
保存时的规则没有变:两个非玩家角色可以同名(查找时是 ambiguous),名字等于别人的别名才被拒绝。
IMPORTANT
characters 表有 aliases 列。缺少这一列的旧 SQLite / PostgreSQL 开发数据库会被启动时的 schema 检查拒绝;应用不会自动加列。按开发数据说明备份后创建新库。aliases 本身是可选字段;当前格式的浏览器私有存档可以保存没有别名的角色。
座位
座位描述“谁在玩、以什么身份、控制哪些角色”。座位存放在会话记录的 seats 字段上,由内核持有,随快照、分叉和浏览器存档一起保存。
| 字段 | 含义 |
|---|---|
id | 座位在会话里的标识,切换角色的接口用它指定座位 |
controller | 控制者:human(本机的这个人)或 empty(空置)。AI 控制者以后再加 |
role | 身份:player(玩家)、host(主持人)或 spectator(旁观者) |
characters | 绑定的角色:"auto",或一组角色 id |
currentCharacterId | 可选。座位当前扮演的角色 |
默认座位:每个新会话有一个座位 { id: "main", controller: "human", role: "player", characters: "auto" }。单人游戏的体验和以前一样。
绑定规则在读取时推导,不需要写入:
"auto"绑定所有type: player的角色,按角色列表的顺序,跳过别的座位明确列出的角色。- 明确的列表绑定其中仍然存在的角色,按列表的顺序。
- 新建一个玩家角色(建角插件、世界导入、
sync-characters.creates[])不需要写座位:下一次读取时它就绑定到"auto"座位上。
每次写入都检查这些约束:座位 id 不重复;最多一个座位用 "auto";一个角色 id 只能出现在一个座位的列表里,在同一个列表里也只出现一次。
当前角色:座位的 currentCharacterId 绑定在这个座位上并且存在时,就是它;否则是第一个绑定的角色;都没有时没有当前角色。当前角色被删除后,自动退回到第一个绑定的角色。
主座位是第一个 controller: "human"、role: "player" 的座位。故事跟随主座位的当前角色:
- 提示词里的
<player-character>和{{ player.character }}是它(见提示词结构)。 {{ characters.npcs }}和<character-profiles>只跳过它。玩家控制的其他角色带着[player]标签列在里面。PostContextAssembly的载荷用currentCharacterId指出它(见Hook)。- 游玩界面的玩家状态显示它。
插件用 SDK 的辅助函数读同一条规则:
import { currentCharacterOf, primarySeat } from "@covel/plugin-handlers-utils";
const player = currentCharacterOf(
primarySeat(ctx.world.seats),
ctx.world.characters,
ctx.world.seats,
);seatCharacterIds(seat, characters, seats) 返回一个座位绑定的角色 id。
actingCharacterOf(actions, characters, seats) 找出本回合行动的角色:按顺序读取 source: "player" 的行动,返回第一个 actorId 对应的现存角色;没有时退回主座位的当前角色;仍没有时返回 undefined。未知的 actorId 和系统行动不会选中角色。函数 runtime 和 guard 传 ctx.playerActions;扩展提供者和工具没有行动,传 []。后台作业传源回合的 ctx.inputs.turn.value.actions(缺省为 []),保留源行动的角色。
切换当前角色:PATCH /api/sessions/:id/seats/:seatId,请求体 { "currentCharacterId": "…" }。角色必须绑定在这个座位上,座位必须由人控制。从下一回合起生效。见 API。游玩界面在输入框上方(舞台模式在自由输入上方)提供当前角色选择,座位绑定两个或更多角色时显示,见输入框模式。
身份决定能提交的行动。 玩家每次提交的是一组行动(见 API · submit_actions)。服务端按座位身份校验:
身份 role | 能提交的基础行动类型 |
|---|---|
player | say、act、command |
host | narrate、direct、advance、command |
spectator | advance |
- 这张表在
@covel/shared的ROLE_ACTION_KINDS里,服务端只读它。 - 插件的行动类型自己声明哪些身份能用(
contributes.actionKinds[].roles,默认[player])。 - 以角色进行的行动(
say、act和默认的插件类型)只能用绑定在这个座位上的角色,省略时是座位的当前角色。没有人控制的座位不能提交行动。
内置的角色资料、角色创建、骰子判定、行囊、故事记忆和跑团规则按行动角色处理,没有行动角色时跟随主座位的当前角色。排除角色说明、回忆匹配姓名和建角占用姓名时也只排除这个角色,其他玩家角色照常参与。场景舞台仍按第一个玩家角色处理。
动态维度快照
世界包里的 dimensions 是作者的声明:结构、初值和可选的更新规则。ctx.world.dimensions 是这局游戏的当前值,形状由公共契约 world.dimensions@1 规定:
interface DimensionSnapshotEntry {
name: I18nText;
description?: I18nText;
schema: DimensionValueSchema;
value: JsonValue;
version: number;
}
type DimensionSnapshot = Readonly<Record<string, DimensionSnapshotEntry>>;读取的位置:
| 谁读 | 写法 |
|---|---|
| 插件 | ctx.world.dimensions.reputation.value |
| 提示词模板 | {{ world.dimensions.reputation.value }} |
| 客户端的会话视图 | snapshot.dimensions.reputation.value |
| 模型 | 内置工具 world-dimension-get(path 相对于该项的 value)和 world-dimension-list(只列 ID、名称、类型和版本) |
规则:
- 读到的每一项不包含
initialValue、updateRule,也不包含它上次是按哪个回合更新的。 - 值是原始 JSON。显示和查询时,只翻译 schema 明确标了
x-i18n的节点。 - 没有插件负责维度,或者维度还没初始化时,快照可以是空的。创建会话时没有启用负责维度的插件,世界声明的维度就不导入,会话照常开始,见会话 API。内核自己不保存维度。
- 负责维度的插件没有在运行时(没有安装、加载失败,或者缺依赖),游戏照常进行,维度暂停:
- 叙事读已保存的值,没有插件能写维度,也不登记结算义务。
- 会话视图照常返回已保存的值,游戏界面提示玩家维度暂停了。
- 这个插件回来后,维度接着变化。另一个负责维度的插件不能接手,开着它时回合会被拒绝。
- 不能改用世界包的初值,也不能假装是空的。
- 负责维度的插件在运行、但读取失败时,必须报错。
属主与写入
谁负责。 内置的 world-init 提供世界上下文(扩展点 session.world-context@1,只能有一个提供者)和 world.dimensions@1。框架按契约找到唯一负责的插件,不按插件 ID 判断。负责的插件 ID(dimensionProviderPluginId)由宿主填写;dimensionRecovery 里的 editor / tracker runtime ID 必须确实属于这个插件。
只能有一个。 一局游戏的维度只由一个插件负责,框架不会替玩家挑。
- 开局时选了好几个提供
world.dimensions@1的插件:预检和创建会话返回409 dimension_provider_conflict,details.candidates列出这些插件。准备页让玩家留下其中一个,其余的关掉。 - 游戏中再打开一个负责维度的插件,同样返回 409,见启停插件。
- 游戏中打开第一个负责维度的插件,世界的维度马上导入,见开局后加入的接收插件。
存在哪里。 每个维度在负责插件的受保护区域 _dimensions 里是一条 {definition, value, version, lastTrackedSource?} 记录。
- 声明和当前值共用一个版本号,从 1 开始。
- 同一个世界的各局游戏互相隔离,不回写世界包。
- 不会再往别处存一份,也没有常驻的资料库条目。
怎么写。 只有两种 proposal 可以写:
| proposal | 内容 | 行为 |
|---|---|---|
dimension.initialize | {definitions} | 只创建还没有的记录。用同样的声明重复初始化什么都不做,不重置进度。已采用的声明不一样时是冲突。 |
dimension.update | {updates: [{id, expectedVersion, value, reason?}], source?, readVersions?, settlement?} | 值满足 schema、所有版本都对得上,才整批保存。不会自动合并,也不是“后写的赢”。 |
- 自动结算时的
source/readVersions由宿主提供,不由模型填写。 - 普通的插件数据写入、批量写入、删除和 REST 写入都碰不到这块受保护的区域。
- 玩家改值通过 runtime RPC 提交同一种 proposal。普通的手改不带结算来源,所以不会自动解除待结算的回合。
- 整批保存和版本比较的细节见事务契约。
回合时序与结算回执
已解除上一回合的结算义务
→ dimension-context 在 pre-turn 发布已提交快照 Sₙ(不调用模型)
→ narrator、WorldIR 与 tracker 以同一版本内容为公共读取基线
→ post-turn tracker 按规则求值,提交时再次校验版本
→ 原子写入当前值与成功回执;回合结束后客户端读取已提交新版一个回合内,读到的值不变。 玩家同时手改,或者 tracker 提出了修改,都不会让这一回合读到的值变化。下一次执行重新读取已保存的值。
dimension-context在这次执行里运行了、但没成功时,叙事被跳过(dimension-snapshot-unavailable),整次执行不保存:它之前的 runtime 的写入、对话记录和回合计数一并回滚。玩家可以重试同一个动作。dimension-context没有被安排运行时(指定只跑叙事的手动运行,或重试),叙事直接使用执行开始时已保存的值。- 叙事不能反过来依赖回合后才运行的 tracker 的输出。
recordAs导出也不能代替保存下来的值。 - 提示词里放的是有长度预算的摘要,需要时再查询,不会默认展开所有大表。
结算义务。 有自动维护规则时,叙事保存的同时,宿主登记一条“这一回合的维度还没结算”。它和这一回合的叙事结果、回合号绑定。
- tracker 没有运行、失败,或者发生版本冲突,都不等于没有变化。
- 没有自动规则时,没有维护用的模型调用,也没有结算义务。
回执保存在 _dimension-settlements,有五种状态:
| 状态 | 含义 | 允许继续叙事 |
|---|---|---|
pending-settlement | 尚未成功结算,包括未运行、维护失败、冲突 | 否 |
settled | 数值变更与成功回执已原子提交 | 是 |
no-change | 明确求值并校验读取版本后的无变化回执 | 是 |
manual | 玩家明确确认已人工处理该来源 | 是 |
skipped | 玩家明确跳过该来源,保留当前值且不再自动补算 | 是 |
- 已经保存的故事可以保留,附带一条待结算的回执。结算被拒绝不会让有效的叙事作废;数据库出错时仍然整体回滚。
- 下一次叙事之前检查有没有未解决的义务。解决的办法有三种:重试原回合、人工确认、明确跳过。
- “改一个值”和“确认已人工处理”是两个不同的操作。
回执里存什么:
- 待结算的回执保存:来源的引用、这一回合读到的各维度版本(
readVersions)、带更新规则的维度声明,以及状态。它不重复保存叙事正文;重试时从原回合保存的结果里读。 - 回执进入最终状态后,清掉声明和
readVersions,只留下结果的身份、状态和版本,用来防止重复结算。 - 已经有最终状态的来源不会再结算一次。
- 恢复、重启、分叉和浏览器的存档点都保留义务和回执。
- 客户端只收到公开的摘要和恢复入口,收不到私有的规则和读取基线。
初始化的细节。 维度初始化只通过 dimension.initialize proposal 写入。它同样经过 PreStateCommit 这一步检查,并在提出它的 runtime 自己保存的时候落库。被否决就不写;被改写就写改写后的声明。回执记录的是已经保存的记录,所以同一事务里被否决、或随 runtime 一起丢弃的初始化不会出现在回执里。
玩家在界面上看到的:
- 游玩时,维度的值是只读的。
- 有待结算的回合时,对话上方出现提示,可以重试或明确跳过。
- 自由改值和“确认已人工处理”在调试用的会话数据视图里。
- 条目的标签和给玩家看的错误信息使用界面语言。
IMPORTANT
维度必须按当前声明格式写。早期“只写一个值”的维度、存在资料库里的维度副本和那时的快照都读不了,也没有自动转换。请更新世界包,并重新创建受影响的开发世界、会话和快照。维度只提供会随剧情变化的数据,不负责隐藏事件和按条件触发,那是隐藏数据和 story-events 插件的事。
Lorebook
资料库(lorebook)是一局游戏里的设定条目。
条目归谁。 一条条目由 (sessionId, owner, id) 三项确定。owner 可以是 world、player 或 plugin:
- 从世界包导入的条目属于 world。
- 玩家编辑的接口只处理 player 的条目。
- 插件通过
lorebook.upsert写入的条目,归属由 proposal 的来源决定。插件不能覆盖其他所有者的同 ID 条目。
管理接口见 Lorebook API,导入目标见世界数据目标。
禁用一条条目。 enabled: false 表示完全停用:
- 它不再自动进入提示词,也不参与关键词检索和向量检索。
- 向量索引是后台慢慢更新的。即使旧的向量还没清掉,检索时也会看条目现在的状态,停用条目的旧向量不会被返回。之后的索引更新会清掉它的向量和进度。
- 重新启用后,关键词检索立刻恢复。还没清掉的向量可以马上接着用;已经清掉的由之后的索引更新重建。
- 停用不删除条目,也不影响其他所有者的同 ID 条目、角色或插件的记忆块。
NOTE
只有 plugin_id 一列、没有所有者的旧资料库表不会自动升级。开发环境的处理办法见迁移说明。
自动推进的座位与结算
内核生成的 advance 不经过人类动作种类的角色门槛,仍使用 source: system 和已有的人类座位:优先原输入座位,否则按持久化顺序选择第一个人类座位。它不指定角色、文本或字段;找不到人类座位就停止。公开请求不能伪造这个来源,人类角色表保持不变。维度结算上下文使用根输入冻结的逻辑回合号,每次实际执行仍独立记录结算义务。
角色亲历的故事
await ctx.historyFor(characterId, {limit: 20}) 返回 {messages: [{role: "assistant", content, segments}], truncated}。函数、guard 和插件工具都可读取;角色委托自动使用它。segments 只有 {kind, speaker?, text},content 用同一故事文本渲染器生成,没有存储 ID、时间戳、附件或原始 metadata。
一次执行懒加载并冻结最近 200 条 display messages,并发调用共享一次读取。只匹配已提交的 assistant story,且 metadata 的 presentCharacterIds 合法并精确包含角色 ID。一个消息匹配后保留该消息所有段落;不按发言者再删段。缺失、空、错误的 presence 不推断;未知或歧义的原始名字也不会凭空增加 ID。玩家行动、direct/system/tool 消息、压缩摘要、重写后的历史和当前未提交故事都不在来源里。规范历史压缩不删除 display-message 来源。
先匹配再取最近的消息,按时间顺序返回。limit 默认 20,必须是 1–50 的整数;总文本最多 16,000 字符,只保留最近的完整消息/段落,不切 JSON 或代理对。扫描满 200 条、消息数或文本被裁减时 truncated: true。不存在于当前 World Model 的 ID 返回 not-found;已知角色但没有记录通常得到空数组。
恢复沿用源执行的历史快照,不看到期间提交的新场景;新的根执行重新捕获。分叉只包含快照边界以前复制的消息。这个 API 只规定宿主提供哪段历史,不能保证任意简报、字段、已批准工具或服务不会透露其他知识。
创建时选择身份
创建请求的可选 seatRole 选择一个人类座位的身份。省略或 player 保留原有 auto 绑定;host 和 spectator 的 characters 为空,且没有 currentCharacterId。只能创建时选择,不迁移已有座位;快照、分叉、checkpoint 保留原有座位。主持人是游戏内身份,不授予服务器 owner/operator 权限。动作权限表保持不变。