DOCUMENTATION · 更新日志
更新日志
本文件记录「插件化剧情引擎」的版本变更。 发版约定(本项目自己的规矩)
- 条目粒度:不必每改一处都写一条 ✗ —— 琐碎的内部修复(重构、错字、测试设施、只在极端条件下才触发的健壮性修复)可以不写 ✓。值得写的是三类:行为/语义变了、API/契约变了、使用者能感知。(
## [x.y.z]版本段落仍然必需 —— 版本四处自洽的检查在读它 ✓。) - 发版节奏:默认攒够一批再发(曾经定的是 50 条 —— 一条 = 一个
###条目,不是提交数)。 但这是默认值,不是硬规矩:维护者认为"该发了"就可以提前发(1.3.0就是在 18 条时发的)。 唯一别破的底线:修复类不攒(见下)。 - 例外:修复类不攒 —— 影响存档兼容或接口语义的修复,单独发补丁版(如
1.2.1)。 功能可以攒,坏了的东西不该跟着攒 ✗。 - 发版时要动四处版本号(
package.json/index.html页头 / 本文件最新版 / 版本兼容表), 由tests/docs-consistency.test.ts的"版本号自洽"用例兜底。
格式参考 Keep a Changelog,版本号遵循 语义化版本。
[Unreleased]
修复 · restart() 之后插件没有重新加载(一次加载失败会卸掉所有在跑的插件)
现象(使用者视角):一份剧情同时用了两个插件,点"重新开始"之后,一个的效果还在、另一个全没了 ✗ —— 看起来像"两个插件打架",其实是引擎的问题:
插件加载失败时(网络抖动 / 路径解析出问题),loadStory() 会把"这次成功加载的插件 id"
当作"剧情声明的插件" ✗ ⇒ 于是 loaded 为空 ⇒ disposeUnusedPlugins([]) 把正在跑的插件全卸掉 ✗。
日志里只有一串"已卸载"、没有"已加载" —— 这个症状很难看出来 ✓。
修法是 fail-safe ✗:只有"这次确实解析出了声明"时才敢卸载别的插件; 一份都没加载成功而剧情又声明了插件时 ⇒ 保留现有插件、不执行卸载,并打一条明确的错误日志 ✓。
回归测试:tests/stories-shapes.test.ts 里"两个插件同用一份剧情,restart 之后两个 start() 的效果都在" ✓。
(还没有未发布的改动)
[1.5.0] - 2026-10-05
这一版的主线是把引擎的"里子"补齐 —— 全部围绕一个判据: "它经得起真的被用吗?" 于是七条能力里有五条是"已有的东西在真实环境下漏掉的那一半"。
- ⑦ 跨宿主一致性(运行指纹)+ 无头回放 CLI:确定性是旗舰承诺,此前只在 node 里验过 ✗。
现在把一局跑成指纹、在另一台宿主比指纹(
npm run replay -- --expect …✓), 并且顺手撞出两个真缺陷:输入日志带at时间戳(同操作两次指纹不同 ✗)、 截断必须进指纹(否则"跑到哪停下"会被误当成不确定 ✗)。 - ① 派生状态
derived:声明一次、引擎重算(依赖变化就更新 ✓,派生项不许直接写 ✗)。 写它时撞出这一版最要紧的修复 ↓ - ③ 插件契约总线:插件之间不再靠共享状态间接说话 ✓(manifest 声明
provides/requires✓、 缺依赖启动时就报 ✓、名字先到先得 ✓、卸载自动撤销 ✓、audit()查"声明 vs 实现"); - ④ 运行期权限审计:每次过权限门的尝试都留一笔(拒绝也记 ✓),与静态的"插件能力清单"配套 —— 静态是许诺,动态是证据 ✓;
- ⑥ 每插件开销归因:
runtime.pluginCosts()答"是哪个插件在花时间" ✓(看门狗答的是整页帧预算 ✗), 滚动窗口 + 最小样本(一次倒霉调用不判死插件 ✓)、默认只量不罚 ✓; - ⑤ 表达式函数注册:插件/宿主能给 DSL 加函数(不许遮蔽内置 ✗、先到先得 ✓、可选 arity 检查 ✓);
- ② 状态来源追踪:
explain('var.金币')→ "= 9 ← 插件 com.a(set,第 3 次写入)" ✓ —— 这是因果,不是次数 ✓; - 诊断包:把上面这些 + 指纹 + 输入日志打包成一个 JSON ✓(玩家说卡住了 → 导出 → 发你 ✓), 一律截断 ✓;
- 三个「真实世界才会暴露」的洞:后台限流(
wait被拖长 →story:waitOverrun报事实 ✓)、 多标签页/多设备存档(origin+compareSaveEnvelopes→ 含diverge✗ 不假装能自动合并)、 切剧情的漏网(runtime.leakReport()✓)。
修复 · 表达式语言不认中文状态路径(这一版最要紧的一条)
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 ✓ ——
版本 + 剧情/种子/输入日志 + 指纹(⑦)+ 时间输入(②)+ 审计流水(④)+ 开销表(⑥)
- 来源表(②)+ 漏网审计(⑤)✓。一律截断(别让包长成 100MB ✓),只放事实不放判断 ✓。 它是那四条能力的收口 ✓:玩家说卡住了 → 让他点一下导出 → 把包发给你 ✓。
② 实时玩法的确定性缺口(我们自己开的洞 ✗):指纹以前没有时间输入 ——
跑了几帧、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 了"时,要的正是这一句 ✓。
runtime.stateProvenance:lastWriteOf(path)✓ /entries()✓ /paths()✓ /explain(path)✓ (给一句人话:"var.金币 = 9 ← 插件 com.a(set,第 3 次写入)" ✓);- 来源串是现成的 ✗:引擎早就用
set:<nodeId>/choice:<nodeId>#<n>/plugin:<id>:<command>在applyEffects里打了标 ✓ —— 这里只是把它记下来,不新造一套坐标系统 ✓; - 两处记录:引擎的效果漏斗(
by: node✓)与插件的状态 API(by: plugin✓); - 每路径只留最后一条 ✓ + 有上限的流水(300 条 ✓)—— 一个每秒写 10 个路径的插件撑不爆内存 ✓。
新增 · 每插件开销归因与配额(⑥)
看门狗答的是"这一帧超没超" ✓;这一条答的是"是哪个插件花的" ✓ ——
插件是第三方代码,归因才是需要的那一半 ✓。所有插件调用都过 PluginManager.invoke() 那道门 ✓,
在那里计时 ✓,结果通过 runtime.pluginCosts() 拿到 ✓。
三条取舍:
- 口径与看门狗一致 ✗(
budgetMs/ "超预算")—— 不另造一套词 ✓; - 滚动窗口均值 + 最小样本 ✗:一次倒霉的调用不该判死一个插件 ✓(窗口没满就不判定 ✓);
- 默认只量不罚 ✓:没声明预算的插件永远不会被停 —— 否则引擎升个级就可能停掉线上插件 ✓。
声明了(manifest 的
budgetMs✓)才执行,而且执行方式是复用已有的熔断: 状态标error→ 之后invoke()直接拒绝它 ✓(不另造一套禁用机制 ✗)。
新增 · 表达式函数注册(⑤):给 DSL 加函数,而不是各写各的
插件以前能注册条件 ✓、不能给表达式加函数 ✗ → "取最大值 / 格式化时间 / 算距离"
这类东西在每个剧情里各写一遍 ✓。现在:ctx.expressions.register(name, fn, { arity }) ✓(权限 expressions:register ✓),
宿主也能直接往 runtime.expressionFunctions 里注册 ✓(同一份注册表 ✓)。
三条纪律:不许遮蔽内置 ✗(覆盖 min 会让人读不懂剧情 ✓);名字先到先得 ✓(冲突报错 ✓);
声明了 arity 就检查参数个数 ✓(金币够(var.gold) 少给参数当场说清 ✓)。
⚠ 一处语义变化,要记明 ✗:表达式编译期以前会拒绝未知函数名 ✓ —— 那会让"插件注册的函数"根本写不进剧情(编译时看不到注册表 ✗)。现在把这条判定推迟到求值期: 内置 + 注册表都没有 → 抛"未知函数:内置里没有,也没有插件注册过这个名字" ✓。 代价:内置函数名的拼写错误不再在编译期被抓住 ✗(改在求值时报 ✓), 换来的是一条能长大的语言 ✓。
还有一条引擎没法自动强制的 ✗:注册的函数必须是纯的(同样输入同样输出、不读时钟、不写状态 ✓)—— 否则确定性就没了 ✓。这一点写在文档与代码注释里,并可用运行期审计(④)看谁在用 ✓。
新增 · 运行期权限审计(④)
和"插件能力清单"(⑦)配套:⑦ 是静态的(manifest 说要哪些权限 ✓), ④ 是动态的 —— 刚才到底是哪个插件读了哪个状态、改了哪个值、调了谁提供的契约 ✓。 装第三方插件时,静态清单是许诺 ✓,运行期审计才是证据 ✓。
AuditLog:环形的、有上限的 ✗(一个每秒 60 帧的插件若把每次读写都记下来会爆内存 ✓)—— 只留最近 N 条流水 ✓,同时按「插件 + 权限」聚合计数 ✓(看总量比看流水更有用);- 记的是"尝试",带结果 ✓:被拒绝的也留下 ✓ ——"它想干但没被允许"同样是事实 ✓;
- 插在权限门上(
createStateApi/createStorageApi各自的requirePermission✓), 所以状态读写这类高频动作一次不落 ✓;管理器的 UI 注入路径也接同一个日志 ✓。
补测 · 契约依赖的管理器级验证(还上一轮欠的债)
上一轮只覆盖了注册表与 ctx 两层 ✗,而"缺依赖就不启动"这句话得真的走一遍 startAll() 才算验过 ✓。
新增两个夹具插件(提供者 / 消费者)+ 两份剧情:提供者在场 → 消费者被启动并真的调到契约 ✓;
提供者缺席 → 消费者不被启动(用状态标记从外部证明 ✓,不去问管理器内部 ✗)。
写这条测试时第二次栽进同一个坑 ✗:manifest 也是逐字段读出来的 (
PluginManager里readOther/readStringArray那种写法 ✓)—— 新加的contracts字段 没被读走,于是requires永远是空的、插件当然照常启动 ✓。这和剧情那边validateStory吃掉derived是同一个陷阱 ✓ —— 值得写死在两处代码的注释里 ✓。
新增 · 插件之间的契约总线(③)
以前插件之间只能靠共享状态间接说话 ✓(谁都能读、也能悄悄改别人写的 flag ✗), 缺依赖要到运行时才表现为"某个东西没反应" ✗。现在有了明确的契约:
- manifest 声明:
contracts: { provides: [...], requires: [...] }✓ (名字收进嵌套字段,是为了不和已有的requires(依赖的插件 id)撞车 ✗—— 那个问"要哪些插件在场",这个问"要哪些能力在场" ✓); - 运行期 API:
ctx.contracts.provide(name, fn)/.call(name, payload)/.has/.list✓; - 两把权限门:
contracts:provide与contracts:call✓(要不要允许耦合,由 manifest 说了算 ✓); - 缺依赖在启动时就说:插件的
contracts.requires没人提供 → 该插件不启动、状态标 error ✗, 并打一条明确的日志 ✓(而不是运行时"没反应" ✓); - 名字先到先得:两个插件抢同一个契约名 → 报错 ✓(不静默覆盖 ✓);卸载时自动撤销自己的契约 ✓ (免得别人调到一个死插件 ✓);
- 声明 vs 实现可审计:
ContractRegistry.audit()一次给出三组 —— 声明了没实现 / 实现了没声明 / 依赖但没人提供 ✓(这是 ④ 运行期审计的前哨 ✓)。
新增测试 8 条(tests/plugin-manager.test.ts):名字归一化(全角冒号/大小写/空格算同一个 ✓)、
没人提供就抛错 ✓、名字冲突报错但允许自己重注册 ✓、单个/整插件撤销 ✓、三组审计 ✓、
跨插件提供与调用(两个 ctx 共用一张表 ✓)、两把权限门各拦一次 ✓、卸载后别人调不到死插件 ✓。
新增 · 跨宿主一致性检查(运行指纹)+ 无头回放 CLI
确定性是旗舰承诺,但它以前只在 node 里被验过 ✗。现在它可检查了:
runStoryHeadless() 把一局跑成运行指纹 ✓,npm run replay 把指纹打出来 ✓ ——
在两台宿主(浏览器 / node / 不同 V8)上跑同一条命令、比指纹,就能发现"这台宿主上有东西不确定" ✓。
--expect <指纹> 不一致直接退出码 1 ✓(可直接进 CI)。CLI 跑的是库产物 ✓,顺手验了"库产物能被 node 直接用" ✓。
写它的过程中撞出两个真问题(都已修,且都值得记):
- 输入日志带
at: Date.now()✗ → 直接拿整条日志算指纹,同一操作两次运行指纹不同 ✓, 看起来像引擎不确定,其实是日记字段在动 ✓。修法:指纹只吃投影后的日志(stableInputs()✓)。 同一个坑还埋伏在测试里:任何"两份输入日志相等吗"的断言都会偶发红 ✗(这一轮我就写出一条 ✓, 幸好它当时碰巧过了 —— 这类"碰巧通过"比失败更危险 ✓)。仓库里已有的 replay 断言写得很稳(逐字段 ✓), 不用改 ✓。 - 截断必须如实报、并且进指纹 ✗:剧情"永远不结束"时(例如
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 ✓)。
- 传输可注入 ✓:
createBroadcastTransport(name)(浏览器 ✓BroadcastChannel,跨窗口不需要窗口引用) 与createLoopbackTransport()(测试 ✓)。 - 与嵌入同一套协议纪律 ✓:
kind+ 版本 + 类型白名单 + 逐字段校验,不认识就丢 ✗。 - 视图端是 UI 装饰器 ✓(
Proxy包住任意StoryUi):往外播say/choices/end, 收回来的pick用Promise.race抢在途选择的解析权 ✓(与键盘/触屏同一条路)✓ → 引擎零内核改动 ✓。 - 没有
BroadcastChannel直接抛错 ✗:不静默降级(否则"手机没反应"会变成最难查的 bug)。
刻意不含页面 ✗:两块屏长什么样是产品的事 —— 见 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)。
debrief(复盘):结束时告诉玩家两句话 —— 「你没走的路」(只在去过的场里、只报没有条件的选项:不剧透没去过的场,也不报"当时未必可选"的, 两条都是"别骗玩家")与 「因为你」(把一路上的选择连成一条能读的路线)。 同时写进状态(var.debriefMissed/var.debriefRoute),任何宿主、甚至后续剧情都能读。memory(记忆):把上局怎么结束的写进跨局存储,下局开局读回来 →var.runCount/flag.returning/flag.lastWasBad(另注册条件名旧账)→ NPC 会记得你上一周目干过什么。读取放在start()(每次开局都跑)而不是init()(只跑一次)。- 宿主侧:
src/host/debrief.ts只负责排版(纯函数、可测);Renderer.renderDebrief()接在结局正文下面 (它可能比结局先到,所以先到就先存着 —— 顺序不该由事件竞态决定)。 - 新增示例剧情
returns.json「归途」(第二周目门房会认出你)。 - 新增测试:复盘已验通过 ✓(含"没走的路 / 因为你 / 写进状态")。
修复 · restart() 之后插件不会重新启动(漏了两处)
写「跨周目记忆」时撞出来的:restart() 之后插件的 start() 效果会丢 ✗
(第一局开局时正常 ✓,restart() 之后 flag.returning 变成 undefined ✗)。
顺着查下去,漏的不是一处,是两处:
restart()只engine.pause(),不stopAll()✗ —— 插件仍处running→ 而start(id)对running是幂等的(直接返回 ✓)→ 于是"每局初始化"根本没机会重跑。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
这一版的主线是让引擎被三种形态真的用过,围绕它补齐了内容侧、发行侧与工具侧:
- 内容:三份风格完全不同的真剧情(文学向 / 涌现向 / 实时向)+ 两个插件 ——
其中"实时向"(
ctx.frame+ 输入认领 + 受管画布)以前从没被真剧情用过; 三种都在 node 里被确定性地跑一遍(tests/stories-shapes.test.ts)。 - 能力:音频分组 / 淡入淡出 / 并发与优先级 / 切后台停声;触屏支持(屏幕控件输出与键盘相同的键码); 存档版本迁移(逐级迁移、缺级报错、未来版本拒绝)与存档文件导出/导入; 性能看门狗(帧预算 → 档位 + 策略建议,带防抖);世界事件能在玩家犹豫时打断在途选择。
- 发行与被使用:库形态(
build:lib)/ iframe 嵌入 / 一行挂载mount(); 展示页(故事库 + 内嵌演示 + 发布三步)+ 性能面板(?perf=1)。 - 编辑器(给编剧):文本优先写作(文本是真源,往返幂等有测试钉住)、 往返保真(编辑器表达不了的节点原样带走、附加字段也不丢)、JSON 视图、状态写入与状态项面板、 可打印剧本、一键落地。
- 修掉两个真 bug:编辑器导入的死循环(场的边界判错导致自指);
observe跳 end 后story:end不发(renderReset()不处理在途的choice)。以及一个测试设施层面的坑(假时钟污染)。
PLUGIN_API_VERSION仍为 1:本版对插件契约全是向后兼容的加法 (新权限audio:mix、ctx.assets的播放与分组 API、指针事件新字段等),没有破坏任何已有插件。
(同一节点连续重入即判定死循环)
修复 · 空转保护(同一节点连续重入即判定死循环)
wait(可跳过)用 Promise.race 撞 UI.waitForAdvance():只要后者立刻 resolve
(测试空 UI;或玩家按住"继续"不放 ✗),"可跳过的等待"就被无限跳过 ——
若 next 指向自己,主循环空转:刷日志之外还会饿死宏任务
(连 setTimeout(0) 都排不上,页面上其它定时器全部停摆 ✗)。
- 新增
createLoopGuard()(纯逻辑、可单测):同一节点连续重入超过阈值即判定死循环 (默认 200 次)。为什么按"连续重入"而不是"多久没等过时间":正常剧情也会一连串say/set/if快速走过,后者会误判 ✗;而空转的特征就是反复回到同一节点。 - 接线到主循环:命中即抛
StoryRuntimeError(说得清原因),load()时重置。 - 内容侧同时避开该写法:
orbit.json的"停住等世界"改成选择节点 (选择会真阻塞),不再用自环 wait。 - 为什么把判定抽成纯函数:真在引擎里触发空转时宏任务会被饿死,测试自己就跑不下去 (实测两条回归测试都超时 ✗)→ 于是逻辑用纯函数测死,接线由"正常剧情不误判"覆盖。
修复 · 世界事件能在玩家「犹豫」时插话(打断在途的选择)
observe 跳到 end 节点后 story:end 一直不发 —— 根因是:
StoryEngine.dispatchObserver() 唤醒在途等待用的是 ui.renderReset?.(),
而它只放行 say 的「继续」与在途 wait,完全不碰在途的 choice ✗。
于是"玩家还在犹豫、世界事件插话"这条路径走不通:currentNodeId 已被改成跳转目标,
主循环却仍等在选项上 → 永远走不到 end。
- UI 契约新增哨兵
CHOICE_CANCELLED(-2),与CHOICE_TIMEOUT(-1)分开: 超时要按timeoutNext兜底跳转;被打断只是"这次选择不作数"——不记输入、不落效果、不发story:choice。 - 真实
Renderer与套件的假 UI 都在renderReset()里结算在途选择(套件那条注释本来就写着 "与真实 Renderer 一致",所以两边一起改 ✗ 少一边就会继续卡)。 - 新增 2 条回归:打断后走到结局且发出
story:end;以及被打断的选择不落效果、不记输入。tests/stories-shapes.test.ts的"实时向"也收回了结束事件断言。
修复 · 测试里那个"假时钟污染"(我连吃三次 30 秒超时的真凶)
三次超时(空转守卫、正常 wait、打断选择)都不是引擎的问题 ✗:
某个测试文件把 vi.useRealTimers() 的恢复挂在 describe 内部 ✗ ——
而新测试追加在文件顶层,于是继承了上一个测试的假时钟 → setTimeout 永不触发 →
await new Promise((r) => setTimeout(r, 0)) 永久挂起(表现像引擎挂了 ✗)。
已把恢复提到文件顶层,并把这条坑写进 docs/TESTING.md。
新增 · 三份"不同形态"的真剧情 + 两个插件(引擎抽象被三种用法验证)
以前 public/stories/ 里只有压测用的剧情,所以"引擎的抽象"只被一种形态用过 —— 没被证明。
- 文学向
letters.json「未寄出的信」:纯内置节点(条件选项 /set/if/ 可跳过的wait/ 四结局 / 文本插值)。 - 涌现向
tidepool.json「潮池」+ 插件tidepool:世界每拍演化藻类·螺·捕食者, 越过阈值发布pool:bloom/pool:collapse;剧情不主动推进,玩家只是蹲着看,observe到事件才插话。 - 实时向
orbit.json「轨道」+ 插件orbit:ctx.frame固定步长物理 +ctx.input认领左右键 +ctx.view受管画布;玩家侧只有两个键。这条路以前从没被真剧情用过 ✓。 - 新增
tests/stories-shapes.test.ts:三份各在 node 里跑一遍(实时向是手动frame.advance+ 喂按键 → 断言到达结局节点 + 引擎结束事件)—— 确定性让"实时玩法"也能被自动化验证。
过程里踩了三个坑,都值得记:①
load()只加载定位、不起跑,要先sb.begin(); ② 世界驱动要用套件的tickWorld()(自己摆假时钟会跟 await 打架 → 超时); ③observe跳转是异步继续的,断言前要让出一条定时器。
新增 · 一行挂载 mount()(可选入口,一个新文件)
用户要的形态:「新建一个文件,调用它的接口就能直接挂载上」。一个文件,不新造任何能力:
import { mount } from 'plugin-story-engine'
const app = mount('#app', { story: '/stories/demo.json' }) // 挂上就能玩src/mount.ts= 现成的Renderer(官方默认 UI)+ DOM 模板搬进代码 +styles.css用?inline内联成字符串- 移动端屏幕控件/性能面板/主题接线 + 工具栏(存档/读档/导出存档/导入存档/主题)全走引擎的公开 API。
- 不违背"引擎不含 UI":
Runtime的构造函数里没有它、也不 import 它 → 不用它的人零影响; 它是"可替换的默认值",不是"必经之路";官方默认 UI 有意不提供扩展点(想加东西请自己实现RuntimeUi)。 - 顺带把
Renderer改成认容器(new Renderer(container)):DOM 由模板生成,于是同一页可挂多个实例, 不再像"全局 id + document.body"那样撞车(老用法new Renderer()完全不变)。 - 新增 6 个用例:模板 ⊇ Renderer 需要的 11 个 id(少一个就会运行时白屏,所以用测试钉死)、 契约清单自身、工具栏开关、标题转义(标题来自数据,不能变成 HTML 注入)、node 下调用的明确报错。
新增 · 示例宿主的玩家侧打磨(引擎一行没动)+ 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) |
示例宿主里补齐的(都是宿主代码):
- 打字机:纯逻辑抽到
src/host/typewriter.ts(只算「这一帧显示哪几个字」,自己不定时 → 一整类「慢机器上打不完」的问题能在 node 里确定性测出来);Renderer 只负责画与计时。 三条产品判断:长文本自动加速(按总时长上限反推步长)、可以被打断(第一次点击补完、 第二次才继续;打字时按钮显示「跳过」)、尊重prefers-reduced-motion(直接出全文)。 另外:同一句重复渲染不打字(否则每次状态刷新都重打一遍 ✗)、renderReset()会停掉在途打字机。 - 主题:CSS 变量本来就已 token 化 ✓,补了一套亮色主题 + 工具栏切换(选过就记住, 没选过跟随系统)。
- 状态反馈:状态栏在
state:change时闪一下 —— 这是「反馈由引擎事件驱动」的活示范。 - 无障碍:
@media (prefers-reduced-motion: reduce)关掉动效(动画是享受,不是门槛)。 - 新增 5 个用例(打字机纯逻辑:逐帧 / 加速硬上限 / 跳过 / instant / 空文本)。
修复 · JSON 视图的行列定位(不再读引擎错误消息的措辞)+ 时序测试改用假时钟
这两条都是**"别人的环境一变、我静默坏掉"**的实例 —— 正好是这套东西最该防的那类问题。
check-story-json.ts不再读JSON.parse错误消息的措辞。原来用正则从消息里抠position N—— 那是 V8 的实现细节而不是语言契约。实测(Node 18):{"a": 坏}确实带 position ✓, 但截断的输入({"a":,也就是编剧在 JSON 视图里"打字打到一半"最常见的样子) 根本没有 position ✗ → 行号功能在最需要的场景下静默失效。新版 V8 又把 position 从消息里移除。 现在改用自带的 JSON 结构扫描器定位第一个非法字符,不再依赖任何错误消息的措辞。 顺带把两件一起补上:测试(含"合法 JSON 绝不能被误判"的底线用例、数字边界、截断输入) 与递归爆栈兜底(几万层[[会让递归下降扫描器抛 RangeError → 降级为不带行列号的提示, 而不是把 JSON 视图的输入处理一起炸掉)。- 时序测试改用
vi.useFakeTimers()(plugin-timers/world-loop):原来的await sleep(90)+ "期望 ≥2 拍"这类断言,在慢机器/并行时会因 CPU 抢占偶发失败 —— 时间必须是确定性输入(这正是无头套件里假时钟的同一套道理)。
新增 · 存档文件:导出(下载)/ 导入(恢复)
浏览器会清掉本地存储:Safari 的 ITP 会在玩家一两周没来之后清退脚本可写存储,无痕模式会话结束就清空,
用户点"清除浏览数据"更是随时清。这是设计行为,客户端阻止不了 —— 所以别让进度只存在于 localStorage。
runtime.exportSave()→ 一份带信封的存档文件文本(format: 'rne-save'+ 文件格式版本 + 导出时的存档版本 +data);runtime.suggestSaveFileName()给出建议文件名(minimal-存档-2026-10-03.json)。runtime.importSave(text)→ 校验 →(必要时迁移)→ 写进槽位 → 直接读档。- 导入与读档走同一条路(抽出了
resolveSaveData()):老版本的存档文件照样能读(同一个迁移器 ✓), 否则"本地读档会迁移、导入不会"这种不一致迟早出事。 - 拿错文件的情形明确报错:不是 JSON / 不是本引擎导出的 / 文件格式比引擎新 / 属于另一部剧情
(
SAVE_FILE_WRONG_STORY)—— 宁可多几个错误码,也不要"导入成功但进度是乱的"。 - 宿主(引擎演示页)加了「导出存档」「导入存档」两个按钮。
- 新增 5 个用例:信封校验(坏 JSON / 非本引擎 / 更新的格式)、文件名建议、导出→导入往返、 导入别的剧情的存档被拒、导入老版本文件会被迁移。
新增 · 展示页 + 性能面板(把能力摆到一页上给人看)
此前每个页面只讲一件事(演示 / 编辑器 / 嵌入)。这一版补上"一分钟看懂这个项目"的那一页:
showcase.html(展示页):① 故事库(读public/stories/index.json出卡片 → 点进引擎演示页直接玩, 支持?story=<id>直达);② 内嵌演示(iframe 里真跑,父页只用公开的createEmbedHost—— 这页本身也是"库形态好不好用"的一次实测);③ 发布三步(导 JSON → 放public/stories/→ 在index.json加一条 → 刷新就出现在故事库里,这条链路是真的)。- 性能面板(
?perf=1):PerfOverlay.ts把看门狗的 fps / 平均与峰值帧耗时 / 档位画成一个只读浮层, 挂在引擎演示页与嵌入子页上;展示页会把?perf=1透传给 iframe。 它接的是结构化小接口(perfReport()+onQualityChange())而不是Runtime类型 —— 没有循环依赖。 - 新增 1 个用例:浮层那行文本的格式化(含"高帧率档不显示渲染上限")。
新增 · 性能看门狗(帧预算 → 档位 + 策略建议)
以前"这一帧花了多久"没人知道 —— 性能问题只表现为"某些手机上莫名卡"。
FrameLoop实测每帧耗时(逻辑步 + 渲染回调)→lastCostMs/renderInfo.costMs, 并通过FrameLoopDeps.onCost交给看门狗(回调而非 handler 订阅,所以不会让循环一直跑)。src/runtime/PerfWatchdog.ts:帧耗时 → 档位(high/medium/low)+policy()(世界节拍倍率 1/1.5/2.5、渲染上限 不限/60/30)。带防抖:连续超预算才降、 连续明显富余才升(默认 45 / 180 帧),避免来回跳。sample()是纯输入(不读时钟),可单测可复现。Runtime:档位变化时只自动采纳引擎自己那个旋钮(世界节拍按倍率重算,基准值记牢不叠乘), 并广播engine:quality事件(插件/宿主可据此降渲染频率);runtime.perfReport()/runtime.onQualityChange()是给宿主看的入口。自动化降档很容易改坏玩法,所以它是建议不是黑箱。- 新增 6 个用例:单帧飙高不降档、连续超预算才降、连续富余才升、一帧超一帧闲不抖动、 档位→策略映射、统计(平均/最差/fps)+ 帧循环 onCost 接线。
新增 · 存档版本迁移(老存档不静默错读)
原来 load() 遇到版本不符只 warn 一句就照读 —— 读 v1 存档会静默丢掉随机源与插件持久数据,
读"未来版本"的存档更糟(字段含义可能已经变了,等于把玩家进度改坏)。
src/runtime/SaveMigrations.ts:迁移器只升一级(from → from + 1),多级由 runner 串起来(每级可单独测)。 内置 v1 → v2(补pluginState,并如实说明"v1 没有随机源快照,读档后随机序列会从新种子开始")。runtime.registerSaveMigration(from, fn):宿主/项目自己加过私有字段就自己迁; 同版本迁移以注册的为准(可覆盖内置)。- 三条边界:比引擎新 → 直接拒绝(
SAVE_VERSION_TOO_NEW);缺某一级 → 报缺哪一级 (SAVE_MIGRATION_MISSING);迁移器抛错 → 报卡在哪一级(SAVE_MIGRATION_FAILED)。 - 新增 6 个用例:v1 存档可读且状态保住、内置 1→2 的说明、未来版本被拒、逐级顺序、缺级报错、抛错包装、 宿主注册的迁移生效(用"把 var.gold 改名成 var.coins"这种可观测的方式验证)。
新增 · 音频:分组 / 淡入淡出 / 并发与优先级 / 切后台停声
音频原先已有 play(含 loop 与 volume)+ 全局音量/静音 —— 这里更正我早先"只有 play/stop/setVolume"的说法。
缺的是互动剧真正需要的混音能力,这一版补上:
- 分组(
bgm/sfx/voice,可自定义):各自音量与静音 + 并发上限(默认bgm1、voice2、其它 8) → "音乐关掉、音效留着"是一行代码。 - 音量三层相乘:
这一路 × 分组 × 全局;改任一层,正在响的声音立刻跟着变 (顺带修掉一个潜在 bug:改音量以前不动正在响的那个 gain → "下一条才生效")。 - 淡入淡出:
fadeInMs/fadeOutMs/fadeMs(WebAudio 原生 ramp;<audio>用定时器线性淡变)。 - 并发上限 + 优先级:满了踢最弱的(同优先级踢更早的);新来的更弱 → 这次不播(不抛异常)。
- 切后台停声:页面
hidden时setSuspended(true)(停掉在响的、拒绝新的),不自动恢复。 play()返回句柄:可单独setVolume(v, {fadeMs})/stop({fadeOutMs})。- 最小权限:播放自己的声音只要
assets:load;改分组音量/静音/暂停/按组停止需要新权限audio:mix(它们改的是"玩家整体听到什么")。 - 新增 6 个用例:三层音量相乘、bgm 单实例、优先级踢人与拒绝、按组停止、切后台、淡变参数透传。
新增 · 触屏支持(不做手柄)
引擎的输入层原本只有键鼠:插件能认领 ArrowLeft/Space,但触屏设备没有键盘,
认领了也永远收不到。这一版把触屏补齐 —— 而且不给插件加新 API:
- 屏幕控件(
src/runtime/TouchControls.ts):屏幕上的方向区(像摇杆:从按下点算偏移、 超过死区取主轴)与动作键(默认Space/KeyA/KeyB,宿主可配)。它把手指动作翻译成 与键盘相同的键码喂给同一个InputRouter—— 原来认领ArrowLeft的插件,零改动就能用触屏。 控件touch-action: none+preventDefault()(免得"推方向"变成"滚动页面"), 整层pointer-events: none(只有控件吃事件),中间那片留给"点按翻页"。 - 多点触控:指针事件带
pointerId/pointerType(两个拇指同时按是两个不同的 id)。 pointercancel必装:触屏上浏览器抢走手势时不会有pointerup,而引擎保证 抬起一定回到当时接收 down 的那个认领 —— 否则就是"角色一直朝一个方向跑"。- 失焦 / 切后台释放:
blur或页面hidden时把还按着的键与手指全部释放 (键补onKeyUp、手指补onPointerUp({ cancelled: true }))。手机上接电话、切 App 是常态。 - 宿主(演示页与嵌入子页)默认在"以触摸为主"的设备上显示控件;
?touch=1/?touch=0可强制。 - 手柄不做(有意):Gamepad API 需要轮询 + 映射表 + 各家差异处理,收益只有主机玩家那一小块。
- 新增 4 个用例:多点触控靠 pointerId 区分、
pointercancel回到同一个认领、失焦释放、 方向判定的死区与主轴。
新增 · 附加字段:能映射的节点上多出来的字段也不丢
上一条把"编辑器表达不了的整节点"保住了,但还剩一个缺口:能被映射的节点上多出来的字段
(say 的 effects、choice 的 timeoutMs / timeoutNext…)导出时会丢 —— 那等于
"改一句台词顺手把定时器和副作用删了"。这一版补上:
- 节拍新增
extra(BeatExtra):能映射的字段照常映射(编剧照常编辑),多出来的字段 收进extra.fields,导出时原样写回(已知字段优先,避免旧值盖新值)。 - 字段里若含引用(如
timeoutNext)→ 收进extra.refPatches,导出时和保留段的引用一样 按位置重新指向(被引用的节点会被重新编号)。界面上以⟪原样保留的字段:timeoutMs、timeoutNext⟫只读标出,要精确编辑用 JSON 模式。 - 文本模式的接回:文本是真源 → 每次改文本都会从零重造模型,于是
extra与保留段会没地方存。 新增preserveEditorOnlyData():只有那一场的节拍"种类序列"完全一致时才接 (结构没动);编剧在文本里插了节拍导致错位 → 不接、并明确告诉你 (宁可说"这几场没保住",也不把 A 的附加字段安到 B 身上)。 - 顺带修掉一个自己写出来的 bug:"类型认识、但这一份实例映射不了"的节点(如
set的values多项写法)会生成空的保留段(entry是 undefined)→ 现在这类实例也按"表达不了" 收进保留段(探测改用同一个mapNode判断,不再两处不一致)。 - 新增 4 个用例:
say的effects往返、choice的timeoutNext被重新指向、set的values不再产生空保留段、以及文本模式"结构没动就接回 / 结构动了如实报告"。
新增 · 往返保真:编辑器表达不了的节点原样带走(不再丢数据)
原来的行为是"记下来 + 挂横幅",提示不等于保护:一份含 plugin / observe / wait 的剧情,
用编辑器打开、改一句台词、再导出,那些节点就没了。现在改成保留:
script-model.ts新增CarrySegment(保留段):一段编辑器表达不了的节点原样存下来 (键是原节点 id),每次导入的最长连续段收成一段;ScriptScene.carries记录它接在第几个节拍之后。- 引用怎么办(这是关键):保留段里的节点会引用别的节点,而被引用的节点在编辑器里会被重新编号。
所以保留段带一张
references补丁表:记下"哪个节点的哪个字段原本指向哪个位置" ({kind:'next'}/{kind:'beat',sceneIndex,beatIndex}/{kind:'scene',sceneIndex}/{kind:'carry',nodeId}/{kind:'end'}),导出时按当前结构重新解析成新 id —— 布局怎么改都不会指空。 compile.ts把保留段按位置插回序列(节拍之间 / 场最前面),并把每条引用补丁写回, 与编程结构一起过引擎自己的validateStory兜底。- 界面:保留段在节拍列表里以
⟪原样保留:1 个节点(wait)⟫标出 —— 编剧要能看见"这里有一段我看不到、但不会丢";措辞也从"会丢失"改成"已原样保留"。 - 新增 4 个用例(含验收:导入一份
say→wait→say→plugin→end的剧情,改一句台词再导出, 断言保留节点仍在、接线为a→w→b→p→下一场、且产物过引擎校验;另有端到端"wait 没被吃掉")。
注:本条当时如实标注了一个缺口 —— "能被映射的节点上多出来的字段"(
effects/timeoutMs等) 还会丢。同版已补上,见上面的「附加字段」一条。
新增 · JSON 视图(不再把 JSON 当禁区)+ 修掉导入的死循环 bug
- 编辑器中间栏加第三个模式
表单 | 文本 | JSON。JSON 模式显示引擎真正加载的那份剧情 (编译产物),可以直接改:- JSON 语法错误定位到行列(手写 JSON 最需要的就是"错在第几行");
- 其余校验交给引擎自己的
StoryEngine.validateStory—— 不另立一套规则 (新增src/editor/check-story-json.ts只做"定位 + 转述"); - 点「应用改动」走导入路径回到编剧模型(若含编辑器表达不了的节点会明确提示), 并压一次撤销栈;另有「从当前内容重新生成」。
- 修掉一个严重的导入 bug(是这一轮新写的"JSON 往返"测试逼出来的):
导入时把线性
next也当成了跳转引用,于是场的边界判错 —— 被跳转的节点会被前一场吞掉,它的跳转目标随后被映射回"自己所在的那一场", 轻则落点偏移,重则自指死循环(实测:一份三场的剧本导入再导出后,去拿钥匙指回了s_scene1,选它就无限循环)。 现在场的边界只由真正的跳转目标决定(target/else/options[].next/branches[].next/timeoutNext),线性next不算。附带:场的默认标题改用节点内容 (原来是"(原节点 xxx)",对编剧没用)。 验收:新增"JSON 往返:compile → 校验 → 导入 → 再编译,逐句对得上", 以及"直线链不被切成一场一拍""跳转目标恰好指向新场、不指回自己"的回归测试。
新增 · 状态写入 + 状态项面板(条件与状态就是表达式)
编剧此前连"设个旗标"都做不到(编剧模型里没有写入状态的节拍,条件只能读别处写的状态)。 这一版补上,并且明确态度:表达式是一等公民,不做"人话翻译"。
- 新节拍
设置:<路径> = <表达式>(如设置:item.gold = num(item.gold) + 10)—— 编译成引擎的set节点;导入时把set节点还原回这个节拍(expr原样保留, 字面量写成合法的表达式文本)。 - 右侧新增**「状态项」面板**:自动收集剧本里出现的
var./flag./item./actor.并标出 谁写谁读(例如var.debt 只读没写)—— 给的是信息,省得去别处翻变量名, 也顺带暴露"读了从没写"这种可疑情况。不把表达式翻译成人话。 - 体检新增:
设置缺路径/缺值会提醒;搜索能搜到状态路径与表达式文本。 - 修掉一个自己写出来的真 bug:
set没进导入的可表达类型白名单, 于是含set的整条链会被当成"编辑器表达不了"而丢内容 —— 是新加的导入测试逼出来的。
改变 · 文本语法解封:把砍掉的能力还回去
上一版为了解析方便,砍掉了编剧本来就该有的写法。这一版还回去(都有往返测试钉住):
- 同一说话人连续多段:用缩进续段表达(
我说完了。),段落以换行分隔; 没有可接的缩进行时按动作处理,不丢内容。 - 幕:第一个
#是剧名,之后的一级标题都是幕(# 第一幕),一直延续到后面的场; 幕只影响结构与排版(大纲分组、打印稿幕标题),不影响引擎。体检会提醒"部分场有幕、部分没有"。 - 表演提示:
铁颚(低声):进去,或者滚。—— 提示与台词分开存,编译时并进台词 ((低声)进去,或者滚。,游戏里也要交代),导入时会还原回提示(往返不丢)。 - 新增 docs/SCRIPT_TEXT.md:文本模式的完整语法参考(不是入门小抄), 含边界、诊断清单与"有意不做什么";README 与文档索引都指向它。
[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(产物入库)
- 项目决定:不发 npm registry(
package.json的private: true是有意的),只走 git。 于是dist-lib/(rne.js+ 类型声明)随仓库一起提交:克隆/安装下来就能直接用, 不需要构建、不需要 devDependencies、离线也行(实测:git clone之后直接跑通一段剧情)。 代价是"入库产物可能过期",所以pre-push钩子新增同步检查:构建一遍,有差异就拒绝推送 (实测会拦,提示npm run build:lib && git add dist-lib)。 prepare回到"只装钩子"(安装不再需要构建);prepack仍是构建(打包前保证产物新鲜)。
发布 · 1.2.0 打包与类型
package.json:version1.1.0 → 1.2.0(index.html页头、package-lock.json两处同步);PLUGIN_API_VERSION保持 1 —— 本版对插件契约全是向后兼容的加法,只有破坏性改动才提升它。- 新增
exports/files/prepack,让库真能被拿走:npm pack→plugin-story-engine-1.2.0.tgz(72 个文件 / 417 KB,含dist-lib/rne.js与类型声明, 不含src/、tests/、node_modules)。已实测:装进一个干净项目后import就能跑通剧情。 - 修掉两处发布缺陷(都是"第三方消费者"这一步才暴露的):
①
tsc生成的.d.ts相对导入不带扩展名 →moduleResolution: node16/nodenext下报 TS2834, 新增scripts/fix-lib-types.mjs在build:lib末尾补.js(27 个文件 / 115 处); ②exports补./package.json(第三方常要读版本与包内资产)。
新增 · 世界节拍控制(暂停 / 恢复 / 倍速 / 单步)
WorldLoop:pause(reason?)/resume()/configure({ intervalMs, timeScale })/step(n), 并广播world:pause/world:resume;isPaused/timeScale/intervalMs只读访问器。step(n)在暂停期间也能用(确定性快进 / 单步调试)。非法节拍值会被忽略,运行中调整会立刻重排下一拍。- 剧情顶层新增
world字段({ intervalMs, timeScale }):把「这个世界多快」交给内容声明, 由Runtime.loadStory()应用到WorldLoop。 - 插件侧新增
ctx.world.isPaused/pause()/resume()/timeScale()/step()(需world:register)。
新增 · 插件定时器 ctx.schedule
- 新增
src/runtime/PluginTimers.ts:以代数(generation)机制管理插件排下的定时器 ——Runtime.loadStory()/restart()使全部在途定时器失效,插件卸载时按 owner 回收, 运行时关闭时整体清空。ctx.schedule(seconds, handler, { repeat })返回取消函数。 这消灭了「剧情重载后幽灵定时器醒来」这一类 bug。
新增 · 意图通道 ctx.intents(自带背压)
StoryEngine跟踪主循环的等待点:isAwaitingIntent、acceptsIntent(name)、deliverIntent(name, payload)。 只有「剧情停在say/wait」且「有observe节点监听intent:<name>」时才受理,否则返回false。- 插件侧新增
ctx.intents.isIdle/canSend(name)/send(name, payload?)(权限复用events:emit)。 受理后引擎广播intent:<name>,路由仍由剧情 JSON 的observe决定 —— 不新增节点类型, 面板不必再自己判「剧情空闲吗」、拼if分派链。
新增 · 状态层:批处理 / 按路径订阅 / 插件私有存储
StateStore.batch(fn)/patch(obj):批处理写入,结束时按路径去重发出通知(支持嵌套); 解决「每帧连续写很多键、每次通知都全量深拷贝快照」造成的放大成本。StateStore.watch(path, cb):按路径(前缀匹配)订阅,只在该支变化时回调;整体reset()/restore()会以空路径通知,watcher 一律收到。- 插件侧
ctx.state.batch/ctx.state.patch(state:write)与ctx.state.watch(state:read)。 - 新增
ctx.storage(src/plugins/PluginContext.ts的createStorageApi):按插件 id 命名空间隔离的localStorage,用于跨剧情 / 跨局的持久数据(最高纪录、设置);新增两个权限storage:read/storage:write,非浏览器环境下整体降级为available === false而不抛错。state与storage的分工写进了 PERMISSIONS.md 与 PLUGIN_JS.md。
新增 · 剧情表达力(校验 / 节奏 / 事件节流)
validateStory全量引用检查:以前只校验next,target/else/branches[].next/options[].next写错要等运行到那一步才炸(甚至静默按null结束)。现在加载即报错并指出字段路径; 插件自定义节点退化为扫描通用引用字段。say.delayMs(别名delay):配合auto: true给旁白留呼吸感 —— 以前auto只能「立即」推进,要停顿只能塞一个wait节点。延时是引擎自己的可取消定时器,load/pause/ 结束时清理。observe新增once与cooldownMs:前者只响应一次,后者对高频世界事件做节流。- 空格 / 回车不再抢键盘:默认 Renderer 改为「未被消费(
defaultPrevented)才接」并跳过表单元素 —— 插件组件在自己的监听里preventDefault()即可先声明,不必再用stopPropagation()硬挡。
新增 · 可观测性(dev 调试)
EventBus事件轨迹:enableTrace(limit)/getTrace()/isTracing/disableTrace(), 记录名字 / 来源 /pluginId/ 命中监听器数 / 抛错监听器数 / payload 摘要(summarizePayload(), 只描述形状不复制内容),环形缓冲。StateStore写入热点:enableWriteStats()/getWriteStats()(按路径累计,倒序)—— 专治「每秒写 4 次 × 17 个键把状态栏打爆」这类问题。StoryEngineobserve 决策轨迹:getObserverTrace()逐条说明「为什么不跳」 (不属于当前剧情 /once已用掉 / 冷却中 / 条件不满足 / 优先级低于谁)。Runtime新增trace?: boolean | number选项与debugReport()(事件轨迹 + observe 决策 + 写入热点 + 插件列表);dev 下可用?trace=1打开。
新增 · 存档与确定性(可复现 / 读档接续)
- 新增
src/runtime/RandomSource.ts:种子化随机源(next/int/pick/fork(label)/snapshot/restore)。同一种子必然给出同一串数,种子与内部状态随存档走;fork(label)按 label 派生并记忆化独立流,不同子系统互不干扰。构造函数带「热身」, 避免 LCG 相邻种子开局高度相关(实测种子 777/778 首次int(1,1000)都是 538)。 - 插件侧新增
ctx.random。别用Math.random()—— 它会让同一局没法复现、存档存不住随机进度。 Runtime新增seed选项;存档新增random(随机源快照)。PluginDriver/JsPlugin新增可选snapshot()/restore(data):插件可以把自己的持久数据 交给存档保管。PluginManager新增collectPluginState()/restorePluginState(data),SaveData新增pluginState(SAVE_VERSION1 -> 2)。读档顺序改为 「恢复状态与随机源 -> 交还插件数据 -> 启动插件」,避免start时读到空数据。
修复 · start() / load() 的 Promise 语义(真 bug)
Runtime.start()/load()/loadStory({ autoStart })内部不再await剧情主循环: 剧情停在say/choice上是正常状态,那时主循环的 Promise 永不 resolve, await 它会让读档 / restart / 自动开始永久挂住。现在这三处只保证「剧情已经开跑」, 要等结束请听story:end。
修复 · 文档方法名错字
docs/FRONTEND.md里把StoryUi.renderChoice写成了renderChoices(5 处)。 照着写假 UI 会得到this.ui.renderChoice is not a function。
新增 · 宿主能力探测 ctx.capabilities
- 新增
src/runtime/Capabilities.ts:runtime.capabilities/ctx.capabilities给出{ wasm, simd, sharedArrayBuffer, crossOriginIsolated, threads },构造时记日志, 并在「有 SAB 但未隔离」时明确提示。把静默降级变成可查询的事实, 重计算插件据此显式降档(降负载,不换实现语言)。
新增 · observe 的 match(按 payload 分流)
observe新增match字段:用事件 payload 求值的条件(表达式里用args.*, 可与状态混用)。于是同一个事件能按 payload 路由到不同节点, 不必再写observe → if分派链。决策轨迹会记录match 不满足的原因。
新增 · 回放素材 replayLog()
StoryEngine.getInputLog()/Runtime.replayLog():记录本局种子 + 玩家/UI 输入序列 (选项按index、意图按name+ payload,最近 200 条)。 配合确定性世界即可重跑同一局(有端到端测试:同种子 + 同输入 ⇒ 同轨迹同结局)。
新增 · 对外形态:库形态与 iframe 嵌入
- 库形态:新增
src/index.ts作为契约面(该导出的东西只在这一个文件里列),vite.lib.config.ts+tsconfig.lib.json+npm run build:lib→dist-lib/rne.js(ESM,145 KB / gzip 50 KB)+dist-lib/types/**(43 个 .d.ts)。 构建不拷贝public/(库不该带演示资产)。 验证很硬:在 node 里 import 构建产物、用它跑完一段剧情;另有一条"只用公开 API"的端到端测试。 顺带修了一个坑:vite 的emptyOutDir会把 tsc 刚写出的声明清掉 —— 顺序必须先 vite 后 tsc。 - 嵌入形态:新增
src/embed/:protocol.ts(消息形状 / 命令白名单 / 负载校验,纯函数)、child.ts(把 Runtime 包成 postMessage 服务)、parent.ts(createEmbedHost:请求-答复配对、 超时、事件分发)、browser.ts(window / iframe 的薄适配);另加embed.html(子页) 与embed-demo.html(父页演示 —— 只认协议,不 import 引擎运行时)。 - 安全写进了代码与文档:消息带
channel: 'rne'命名空间;命令走白名单(不是反射调用); 父页只处理event.source === 该 iframe的消息;子页记住父页 origin 后只往它回(不全程'*')。 tests/embed.test.ts:消息表面是注入的 —— 用一对假 surface 把父页与子页接起来, 子页里放一个真的 Runtime,于是「父页下命令 → 子页真的跑剧情 → 事件回到父页」是被真验证的。
新增 · 文本优先写作(文本剧本 + 实时解析)
src/editor/text-format.ts:文本剧本格式的parseScriptText()/stringifyScript()。 语法照中文剧本习惯,不发明怪语法:# 剧名/## 场名(场标题行)/ 普通行 = 动作 /名字:台词= 对白(旁白:就是旁白)/选择:+- 选项 -> 目标场/如果 表达式 -> 场+否则 -> 场/跳到 场/结局:名+ 正文 ///注释。- 设计契约:文本是真源,模型是它的产物 ——
parse(stringify(doc))深等于doc(tests/editor-text.test.ts钉住,含"输出稳定"与"空场 / 空选择 / 多分支"的边界)。 否则"表单改了文本没变、或反过来"迟早不一致。 - 解析出错不抛异常,返回带行号的诊断:跳向不存在的场、场名重复(指出两处行号)、
条件表达式不合法、选项没有「选择:」、
##之前就写内容(自动放进兜底场并提醒)。 - 编辑器中间栏加
表单 / 文本切换:文本解析通过才替换内容,写坏了只报错、不动你的稿子; 离开文本模式时若内容变了,压一次撤销栈(Ctrl+Z 能整段退回来)。界面里附语法速查。
新增 · 可打印剧本(给导演看的那一份)
export-script.ts拆成两种导出:可打印 HTML 与增强版 Markdown。 原因很实在:Markdown 印不出剧本的样子 —— 它没有居中与缩进的概念, 打印出来是一篇文章而不是剧本;而编剧写出来第一件事就是拿给别人看。- HTML 版排式照中文剧本通行做法:A4 + 印刷体字号行距、人物名居中、对白缩进、 动作/舞台指示缩进加括号(和旁白分开,导演/演员才分得清"演的"还是"说的")、 转场右对齐、封面(标题/编剧/场数·结局数·字数/日期)、每场可选另起一页; 所有文本转义(导出的文件不能变成可执行的东西)。
- 页码:不假装能做 —— Chromium 不支持 CSS 的
@page页码盒,所以改为每场标注「场 N/M」, 并在页面上说明可用浏览器打印对话框自带的页眉页脚。 - 创作侧补齐两个行业惯例的构件:场标题行(
heading,如「内景 · 锈蚀的门厅 · 夜」) 与 动作/舞台指示(新节拍类型action)。动作编译进引擎时与旁白同为say(引擎不区分),但排版上分开;导入别人的剧情时会明确说明这一点(有损就要说)。 tests/editor.test.ts追加 8 个用例(场标题行 / Markdown 排式 / HTML 结构与转义 / 分页选项 / 动作的编译与体检 / 导入提示 / 端到端)。
新增 · 编辑器的"落地"与"单场试读"
src/editor/publish.ts:把"写完"接上"能在引擎里跑起来"。剧情 id 从标题派生 (派不出来用story-<短哈希>兜底,确定性),并可让编剧显式指定(它会变成 URL)。story.id与index.json条目的 id 由同一个函数算 ——story:lint要求两侧一致, 这样就不可能对不上。界面提供两个动作:复制 index 条目、合并并下载完整的 index.json (按 id 去重,别人的条目一条不动;拿到不是数组的文件会明确报错)。- 从这场试读:每个场一个
▶,直接落在那一场 —— 用引擎「读档恢复执行位置」那条路 (engine.load→restore({ nodeId })→resume()),没有新机制。 tests/editor.test.ts追加 9 个用例(slug / id / 条目 / 合并 / 清单 / id 一致性契约, 以及"从指定场开始时确实不经过前面的场"的端到端)。
新增 · 剧本编辑器的手感(撤销 / 搜索 / 快捷键)
src/editor/history.ts:撤销 / 重做(Ctrl+Z / Ctrl+Shift+Z,上限 50 步)。 存快照而不是命令对象 —— 剧本对象很小,快照换来"绝不可能撤错"; 只覆盖结构性改动(加/删/移动场与节拍),打字交给浏览器原生的 textarea 撤销, 否则每敲一个字压一次快照,"撤销"会退化成"逐字回退"。src/editor/search.ts:全文搜索(Ctrl+F):对白 / 说话人 / 选项 / 结局名, 命中列表带位置与上下文片段,点一条直接跳过去并把那个节拍闪一下。- Ctrl+S 保存草稿(并阻止浏览器"保存网页");加完节拍自动把光标放进新输入框 (编剧的下一步一定是打字);结构性改动后自动停止试读并提示, 避免"看到的还是旧稿"。
tests/editor.test.ts追加 8 个用例(撤销栈的压/撤/重做/上限/清空,搜索的命中与去重)。
新增 · 给编剧用的剧本编辑器(editor.html)
- 新增第二个页面入口
editor.html与src/editor/(宿主应用,不改引擎核心一行): 编剧写「场 / 对白 / 选择 / 条件 / 跳转 / 结局」,编辑器把它编译成剧情 JSON (产物一定过validateStory);一键试读复用Runtime;导出可读剧本(Markdown)。 - 核心是那层编剧模型(
script-model.ts↔compile.ts/import.ts):把引擎的say/choice/if/jump/end翻译成编剧的词汇;plugin/ui/observe这类工程节点 故意表达不了,导入时明确列出"管不了的部分"(有损导入摆在明面上); 条件还原不了形状时原样保留为"高级条件",不猜。 - 结构体检(
health.ts):引用不存在的场、一个结局都没有(硬错);没人进的场、 点了就结束的选项、只会跳回自己的场、空对白(提醒);条件语法复用引擎表达式求值器校验。 外加统计:场数 / 结局列表 / 字数 / 角色出场次数。 - 干净阅读模式(
reader-ui.ts)实现RuntimeUi,但只有对白 / 选项 / 继续 —— 状态栏、插件列表、事件日志都不显示(那些是给开发者看的)。 - 界面是原生 DOM + TS(与仓库其它界面一致,不引框架);结构性改动整块重绘、打字只改模型 (避免丢光标);草稿每次改动自动存浏览器本地。
vite.config.ts增加第二个构建入口;tests/editor.test.ts18 个用例覆盖编译 / 导入 / 体检 / 导出,以及"编译出来的剧本真的能跑到底"的端到端。
新增 · 插件脚手架 npm run create-plugin
- 新增
scripts/create-plugin.mjs:一条命令生成能跑的四件套 —— 插件清单(最小权限集)、插件骨架(命令 / 条件 / 效果各一个,附宿主能力速查注释)、 用到它的剧情(say / plugin / if / end)、以及一个用无头套件跑通该剧情的测试; 并自动把剧情登记进public/stories/index.json(避免漏登记)。 - 选项:
--root/--name/--no-story/--no-test/--force;非法 id 直接拒绝。 - 新增
tests/scaffold.test.ts:在临时目录里生成一整套并真的加载运行, 确保脚手架产物始终可用(而不是"看着像能跑")。
新增 · 门禁接线:git 钩子 + CI
.githooks/pre-push:推送前自动跑npm run typecheck+npm test(选 pre-push 而非 pre-commit: 提交要快,推送才是给别人看的动作)。SKIP_HOOKS=1 git push是逃生门(会打印醒目警告)。scripts/install-hooks.mjs+hooks:install/prepare:npm install之后自动把core.hooksPath指向.githooks(用仓库内目录而不是.git/hooks—— 钩子能提交、能 review、随克隆带走)。.github/workflows/ci.yml:npm ci → typecheck → test → story:lint → build。 ⚠️ 文件里明确标注它未在 GitCode 上验证(编写时读不到它的流水线文档);已验证可用的是钩子。- 顺带修掉文档一致性检查器的一处漏判:它的
npm run ([a-z][\w:]*)漏了连字符, 会把npm run create-plugin截成create,等于在检查一个不存在的名字。
新增 · 资产与音频管线 ctx.assets
- 新增
src/runtime/AssetStore.ts+ctx.assets:图片 / 音频 / JSON / 文本的加载。 按kind+url去重(并发请求共享同一个 Promise)、引用计数 + owner (总数为 0 才卸载,插件卸载自动归还)、进度快照与回调(做加载页用)。 - loader 可插拔:
runtime.assets.registerLoader(kind, loader);内置只在环境支持时注册 (json/text走 fetch;image需要 DOM;audio优先 WebAudio、退化为<audio>)。 于是 node 单测可以注入假 loader,把去重/计数/进度/失败全部测出来。 - 全局音量与静音:
ctx.assets.setVolume/setMuted(宿主可runtime.assets.setMuted(true)做一个全局静音按钮)—— 以前每个插件各管音量,宿主无从下手。 - 新增权限
assets:load(加载与控制音量;release不需要权限)。 - 修掉两个实测问题:引用计数与 owner 两套账对不上(总引用永远归不了零); 进度广播早于出队,导致界面永远以为「还有一个在加载」。
新增 · 实时地基:ctx.frame(帧循环)与 ctx.input(输入所有权)
- 新增
src/runtime/FrameLoop.ts+ctx.frame:固定步长逻辑 + 插值渲染。onStep(默认 16.67ms,帧率无关,物理/判定写这里)与onFrame(带alpha插值,只做渲染)分开; 没有订阅不烧帧、一帧最多补 5 个逻辑步(starved计数暴露"逻辑太重")、node 下靠advance()手动推进 (所以数学是确定性可测的)。改节拍(pause/resume/timeScale)需要world:register。 累加器用毫秒 + 容差比较 —— 修掉了浮点漂移导致的步数误差。 - 新增
src/runtime/InputRouter.ts+ctx.input:认领式输入所有权。引擎独占一层捕获阶段 监听并仲裁(priority大者赢、同级后认领者赢、scope限定作用域),被认领的事件preventDefault + stopPropagation—— 插件不必再各自挂window监听,引擎自己的「继续」键 也不会再被动作游戏抢走。isDown/isClaimed让宿主与插件都能问清"这个键归谁"。 - 两个时钟的分工写进了文档:
WorldLoop(1s 世界拍)与FrameLoop(16.67ms 逻辑步 + 渲染帧)。
新增 · 限时选择与可跳过的等待
choice新增timeoutMs(+timeoutNext):限时选择。引擎把时限交给 UI (StoryUi.renderChoice(options, { timeoutMs })),UI 超时时 resolve 哨兵值CHOICE_TIMEOUT(-1), 剧情走timeoutNext(缺省走节点next)。超时也会记进回放素材(index: -1), 默认 Renderer 已实现超时。wait新增skippable:进度条与玩家「继续」谁先到算谁,并广播story:waitSkipped。
新增 · 权限粒度:作用域限定 + story:read
- 权限名支持「基础权限 + 状态路径前缀」写法(如
state:write:var.arena): 只允许写该路径及其后代,前缀按点号分段匹配。snapshot()/batch()会触达任意路径, 因此只认未限定的基础权限;patch()逐键检查(不留后门)。 新增导出isPermission()/splitScopedPermission()/permissionAllowsPath()。 - 新增权限
story:read:ctx.story.currentNodeId()/isEnded()从「不需要权限」 改为需要它(持有story:control视为同时可读,保持向后兼容)。
新增 · 插件依赖 / 顺序 / 生命周期归属(manifest)
manifest新增三个字段:requires(依赖的插件 id,必须先加载且必须存在)、order: { before, after }(纯顺序提示)、scope: 'story' | 'app'(story默认随剧情切换卸载,app跨剧情常驻)。PluginManager.loadMany改为两段式:先解析全部 manifest → 用新的纯函数orderPlugins()做稳定拓扑排序 → 再按序加载;依赖缺失的插件会被明确拒绝并卸载, 而不是跑到一半才炸。出现环时保持原序(不让加载整体失败)。Runtime.disposeUnusedPlugins不再卸载scope: 'app'的插件。
新增 · 状态写入的安全姿势(ctx.state.update)
StateStore.update(path, fn)与ctx.state.update:安全的读-改-写(读当前值 → 改 → 写回,返回新值)。 治的是「整块状态读进局部变量、改完整块写回」这个坑 —— 一拍之内有第二个参与者时, 后写的会整块覆盖先写的(《血月角斗场》里真实撞到:内核发命中事件 → 人格插件算完突变发回来 → 内核本拍结束时整块写回,把突变冲掉了)。文档补了「拍内写入的注意事项」。
新增 · 剧情 lint 与编辑器 schema
- 新增
tests/stories-valid.test.ts(npm run story:lint):用引擎自己的校验器校验public/stories下每个剧情,并检查「声明的插件是否存在」「index.json与磁盘是否一致 (含漏登记提醒)」。不重复实现规则,因此不会与引擎漂移。 - 新增
public/story.schema.json:给编辑器的字段补全 / 即时校验(文件里加"$schema": "/story.schema.json");跨字段约束仍由 lint 与validateStory负责。
新增 · 受管画布 ctx.view 与 WASM ABI 协商
- 新增
src/runtime/ViewCanvas.ts:ctx.view.canvas(host, { logical, background, clear, autoResize })返回受管画布 —— DPR 适配、容器尺寸变化、rAF 帧循环、卸载收摊都由引擎负责, 插件只注册onFrame自己画。变换数学抽成纯函数computeViewTransform()(可单测)。 没有帧订阅时不跑 rAF;插件卸载时自动destroy()(消灭「幽灵 rAF」)。 非 DOM 环境(node 单测)会明确报错。 - WASM ABI 版本协商:
plugin_abi_version以前是「可选且没人校验」的装饰。 现在negotiateWasmAbi()真的协商:声明了就必须等于宿主版本,否则拒绝加载 (PLUGIN_WASM_ABI_MISMATCH,提示「请按 docs/ABI.md 重新编译」); 没声明(或返回值不是数字)只告警,按「跟随宿主」处理 —— 老插件不会因为这条检查突然不能用。
新增 · 无头测试套件 @rne/testing
- 新增
tests/kit/index.ts:假 UI +file://资源加载 + 宏任务泵 + 驱动脚本 (createSandbox()/driver.advance|choose|until|runUntilEnd|tickWorld), 把「整套剧情 + 插件链路」搬进 node 里跑,替代跑不起来的 E2E。 - 新增
tests/kit.test.ts:用真实Runtime+ 真实插件加载器(含 WASM)跑通三条自带剧情。
[1.1.0] - 2026-09-30
新增 · 世界模拟器
WorldLoop.ts:独立于剧情引擎的世界循环,每 tick 广播world:tick事件,驱动插件计算与事件发布。observe节点:剧情不再主动推进,而是被动听世界说话——通过监听世界事件触发跳转, 支持event/condition/priority/next字段;换剧情时自动撤销订阅。- 世界权限模型:新增
world:register/world:emit/world:listen三个权限, 对应ctx.world.registerRule/ctx.world.publish/ctx.world.subscribe,未授权时抛PluginPermissionError。 persistent顶层字段:标记剧情永不结束,世界循环持续运行。- WASM 插件参与世界模拟无需新 ABI:复用事件代理与 imports,声明监听
world:tick即可每拍推进状态机。
新增 · 测试
tests/world-loop.test.ts:世界循环生命周期与world:tick广播、规则错误隔离、规则按 owner 回收、observe节点(条件 / 优先级 / 换剧情撤销订阅)、WorldLoop ↔ observe 端到端(9 用例)。- 单测累计 31 个,
npm test全绿。 - 新增 E2E:
tests/e2e/world.spec.ts(世界模拟器)。
更新 · 文档
- 全部 13 篇文档同步世界模拟器新能力。
[1.0.0] - 2026-09-29
首个可运行版本。目标是把「剧情执行」与「玩法能力」彻底解耦: 引擎核心不认识任何具体剧情,所有能力由插件在运行时动态注册。
新增 · 运行时(Runtime)
Runtime.ts:运行时门面,负责装配StateStore/EventBus/PluginManager/StoryEngine, 暴露加载剧情、存档读档等对上层(UI)友好的 API。StoryEngine.ts:剧情引擎核心。- 剧情文档解析与校验(
id/title/start/nodes/plugins); - 节点执行主循环,支持
next跳转与null/stop结束; - 效果(effect)落地:把插件返回的
effects应用到状态仓库; - 文本插值
{{ 表达式 }}; - 节点级自动存档点。
- 剧情文档解析与校验(
StateStore.ts:状态仓库,四个命名空间var/flag/item/actor, 支持路径式读写(如var.gold、actor.hero.name)、快照与恢复。EventBus.ts:发布订阅(on/once/off/emit), 监听器异常逐个隔离并计数,单个监听器抛错不影响其它监听器。Expression.ts:条件表达式 DSL 的安全求值器。 手写词法与递归下降语法分析,绝不使用eval/new Function。Registry.ts:带归属(owner)的注册表,支持按插件回收能力,重名冲突报REGISTRY_CONFLICT。
新增 · 插件系统(plugins)
PluginManager.ts:- 按
manifest.json登记并加载插件; - 生命周期
init → start → stop → dispose全流程管理; - 权限模型:manifest 声明 + 每次宿主能力调用校验;
- 单次调用超时(默认 3000ms,可在 manifest 用
timeoutMs覆盖); - 异常隔离与连续失败 3 次熔断(
maxConsecutiveFailures); - 插件注册表与运行时信息(状态、错误、已注册能力)。
- 按
JsPluginLoader.ts:动态import()加载 ESM 插件, 支持默认导出 / 具名钩子 /createPlugin()工厂三种形态,import全程try/catch。WasmPluginLoader.ts:fetch+WebAssembly.instantiate加载 Rust 产物, 注入 4 个宿主 import(host_log/host_emit/host_get_state/host_set_state), 并校验必需导出,缺失时给出明确错误。PluginContext.ts:插件上下文工厂,产出经权限校验的宿主能力 API (state/events/commands/conditions/effects/nodes/ui/story/logger), 插件拿不到StateStore/StoryEngine的原始引用。types.ts:插件系统核心契约(manifest、权限清单、JsPlugin、PluginContext、 处理器签名、WASM 请求/返回值、统一PluginDriver接口)。
新增 · UI 渲染层(ui)
Renderer.ts:原生 DOM 渲染层,负责对白、选项、状态栏、插件 UI 组件挂载、错误横幅。styles.css:应用样式。- UI 组件可被插件扩展:
ctx.ui.register(name, renderer),宿主在剧情ui节点或面板上挂载。 插件卸载时宿主自动卸载其组件。
新增 · 示例内容
- JS 插件
public/plugins/hello-js/: 注册命令greet、自定义节点note、条件与 UI 组件,可写状态、发事件。 - Rust WASM 插件
public/plugins/hello-wasm/+wasm-src/hello-wasm/: 纯 Rust、wasm32-unknown-unknown、crate-type = ["cdylib"]、仅依赖serde_json, 不使用 wasm-bindgen,直接导出 C ABI;响应init与wasm_greet命令,读写状态并广播事件。 不提供 AssemblyScript 版本。 - 剧情
public/stories/demo.json:走通「对话 → 选择 → JS 插件命令 → Rust WASM 插件命令 → 自定义节点 → 结束」。 - 剧情
public/stories/minimal.json:只用内置节点,验证引擎可完全脱离插件运行。
新增 · 工具与脚本
scripts/build-wasm.mjs:纯 Node.js 的 WASM 构建脚本, 执行cargo build --target wasm32-unknown-unknown --release并把产物复制到public/plugins/hello-wasm/plugin.wasm。src/utils/json.ts:JSON 解析、UTF-8 编解码、深拷贝。src/utils/errors.ts:统一错误体系(EngineError+ 带code/pluginId的子类)。src/utils/async.ts:withTimeout/sleep,插件调用超时保护的基础。src/utils/logger.ts:双通道日志器(console + UI 事件日志),插件日志带plugin:<id>前缀。
新增 · 测试(Vitest)
tests/expression.test.ts:表达式 DSL 词法 / 语法 / 求值。tests/story.test.ts:剧情解析与节点执行。tests/plugin-manager.test.ts:插件加载、生命周期、权限、超时、熔断。tests/wasm-abi.test.ts:WASM ABI 集成(JSON 编解码、结果读取)。- 共 22 个用例,
npm test全绿。
新增 · 文档
- 根
README.md:项目简介、特性、目录结构、快速开始、加载剧情 / JS 插件 / WASM 插件、 新增插件、编写剧情、文档索引、许可证建议。 docs/README.md:文档总索引与阅读路线。docs/ARCHITECTURE.md:架构图(Mermaid)、模块职责、数据流、插件生命周期、错误隔离与超时、权限模型。docs/PLUGIN_JS.md:JS 插件目录结构、manifest 字段、JsPlugin接口、PluginContext完整 API、 生命周期调用时机、事件监听与注销、权限校验、完整示例、调试与日志。docs/PLUGIN_WASM.md:Rust WASM 插件目录结构、manifest 字段、ABI、导出/导入函数、 消息与返回值格式、内存管理与字符串传递、完整 Rust 源码与Cargo.toml、构建命令、常见错误排查。docs/STORY_FORMAT.md:剧情 JSON Schema、顶层字段、全部节点类型字段与示例、 条件表达式 DSL 语法、变量/标志/物品/角色状态、插件命令调用、自定义节点、热替换。docs/ABI.md:ABI 版本、内存布局、UTF-8 字符串、指针与长度、函数签名与返回值、错误码、交互时序图、Rust 实现注意事项。docs/PERMISSIONS.md:权限列表与含义、声明方式、未授权行为、校验实现位置、安全建议。docs/EVENTS.md:事件契约,引擎/插件发布的事件名、payload 结构与来源、监听方式(含通配*)。docs/FRONTEND.md:前端集成指南,把引擎当地库嵌入自己的页面(RuntimeAPI、RuntimeUi接口、框架嵌入示例)。docs/TESTING.md:测试指南,Vitest 单元测试与 Playwright 端到端、目录约定、常见问题。docs/BUILD.md:环境要求、依赖安装、开发/生产构建、WASM 构建步骤、静态资源说明、常见构建问题。docs/FAQ.md:插件加载失败、WASM 实例化失败、剧情不更新、调试、扩展节点、权限收紧、打包发布、错误码速查。docs/CHANGELOG.md:本文件。
设计约束(贯穿全部版本)
- WASM 插件仅使用 Rust;不使用 AssemblyScript / C / C++ / Go / Zig。
- 不使用 Python / Pyodide,仓库内无任何
.py文件,无 Python 构建步骤。 - 条件表达式不使用
eval,走安全 DSL。 - 单个插件崩溃 / 超时 / 连续失败不会拖垮主引擎与其它插件。
后续计划(Roadmap)
以下为规划方向,尚未实现,欢迎按需取舍。
1.x 系列(兼容 PLUGIN_API_VERSION = 1)
- YAML 剧情格式:在 JSON 之外支持
.yaml,复用同一套节点语义(解析后归一化为 JSON)。 - 存档可视化:多槽位存档管理界面(列出 / 覆盖 / 删除 / 导出 JSON)。
- 插件热重载:在不刷新页面的情况下卸载并按需重载单个插件(复用现有
dispose+ 注册表回收)。 - 节点类型补全:
input(文本输入)、call(调用插件条件做分支)、await(等待插件事件)。 - 表达式能力增强:字符串函数(
lower/upper/len)、数组/对象字面量、更完善错误定位(行号列号)。 - 调试面板增强:状态实时编辑、事件回放、插件调用耗时统计。
- 插件依赖声明:manifest 支持
dependencies,管理器按拓扑序加载。
2.x 系列(可能提升 PLUGIN_API_VERSION)
- WASM 双模式:除 C ABI 外评估可选的组件模型(Component Model)支持,同时保留现有 ABI 兼容。
- WASM 内存与沙箱加固:限制插件可分配内存上限、调用栈深度,超限即熔断。
- 多线程 / Worker 隔离:把插件执行放到 Web Worker,进一步隔离主线程卡顿。
- 插件签名与来源校验:对插件包做哈希校验,降低供应链风险。
- 剧情可视化编辑器:基于现有 JSON Schema 的节点图编辑器。
明确不在计划内
- ❌ 不提供 AssemblyScript 版本或构建流程。
- ❌ 不引入 Python / Pyodide 工具链。
版本兼容性说明
| 宿主版本 | PLUGIN_API_VERSION | 兼容的插件 apiVersion |
|---|---|---|
| 1.1.0 | 1 | 必须为 1 |
| 1.2.0 | 1 | 仍为 1(本版全是向后兼容的加法;存档格式另计,见上文 1 → 2) |
| 1.3.0 | 1 | 仍为 1(同样是向后兼容的加法:新权限 audio:mix、ctx.assets 播放/分组、指针事件新字段) |
| 1.4.0 | 1 | 仍为 1(本版未新增也未破坏插件契约;修的是 stop() 之后"可再次 start"这条既有承诺) |
| 1.5.0 | 1 | 仍为 1(本版对插件契约全是向后兼容的加法:新权限 contracts:* / expressions:register、manifest 新字段 contracts / budgetMs、ctx.contracts / ctx.expressions) |
| 1.0.0 | 1 | 必须为 1 |
当 PLUGIN_API_VERSION 提升时,旧插件因 apiVersion 不匹配会被拒绝加载,
日志会给出明确提示;届时请参考本文件的对应版本条目进行迁移。