rne:基于 TypeScript 与原生 DOM 的浏览器插件化剧情引擎项目

可用于在浏览器中加载 JSON 剧情并扩展互动玩法。基于 TypeScript 与原生 DOM,支持 JS 和 Rust WASM 插件注册指令、条件、UI 与事件,并提供权限校验、错误隔离、安全表达式、存档读档。【此简介由AI生成】

分支4Tags7

插件化剧情引擎(Plugin Story Engine)

一个运行在浏览器中的插件化剧情运行时。引擎核心不认识任何具体剧情,只负责解析并执行剧情文件; 所有玩法能力(指令、条件、效果、UI 组件、自定义节点、事件监听)都由插件动态注册。

  • 技术栈:Vite + TypeScript + 原生 DOM(不依赖 React),包管理用 npm。
  • 插件形态:JS 插件(ES Module)与 WASM 插件(仅 Rust,wasm32-unknown-unknown,不使用 wasm-bindgen,直接走 C ABI)。
  • 剧情格式:JSON(public/stories/*.json),支持热替换。
  • 状态存储:内存 + localStorage(存档 / 读档)。
  • 测试:Vitest(440 个用例 = 417 个功能用例 + 23 个文档一致性检查器;覆盖表达式、剧情执行、插件管理、WASM ABI、世界循环、实时帧循环、输入仲裁、资产管线、权限作用域、存档确定性、剧本编辑器,以及用无头套件在 node 里跑通的整条剧情 + 插件链路)。

本项目完全不使用 Python / Pyodide,也不存在任何 .py 文件或 Python 构建步骤; WASM 插件只提供 Rust 实现,不提供 AssemblyScript 版本。

声明:本仓库内附的剧情(public/stories/)与插件(public/plugins/,如 hello-js、hello-wasm)均为示例/演示用途,不是正式功能,请勿直接用于生产业务。曾内附的压测资产(entropy-lab/entropy-core 等)已移出仓库,完整原始内容归档于 archive/original 分支。


目录


功能特性

能力 说明
自定义剧情解析与执行 JSON 剧情,内置 say / choice / jump / set / if / wait / emit / plugin / ui / observe / end 十一种节点
世界模拟器 WorldLoop 以固定节拍广播 world:tick;剧情写 observe 节点即可被动听世界说话,persistent: true 时世界持续运转、剧情永不结束
动态加载 JS 插件 运行时 import() 入口模块,支持 default 导出 / 具名钩子 / createPlugin() 工厂
动态加载 WASM 插件 Rust 编译产物,WebAssembly.instantiate 直接实例化,UTF-8 JSON 跨边界通信
插件注册新能力 命令 / 条件 / 效果 / 自定义节点类型 / UI 组件 / 事件监听
剧情引用插件能力 剧情 plugin 节点调用插件命令,ui 节点挂载插件组件,type: "插件自定义名" 走插件节点处理器
引擎与剧情解耦 换 demo.json 即换内容,无需改动引擎代码
错误隔离 单个插件崩溃 / 超时 / 连续失败熔断,都不会拖垮主引擎与其它插件
权限模型 manifest 声明权限,宿主对每一次宿主能力调用做校验,插件拿不到引擎内部对象
安全表达式 DSL 手写词法 + 语法分析(递归下降),绝不使用 eval / new Function
存档 / 读档 localStorage 多槽位存档 + 节点级自动存档
事件总线 on / once / off / emit,监听器异常被逐个隔离并统计
调试面板 状态栏、插件列表、事件日志(日志 scope 带插件 id)一站式可见
实时帧循环 ctx.frame:固定步长逻辑(onStep,帧率无关)+ 渲染帧(onFrame + alpha 插值);没有订阅就不烧帧
跨窗口/跨设备链接 src/link/:协议 + 可注入传输(BroadcastChannel / 回环)+ UI 装饰器 —— 让另一块屏、另一台设备或一个脚本替玩家做选择 / 送意图进剧情 ✓。只提供能力,不提供页面 ✗(见 docs/FRONTEND.md 与 ARCHITECTURE.md 的边界声明)
状态来源追踪 runtime.stateProvenance:谁写的、在哪一步(explain('var.金币') → "= 9 ← 插件 com.a(set,第 3 次写入)")✓。与"写入热点"(统计次数)不同 —— 这是因果 ✓。每路径只留最后一条 + 有上限的流水 ✓
诊断包 runtime.exportDiagnostics():一只手里拿得出来的排查包 ✓(版本 + 输入日志 + 指纹 + 时间输入 + 审计 + 开销 + 来源 + 漏网 ✓,全部截断 ✓)。玩家说卡住了 → 让他导出 → 你就能远程复现 ✓
后台限流 story:waitOverrun:浏览器限流后台标签页的定时器会让 wait 静默变长 ✓ → 引擎只报事实(要了多久/实际多久 ✓),怎么处理由剧情决定 ✓
多标签页 / 多设备存档 存档带 origin(写入者 ✓)+ compareSaveEnvelopes()(same / 左新 / 右新 / diverge)✓ —— 引擎给事实与工具,合并策略归产品 ✓
漏网审计 runtime.leakReport():切剧情之后还挂着几个插件定时器、属于谁 ✓("玩久了越来越卡"那类 ✓)
每插件开销归因 runtime.pluginCosts() + manifest 的 budgetMs(可选):谁在花时间、花了多少、有没有超预算 ✓。默认只量不罚 ✓;声明了才执行,且复用已有熔断 ✓。滚动窗口 + 最小样本(一次倒霉调用不判死插件 ✓)
表达式函数注册 ctx.expressions.register(name, fn, { arity })(权限 expressions:register ✓)→ 剧情里直接 金币够(var.gold, 3) ✓。不许遮蔽内置 ✗、名字先到先得 ✓、声明了 arity 就查 ✓;宿主也能往同一份注册表里加 ✓
运行期权限审计 AuditLog:每次过权限门的尝试都留一笔(拒绝也留 ✓),环形有上限 + 按「插件+权限」聚合计数 ✓ —— 与静态的"插件能力清单"(⑨ 那一行)配套:静态是许诺,动态是证据
插件契约总线 manifest 声明 contracts.provides/requires + ctx.contracts.provide/call/has/list(权限 contracts:provide / contracts:call ✓)。缺依赖 → 启动时不启动该插件并标 error ✗(不再"运行时没反应");名字冲突报错 ✓;卸载自动撤销 ✓;audit() 能查"声明了没实现 / 实现了没声明 / 依赖没人提供" ✓
跨宿主一致性 + 回放 CLI runStoryHeadless() + npm run replay —— 把一局跑成运行指纹(稳定序列化 ✓、输入日志先投影掉 at 时间戳 ✗、截断也会进指纹 ✓)→ 在另一台宿主跑同一条命令比指纹(--expect 不一致就退出码 1 ✓)。顺手验了库产物能被 node 直接用 ✓。⚠ 只跑不带插件的剧情 ✗
派生状态 derived:{ "var.总价": "var.单价 * var.数量" } —— 加载时校验(含成环 ✗)、任何状态变化后重算(含插件写的 ✓)、派生项不能被直接写 ✗(当场报错 ✓)
表达式语言认中文 var.单价 / flag.门开了 能写、能求值 ✓(标识符规则允许 Unicode 字母;只放宽不收紧 ✓)
剧情穷举压测 结构上所有路线走一遍 → 路线数 / 每个结局各多少条 / 最长最短 / 走不通的死路 / 是否撞上限(撞了如实标注 ✓)。纯结构:不判条件 ✗,看不到副作用与插件/世界行为(注释与 CHANGELOG 都写清了 ✓)
无效选择检测 结构体检里点名"读者选哪个都一样"的选项组(无条件 + 跳向同一场;带条件的不参与比对 ✓,只报 warn ✓)—— 把"我写了 8 个选择"变成"我有几个真选择"
叙事插件(情绪三件) debrief:结束时列出「你没走的路」(只在去过的场里、只报无条件选项 —— 不剧透)与「因为你」(把选择连成路线),同时写进状态;memory:把上局结果写进跨局存储,下局开局读回来 → NPC 跨周目记得你(var.runCount / flag.returning / flag.lastWasBad)。两者都不需要引擎改任何东西之外的支持(memory 曾撞出 restart() 不动插件的引擎缺陷,已修)
世界事件插话(含玩家犹豫时) observe 被动跳转不只在等 say/wait 时生效 —— 玩家停在选择上犹豫时,世界事件照样能打断它(引擎用 CHOICE_CANCELLED 结算在途选择,"这次选择不作数")
输入所有权 ctx.input:插件认领键盘/指针,引擎独占一层捕获监听并仲裁(优先级 + 同优先级后认领者优先 + 作用域),认领成功的事件被 preventDefault + stopPropagation —— 插件不必各自抢 window
性能看门狗 帧循环实测每帧耗时(逻辑步 + 渲染回调)→ 档位(high/medium/low)与策略建议(世界节拍倍率、渲染上限);带防抖(连续超预算才降、连续富余才升,避免来回跳);档位变化发 engine:quality 事件(插件/宿主据此降载)。引擎只自动采纳自己那个旋钮(世界节拍),渲染上限交给宿主决定。?perf=1 会挂一个只读浮层把 fps / 帧耗时 / 档位显示出来
存档文件(导出/导入) 浏览器会清掉本地存储(Safari ITP、无痕、清数据),客户端拦不住 —— 所以让玩家自己保管一份:runtime.exportSave() 下载一个带信封的存档文件,importSave() 恢复(走同一个迁移器,老版本文件也能读;拿错文件/别的剧情会明确报错,不会"导入成功但进度乱了")
存档迁移 老存档不再"warn 一句就照读":runtime.registerSaveMigration(from, fn) 逐级迁移(引擎内置它自己格式的迁移,例如 v1→v2 补上随机源与插件持久数据);比引擎新的存档直接拒绝(不试读,避免静默改坏玩家进度);缺哪一级、哪一级失败都会明确报出来
音频 ctx.assets.play():分组(BGM / 音效 / 人声各自的音量与静音)、淡入淡出、并发上限 + 优先级(满了踢最弱的)、切后台停声;音量是"这一路 × 分组 × 全局"三层相乘,改哪层都立刻生效。改分组需要 audio:mix 权限(最小权限)
触屏(不做手柄) 屏幕控件把手指动作翻译成与键盘相同的键码 → 插件零改动即支持触屏;pointerId 支持多点触控;pointercancel 必回同一个认领;失焦/切后台释放全部按下状态
受管画布 ctx.view:DPR / 容器尺寸 / rAF / 卸载收摊全由引擎负责,插件只管画(消灭幽灵 rAF)
资产与音频 ctx.assets:图片 / 音频 / JSON 按 kind+url 去重、引用计数(归零才卸载)、进度回调、全局音量与静音;loader 可插拔
确定性随机 ctx.random:种子化(同种子必然复现)+ fork(label) 独立流;种子与内部状态随存档走
插件定时器 ctx.schedule:秒级定时器,剧情重载 / 插件卸载自动失效(消灭幽灵定时器)
意图通道 ctx.intents:自带背压 —— 只在剧情空闲且确有 observe 在听 intent:<名字> 时受理,否则返回 false
状态层 ctx.state 的 batch / patch / watch / update(安全读-改-写)+ ctx.storage(插件私有、跨剧情持久的存储)
插件持久数据 PluginDriver.snapshot() / restore() 随存档一起存还(SaveData.pluginState,存档版本 2)
宿主能力探测 ctx.capabilities:wasm / simd / sharedArrayBuffer / crossOriginIsolated / threads —— 把静默降级变成可查询,重计算插件据此显式降档
可观测性 事件轨迹 + 状态写入热点 + observe 决策轨迹(逐条说明为何没跳);runtime.debugReport()、?trace=1、replayLog()(种子 + 输入序列 → 可重放)
权限粒度 18 项权限,支持作用域限定写法(如 state:write:var.arena 只许写那一支)
插件治理 manifest 的 requires / order / scope:依赖优先加载、缺依赖明确拒绝、app 级插件跨剧情常驻
剧情表达力 全量引用校验(加载即报错并指出字段路径)、say.delayMs、observe 的 once / cooldownMs / match(按 payload 分流)、限时选择、可跳过的等待
无头测试套件 tests/kit:整套「剧情 + 插件(含 WASM)」在 node 里跑完(假 UI + 驱动 DSL),不需要浏览器
一行挂载(可选 UI) mount('#app', { story }) 就把引擎挂成一个能玩的界面(自带存档 / 读档 / 导出存档 / 导入存档 / 亮暗主题 / 移动端屏幕控件 / 打字机)。它是可选入口:Runtime 不认识它,不用的人零影响;官方默认 UI 有意不提供扩展点(要定制请自己实现 RuntimeUi)
能被别人用(库 + 嵌入) ① 库形态 npm run build:lib → dist-lib/rne.js + 类型声明,import { Runtime } from 'rne' 直接用(已在 node 里用构建产物跑通剧情);② 嵌入形态 iframe + postMessage 桥(embed.html 子页 / createEmbedHost 父页,protocol 有白名单与来源校验),父页不需要 import 引擎运行时
脚手架与门禁 npm run create-plugin 生成能跑的「插件 + 剧情 + 测试」四件套;npm run story:lint;.githooks/pre-push 把类型检查 + 全量测试焊在推送动作上
剧本编辑器(给编剧用) editor.html:编剧写「场 / 动作 / 对白 / 选择 / 条件 / 跳转 / 结局」,编辑器编译成剧情 JSON(产物一定过 validateStory)、一键试读(复用引擎本身)、导出可读剧本;自带结构体检(断头路 / 没人进的场 / 一个结局都没有 / 选项点了就莫名结束 / 条件写错)+ 撤销重做(Ctrl+Z)+ 全文搜索(Ctrl+F)+ Ctrl+S 存草稿 + 草稿自动落本地 + 一键落地(复制 index.json 条目 / 合并并下载 index.json)+ 三种模式:表单 / 文本 / JSON(能看能改引擎真正加载的那份剧情,语法错误定位到行列)
  • 往返保真(打开含插件的剧情、改一句、导出 —— 那些节点与它们多带的字段都原样保留并接回原位; 表单里只读标出,要精确编辑用 JSON 模式)
  • 状态项面板(本剧本用到哪些 var./flag./item./actor.,谁写谁读)+ 两种导出:可打印剧本 HTML(A4、人物名居中、对白缩进、动作缩进、每场可选另起一页 → Ctrl+P 存 PDF 给导演)与增强版 Markdown(进仓库 / diff)+ 从这场试读 |

目录结构

plugin-story-engine/
├── index.html                     # 应用页面结构(舞台 / 状态栏 / 插件列表 / 事件日志)
├── editor.html                    # 编剧用的剧本编辑器(第二个入口,复用同一个 Runtime)
├── embed.html                     # 嵌入用的子页(在别人的 iframe 里跑剧情)
├── embed-demo.html                # 嵌入演示的父页(只认协议,不 import 引擎运行时)
├── showcase.html                  # ★ 展示页:故事库 + 内嵌演示 + 发布三步(一分钟看懂这个项目)
├── package.json                   # 脚本与依赖(无运行时依赖,只有开发依赖)
├── tsconfig.json                  # 严格模式 TypeScript 配置
├── vite.config.ts                 # Vite + Vitest 配置(两个页面入口)
├── vite.lib.config.ts             # 库形态构建(npm run build:lib → dist-lib/rne.js)
├── dist-lib/                      # ★ 库形态产物(**入库**:git 安装无需构建)+ types/
├── tsconfig.lib.json              # 库形态的类型声明(tsc -p)
├── README.md                      # 本文件
├── src/
│   ├── host/                      # **示例宿主**的宿主侧实现(不是引擎:观感属于宿主)
│   │   ├── typewriter.ts          # 打字机的纯逻辑(只算「这一帧显示哪几个字」,不定时 → 可单测)
│   │   ├── debrief.ts             # 复盘排版:把插件算出的「没走的路 / 因为你」排成几行(纯函数)
│   │   ├── plugin-catalog.ts      # 插件能力清单:从 manifest 排成"能干什么/要哪些权限"的表(纯函数)
│   │   ├── headless-ui.ts         # 无头 UI(正式发布的一份:自动继续 + 按记录作答 + 文字稿)
│   │   └── headless.ts            # 无头跑一局:给剧情 + 记录 → 节点序列 / 文字稿 / 运行指纹
│   ├── link/                      # 跨窗口 / 跨设备链接(**引擎侧**:协议 + 可注入传输 + UI 装饰器,不含页面 ✓)
│   │   ├── protocol.ts            # 消息形状与校验(不认识就丢 ✗)
│   │   ├── transport.ts           # 回环传输(测试)/ BroadcastChannel 传输(浏览器)
│   │   ├── view.ts                # 视图端:装饰 UI —— 往外播状态、收回 pick/intent
│   │   └── controller.ts          # 控制器端:不碰 DOM 的一侧(所以可测)
│   ├── showcase/                  # 展示页(故事库 / 内嵌演示 / 发布三步)
│   ├── main.ts                    # 应用入口:装配 Runtime + Renderer,加载剧情清单
│   ├── index.ts                   # ★ 公开入口(库形态的契约面:这里列的就是 API)
│   ├── mount.ts                   # ★ 一行挂载(可选入口:Runtime + 官方默认 UI 拼成能玩的界面)
│   ├── embed/                     # 嵌入(iframe + postMessage)
│   │   ├── protocol.ts            # 协议:消息形状 / 白名单 / 校验(纯函数,可测)
│   │   ├── child.ts               # 子页侧:把 Runtime 包成 postMessage 服务
│   │   ├── parent.ts              # 父页侧:createEmbedHost(请求-答复配对 / 超时 / 事件)
│   │   ├── browser.ts             # 薄适配层:window / iframe ↔ EmbedSurface
│   │   ├── main.ts                # embed.html 的入口(子页)
│   │   └── demo.ts                # embed-demo.html 的入口(父页演示)
│   ├── editor/                    # 剧本编辑器(宿主应用,不含任何引擎核心改动)
│   │   ├── main.ts                # 三栏界面:大纲 / 本场内容 / 体检 + 试读
│   │   ├── script-model.ts        # 编剧模型:场 / 对白 / 选择 / 条件分支 / 跳转 / 结局
│   │   ├── compile.ts             # 编剧模型 → 剧情 JSON(产物过引擎校验)
│   │   ├── import.ts              # 剧情 JSON → 编剧模型(有损,且把损失列在明面上)
│   │   ├── health.ts              # 结构体检:断头路 / 不可达场 / 无结局 / 条件写错 / 统计
│   │   ├── export-script.ts       # 导出可读剧本(Markdown)
│   │   ├── reader-ui.ts           # 干净阅读模式(给编剧试读,不带状态栏与日志)
│   │   ├── history.ts             # 撤销 / 重做(结构性改动压快照;打字交给浏览器原生)
│   │   ├── search.ts              # 全文搜索(对白 / 说话人 / 选项 / 结局名)
│   │   ├── text-format.ts         # ★ 文本剧本格式:parse / stringify(文本优先写作的底座)
│   │   ├── check-story-json.ts    # JSON 视图的检查(语法错误定位到行列;其余交给引擎自己的校验器)
│   │   ├── publish.ts             # 一键落地:index.json 条目 / 合并 / id 与文件名约定
│   │   └── styles.css             # 编辑器的样式(原生 CSS)
│   ├── runtime/
│   │   ├── fingerprint.ts           # 运行指纹:稳定序列化(键排序)+ 输入日志投影(去掉 at)+ FNV-1a 64
│   │   ├── Runtime.ts             # 运行时门面:装配子系统、加载剧情、存档读档
│   │   ├── StoryEngine.ts         # 剧情引擎:解析校验、节点执行、效果落地
│   │   ├── StateStore.ts          # 状态仓库:var / flag / item / actor 四个命名空间
│   │   ├── EventBus.ts            # 事件总线:带 owner 的发布订阅 + 异常隔离
│   │   ├── Expression.ts          # 条件表达式 DSL(安全解析器,无 eval)
│   │   ├── Registry.ts            # 带归属(owner)的注册表,支持按插件回收
│   │   ├── WorldLoop.ts           # 世界循环:每 tick 广播 world:tick,驱动世界模拟器(不依赖任何业务语义)
│   │   ├── FrameLoop.ts           # 帧循环:固定步长逻辑 + 渲染帧(实时玩法的节拍)
│   │   ├── InputRouter.ts         # 输入路由:认领式输入所有权(引擎独占捕获层监听并仲裁)
│   │   ├── TouchControls.ts       # 触屏屏幕控件(方向键 + 动作键,输出与键盘相同的键码)
│   │   ├── ViewCanvas.ts          # 受管画布:DPR / 尺寸 / rAF / 卸载收摊
│   │   ├── AssetStore.ts          # 资产仓库:图片/音频/JSON 去重 + 引用计数 + 全局音量
│   │   ├── Capabilities.ts        # 宿主能力探测(wasm / simd / SAB / 隔离)
│   │   ├── PerfWatchdog.ts        # 性能看门狗:帧预算 → 档位(带防抖)+ 策略建议
│   │   ├── PerfOverlay.ts         # 性能面板:?perf=1 的只读浮层(fps / 帧耗时 / 档位)
│   │   ├── SaveMigrations.ts     # 存档版本迁移(逐级迁移器 + 内置 1→2;新旧版本的边界处理)
│   │   ├── SaveFile.ts            # 存档文件(导出/导入):信封 + 校验 + 文件名建议
│   │   ├── RandomSource.ts        # 确定性随机源(同种子可复现)
│   │   └── PluginTimers.ts        # 插件定时器(剧情重载自动失效)
│   ├── plugins/
│   │   ├── ├── ContractRegistry.ts      # 插件之间的契约总线(provide/call/audit)
├── ExpressionFunctions.ts    # 表达式函数注册表(⑤:不许遮蔽内置 / 先到先得 / arity 检查)
├── ├── PluginCostTracker.ts    # 每插件开销归因与配额(滚动窗口 + 最小样本;默认只量不罚)
├── ├── ├── StateProvenance.ts       # 状态来源追踪(②:谁写的 / 哪一步;每路径留最后一条 + 有上限流水)
├── ├── ├── ├── Diagnostics.ts         # 诊断包(①:版本/输入/指纹/时间输入/审计/开销/来源/漏网,一律截断)
│   │   ├── ├── ├── AuditLog.ts              # 运行期权限审计(环形流水 + 聚合计数;拒绝也记)
│   │   ├── PluginManager.ts       # 插件管理器:加载 / 生命周期 / 权限 / 超时 / 熔断
│   │   ├── JsPluginLoader.ts      # JS 插件加载器(动态 import)
│   │   ├── WasmPluginLoader.ts    # WASM 插件加载器(fetch + instantiate + host imports)
│   │   ├── PluginContext.ts       # 插件上下文工厂(受权限校验的宿主能力 API)
│   │   └── types.ts               # 插件系统核心契约(manifest / ctx / ABI / 驱动接口)
│   ├── ui/
│   │   ├── Renderer.ts            # UI 渲染层:对白 / 选项 / 状态栏 / 插件组件挂载
│   │   └── styles.css             # 样式
│   └── utils/
│       ├── json.ts                # JSON 解析、UTF-8 编解码、深拷贝等
│       ├── errors.ts              # 统一错误体系(带 code / pluginId)
│       ├── async.ts               # withTimeout / sleep
│       └── logger.ts              # 双通道日志器(console + UI 面板)
├── public/
│   ├── stories/
│   │   ├── index.json             # 剧情清单(页面下拉框数据源)
│   │   ├── world.json             # 世界模拟器演示:WASM 模拟物种、JS 弹警示、JSON observe 转向
│   │   ├── demo.json              # 演示剧情:对话 / 选择 / JS 命令 / WASM 命令 / 自定义节点 / 结束
│   │   ├── minimal.json           # 最小剧情:只用内置节点,验证引擎可脱离插件运行
│   │   └── reload.json            # 剧情重载回归用例
│   └── plugins/
│       ├── hello-js/
│       │   ├── manifest.json      # JS 插件清单
│       │   └── index.js           # JS 插件入口(命令 greet / 节点 note / 条件 / 效果 / UI 组件)
│       └── hello-wasm/
│           ├── manifest.json      # WASM 插件清单
│           └── plugin.wasm        # Rust 构建产物(由 npm run build:wasm 生成)
├── wasm-src/
│   └── hello-wasm/
│       ├── Cargo.toml             # crate-type = ["cdylib"],仅依赖 serde_json
│       └── src/lib.rs             # Rust WASM 插件实现(ABI 导出 + 宿主 imports 调用)
├── scripts/
│   ├── create-plugin.mjs          # 插件脚手架:生成能跑的「插件 + 剧情 + 测试」四件套
│   ├── fix-lib-types.mjs          # 修正库形态类型声明里的相对导入(补 .js 扩展名)
│   └── build-wasm.mjs             # WASM 构建脚本(Node.js,无 Python)
├── tests/
│   ├── expression.test.ts         # 表达式 DSL 测试
│   ├── story.test.ts              # 剧情执行测试(含 runToken 重载语义)
│   ├── plugin-manager.test.ts     # 插件管理器测试(含依赖 / 顺序 / 生命周期归属)
│   ├── wasm-abi.test.ts           # WASM ABI 集成测试
│   ├── world-loop.test.ts         # 世界循环、observe、节拍控制(暂停/倍速/单步)
│   ├── capabilities.test.ts       # 宿主能力探测(wasm / simd / SAB / 跨域隔离)
│   ├── frame-loop.test.ts         # 帧循环:固定步长逻辑 + 渲染帧 + 插值 + 子步截断
│   ├── input-router.test.ts       # 输入认领与仲裁(优先级 / 后认领者优先 / 作用域)
│   ├── plugin-timers.test.ts      # 插件定时器与 ctx.schedule 的生命周期回收
│   ├── state-layer.test.ts        # 状态层:批处理 / 按路径订阅 / 私有存储 / 安全读改写
│   ├── determinism.test.ts        # 确定性随机 + 存档往返(插件内存与随机游标一起回滚)
│   ├── intents.test.ts            # 意图通道(两道闸:剧情空闲 + 确有人在听)
│   ├── permission-scope.test.ts   # 权限:作用域限定写法 + story:read
│   ├── runtime-semantics.test.ts  # Runtime 的 Promise 语义 / observe 的 match / 回放素材 / 插件生命周期
│   ├── observability.test.ts      # 事件轨迹 / 写入热点 / observe 决策轨迹
│   ├── view-and-abi.test.ts       # 受管画布 + WASM ABI 版本协商
│   ├── assets.test.ts             # 资产仓库:去重 / 引用计数 / 进度 / 全局音量
│   ├── stories-valid.test.ts      # 内置剧情 lint(= npm run story:lint)
│   ├── scaffold.test.ts           # 插件脚手架:生成物真的能跑(临时目录里生成并运行)
│   ├── editor.test.ts             # 剧本编辑器:编译 / 有损导入 / 体检 / 导出 / 端到端
│   ├── stories-shapes.test.ts     # ★ 三种形态各跑一遍(文学 / 涌现 / 实时)—— 验的是引擎抽象
│   ├── mount.test.ts              # 一行挂载:模板与 Renderer 的 id 契约(少一个 id 就会在运行时炸)
│   ├── host-typewriter.test.ts
│   ├── link.test.ts
│   ├── determinism.test.ts        # 确定性:运行指纹(稳定序列化 / 投影易变字段)+ 无头回放(含"截断如实标注")               # 跨窗口链接(协议校验 / 装饰器 / 回环传输)    # 示例宿主打字机:逐帧 / 长文本加速 / 跳过 / reduced-motion
│   ├── editor-text.test.ts        # 文本剧本格式:解析 / 诊断行号 / **往返幂等契约** / 端到端
│   ├── embed.test.ts              # 嵌入:协议与白名单 / 请求-答复配对 / 父页驱动子页里的真 Runtime
│   ├── lib.test.ts                # 库形态:公开入口契约面 + 只用公开 API 跑完一段剧情
│   ├── docs-consistency.test.ts   # ★ 文档一致性检查器(权限表 / 节点 / 事件 / 路径 / 用例数 / 子系统落点)
│   ├── setup.ts                 # 全局兜底:每个用例后还原假时钟 / 桩 / 被替换的全局(不靠「每个文件记得还原」)
│   ├── kit.test.ts                # 无头套件自测:三条自带剧情在 node 里跑通(含 WASM 插件)
│   ├── kit/index.ts               # @rne/testing 无头测试套件(假 UI + file:// 加载 + 驱动 DSL)
│   └── e2e/
│       ├── reload.spec.ts         # 剧情重载端到端(Playwright,需 Node ≥ 20)
│       ├── world.spec.ts          # 世界模拟器端到端
│       └── entropy.spec.ts        # 已停用(依赖移出的压测资产,testIgnore)
└── docs/
    ├── README.md                  # 文档索引
    ├── ARCHITECTURE.md            # 架构、数据流、生命周期、错误隔离、权限模型
    ├── PLUGIN_JS.md               # JS 插件开发指南
    ├── PLUGIN_WASM.md             # Rust WASM 插件开发指南
    ├── STORY_FORMAT.md            # 剧情 JSON 格式与表达式 DSL
    ├── SCRIPT_TEXT.md             # ★ 给编剧的文本剧本语法(完整参考)
    ├── ABI.md                     # WASM ABI 规范
    ├── PERMISSIONS.md             # 权限列表与校验规则
    ├── EVENTS.md                  # 事件契约(事件名与负载)
    ├── FRONTEND.md                # 前端集成指南(把引擎当地库嵌入自己的 UI)
    ├── CROSS_ORIGIN.md            # 跨域隔离与部署(COOP/COEP 的代价、生产头配置、iframe 嵌入)
    ├── TESTING.md                 # 测试指南(单元 + 端到端)
    ├── BUILD.md                   # 环境要求与构建步骤
    ├── FAQ.md                     # 常见问题
    └── CHANGELOG.md               # 版本记录

快速开始

环境要求

  • Node.js ≥ 18(开发时使用 Node 22 验证)
  • npm ≥ 9
  • 仅在构建 WASM 插件时需要:Rust(stable)+ wasm32-unknown-unknown 目标
# 检查环境
node -v && npm -v

1. 安装依赖

npm install

2. 启动开发服务器

npm run dev

浏览器打开终端输出的地址(默认 http://localhost:5173/,端口被占用时 Vite 会自动顺延,例如 5174)。 页面会自动加载 public/stories/index.json 中的第一个剧情(当前为 world.json)并开始播放。

3. 生产构建

npm run build      # tsc --noEmit 类型检查 + vite build,产物在 dist/
npm run preview    # 本地预览产物(http://localhost:4173/)

4. 类型检查与测试

npm run typecheck  # tsc --noEmit
npm test           # vitest run,440 个用例(417 功能 + 23 检查器)

5. 构建 Rust WASM 插件(可选,仓库已内置构建产物)

rustup target add wasm32-unknown-unknown   # 只需执行一次
npm run build:wasm                          # cargo build --release 并复制到 public/plugins/hello-wasm/plugin.wasm
npm run build:all                           # build:wasm + build(一条命令全建)
npm run build:lib                           # 库形态:dist-lib/rne.js(ESM)+ dist-lib/types/**(声明)
npm run story:lint                          # 校验内置剧情(引用 / 插件存在性 / index.json 一致性)
npm run create-plugin -- com.example.x      # 生成能跑的「插件 + 剧情 + 测试」四件套
npm run hooks:install                       # 把 core.hooksPath 指到 .githooks(npm install 会自动做)

给编剧:剧本编辑器

npm run dev        # 然后开 http://localhost:5173/editor.html

编剧不需要看 JSON。编辑器把引擎概念翻译成编剧的词汇:

编剧写的 引擎里是
场(scene) 一组节点;场与场按文档顺序串成主线
对白 / 旁白 say(带 / 不带 speaker)
选择 choice(选项可带条件)
条件分支 if
跳转 jump
结局 end

文本模式(推荐):中间栏切到「文本」,直接像写剧本一样写;编辑器实时解析, 解析不通过只报错(带行号)、不动你写的内容(所以写坏了也不会丢东西)。 完整语法参考见 docs/SCRIPT_TEXT.md(编辑器界面里是同一份):

# 锈蚀带
作者:编剧甲

# 第一幕                            ← 之后的一级标题都是「幕」

## 门厅(内景 · 锈蚀的门厅 · 夜)    ← 括号里是打印用的场标题行,可省
铁门被推开,灰尘落下来。              ← 不带「:」的普通行 = 动作 / 舞台指示
铁颚(低声):进去,或者滚。          ← 「名字(表演提示):台词」,提示可省
  我说完了。                          ← 缩进 = 续段(同一说话人连续多段)
旁白:风从缝里灌进来。                ← 「旁白:」就是旁白
设置:item.gold = num(item.gold) + 10 ← 写入状态(右边是表达式)
选择:怎么办?
- 拿钥匙 -> 里间
- 空手进去(如果 flag.brave == true)-> 里间
- 就此离开                          ← 省略 `->` 就是"选它剧本结束"
如果 item.gold >= 10 -> 富人区        ← 条件分支(条件就是引擎表达式)
否则 -> 贫民窟
跳到 门厅
结局:好结局
你活着出来了。

// 整行注释

文本是真源,模型是它的产物 —— 所以有一条硬契约被测试钉住:parse(stringify(模型)) 必须 深等于模型(tests/editor-text.test.ts)。否则"表单改了文本没变、或反过来"迟早会发生。

几个动作就够了:导出剧情 JSON(丢进 public/stories/ 就能跑,产物一定过引擎校验; 发布区还会给出该贴进 index.json 的那一条)、导出剧本 .html(打印)(拿给导演/演员看的那一份 —— 浏览器 Ctrl+P 存 PDF)、导出剧本 .md(进仓库、能 diff)、▶ 试读(含"从这场开始")、 以及顶栏那行结构体检(断头路 / 没人进的场 / 一个结局都没有 / 条件写错……)。草稿每次改动自动存浏览器本地。

排式说明:Markdown 印不出剧本的样子(它没有居中与缩进的概念),所以"交付稿"是那份 HTML: A4、印刷体字号、人物名居中、对白缩进、动作/舞台指示缩进加括号、转场右对齐。 页码没有用 CSS 硬做(Chromium 不支持 @page 页码盒),改为每场标注「场 N/M」, 页码可用浏览器打印对话框自带的页眉页脚。

两条有意为之的边界:导入别人的剧情是"有损"的 —— 遇到 plugin / ui / observe 这类 工程节点,编辑器会明确挂横幅告诉你「这些它管不了」,而不是假装能往返; 编剧模型故意表达不了工程能力 —— 这条线要是不划清,编剧一碰就掉进技术细节里。

三份"不同形态"的剧情(引擎抽象被三种用法验证过)

public/stories/ 里除了压测用的 demo / world / minimal,还有三份风格完全不同的真剧情 —— 它们的作用不是好看,而是证明同一套内核撑得住三种用法:

剧情 形态 用到的能力
letters.json 「未寄出的信」 文学向 纯内置节点:say / 条件选项 / set / if / wait(可跳过)/ 四结局;文本插值 {{ var.readCount }}
tidepool.json 「潮池」 涌现向 插件在 world:tick 上演化生态 → 发布 pool:bloom / pool:collapse;剧情不主动推进,靠 observe 被动接住
variants.json 「两种开场」 A/B 变体 同一场两个版本按玩家分流(一个玩家只分一次、可复现)—— 用来测"哪一版读者走得更远"
returns.json 「归途」 叙事插件 debrief(结束时告诉你「你没走的路」与「因为你」)+ memory(NPC 跨周目记得你)—— 两个插件都不需要引擎改任何东西
orbit.json 「轨道」 实时向 ctx.frame 固定步长物理 + ctx.input 认领左右键 + ctx.view 受管画布;玩家侧只有两个键

三条都在 node 里被确定性地跑通(tests/stories-shapes.test.ts)—— 其中"实时向"这条路 在写这份剧情之前从来没被真剧情用过。

一行挂载(想要界面就直接用)

import { mount } from 'plugin-story-engine'

const app = mount('#app', { story: '/stories/demo.json' })  // 挂上就能玩
// 自带:存档 / 读档 / 导出存档 / 导入存档 / 亮暗主题 / 移动端屏幕控件 / 打字机
// await app.destroy()
  • 它是可选入口:Runtime 的构造函数里没有它,不用它的人一行都不受影响。
  • 它是"默认值",不是"必经之路":要做产品级界面请自己实现 RuntimeUi(见 docs/FRONTEND.md)。 官方默认 UI 有意不提供扩展点 —— 想加东西就该自己实现,这样才能守住"引擎不含 UI"这条线。
  • 只要 UI 不要 Runtime:createRuntimeUi(container)。

当库用(import 进你自己的项目)

这个包不发公共 registry(npmjs),只走 git:package.json 里 private: true 是有意的 —— 它让 npm publish 从机制上直接失败,而不是靠文档里的约定。

如果哪天要发公共 registry,先确认三件事(按 npm 现行政策,政策会变,发布前再去核一遍): ① 发布后基本不可撤回 —— 72 小时内可 unpublish,之后只能作废版本(deprecate); ② 包名在全球先到先得,内容永久公开可查(含历史版本),所以别把密钥、内部地址放进去; ③ 账号需要开 2FA(账号被盗会连累包)。 现阶段发行方式是:git tag + npm pack 出来的 tarball,或直接 git 直装。 所以 dist-lib/(rne.js + 类型声明)随仓库一起提交 —— 克隆/安装下来就能直接用, 不需要构建、不需要 devDependencies、离线也行:

# ① 最直接:git 直装(克隆里已经带着构建好的库)
npm install git+https://gitcode.com/rincjiyu/rne.git#main

# ② 或者克隆下来手工引(不装任何依赖也能跑)
git clone https://gitcode.com/rincjiyu/rne.git
node -e "import('./rne/dist-lib/rne.js')"

# ③ 或者打成 tarball 离线分发
npm run prepack && npm pack      # → plugin-story-engine-1.2.0.tgz
import { Runtime, StateStore, EventBus, type RuntimeUi } from 'plugin-story-engine'

// 引擎不认任何具体界面:给它一个实现 RuntimeUi 的东西就行(你的是你自己的 UI)
const runtime = new Runtime({ ui: myUi })
await runtime.loadStory('/stories/demo.json', { autoStart: true })
runtime.state.get('var.gold')      // 读状态
runtime.events.on('story:say', …)  // 听事件

引擎的契约面只列在 src/index.ts 一个文件里(其它都是实现细节)—— 改动那个文件等于改 API 版本。

三件发布细节(都实测过,免得你再踩一遍): ① tsc 生成的 .d.ts 里相对导入不带扩展名,现代 moduleResolution: node16/nodenext 会报 TS2834 —— 所以 build:lib 末尾有一条 scripts/fix-lib-types.mjs 补上 .js; ② exports 里额外导出了 ./package.json(第三方常要读版本与包内资产); ③ dist-lib/ 是入库的产物,所以它可能与 src/ 不同步 —— pre-push 钩子会构建一遍, 有差异就拒绝推送(提示 npm run build:lib && git add dist-lib)。 改了引擎代码请顺手 npm run build:lib 再提交。

嵌进你的页面(iframe + postMessage)

父页不需要 import 引擎,只要认协议:

<iframe src="https://你的站点/embed.html"></iframe>
import { createEmbedHost, parentSurface } from 'plugin-story-engine'

const host = createEmbedHost({ surface: parentSurface(iframe, location.origin) })
await host.ready()                              // 等子页就绪
await host.load('/stories/demo.json')
await host.start()
host.on('story:say', ({ payload }) => console.log(payload))

协议(src/embed/protocol.ts)刻意做得很窄:

内容
命名空间 每条消息都带 channel: 'rne'(别人的页面里可能还有别的 postMessage 通信)
命令白名单 只有 load / start / restart / snapshot / save / loadSave —— 不是"任意方法都能远程调用"
请求-答复 父页发命令带 id,子页原样带回;父页有超时(默认 8s),宁可报错也不无限等
来源校验 父页只处理 event.source === 那个 iframe 的消息;子页记住父页 origin 之后只往它回(不全程用 '*')

演示页:npm run dev 之后开 /embed-demo.html(父页套 embed.html,能看事件日志)。

如何加载剧情

剧情文件放在 public/stories/ 下,浏览器直接通过静态资源路径访问。

  1. 登记到清单:编辑 public/stories/index.json 追加一条:

    {
      "id": "mystory",
      "title": "我的剧情",
      "url": "/stories/mystory.json",
      "description": "剧情简介"
    }
    
  2. 页面顶部下拉框会自动出现新剧情,选中即加载(带 cacheBust,绕过缓存)。

  3. 剧情文件内用 plugins 数组声明它依赖哪些插件,引擎加载剧情时会自动按需加载这些插件, 并把当前剧情未声明的插件自动卸载。

最小剧情文件示例(public/stories/mystory.json):

{
  "id": "mystory",
  "title": "我的剧情",
  "start": "hello",
  "nodes": {
    "hello": { "type": "say", "speaker": "旁白", "text": "你好,世界。", "next": "end" },
    "end": { "type": "end" }
  }
}

热替换:修改 public/stories/*.json(或插件文件)后直接刷新页面即可生效, 无需重启 dev server。页面顶部的「重新开始」按钮会带时间戳重新拉取剧情文件,绕过浏览器缓存。


如何加载 JS 插件

  1. 新建目录 public/plugins/my-plugin/。

  2. 放入 manifest.json:

    {
      "id": "com.example.myplugin",
      "name": "My Plugin",
      "version": "1.0.0",
      "type": "js",
      "entry": "index.js",
      "apiVersion": 1,
      "permissions": ["state:read", "state:write", "commands:register", "events:listen"]
    }
    
  3. 放入 index.js(默认导出实现 init / start / stop / dispose 的对象):

    export default {
      init(ctx) {
        // 注册剧情指令:剧情中 { "type": "plugin", "command": "greet" } 会调用它
        ctx.commands.register('greet', (args, api) => {
          api.state.setFlag('greeted', true)              // 需要 state:write
          api.logger.info(`greet 被调用:name=${args.name}`) // 日志会自动带 plugin:<id> 前缀
          return {
            ok: true,
            message: `你好,${args.name}!`,
            effects: [{ type: 'addItem', item: 'torch', count: 1 }]
          }
        })
      },
      dispose() {
        // 释放自己持有的资源;引擎还会兜底回收注册的能力与事件监听
      }
    }
    
  4. 让剧情引用它:在剧情文件里加 "plugins": ["/plugins/my-plugin/manifest.json"]。 随后即可用 plugin 节点调用 greet。

加载失败只会让这一个插件进入 error 状态并在页面与日志中提示,不会影响引擎和其它插件。


如何加载 Rust WASM 插件

  1. 新建 Rust crate:wasm-src/my-wasm/,Cargo.toml 关键片段:

    [lib]
    crate-type = ["cdylib"]        # 必须,才会产出可被 WebAssembly.instantiate 加载的 .wasm
    
    [dependencies]
    serde_json = "1"               # 跨边界统一用 UTF-8 JSON 字符串通信
    
  2. src/lib.rs 用 #[no_mangle] pub extern "C" 导出 ABI 要求的函数: alloc / dealloc / plugin_init / plugin_handle / plugin_result_ptr / plugin_result_len, 并按 env.host_log / env.host_emit / env.host_get_state / env.host_set_state 调用宿主能力。 完整可运行代码见 wasm-src/hello-wasm/src/lib.rs 与 docs/PLUGIN_WASM.md。

  3. 构建并复制产物:

    cargo build --target wasm32-unknown-unknown --release
    # 产物:target/wasm32-unknown-unknown/release/<crate>.wasm
    # 复制到 public/plugins/my-wasm/plugin.wasm
    

    仓库内的 npm run build:wasm 已经把这两步自动化(见 scripts/build-wasm.mjs)。

  4. 放入 public/plugins/my-wasm/manifest.json:

    {
      "id": "com.example.mywasm",
      "name": "My WASM Plugin",
      "version": "1.0.0",
      "type": "wasm",
      "entry": "plugin.wasm",
      "apiVersion": 1,
      "permissions": ["state:read", "state:write", "commands:register", "events:emit"]
    }
    
  5. 让剧情声明该插件,即可用 plugin 节点调用它在 plugin_init 中声明的命令。

不要使用 AssemblyScript / C / C++ / Go / Zig。宿主 ABI 只保证与「Rust + wasm32-unknown-unknown + C ABI」的组合兼容。


如何新增插件

新增插件只需要放清单和入口文件,不需要改动引擎核心代码:

1. mkdir public/plugins/<你的插件目录>
2. 写入 manifest.json(id 必须全局唯一,apiVersion 必须为 1)
3. 写入入口文件(JS: index.js;WASM: plugin.wasm)
4. 在剧情文件的 plugins 数组里加上 "/plugins/<你的插件目录>/manifest.json"
5. 刷新页面
  • 插件注册的能力会带上 owner = pluginId,因此不会互相覆盖(重名会报 REGISTRY_CONFLICT)。
  • 插件卸载时,引擎自动回收它的全部能力与事件监听,并卸载它的 UI 组件。

如何编写自定义剧情

剧情是一个 JSON 对象,核心字段:

字段 类型 说明
id string 必填,剧情唯一 id
title string 可选,展示标题
start string 必填,起始节点 id
nodes object 必填,节点表(键为节点 id)
plugins string[] 可选,本剧情依赖的插件 manifest 路径

节点通用字段 type(必填)与 next(可选,下一个节点,null 表示结束)。 最常用的内置节点:

{
  "id": "demo",
  "title": "演示剧情",
  "start": "start",
  "plugins": ["/plugins/hello-js/manifest.json", "/plugins/hello-wasm/manifest.json"],
  "nodes": {
    "start": {
      "type": "set",
      "values": { "actor.hero.name": "旅行者", "var.gold": 5, "flag.hasKey": true },
      "next": "intro"
    },
    "intro": {
      "type": "say",
      "speaker": "旁白",
      "text": "你来到一个岔路口。背包里有 {{ var.gold }} 枚金币。",
      "next": "choice1"
    },
    "choice1": {
      "type": "choice",
      "options": [
        { "text": "去森林", "next": "forest" },
        { "text": "回村庄", "next": "village", "condition": "flag.hasKey" }
      ]
    },
    "forest": {
      "type": "plugin",
      "plugin": "com.example.hello",
      "command": "greet",
      "args": { "name": "{{ actor.hero.name }}" },
      "next": "end"
    },
    "village": { "type": "say", "speaker": "村民", "text": "欢迎回来。", "next": "end" },
    "end": { "type": "end" }
  }
}
  • 条件表达式不直接 eval,而是走安全 DSL:flag.hasKey && item.key >= 1、var.gold + 5 > 10、 has(item.potion)、num(var.gold) + 10。
  • 文本插值:{{ 表达式 }} 会替换成求值结果,例如 {{ actor.hero.name }}。
  • 完整节点字段与 DSL 语法见 docs/STORY_FORMAT.md。

文档索引

文档 内容
docs/README.md 文档总索引与阅读路线
docs/ARCHITECTURE.md 架构图、模块职责、数据流、插件生命周期、错误隔离与权限模型
docs/PLUGIN_JS.md JS 插件:目录结构、manifest 字段、JsPlugin 接口、PluginContext 完整 API、示例与调试
docs/PLUGIN_WASM.md Rust WASM 插件:目录结构、ABI、导出/导入函数、消息与返回值、内存管理、完整源码与排查
docs/STORY_FORMAT.md 剧情 JSON Schema、全部节点类型字段说明、表达式 DSL、变量/标志/物品/角色、热替换
docs/SCRIPT_TEXT.md 给编剧的文本剧本语法:幕 / 续段 / 表演提示 / 选择 / 条件 / 跳转 / 结局的完整参考与诊断清单
docs/ABI.md WASM ABI 版本、内存布局、函数签名、错误码、交互时序图、Rust 实现注意事项
docs/PERMISSIONS.md 权限列表与含义、声明方式、未授权行为、校验实现位置、安全建议
docs/EVENTS.md 事件契约:引擎/插件发布的事件名、payload 结构与来源、监听方式
docs/FRONTEND.md 前端集成:把引擎当地库嵌入自己的页面,Runtime API 与 RuntimeUi 接口、框架嵌入示例
docs/CROSS_ORIGIN.md 跨域隔离与部署:COOP/COEP 的代价(CDN 资源被拦、弹窗断链)、三档位、生产头配置、iframe 嵌入
docs/TESTING.md 测试指南:Vitest 单元测试、Playwright 端到端、目录约定、常见问题
docs/BUILD.md 环境要求、依赖安装、开发/生产构建、WASM 构建步骤、静态资源说明、常见构建问题
docs/FAQ.md 插件加载失败、WASM 实例化失败、剧情不更新、调试、扩展节点、权限收紧、打包发布
docs/CHANGELOG.md 版本记录、初版功能清单、后续计划

许可证建议

代码默认以 Apache-2.0 License 发布(package.json 的 license 字段与 Cargo.toml 均为 Apache-2.0)。 若你希望更宽松或更严格,可考虑:

  • MIT / Apache-2.0:最常见,允许闭源商用,适合引擎与插件生态;
  • MPL-2.0:修改引擎文件需回馈,插件可闭源;
  • AGPL-3.0:强 copyleft,适合希望衍生作品也开源的项目。

选定后,请在仓库根目录添加 LICENSE 文件,并同步更新 package.json 与 wasm-src/*/Cargo.toml 的 license 字段。

项目介绍

可用于在浏览器中加载 JSON 剧情并扩展互动玩法。基于 TypeScript 与原生 DOM,支持 JS 和 Rust WASM 插件注册指令、条件、UI 与事件,并提供权限校验、错误隔离、安全表达式、存档读档。【此简介由AI生成】

定制我的领域