可用于在浏览器中加载 JSON 剧情并扩展互动玩法。基于 TypeScript 与原生 DOM,支持 JS 和 Rust WASM 插件注册指令、条件、UI 与事件,并提供权限校验、错误隔离、安全表达式、存档读档。【此简介由AI生成】
当前访问频次受限,请登录后继续访问
插件化剧情引擎(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/ 下,浏览器直接通过静态资源路径访问。
-
登记到清单:编辑
public/stories/index.json追加一条:{ "id": "mystory", "title": "我的剧情", "url": "/stories/mystory.json", "description": "剧情简介" } -
页面顶部下拉框会自动出现新剧情,选中即加载(带
cacheBust,绕过缓存)。 -
剧情文件内用
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 插件
-
新建目录
public/plugins/my-plugin/。 -
放入
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"] } -
放入
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() { // 释放自己持有的资源;引擎还会兜底回收注册的能力与事件监听 } } -
让剧情引用它:在剧情文件里加
"plugins": ["/plugins/my-plugin/manifest.json"]。 随后即可用plugin节点调用greet。
加载失败只会让这一个插件进入
error状态并在页面与日志中提示,不会影响引擎和其它插件。
如何加载 Rust WASM 插件
-
新建 Rust crate:
wasm-src/my-wasm/,Cargo.toml关键片段:[lib] crate-type = ["cdylib"] # 必须,才会产出可被 WebAssembly.instantiate 加载的 .wasm [dependencies] serde_json = "1" # 跨边界统一用 UTF-8 JSON 字符串通信 -
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。 -
构建并复制产物:
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)。 -
放入
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"] } -
让剧情声明该插件,即可用
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生成】
定制我的领域