| feat: 引擎能力扩展 —— 13 项能力 + 实时地基 + 资产管线 + 插件脚手架 一次性的引擎侧扩展。全部是增量:不改玩法语义、不引 UI 框架进核心、 守住 Expression / StateStore / WasmPluginLoader 的通用性红线。 (旧的 4 个游戏在另一个克隆里,本次未触碰。) 一、无头测试套件与文档纪律 - tests/kit:假 UI + file:// 资源加载 + 宏任务泵 + 驱动 DSL (createSandbox / StoryDriver / createPluginFixture / createEngineHarness) → 整套「剧情 + 插件(含 WASM)」能在 node 里跑完 - 新增 8 个测试文件;文档同步到用例数一致性 二、时间与节拍 - WorldLoop:pause / resume / configure(timeScale) / step + world:pause|world:resume - 剧情顶层 world 字段(intervalMs / timeScale);PluginTimers + ctx.schedule(重载自动失效) 三、意图通道与剧情表达力 - ctx.intents:自带背压(只在 say/wait 且确有人 observe intent:<name> 时受理) - validateStory 全量引用检查(target/else/branches[].next/options[].next,报错带字段路径) - say.delayMs;observe 的 once / cooldownMs / match(按 payload 分流) - choice.timeoutMs + timeoutNext;wait.skippable - 空格/回车不再抢键盘:改为「未被消费(defaultPrevented)才接」并跳过表单元素 四、状态与存档 - StateStore.batch / patch / watch / update(安全的读-改-写,治同拍覆盖) - ctx.storage(按插件命名空间隔离);ctx.random(种子化,同种子可复现) - PluginDriver/JsPlugin 的 snapshot/restore;SaveData.random/pluginState(SAVE_VERSION 1→2) - 修 Runtime.start / load 的 Promise 语义:剧情停在 say/choice 上时不再永不 resolve 五、权限与插件治理 - 作用域限定权限(如 state:write:var.arena);新增 story:read / storage:read|write / assets:load - manifest 的 requires / order / scope:稳定拓扑排序加载、缺依赖明确拒绝、app 级跨剧情常驻 六、可观测性 - EventBus 事件轨迹(含抛错监听器数 + payload 摘要)、StateStore 写入热点、 StoryEngine observe 决策轨迹(逐条说明为何没跳) - runtime.debugReport()、dev 下 ?trace=1、runtime.replayLog()(种子 + 输入序列 → 可重放) 七、实时地基 - FrameLoop + ctx.frame:固定步长逻辑(onStep)+ 渲染帧(onFrame + alpha 插值) - InputRouter + ctx.input:认领式输入所有权(引擎独占捕获层监听并仲裁) - ViewCanvas + ctx.view:受管画布(DPR / 尺寸 / rAF / 卸载收摊) 八、资产与生产线 - AssetStore + ctx.assets:图片/音频/JSON 去重、引用计数、进度、全局音量与静音 - scripts/create-plugin.mjs(npm run create-plugin):生成能跑的「插件+剧情+测试」四件套 - Capabilities(wasm / simd / SAB / 跨域隔离)探测;WASM ABI 版本协商; 剧情 lint(npm run story:lint)+ story.schema.json 验证:npm test = 21 文件 / 213 用例全绿(连跑 3 次稳定);tsc --noEmit 干净; vite build 通过;dev server 全资源 200。全部新增能力都有测试覆盖, 包括「同一颗种子必然复现」「读档后插件内存与随机游标一起回滚」这类端到端断言。 | 30 天前 |
| feat: 诊断包 + 三个「真实世界才会暴露」的洞(① ② ③ ④ ⑤ 一起) 用户说「一起搞完」—— 五条同轮完成,本条一起提交(每条各自验证过)。 ① 诊断包 runtime.exportDiagnostics():版本 + 剧情/种子/输入日志 + 指纹 + 时间输入 + 审计 + 开销 + 来源 + 漏网,一律截断。它是 ②④⑥⑦ 四条能力的收口:玩家说卡住了, 让他点一下导出、把包发过来就能远程排查。诊断包里的 ENGINE_VERSION 加了一条防漂移检查 (必须等于 package.json 的 version),免得版本一升诊断包就开始撒谎。 ② 时间输入进指纹:timeInput { frames, timeScale, elapsedMs } + runtime.timeInput()。 以前指纹里没有时间输入,于是 ctx.frame 驱动的实时玩法,跨宿主一致性检查其实是空的—— 这是我们自己开的洞,现在补上。 ③ story:waitOverrun:浏览器会限流后台标签页的定时器,wait(30s) 可能拖到几分钟, 引擎以前「听浏览器的」→ 剧情被静默改变。现在只报事实(要了多久/实际多久/差多少), 跳过还是补偿由剧情决定。判定抽成纯函数 classifyWaitOverrun + deps 阈值(默认 500ms)。 ④ 存档带 origin(写入者)+ compareSaveEnvelopes() 纯函数:same / 左新 / 右新 / diverge(不同来源各自往下走了 —— 不假装能自动合并,合并策略归产品)。 ⑤ runtime.leakReport() + PluginTimers.owners():切剧情之后还挂着几个插件定时器、属于谁 (「玩久了越来越卡」那类)。引擎只报,不擅自清。 观察(不重要但如实记):这一轮有一次全量跑出现 3 条与本次改动无关的偶发红(叙事插件的两条 + 诊断包一条),紧接着复跑全绿 —— 说明那里存在偶发性,值得下一轮专门盯一下。 验证:npm test = 29 文件 / 439 用例全绿;typecheck 干净;构建通过。 | 27 天前 |
| feat(showcase): 展示页(故事库 + 内嵌演示 + 发布三步)+ 性能面板 ?perf=1 用户说「演示做了吧」→ 做上一条里点名的那件:**把能力摆到一页上给人看**。 showcase.html(第五个页面入口): - ① 故事库:读 public/stories/index.json 出卡片,点「▶ 玩这个」直达引擎演示页(新增 ?story=<id> 支持); - ② 内嵌演示:iframe 里真跑,父页只用公开的 createEmbedHost / parentSurface —— 所以这页本身也是"库形态到底好不好用"的一次实测; - ③ 发布三步:导 JSON → 放 public/stories/ → 在 index.json 加一条 → 刷新即出现在故事库里(这条链路是真的)。 性能面板(?perf=1):PerfOverlay.ts 把看门狗的 fps / 平均与峰值帧耗时 / 档位画成一个**只读**浮层, 挂在引擎演示页与嵌入子页;展示页会把 ?perf=1 透传给 iframe。 它接的是**结构化小接口**(perfReport() + onQualityChange())而不是 Runtime 类型 —— 没有循环依赖, 任何实现这两个方法的东西都能挂。设计上克制:只让数字被看见,不提供调参入口(调参是宿主的事)。 顺带:vite 入口 4 → 5、BUILD.md/README/ARCHITECTURE/CHANGELOG 同步、新增 1 个用例(浮层那行文本的格式化)。 验证:npm test = 25 文件 / 341 用例全绿;typecheck 干净;构建产出 5 个页面; dev server 上五个页面与新模块全部 200。 | 28 天前 |
| docs: 写明 CHANGELOG 条目粒度 —— 不必每改一处都写一条 用户定的规矩:琐碎的内部修复(重构、错字、测试设施、只在极端条件下才触发的健壮性修复) 可以不写 CHANGELOG 条目。值得写的是三类:行为/语义变了、API/契约变了、使用者能感知。 ## [x.y.z] 版本段落仍然必需(版本四处自洽的检查在读它)。 同一条也写进本地的 agents.md(那是给下一个 AI 的作业须知,不进仓库), 并在那里补了两条被纠过的判断规矩: 1. 严重性 = 触发条件 × 后果,不是「在我这儿有多响」—— 别把沙箱的复现频率当生产的触发频率; 2. CHANGELOG 的条目粒度(上面那条)。 (过程如实记:这条提交我第一次用 -m 写消息、里面带 ASCII 双引号,被 shell 切碎、 提交根本没发生——而「消息写文件再 -F」这条规矩就写在我刚加的 agents.md §6.10 里。 已经是第六次栽在引号上了。) | 26 天前 |
| docs(cross-origin): 修正 credentialless 的浏览器兼容性(Firefox 119+ 支持,Safari/安卓 Firefox 不支持) | 1 个月前 |
| feat: 诊断包 + 三个「真实世界才会暴露」的洞(① ② ③ ④ ⑤ 一起) 用户说「一起搞完」—— 五条同轮完成,本条一起提交(每条各自验证过)。 ① 诊断包 runtime.exportDiagnostics():版本 + 剧情/种子/输入日志 + 指纹 + 时间输入 + 审计 + 开销 + 来源 + 漏网,一律截断。它是 ②④⑥⑦ 四条能力的收口:玩家说卡住了, 让他点一下导出、把包发过来就能远程排查。诊断包里的 ENGINE_VERSION 加了一条防漂移检查 (必须等于 package.json 的 version),免得版本一升诊断包就开始撒谎。 ② 时间输入进指纹:timeInput { frames, timeScale, elapsedMs } + runtime.timeInput()。 以前指纹里没有时间输入,于是 ctx.frame 驱动的实时玩法,跨宿主一致性检查其实是空的—— 这是我们自己开的洞,现在补上。 ③ story:waitOverrun:浏览器会限流后台标签页的定时器,wait(30s) 可能拖到几分钟, 引擎以前「听浏览器的」→ 剧情被静默改变。现在只报事实(要了多久/实际多久/差多少), 跳过还是补偿由剧情决定。判定抽成纯函数 classifyWaitOverrun + deps 阈值(默认 500ms)。 ④ 存档带 origin(写入者)+ compareSaveEnvelopes() 纯函数:same / 左新 / 右新 / diverge(不同来源各自往下走了 —— 不假装能自动合并,合并策略归产品)。 ⑤ runtime.leakReport() + PluginTimers.owners():切剧情之后还挂着几个插件定时器、属于谁 (「玩久了越来越卡」那类)。引擎只报,不擅自清。 观察(不重要但如实记):这一轮有一次全量跑出现 3 条与本次改动无关的偶发红(叙事插件的两条 + 诊断包一条),紧接着复跑全绿 —— 说明那里存在偶发性,值得下一轮专门盯一下。 验证:npm test = 29 文件 / 439 用例全绿;typecheck 干净;构建通过。 | 27 天前 |
| feat(config): 跨域隔离默认关闭(改为显式开启的可选开关),文档与检查器同步 | 1 个月前 |
| feat(link): 跨窗口/跨设备链接(引擎侧协议 + 传输 + UI 装饰器,刻意不含页面) 维护者说「记住我们做的只是引擎」—— 于是 ⑥ 只做引擎那一半,并**把这条边界写进仓库** (docs/ARCHITECTURE.md 新增「边界:我们做引擎,不做产品」一节 + HANDOFF §39), 以后新东西先问"这是引擎的能力,还是一个壳?":能力进 src/(配测试),壳只做到能证明能力为止。 src/link/:让"跑剧情的那一端"与"另一端的控制器"(另一块屏 / 另一台设备 / 一个自动化脚本) 用同一套消息对话 —— 控制器能替玩家作出选择(pick)或把意图送进剧情(intent)。 - 传输可注入:BroadcastChannel(浏览器,跨窗口不需要窗口引用)+ 回环(测试)。 - 与嵌入同一套协议纪律:kind + 版本 + 类型白名单 + 逐字段校验,不认识就丢。 - 视图端是 UI 装饰器(Proxy 包住任意 StoryUi):往外播 say/choices/end;收 pick 时用 Promise.race 抢在途选择的解析权(与键盘/触屏同一条路)→ **引擎零内核改动**。 - 没有 BroadcastChannel 直接抛错(不静默降级,否则"手机没反应"会是最难查的 bug)。 **刻意不含页面**:两块屏长什么样是产品的事;文档里给了 15 行用法说明,谁要谁写。 验证:npm test = 29 文件 / 383 用例全绿;typecheck 干净;构建通过。 (改过一次提交消息:先前手写"385 用例"是错的,实际 383 —— 自造数字这件事我又犯了一次, 好在这次还没推出去,能改。) | 27 天前 |
| feat(expression): 表达式函数注册(⑤)—— 给 DSL 加函数,而不是各写各的 插件以前能注册条件、不能给表达式加函数 → "取最大值/格式化时间/算距离"在每个剧情里各写一遍。 现在:ctx.expressions.register(name, fn, arity?)(权限 expressions:register), 宿主也能往 runtime.expressionFunctions 注册(同一份注册表)。 三条纪律:不许遮蔽内置(覆盖 min 会让人读不懂剧情);名字先到先得(冲突报错); 声明了 arity 就检查参数个数。 一处**语义变化**(写进 CHANGELOG):表达式编译期以前拒绝未知函数名,那会让插件注册的函数 根本写不进剧情(编译时看不到注册表)。现在把判定推迟到求值期:内置 + 注册表都没有 → 抛"未知函数:内置里没有,也没有插件注册过这个名字"。代价是内置函数名拼错不再在编译期被抓住, 换来的是一条能长大的语言。 一条引擎没法自动强制的:注册的函数**必须是纯的**(否则确定性就没了)——写在文档与注释里, 可用运行期审计(④)看谁在用。 测试 6 条(tests/expression.test.ts):注册表行为、内置遮蔽、先到先得、未知函数报错、 端到端(宿主注册 → 剧情条件命中)、权限门。 验证:npm test = 29 文件 / 427 用例全绿;typecheck 干净;构建通过。 | 27 天前 |
| feat(audio): 分组 / 淡入淡出 / 并发与优先级 / 切后台停声(+ 新权限 audio:mix) 补上我在"还差什么功能"里点名的引擎最明显短板。先更正自己的说法:音频**已经有** play(含 loop 与 volume) + 全局音量/静音 —— 我说的"只有 play/stop/setVolume"不准确。真正缺的是互动剧需要的混音能力: - 分组(bgm / sfx / voice,可自定义):各自音量与静音 + 并发上限(默认 bgm 1 / voice 2 / 其它 8,可配) → "音乐关掉、音效留着"成为一行代码。 - 音量三层相乘(这一路 × 分组 × 全局);改任一层,**正在响的声音立刻跟着变**。 顺带修掉一个潜在 bug:setVolume 以前只改字段、不动正在响的 gain("下一条才生效")。 - 淡入淡出:fadeInMs / fadeOutMs / fadeMs。WebAudio 用原生 linearRampToValueAtTime(采样级精确); <audio> 退化路径用定时器线性淡变。 - 并发上限 + 优先级:满了踢最弱的(同优先级踢更早的);新来的更弱就这次不播 (返回 playing=false 的句柄,不抛异常打断剧情)。 - play() 返回句柄:可单独 setVolume(v,{fadeMs}) / stop({fadeOutMs})。 - 切后台停声:Runtime 在装配阶段挂 visibilitychange → 隐藏即 setSuspended(true)(停掉在响的、拒绝新的), 不自动恢复。顺带把套件假 document 缺 addEventListener 暴露出来的问题用防御写法解决。 - **最小权限**:播放自己的声音只要 assets:load;改分组音量/静音/暂停/按组停止需要新权限 audio:mix —— 它们改的是"玩家整体听到什么",不只影响你自己那份声音。(PERMISSIONS.md 与 README 的权限数同步为 18 项。) 验证:npm test = 25 文件 / 323 用例全绿(新增 6 个音频用例);typecheck 干净;构建通过。 | 28 天前 |
| chore: 移除《终局社区》测试剧情 该剧情仅用于验证世界模拟器功能,不应作为仓库交付内容。 - 删除 life-community 剧情 / life-sim WASM 插件 / life-dashboard JS 插件 - 删除专用测试 life-sim.test.ts 与 life-community.spec.ts(单测回落 31 个全绿) - 清理 index.json / build-wasm.mjs / styles.css 及文档引用 - 原始内容归档于 backup/main-life-community-2026-09-30 分支 | 1 个月前 |
| feat(editor): 文本语法解封 —— 把我为省事砍掉的能力还回去 用户的判断:「没有给编剧当傻子的义务」。我复查了自己这一路的决定,确实有几处是 **为了省事而砍掉编剧的能力**,这一版先还最要紧的三样(都有往返测试钉住): 1. **同一说话人连续多段**(原来我强制"每段都得写名字:",理由是解析方便 —— 独白分段 是剧本里最常见的写法之一)。现在用**缩进续段**表达,段落以换行分隔; 没有可接的缩进行时按**动作**处理,**不丢内容**。 2. **幕**:第一个 # 是剧名,之后的一级标题都是幕(# 第一幕),一直延续到后面的场。 幕只影响结构与排版(大纲分组、Markdown/HTML 打印稿的幕标题),不影响引擎; 体检会提醒"部分场有幕、部分没有"这种不一致。 3. **表演提示**:铁颚(低声):进去,或者滚。 —— 提示与台词**分开存**, 编译时并进台词((低声)进去,或者滚。,游戏里也要交代),**导入时还原回提示**。 另外新增 docs/SCRIPT_TEXT.md:文本模式的**完整语法参考**(不是"给不会写的人看的小抄")—— 语法、边界、诊断清单、以及"有意不做什么"。编辑器界面里的参考与它同一份。 README、文档索引表与目录树都指向它(索引表与目录树这两处是**检查器逼我加的**, 它连着抓了我两次:SCRIPT_TEXT.md 没登记、没进目录树)。 验证:npm test = 25 文件 / 291 用例全绿(含扩到"幕 + 续段 + 表演提示"的往返幂等样本); typecheck 干净;软性文档审计七项全过。 | 28 天前 |
|
| 28 天前 |
| feat(engine): 派生状态 derived + 修复"表达式语言不认中文状态路径"(更要紧) 【修复 · 表达式不认中文路径】写派生状态时撞出来的:var.单价 * var.数量 报"无法识别的字符 单"。 根因:tokenizer 的标识符规则是 ASCII-only(/[A-Za-z_$]/)。这是面向中文的引擎里很要紧的一条: var.金币 / flag.门开了 是最自然的写法,而它在运行时**静默按假处理**(求值失败不改流程,只记日志) → 玩家走进错误的分支,编剧只看到"分支没按预期走"。 修法:标识符加 Unicode 规则(\p{L} / \p{Nd}),**只放宽不收紧**(× 这类符号仍然拒)。 回归用剧情路径验: 真的成立; 仍然按假且不中断剧情。 【新增 · 派生状态】顶层字段 derived: { "var.总价": "var.单价 * var.数量" }。 - 加载时校验:路径前缀 / 表达式能解析 / 不自己依赖自己、彼此不成环 → 坏了当场报错。 - 任何状态变化后重算:靠 state.onChange 订阅(插件直接写的也会触发;不需在每个调用点手动调)。 - 派生项不能被直接写:剧本里写它当场报错(写了也会被覆盖,报错更诚实)。 - StateStore 补 isValidStatePath(),复用已有的 STATE_NAMESPACES。 我自己踩的三个坑(更详细的写在 HANDOFF §42): 1. validateStory 是显式重建对象的 → 新增顶层字段必须同时加进它,否则被静默吃掉; 2. set 节点是"一个节点一个键",set:{...} 是选项上的字段 —— 我混了,还写出过一条"碰巧通过"的测试 (碰巧通过比失败更危险); 3. engine.evaluate 私有且求值失败不抛异常 → 测试该走剧情路径。 验证:npm test = 29 文件 / 397 用例全绿(数字抄自运行输出);typecheck 干净;构建通过。 | 27 天前 |
| fix(runtime): restart 之后插件没重新加载 —— 一次加载失败会卸掉所有在跑的插件 现象(使用者视角):一份剧情同时用了两个插件(A/B 变体 + 跨周目记忆),点"重新开始"之后 一个的效果还在、另一个全没了 —— 看起来像"两个插件打架",其实是引擎的问题。 根因:loadStory() 把"这次**成功加载**的插件 id"当成"剧情声明的插件" ⇒ 一次 manifest 加载失败 (网络抖动 / 路径解析出问题)⇒ loaded 为空 ⇒ disposeUnusedPlugins([]) 把**正在跑的插件全卸掉**, 于是重开之后一个插件都没在跑。日志里只有一串"已卸载"、**没有"已加载"**,很难看出来。 修法:**fail-safe** —— 只有"这次确实解析出了声明"时才敢卸载别的插件; 一份都没加载成功而剧情又声明了插件时,保留现有插件、不执行卸载,并打一条明确的错误日志。 回归:tests/stories-shapes.test.ts「两个插件同用一份剧情,restart 之后**两个** start() 的效果都在」 (先完整玩一局 → restart → memory 的每次开局初始化与 variant 的分流都必须在)。 CHANGELOG 里那条"已知问题"随之删掉;顺带把 returns.json 固定成带 variant 的形态(正好是回归夹具)。 验证:npm test = 29 文件 / 440 用例全绿;typecheck 干净;构建通过。 | 26 天前 |