DOCUMENTATION · 更新日志

更新日志

本文件记录「插件化剧情引擎」的版本变更。 发版约定(本项目自己的规矩)

同步自 docs/CHANGELOG.md(1202 行) · commit b2b4124 · 读源文件 ↗


格式参考 Keep a Changelog,版本号遵循 语义化版本。


[Unreleased]

修复 · restart() 之后插件没有重新加载(一次加载失败会卸掉所有在跑的插件)

现象(使用者视角):一份剧情同时用了两个插件,点"重新开始"之后,一个的效果还在、另一个全没了 ✗ —— 看起来像"两个插件打架",其实是引擎的问题:

插件加载失败时(网络抖动 / 路径解析出问题),loadStory() 会把"这次成功加载的插件 id" 当作"剧情声明的插件" ✗ ⇒ 于是 loaded 为空 ⇒ disposeUnusedPlugins([]) 把正在跑的插件全卸掉 ✗。 日志里只有一串"已卸载"、没有"已加载" —— 这个症状很难看出来 ✓。

修法是 fail-safe ✗:只有"这次确实解析出了声明"时才敢卸载别的插件; 一份都没加载成功而剧情又声明了插件时 ⇒ 保留现有插件、不执行卸载,并打一条明确的错误日志 ✓。

回归测试:tests/stories-shapes.test.ts 里"两个插件同用一份剧情,restart 之后两个 start() 的效果都在" ✓。

(还没有未发布的改动)


[1.5.0] - 2026-10-05

这一版的主线是把引擎的"里子"补齐 —— 全部围绕一个判据: "它经得起真的被用吗?" 于是七条能力里有五条是"已有的东西在真实环境下漏掉的那一半"。

修复 · 表达式语言不认中文状态路径(这一版最要紧的一条)

var.单价 * var.数量 报"无法识别的字符 单" ✗ —— tokenizer 的标识符规则是 ASCII-only。 这是面向中文的引擎里最要命的一类缺口 ✓:var.金币 / flag.门开了 是最自然的写法, 而它以前在运行时静默按假处理 ✓(求值失败不改剧情流程,只记一条日志 ✓)—— 于是玩家会走进错误的分支 ✗,编剧只看到「分支没按预期走」✓。 修法:标识符加 Unicode 规则(\p{L} / \p{Nd})—— 只放宽、不收紧 ✓(× 这类真符号仍然拒 ✓)。

修复 · 测试的随机 30 秒超时(假时钟泄漏)+ 全局兜底

连跑 6 遍复现了 2 遍红 ✓:vi.useFakeTimers() 只要有一处漏还原 ✗,后面"真的等一会儿"的用例 就永远不醒 ✓,而且症状出现在别处 ✓。修法是全局兜底(tests/setup.ts + setupFiles ✓) 而不是靠每个文件记得写 ✓ —— 修完连跑 5 遍全绿且总数精确 ✓。

已知问题(带进 1.5.0,未修):variant(A/B 变体)与 memory(跨周目记忆)放进同一份剧情时, 「重新开始」之后 memory 在 start() 里写回的每局初始化会丢 ✗(撤掉 variant 就正常 ✓,两者各自测试都过 ✓)。 现象与修掉的 restart 生命周期缺陷一样,但根因不同 ✓(怀疑与多插件在 startAll() 里的顺序/写入时机有关 ✓), 已如实记录、不假装没有 ✓。 另有一处探不清的:全量跑偶发出现「用例数变少但没有失败」✗(复现不到 ✓), 排查建议写在 HANDOFF §50 ✓。

PLUGIN_API_VERSION 仍为 1 ✓:本版对插件契约全是向后兼容的加法 (新权限 contracts:provide / contracts:call / expressions:register ✓、 manifest 新字段 contracts / budgetMs ✓、ctx.contracts / ctx.expressions 新 API ✓), 没有破坏任何已有插件 ✓。 一处语义变化要记明 ✗:表达式编译期不再拒绝未知函数名(判定推迟到求值期 ✓), 代价是内置函数名拼错不再在编译期被抓住 ✓ —— 换来的是一条能长大的语言 ✓。

新增 · 插件之间的契约总线(假时钟泄漏)+ 全局兜底

上一轮我发现"复跑才绿" ✗,这一轮连着跑 6 遍把它复现了:2 次红 ✓,症状是 30 秒超时 (而打印的耗时是 157744ms / 122123ms 这种"已经跑了几分钟"的数 ✗), 而且每次落在不同的用例上(一次撞在「诊断包」与「涌现向」,一次撞在 say.delayMs 与 debugReport ✓)。

机制很清楚 ✓:vi.useFakeTimers() 把真实的 setTimeout 换成假的 —— 只要有一处忘了还原 ✗, 下一个"真的等一会儿"的用例就永远不醒 ✓,而且症状会出现在别处 ✗(仓库里正好有一处还原 写在 describe 里、覆盖不到顶层新加的用例 ✓)。

修法不是"每个文件记得写" ✗,而是全局兜底 ✓:tests/setup.ts + setupFiles ✓ —— 每个用例之后一律 useRealTimers / restoreAllMocks / unstubAllGlobals ✓。 修完连跑 5 遍全绿,且总数精确是 439 ✓(修复前 6 遍里红 2 遍 ✓)。

还看到第二张脸 ✗:其中一遍出现「437 passed / 0 failed」—— 两条用例不是失败,而是没被收到 ✓。这个我复现不到 ✗,不假装查清了,记在这里待查 ✓。

新增 · 诊断包 + 三个「真实世界才会暴露」的洞

① 诊断包(runtime.exportDiagnostics()):把排查要的东西打成一个 JSON ✓ —— 版本 + 剧情/种子/输入日志 + 指纹(⑦)+ 时间输入(②)+ 审计流水(④)+ 开销表(⑥)

② 实时玩法的确定性缺口(我们自己开的洞 ✗):指纹以前没有时间输入 —— 跑了几帧、timeScale 是多少 ✓。于是 ctx.frame 驱动的剧情(实时玩法 ✓) 跨宿主一致性检查其实是空的 ✓。现在 timeInput 进指纹 ✓、runtime.timeInput() 给宿主/CLI 取值 ✓。

③ 后台限流:浏览器会限流后台标签页的定时器 ✓,wait(30s) 可能拖到几分钟 ✓ —— 引擎以前"听浏览器的",剧情会被静默改变 ✓。现在只报事实:story:waitOverrun (要了多久 / 实际多久 / 差多少 ✓)—— 跳过、补偿、还是当作「玩家离开过」,由剧情决定 ✓。

④ 多标签页 / 多设备存档:存档带上 origin(写入者 ✓)+ compareSaveEnvelopes() 纯函数: same / left-newer / right-newer / diverge(不同来源各自往下走了 ✗)。 引擎给事实与工具,合并策略归产品 ✓ —— 不假装能自动合并两段各自的进度 ✗。

⑤ 切剧情的漏网:runtime.leakReport() 报"切剧情后还挂着几个插件定时器、属于谁" ✓ (现象是"玩久了越来越卡",属最难查那类 ✓)。引擎只报,不擅自清 ✗。

顺带:诊断包里的 ENGINE_VERSION 加了一条防漂移检查 ✓(必须等于 package.json 的 version ✓), 免得版本一升、诊断包就开始撒谎 ✗。

新增 · 状态来源追踪(②):谁写的、在哪一步

统计回答"这个路径被写了多少次" ✓;这一条回答"最后这一次是谁写的、上一步在哪个节点" ✓ —— 也就是因果 ✓。排查"这个 flag 怎么就变成 true 了"时,要的正是这一句 ✓。

新增 · 每插件开销归因与配额(⑥)

看门狗答的是"这一帧超没超" ✓;这一条答的是"是哪个插件花的" ✓ —— 插件是第三方代码,归因才是需要的那一半 ✓。所有插件调用都过 PluginManager.invoke() 那道门 ✓, 在那里计时 ✓,结果通过 runtime.pluginCosts() 拿到 ✓。

三条取舍:

新增 · 表达式函数注册(⑤):给 DSL 加函数,而不是各写各的

插件以前能注册条件 ✓、不能给表达式加函数 ✗ → "取最大值 / 格式化时间 / 算距离" 这类东西在每个剧情里各写一遍 ✓。现在:ctx.expressions.register(name, fn, { arity }) ✓(权限 expressions:register ✓), 宿主也能直接往 runtime.expressionFunctions 里注册 ✓(同一份注册表 ✓)。

三条纪律:不许遮蔽内置 ✗(覆盖 min 会让人读不懂剧情 ✓);名字先到先得 ✓(冲突报错 ✓); 声明了 arity 就检查参数个数 ✓(金币够(var.gold) 少给参数当场说清 ✓)。

⚠ 一处语义变化,要记明 ✗:表达式编译期以前会拒绝未知函数名 ✓ —— 那会让"插件注册的函数"根本写不进剧情(编译时看不到注册表 ✗)。现在把这条判定推迟到求值期: 内置 + 注册表都没有 → 抛"未知函数:内置里没有,也没有插件注册过这个名字" ✓。 代价:内置函数名的拼写错误不再在编译期被抓住 ✗(改在求值时报 ✓), 换来的是一条能长大的语言 ✓。

还有一条引擎没法自动强制的 ✗:注册的函数必须是纯的(同样输入同样输出、不读时钟、不写状态 ✓)—— 否则确定性就没了 ✓。这一点写在文档与代码注释里,并可用运行期审计(④)看谁在用 ✓。

新增 · 运行期权限审计(④)

和"插件能力清单"(⑦)配套:⑦ 是静态的(manifest 说要哪些权限 ✓), ④ 是动态的 —— 刚才到底是哪个插件读了哪个状态、改了哪个值、调了谁提供的契约 ✓。 装第三方插件时,静态清单是许诺 ✓,运行期审计才是证据 ✓。

补测 · 契约依赖的管理器级验证(还上一轮欠的债)

上一轮只覆盖了注册表与 ctx 两层 ✗,而"缺依赖就不启动"这句话得真的走一遍 startAll() 才算验过 ✓。 新增两个夹具插件(提供者 / 消费者)+ 两份剧情:提供者在场 → 消费者被启动并真的调到契约 ✓; 提供者缺席 → 消费者不被启动(用状态标记从外部证明 ✓,不去问管理器内部 ✗)。

写这条测试时第二次栽进同一个坑 ✗:manifest 也是逐字段读出来的 (PluginManager 里 readOther/readStringArray 那种写法 ✓)—— 新加的 contracts 字段 没被读走,于是 requires 永远是空的、插件当然照常启动 ✓。这和剧情那边 validateStory 吃掉 derived 是同一个陷阱 ✓ —— 值得写死在两处代码的注释里 ✓。

新增 · 插件之间的契约总线(③)

以前插件之间只能靠共享状态间接说话 ✓(谁都能读、也能悄悄改别人写的 flag ✗), 缺依赖要到运行时才表现为"某个东西没反应" ✗。现在有了明确的契约:

新增测试 8 条(tests/plugin-manager.test.ts):名字归一化(全角冒号/大小写/空格算同一个 ✓)、 没人提供就抛错 ✓、名字冲突报错但允许自己重注册 ✓、单个/整插件撤销 ✓、三组审计 ✓、 跨插件提供与调用(两个 ctx 共用一张表 ✓)、两把权限门各拦一次 ✓、卸载后别人调不到死插件 ✓。

新增 · 跨宿主一致性检查(运行指纹)+ 无头回放 CLI

确定性是旗舰承诺,但它以前只在 node 里被验过 ✗。现在它可检查了: runStoryHeadless() 把一局跑成运行指纹 ✓,npm run replay 把指纹打出来 ✓ —— 在两台宿主(浏览器 / node / 不同 V8)上跑同一条命令、比指纹,就能发现"这台宿主上有东西不确定" ✓。 --expect <指纹> 不一致直接退出码 1 ✓(可直接进 CI)。CLI 跑的是库产物 ✓,顺手验了"库产物能被 node 直接用" ✓。

写它的过程中撞出两个真问题(都已修,且都值得记):

  1. 输入日志带 at: Date.now() ✗ → 直接拿整条日志算指纹,同一操作两次运行指纹不同 ✓, 看起来像引擎不确定,其实是日记字段在动 ✓。修法:指纹只吃投影后的日志(stableInputs() ✓)。 同一个坑还埋伏在测试里:任何"两份输入日志相等吗"的断言都会偶发红 ✗(这一轮我就写出一条 ✓, 幸好它当时碰巧过了 —— 这类"碰巧通过"比失败更危险 ✓)。仓库里已有的 replay 断言写得很稳(逐字段 ✓), 不用改 ✓。
  2. 截断必须如实报、并且进指纹 ✗:剧情"永远不结束"时(例如 letters.json 全选第 0 项会绕回 ✓), 跑到哪儿停下取决于给了多少让步 ⇒ 两次运行的节点长度不同 ✓,看着像不一致、其实是同一件事被截在别处 ✓。

⚠ 无头回放不加载插件 ✗(插件要动态 import 资源 URL,那是宿主的活 ✓)——CLI 只适用于不带插件的剧情 ✓。

修复 · 表达式语言不认中文状态路径(标识符规则是 ASCII-only)

写派生状态时撞出来的:var.单价 * var.数量 报"无法识别的字符 单" —— 表达式 tokenizer 的标识符规则写的是 /[A-Za-z_$]/,只认 ASCII。

这是面向中文的引擎里很要紧的一条:剧情里写 var.金币 / flag.门开了 是最自然的写法, 而它以前在运行时静默按假处理(求值失败不改剧情流程,只记一条日志)—— 于是玩家会走进错误的分支,编剧只看到"分支没按预期走"。(编辑器侧体检能报出来,运行时那侧是哑的。)

修法:标识符首尾字符各加一条 Unicode 规则(\p{L} / \p{Nd})—— 只放宽、不收紧: 数字、_、$、. 的规则不变,真正的符号(如 ×)仍然拒绝。 回归用剧情路径验:var.单价 * var.数量 == 12 真的成立;× 那种仍然按假且不中断剧情。

新增 · 派生状态(derived):声明一次,运算交给引擎

剧情新增可选顶层字段 derived:{ "var.总价": "var.单价 * var.数量" }。 引擎加载时校验(路径前缀 / 表达式能解析 / 不自己依赖自己、彼此不成环), 并在任何状态变化之后重算(靠状态层订阅,所以插件直接写的状态也会触发,不需要在每个调用点手动调)。

两条硬规矩:派生项不能被直接写(剧本里写它会当场报错 —— 写了也会被覆盖,报错更诚实); 表达式必须是纯读(表达式本来就写不了状态,所以重算不可能递归)。

新增 · 剧情穷举压测("这个剧本一共有多少条路线")

health.ts 新增 exploreRoutes():把结构上的所有路线走一遍,报 路线数 ✓ / 每个结局各多少条 ✓ / 最长最短 ✓ / 走不通的死路 ✗ / 有没有撞上限 ✗ (撞了就如实标出来:"至少这么多",不是精确值 ✓)。

它是纯结构走法 ✓:遇到 goto/branch/choice 每条分支都走(不判条件 ✗), 节拍走完就顺序落到下一场(ScriptScene 没有"下一场"字段,顺序就是默认流向 ✓), 遇到结局或绕回就收一条路线 ✓。所以它说的是"结构上有多少条路线" ✗ —— 它看不到 条件求值(可能永远不成立的路线也算进来了 ✓)、副作用、插件与世界事件的行为 ✓。这句话也写进了代码注释 ✓。

接进体检时只报"编剧会想改的"两件事(warn ✓):撞上限(报告不完整 ✗)与走不通的死路 ✗。

新增 · 无效选择检测("这个选择是真的吗")

health.ts 新增 findEquivalentOptions() 并接进结构体检:同一场里有两个及以上选项 都无条件、且跳向同一个地方 → 报一条 warn:从结构上看它们没有区别,读者选哪个都一样 ✗。

判据刻意保守(宁可漏报也不误报 ✗):带条件的选项不参与比对("当时未必可选", 不该替编剧下结论);跳向不同场的不报(那正是有意义的选择 ✓)。 而且它只说"从结构上看一样" ✓ —— 因为剧本模型里 ScriptOption 只带 text/condition/goto, 选项上的副作用(set/effects)不在模型里 ✗,所以措辞里请编剧对照 JSON 确认 ✓。 这是编剧侧的分析(编辑器是这份引擎的参考工具 ✓),不是运行时特性。

新增 · 跨窗口 / 跨设备链接(引擎侧:协议 + 传输 + UI 装饰器)

src/link/:让"跑剧情的那一端"和"另一端的控制器"(另一块屏 / 另一台设备 / 一个自动化脚本) 用同一套消息对话 —— 控制器能替玩家作出选择(pick ✓)或把意图送进剧情(intent ✓)。

刻意不含页面 ✗:两块屏长什么样是产品的事 —— 见 docs/ARCHITECTURE.md 新增的边界声明。 (这条边界是维护者明确要求记下来的:我们做引擎,不做产品 ✓。)

新增 · A/B 变体(同一场两个版本,按玩家分流)

public/plugins/variant:开局决定这个玩家看 A 版还是 B 版 → 写进 flag.variant, 剧情里写 如果 flag.variant == 'b' 就能走另一版 ✓(示例剧情 variants.json「两种开场」)。

两个"别让它变成随机噪声"的决定:一个玩家只分一次(分流写进跨局存储 ✓ —— 每次刷新都换一版,测出来的数据就没意义了 ✗);没有随机能力时退回 A 版(确定 ✓, 宁可少测一组,也不要"同一个玩家每次不一样" ✗)。确定性随机是引擎的地基,所以回放/复现都成立 ✓。

新增 · 插件能力清单(装之前先看清它要什么)

src/host/plugin-catalog.ts(纯函数)+ 展示页新增一节:扫各剧情声明的插件里的 manifest.json, 排成"能干什么 / 要哪些权限 / 版本"的表 ✓ —— 给审核第三方插件用。 它顺手也是一道体检:每个 manifest 要的权限都必须是引擎认识的 ✓; 测试里真读仓库里所有 manifest 跑一遍(一红就说明 manifest 写错或引擎新增权限时漏登记 ✓)。

新增 · 叙事插件:把已有数据变成情绪(复盘 + 跨周目记忆)

引擎一行没改 —— 这三条全靠已经存在的数据:输入日志(story:choice)+ 节点事件 (story:nodeEnter 带整个节点)+ 跨局存储(ctx.storage)。

修复 · restart() 之后插件不会重新启动(漏了两处)

写「跨周目记忆」时撞出来的:restart() 之后插件的 start() 效果会丢 ✗ (第一局开局时正常 ✓,restart() 之后 flag.returning 变成 undefined ✗)。

顺着查下去,漏的不是一处,是两处:

  1. restart() 只 engine.pause(),不 stopAll() ✗ —— 插件仍处 running → 而 start(id) 对 running 是幂等的(直接返回 ✓)→ 于是"每局初始化"根本没机会重跑。
  2. startAll() 只启动 status === 'ready' ✗ —— 而 stop() 会把状态设成 'stopped' ✓ → 所以就算第 1 处修了,startAll() 也起不来已停的插件 ✗。 而 JsPlugin.stop() 的契约本来就写着"可再次 start" ✓ —— 这里放宽成 "启动所有还没运行的(ready 或 stopped)" ✓(start(id) 自带幂等,安全)。

为什么这两处特别值得记:它们让一整类插件静默失效 ✗ —— 凡是把"每局初始化" 放在 start() 里的插件(读回跨周目记忆、重置自己的回合计数……),在玩家点"重新开始"之后 就不再生效,而且没有任何报错(现象只是"数值莫名是空的")。这类 bug 靠读代码很难发现, 是靠一份真剧情 + 探针逼出来的。

回归:tests/stories-shapes.test.ts 里那条从 it.todo 转正为正式测试(第二周目门房确实记得你)✓。

(还没有未发布的改动)


[1.3.0] - 2026-10-04

这一版的主线是让引擎被三种形态真的用过,围绕它补齐了内容侧、发行侧与工具侧:

PLUGIN_API_VERSION 仍为 1:本版对插件契约全是向后兼容的加法 (新权限 audio:mix、ctx.assets 的播放与分组 API、指针事件新字段等),没有破坏任何已有插件。

(同一节点连续重入即判定死循环)

修复 · 空转保护(同一节点连续重入即判定死循环)

wait(可跳过)用 Promise.race 撞 UI.waitForAdvance():只要后者立刻 resolve (测试空 UI;或玩家按住"继续"不放 ✗),"可跳过的等待"就被无限跳过 —— 若 next 指向自己,主循环空转:刷日志之外还会饿死宏任务 (连 setTimeout(0) 都排不上,页面上其它定时器全部停摆 ✗)。

修复 · 世界事件能在玩家「犹豫」时插话(打断在途的选择)

observe 跳到 end 节点后 story:end 一直不发 —— 根因是: StoryEngine.dispatchObserver() 唤醒在途等待用的是 ui.renderReset?.(), 而它只放行 say 的「继续」与在途 wait,完全不碰在途的 choice ✗。 于是"玩家还在犹豫、世界事件插话"这条路径走不通:currentNodeId 已被改成跳转目标, 主循环却仍等在选项上 → 永远走不到 end。

修复 · 测试里那个"假时钟污染"(我连吃三次 30 秒超时的真凶)

三次超时(空转守卫、正常 wait、打断选择)都不是引擎的问题 ✗: 某个测试文件把 vi.useRealTimers() 的恢复挂在 describe 内部 ✗ —— 而新测试追加在文件顶层,于是继承了上一个测试的假时钟 → setTimeout 永不触发 → await new Promise((r) => setTimeout(r, 0)) 永久挂起(表现像引擎挂了 ✗)。 已把恢复提到文件顶层,并把这条坑写进 docs/TESTING.md。

新增 · 三份"不同形态"的真剧情 + 两个插件(引擎抽象被三种用法验证)

以前 public/stories/ 里只有压测用的剧情,所以"引擎的抽象"只被一种形态用过 —— 没被证明。

过程里踩了三个坑,都值得记:① load() 只加载定位、不起跑,要先 sb.begin(); ② 世界驱动要用套件的 tickWorld()(自己摆假时钟会跟 await 打架 → 超时); ③ observe 跳转是异步继续的,断言前要让出一条定时器。

新增 · 一行挂载 mount()(可选入口,一个新文件)

用户要的形态:「新建一个文件,调用它的接口就能直接挂载上」。一个文件,不新造任何能力:

import { mount } from 'plugin-story-engine'
const app = mount('#app', { story: '/stories/demo.json' })   // 挂上就能玩

新增 · 示例宿主的玩家侧打磨(引擎一行没动)+ UI 驱动契约文档

先说清归属:观感/皮肤/动效是宿主的活,不是引擎的活 ✗。所以这一版动的是示例宿主, 引擎侧只补了一份「怎么用引擎驱动 UI」的契约文档(docs/FRONTEND.md):

UI 想做的事引擎给的语义(都已有)
状态变了要有反馈state:change + state.watch(path)
对白要有节奏story:say + say.delayMs
打字机能被打断wait.skippable(「第一次点击补完」由 UI 决定)
快进 / 暂停 / 倍速world.pause() / world.timeScale()、frame.pause()
限时紧张感choice.timeout
自绘界面实现 RuntimeUi(无头套件本身就是个假 UI)

示例宿主里补齐的(都是宿主代码):

修复 · JSON 视图的行列定位(不再读引擎错误消息的措辞)+ 时序测试改用假时钟

这两条都是**"别人的环境一变、我静默坏掉"**的实例 —— 正好是这套东西最该防的那类问题。

新增 · 存档文件:导出(下载)/ 导入(恢复)

浏览器会清掉本地存储:Safari 的 ITP 会在玩家一两周没来之后清退脚本可写存储,无痕模式会话结束就清空, 用户点"清除浏览数据"更是随时清。这是设计行为,客户端阻止不了 —— 所以别让进度只存在于 localStorage。

新增 · 展示页 + 性能面板(把能力摆到一页上给人看)

此前每个页面只讲一件事(演示 / 编辑器 / 嵌入)。这一版补上"一分钟看懂这个项目"的那一页:

新增 · 性能看门狗(帧预算 → 档位 + 策略建议)

以前"这一帧花了多久"没人知道 —— 性能问题只表现为"某些手机上莫名卡"。

新增 · 存档版本迁移(老存档不静默错读)

原来 load() 遇到版本不符只 warn 一句就照读 —— 读 v1 存档会静默丢掉随机源与插件持久数据, 读"未来版本"的存档更糟(字段含义可能已经变了,等于把玩家进度改坏)。

新增 · 音频:分组 / 淡入淡出 / 并发与优先级 / 切后台停声

音频原先已有 play(含 loop 与 volume)+ 全局音量/静音 —— 这里更正我早先"只有 play/stop/setVolume"的说法。 缺的是互动剧真正需要的混音能力,这一版补上:

新增 · 触屏支持(不做手柄)

引擎的输入层原本只有键鼠:插件能认领 ArrowLeft/Space,但触屏设备没有键盘, 认领了也永远收不到。这一版把触屏补齐 —— 而且不给插件加新 API:

新增 · 附加字段:能映射的节点上多出来的字段也不丢

上一条把"编辑器表达不了的整节点"保住了,但还剩一个缺口:能被映射的节点上多出来的字段 (say 的 effects、choice 的 timeoutMs / timeoutNext…)导出时会丢 —— 那等于 "改一句台词顺手把定时器和副作用删了"。这一版补上:

新增 · 往返保真:编辑器表达不了的节点原样带走(不再丢数据)

原来的行为是"记下来 + 挂横幅",提示不等于保护:一份含 plugin / observe / wait 的剧情, 用编辑器打开、改一句台词、再导出,那些节点就没了。现在改成保留:

注:本条当时如实标注了一个缺口 —— "能被映射的节点上多出来的字段"(effects / timeoutMs 等) 还会丢。同版已补上,见上面的「附加字段」一条。

新增 · JSON 视图(不再把 JSON 当禁区)+ 修掉导入的死循环 bug

新增 · 状态写入 + 状态项面板(条件与状态就是表达式)

编剧此前连"设个旗标"都做不到(编剧模型里没有写入状态的节拍,条件只能读别处写的状态)。 这一版补上,并且明确态度:表达式是一等公民,不做"人话翻译"。

改变 · 文本语法解封:把砍掉的能力还回去

上一版为了解析方便,砍掉了编剧本来就该有的写法。这一版还回去(都有往返测试钉住):


[1.2.0] - 2026-10-02

这一版把「引擎能力」扩到 13 项、给引擎接上了门禁,并且第一次让它能被别人用: 一个给编剧的剧本编辑器(含文本优先写作)、库形态(dist-lib/rne.js + 类型声明)、 以及 iframe 嵌入(postMessage 桥)。

PLUGIN_API_VERSION 保持 1:本版对插件契约全是向后兼容的加法(新权限、新 ctx.*、 manifest 新字段、PluginDriver 可选钩子、StoryUi.renderChoice 的可选参数), 没有破坏任何已有插件;只有破坏性改动才提升它。存档格式则是 1 → 2(SaveData.random / pluginState)。

发布 · 1.2.0 取用方式:只走 git(产物入库)

发布 · 1.2.0 打包与类型

新增 · 世界节拍控制(暂停 / 恢复 / 倍速 / 单步)

新增 · 插件定时器 ctx.schedule

新增 · 意图通道 ctx.intents(自带背压)

新增 · 状态层:批处理 / 按路径订阅 / 插件私有存储

新增 · 剧情表达力(校验 / 节奏 / 事件节流)

新增 · 可观测性(dev 调试)

新增 · 存档与确定性(可复现 / 读档接续)

修复 · start() / load() 的 Promise 语义(真 bug)

修复 · 文档方法名错字

新增 · 宿主能力探测 ctx.capabilities

新增 · observe 的 match(按 payload 分流)

新增 · 回放素材 replayLog()

新增 · 对外形态:库形态与 iframe 嵌入

新增 · 文本优先写作(文本剧本 + 实时解析)

新增 · 可打印剧本(给导演看的那一份)

新增 · 编辑器的"落地"与"单场试读"

新增 · 剧本编辑器的手感(撤销 / 搜索 / 快捷键)

新增 · 给编剧用的剧本编辑器(editor.html)

新增 · 插件脚手架 npm run create-plugin

新增 · 门禁接线:git 钩子 + CI

新增 · 资产与音频管线 ctx.assets

新增 · 实时地基:ctx.frame(帧循环)与 ctx.input(输入所有权)

新增 · 限时选择与可跳过的等待

新增 · 权限粒度:作用域限定 + story:read

新增 · 插件依赖 / 顺序 / 生命周期归属(manifest)

新增 · 状态写入的安全姿势(ctx.state.update)

新增 · 剧情 lint 与编辑器 schema

新增 · 受管画布 ctx.view 与 WASM ABI 协商

新增 · 无头测试套件 @rne/testing

[1.1.0] - 2026-09-30

新增 · 世界模拟器

新增 · 测试

更新 · 文档


[1.0.0] - 2026-09-29

首个可运行版本。目标是把「剧情执行」与「玩法能力」彻底解耦: 引擎核心不认识任何具体剧情,所有能力由插件在运行时动态注册。

新增 · 运行时(Runtime)

新增 · 插件系统(plugins)

新增 · UI 渲染层(ui)

新增 · 示例内容

新增 · 工具与脚本

新增 · 测试(Vitest)

新增 · 文档

设计约束(贯穿全部版本)


后续计划(Roadmap)

以下为规划方向,尚未实现,欢迎按需取舍。

1.x 系列(兼容 PLUGIN_API_VERSION = 1)

2.x 系列(可能提升 PLUGIN_API_VERSION)

明确不在计划内


版本兼容性说明

宿主版本PLUGIN_API_VERSION兼容的插件 apiVersion
1.1.01必须为 1
1.2.01仍为 1(本版全是向后兼容的加法;存档格式另计,见上文 1 → 2)
1.3.01仍为 1(同样是向后兼容的加法:新权限 audio:mix、ctx.assets 播放/分组、指针事件新字段)
1.4.01仍为 1(本版未新增也未破坏插件契约;修的是 stop() 之后"可再次 start"这条既有承诺)
1.5.01仍为 1(本版对插件契约全是向后兼容的加法:新权限 contracts:* / expressions:register、manifest 新字段 contracts / budgetMs、ctx.contracts / ctx.expressions)
1.0.01必须为 1

当 PLUGIN_API_VERSION 提升时,旧插件因 apiVersion 不匹配会被拒绝加载, 日志会给出明确提示;届时请参考本文件的对应版本条目进行迁移。