diff --git a/.env.example b/.env.example index 7a3d0d2e..045ec20c 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,7 @@ VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" # Server-side DashScope endpoint and API key used by the local scene-image proxy. DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1" -DASHSCOPE_API_KEY="" +DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990" # Optional model name for custom-world scene image generation. DASHSCOPE_IMAGE_MODEL="wan2.2-t2i-flash" diff --git a/.gitignore b/.gitignore index 5a86d2a8..110a3757 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,4 @@ dist/ coverage/ .DS_Store *.log -.env* -!.env.example +.env.local diff --git a/docs/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md b/docs/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md new file mode 100644 index 00000000..c6e34062 --- /dev/null +++ b/docs/SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md @@ -0,0 +1,957 @@ +# 服务端部署与 CORS 完整技术方案 + +日期:`2026-04-05` + +## 1. 文档目标 + +本文要解决的不只是“当前部署到服务器后浏览器报 CORS”,而是同时解决下面几类问题: + +1. 浏览器访问大模型、图片生成等第三方服务时的跨域问题 +2. 前端不能安全暴露 API Key 的问题 +3. 当前项目开发期依赖 Vite middleware,生产环境缺少正式 API 服务的问题 +4. 后续服务端能力扩展时,避免再次推倒重来 + +目标结论很明确: + +- **浏览器以后不再直连第三方大模型 / 图片服务** +- **浏览器只访问我们自己的站点域名下的 `/api/*`** +- **生产环境新增独立 Node API 服务,Vite 代理只保留给开发环境** +- **把运行时接口、编辑器接口、异步任务、存储能力分层设计** + +--- + +## 2. 结合当前仓库的现状判断 + +从当前仓库可以确认几件事: + +### 2.1 前端已经按“走本地代理”在写 + +当前前端代码并不是直接请求第三方接口,而是请求: + +- `/api/llm/chat/completions` +- `/api/custom-world/scene-image` +- `/api/item-overrides` +- `/api/npc-visual-overrides` +- `/api/character-overrides` +- `/api/scene-overrides` +- `/api/state-function-overrides` +- `/api/character-visual/publish` +- `/api/animation/publish` + +这说明项目方向本身就是对的:**前端应该访问自己的 API 层,而不是浏览器直连外部服务。** + +### 2.2 当前 API 层还只是开发期能力 + +这些接口现在主要由 [scripts/dev-server/localApiPlugins.ts](/E:/Repos/Genarrative/scripts/dev-server/localApiPlugins.ts) 挂在 Vite dev/preview 服务器里。 + +这在本地开发阶段很方便,但生产环境存在几个明显问题: + +1. `Vite dev server / preview server` 不适合长期承担正式后端职责 +2. 编辑器写接口、文件读写、代理转发都混在构建配置链路里 +3. 未来加入鉴权、限流、审计、任务队列、数据库时会非常难扩展 +4. 浏览器部署后如果没有这层正式 API 服务,就会重新退回“直连第三方接口”,于是重新触发 CORS + +### 2.3 工程审查文档也已经指出同样风险 + +[docs/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md](/E:/Repos/Genarrative/docs/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md) 明确沉淀过一条经验: + +- 浏览器直连会遇到 CORS +- 更稳的方案是开发服务器代理,再由前端请求 `/api/llm/...` + +[docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](/E:/Repos/Genarrative/docs/ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 也明确指出: + +- 编辑器、运行时、类后端能力全部耦合在 Vite 配置里 +- 未来如果做独立部署、多人协作、远程编辑、权限控制,会非常难迁移 + +所以这次不是简单“补个 CORS header”就完了,而是应该顺势把正式服务端边界立起来。 + +--- + +## 3. CORS 问题的根因 + +## 3.1 浏览器的跨域限制不是第三方接口“能调用”就能绕过 + +只要浏览器页面域名和目标接口域名不一致,就可能触发跨域限制。 + +典型场景: + +- 页面在 `https://game.example.com` +- 浏览器直接请求 `https://ark.cn-beijing.volces.com/...` +- 或直接请求 `https://dashscope.aliyuncs.com/...` + +此时就算第三方服务本身可用,只要对方没有返回允许你站点的 CORS 头,浏览器依然会拦截。 + +## 3.2 这类请求几乎一定会触发预检 + +因为现在请求通常具备以下特征: + +- `Content-Type: application/json` +- `Authorization: Bearer ...` +- `POST` +- 流式返回 / SSE + +这类请求很容易先发一个 `OPTIONS` 预检请求。只要预检没过,真正请求根本发不出去。 + +## 3.3 即使第三方临时支持 CORS,也不应该让浏览器直连 + +因为浏览器直连还有更大的问题: + +1. API Key 会暴露到前端 +2. 无法统一限流和熔断 +3. 无法统一记录调用日志 +4. 无法对不同上游做协议适配 +5. 无法在后续接入鉴权、配额、用户级审计 + +所以**正确方向不是“让浏览器跨域成功访问第三方”,而是“让浏览器根本不需要跨域访问第三方”。** + +--- + +## 4. 推荐的总体方案 + +## 4.1 核心原则 + +1. **同源优先**:浏览器尽量只访问当前站点同域下的 `/api/*` +2. **密钥只在服务端存在**:前端不再持有真实第三方 Key +3. **运行时接口与编辑器接口分层** +4. **长耗时任务异步化**:图片、视频、资产发布不要长期卡在同步 HTTP 请求里 +5. **存储外置化**:生产环境不要继续把生成文件直接写回源码目录 +6. **可观测性内建**:日志、追踪、限流、告警一开始就留口子 + +## 4.2 推荐目标架构 + +```mermaid +flowchart LR + A["玩家浏览器 / 编辑器浏览器"] --> B["CDN / Nginx"] + B --> C["静态前端 dist"] + B --> D["Node API Gateway / BFF"] + + D --> E["LLM Provider Adapter"] + D --> F["Image / Media Adapter"] + D --> G["业务服务层"] + D --> H["Redis / Queue"] + D --> I["PostgreSQL"] + D --> J["对象存储 OSS / S3"] + + H --> K["Worker 异步任务进程"] + K --> F + K --> J + K --> I +``` + +这套架构的意思是: + +- `Nginx` 负责统一入口、静态资源、反向代理 +- `Node API Gateway / BFF` 负责真正的 API 接入 +- 第三方大模型、图片服务不再暴露给浏览器 +- `Redis / Queue + Worker` 负责后续长任务 +- `PostgreSQL` 负责持久化业务数据 +- `OSS / S3` 负责图片、动画、导出资产等对象存储 + +--- + +## 5. 服务分层设计 + +## 5.1 Web 层 + +职责: + +- 托管前端 `dist` +- 提供 SPA 回退到 `index.html` +- 将 `/api/*` 代理给 Node API 服务 +- 统一做 TLS、压缩、缓存、静态资源头 + +推荐: + +- `Nginx` 或 `OpenResty` +- 线上前面可再加 CDN + +## 5.2 API Gateway / BFF 层 + +职责: + +- 接收浏览器请求 +- 做 CORS、鉴权、限流、审计 +- 统一代理 LLM、图片生成、编辑器接口 +- 把前端协议转换成上游协议 +- 屏蔽第三方差异 + +推荐技术: + +- `Node.js 22 + TypeScript` +- Web 框架可选 `Fastify` 或 `Express` + +建议取舍: + +- 如果要**最快迁移当前项目**,可以先上 `Express` +- 如果要**从一开始就更重视插件化、schema、性能**,可以直接上 `Fastify` + +本方案不强绑定框架,重点是边界,而不是框架名。 + +## 5.3 Adapter 层 + +不要让业务代码直接到处写第三方接口细节,建议抽成单独适配层: + +- `llmAdapter` +- `imageAdapter` +- `storageAdapter` +- `queueAdapter` + +职责: + +- 统一请求签名 +- 统一错误结构 +- 统一超时、重试、熔断 +- 统一日志字段 + +## 5.4 业务服务层 + +建议把业务服务按域拆开,而不是继续长在 Vite 插件里: + +- `runtimeService` + - 聊天/剧情推进 + - 自定义世界生成 + - 场景图片生成 +- `editorService` + - 预设读写 + - override 管理 + - 资源发布 +- `assetService` + - 生成图片入库 + - 角色/动画资源清单 +- `authService` + - 登录 + - 用户角色 + - 会话/Token + +## 5.5 Worker 层 + +以下能力建议逐步迁移到异步任务: + +- 自定义世界场景图生成 +- 角色动画素材生成 +- 视频/大图后处理 +- 导出包构建 + +理由: + +1. 任务时间长,浏览器同步等待容易超时 +2. 失败重试不方便 +3. 需要排队与限流 +4. 后续多用户并发时,不能把 Node API 线程长期占住 + +--- + +## 6. 推荐的域名与流量策略 + +## 6.1 最推荐:前端与 API 同域 + +推荐站点入口: + +- `https://game.example.com/` 提供前端静态资源 +- `https://game.example.com/api/*` 提供 API + +这种方式下: + +- 浏览器看见的是**同源** +- 主站绝大多数请求**根本不需要 CORS** +- 部署最稳 +- Cookie、会话、CSRF、防缓存等策略也更容易统一 + +这是**解决 CORS 的首选方案**。 + +## 6.2 备选:前后端分子域 + +如果以后要把 API 单独托管,也可以用: + +- 前端:`https://app.example.com` +- API:`https://api.example.com` + +此时才需要真正开启 CORS 白名单: + +- 只允许 `https://app.example.com` +- 管理后台再额外允许 `https://admin.example.com` +- 不允许 `*` + +## 6.3 不推荐:浏览器直接访问第三方服务 + +以下方式不再推荐: + +- 浏览器直接请求火山、DashScope、OpenAI 等服务 +- 浏览器直接持有厂商 Key +- 浏览器直接上传生成资产到第三方后再回写本地 + +这种方式即使短期能跑,也会反复被 CORS、安全和审计问题反噬。 + +--- + +## 7. CORS 策略设计 + +## 7.1 总体策略 + +### 运行时主站 + +如果使用同域反代: + +- 玩家访问 `game.example.com` +- API 也在 `game.example.com/api/*` + +此时**运行时主站可以不依赖 CORS**。 + +### 管理后台 / 编辑器 + +如果未来编辑器单独部署到: + +- `https://editor.example.com` + +而 API 在: + +- `https://api.example.com` + +才对编辑器开启白名单 CORS。 + +## 7.2 允许策略 + +建议只允许这些 Origin: + +- `https://app.example.com` +- `https://editor.example.com` +- 开发环境 `http://localhost:3000` + +并通过环境变量配置: + +```env +CORS_ALLOW_ORIGINS=https://app.example.com,https://editor.example.com,http://localhost:3000 +ADMIN_CORS_ALLOW_ORIGINS=https://editor.example.com,http://localhost:3000 +``` + +## 7.3 响应头建议 + +如果命中白名单,返回: + +```http +Access-Control-Allow-Origin: https://app.example.com +Vary: Origin +Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS +Access-Control-Allow-Headers: Authorization,Content-Type,X-Request-Id,X-CSRF-Token +Access-Control-Max-Age: 600 +``` + +只有在你明确使用 Cookie 会话时,才加: + +```http +Access-Control-Allow-Credentials: true +``` + +## 7.4 不建议的配置 + +不要这样配: + +```http +Access-Control-Allow-Origin: * +Access-Control-Allow-Credentials: true +``` + +这既不安全,也不符合规范。 + +## 7.5 预检请求处理 + +`OPTIONS` 请求必须在 API 层快速返回 `204`,不要把它继续转发到业务逻辑。 + +建议: + +- 统一中间件处理 +- 只要 Origin 不在白名单,直接拒绝 +- 预检通过后才进入业务处理 + +## 7.6 流式接口注意项 + +像 `/api/llm/chat/completions` 这种流式输出接口,还要补这些处理: + +- `Cache-Control: no-cache` +- `Connection: keep-alive` +- `X-Accel-Buffering: no` + +否则 `Nginx` 可能把流式内容缓存/聚合后再一次性吐给浏览器,导致前端看起来“流式失效”。 + +--- + +## 8. API 边界重构建议 + +建议不要继续把所有接口都平铺在根 `/api` 下,最好按域分层。 + +## 8.1 运行时 API + +建议: + +- `/api/runtime/llm/chat/completions` +- `/api/runtime/custom-world/scene-image` +- `/api/runtime/story/*` +- `/api/runtime/save/*` + +职责: + +- 面向玩家运行时 +- 高并发、可限流 +- 不允许直接写源文件 + +## 8.2 编辑器 API + +建议: + +- `/api/editor/item-overrides` +- `/api/editor/npc-visual-overrides` +- `/api/editor/character-overrides` +- `/api/editor/scene-overrides` +- `/api/editor/state-function-overrides` +- `/api/editor/assets/character-visual/publish` +- `/api/editor/assets/animation/publish` + +职责: + +- 面向创作者、运营、内部编辑器 +- 必须鉴权 +- 必须审计 +- 不建议对公网完全开放 + +## 8.3 内部任务 API + +建议: + +- `/api/internal/jobs/*` +- `/api/internal/hooks/*` + +职责: + +- Worker 回调 +- 内部系统同步 +- 不给浏览器直接调用 + +--- + +## 9. 生产环境最小可行架构 + +如果你希望先尽快上线,而不是一上来就做重微服务,推荐先落这个版本。 + +## 9.1 单机版拓扑 + +```mermaid +flowchart TD + A["Browser"] --> B["Nginx"] + B --> C["dist 静态站点"] + B --> D["Node API Service"] + D --> E["LLM / DashScope"] + D --> F["本机持久化目录或对象存储"] + D --> G["PostgreSQL(可后补)"] +``` + +## 9.2 单机版适合解决的事 + +- 当前 CORS +- API Key 服务端托管 +- 基础流式代理 +- 简单编辑器接口 +- 初步日志与限流 + +## 9.3 单机版暂时接受的妥协 + +- 图片生成先继续同步请求 +- 小规模文件先存本机挂载目录 +- 先不拆 Worker +- 编辑器暂时只给内网/白名单用户 + +这能保证你**先把项目稳稳部署起来**,而不是为了“最终形态”迟迟不落地。 + +--- + +## 10. 中期演进架构 + +当出现这些需求时,再进入下一阶段: + +- 多人同时在线 +- 多创作者协作 +- 图片/视频生成任务变多 +- 需要账号体系、存档、云同步 +- 需要审计和版本回滚 + +推荐演进为: + +```mermaid +flowchart LR + A["CDN / Nginx"] --> B["Web"] + A --> C["API Cluster"] + C --> D["Redis"] + C --> E["PostgreSQL"] + C --> F["OSS / S3"] + C --> G["Worker Cluster"] + G --> H["LLM / Image Vendor"] +``` + +演进重点: + +1. API 服务可多实例部署 +2. 任务通过队列解耦 +3. 文件写对象存储 +4. 业务状态入数据库 +5. 后台操作有审计日志 + +--- + +## 11. 对当前仓库最关键的改造建议 + +## 11.1 第一优先级:把 Vite 里的 API 能力抽出来 + +当前 [scripts/dev-server/localApiPlugins.ts](/E:/Repos/Genarrative/scripts/dev-server/localApiPlugins.ts) 里的能力,建议分三类迁移: + +### A. 运行时代理接口 + +- `LLM_PROXY_PATH` +- `CUSTOM_WORLD_SCENE_IMAGE_PATH` + +这两类要迁移到正式 `server/` 服务里。 + +### B. 编辑器读写接口 + +- `item-overrides` +- `npc-visual-overrides` +- `character-overrides` +- `monster-overrides` +- `scene-overrides` +- `scene-npc-overrides` +- `state-function-overrides` + +这类接口要保留,但必须加: + +- 登录鉴权 +- 角色权限 +- 操作日志 +- 环境隔离 + +### C. 资源发布接口 + +- `character-visual/publish` +- `animation/publish` + +这类接口后续最适合迁移到: + +- 对象存储 +- 任务队列 +- 元数据表 + +生产环境不建议继续“直接写 `public/generated-*` + 回写源码 JSON”。 + +## 11.2 第二优先级:把写源码文件改成写业务存储 + +开发期把 JSON 直接写回 `src/data/*.json` 可以接受,但生产环境不建议继续这样做。 + +建议演进路径: + +### 短期 + +- 写到 `data/overrides/*.json` +- 作为运行数据目录挂载到服务器磁盘 +- 和源码目录分离 + +### 中期 + +- `override` 元数据写 PostgreSQL +- 原始 JSON 内容也可写数据库 JSONB +- 大文件、图片、动画写对象存储 + +## 11.3 第三优先级:前端环境变量收敛 + +前端建议只保留少量公开变量,例如: + +```env +VITE_API_BASE_URL=/api +VITE_LLM_PROXY_BASE_URL=/api/runtime/llm +VITE_SCENE_IMAGE_PROXY_BASE_URL=/api/runtime/custom-world/scene-image +``` + +而这些变量必须只存在服务端: + +```env +LLM_BASE_URL=... +LLM_API_KEY=... +DASHSCOPE_BASE_URL=... +DASHSCOPE_API_KEY=... +DATABASE_URL=... +REDIS_URL=... +OBJECT_STORAGE_BUCKET=... +JWT_SECRET=... +``` + +--- + +## 12. 推荐的目录结构 + +在当前仓库基础上,建议逐步演化成: + +```text +src/ # 前端 +server/ + app.ts # API 入口 + routes/ + runtime/ + llm.ts + customWorld.ts + editor/ + overrides.ts + assets.ts + internal/ + jobs.ts + services/ + llmService.ts + imageService.ts + overrideService.ts + publishService.ts + adapters/ + llmAdapter.ts + dashscopeAdapter.ts + storageAdapter.ts + queueAdapter.ts + middleware/ + cors.ts + auth.ts + rateLimit.ts + requestId.ts + config/ + env.ts +workers/ + mediaWorker.ts +storage/ + uploads/ + generated/ +docs/ +``` + +这样做的好处: + +1. 前后端职责清晰 +2. Vite 回到构建职责 +3. 服务端逻辑可以独立部署 +4. 以后做测试、监控、扩容都更自然 + +--- + +## 13. Nginx 参考配置 + +下面是一份适合当前项目思路的参考配置。 + +```nginx +server { + listen 80; + server_name game.example.com; + + root /srv/genarrative/dist; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /assets/ { + expires 30d; + add_header Cache-Control "public, max-age=2592000, immutable"; + } + + location /api/ { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + # 对流式接口很关键 + proxy_buffering off; + add_header X-Accel-Buffering no; + } +} +``` + +如果未来拆成 `app.example.com` + `api.example.com`,再在 API 服务层开启精确 CORS 白名单。 + +--- + +## 14. API 服务中的 CORS 中间件建议 + +伪代码如下: + +```ts +const allowOrigins = new Set([ + 'https://app.example.com', + 'https://editor.example.com', + 'http://localhost:3000', +]); + +function applyCors(req, res) { + const origin = req.headers.origin; + if (!origin) { + return; + } + + if (!allowOrigins.has(origin)) { + return; + } + + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + res.setHeader( + 'Access-Control-Allow-Methods', + 'GET,POST,PUT,PATCH,DELETE,OPTIONS', + ); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Authorization,Content-Type,X-Request-Id,X-CSRF-Token', + ); + res.setHeader('Access-Control-Max-Age', '600'); +} + +app.use((req, res, next) => { + applyCors(req, res); + + if (req.method === 'OPTIONS') { + res.status(204).end(); + return; + } + + next(); +}); +``` + +要点: + +1. 返回的是**请求方 origin 原值**,不是 `*` +2. 一定带 `Vary: Origin` +3. `OPTIONS` 在中间件层直接结束 +4. 未命中白名单就不放行 + +--- + +## 15. 鉴权与安全建议 + +## 15.1 运行时接口 + +如果当前只是单人原型或内测,可以先允许匿名访问部分运行时接口,但仍建议至少加: + +- IP 级限流 +- 请求大小限制 +- 上游超时 +- 请求日志 + +## 15.2 编辑器接口 + +编辑器接口不能继续裸奔。 + +至少加: + +1. 登录态 +2. 角色权限 +3. 操作人标识 +4. 审计日志 +5. 管理后台 Origin 白名单 + +建议角色最少拆成: + +- `player` +- `editor` +- `admin` + +## 15.3 密钥管理 + +所有第三方 Key 都只放在服务端: + +- `.env.production` +- 服务器 secret manager +- 容器 secret + +不要放到: + +- `VITE_*` +- 浏览器 localStorage +- 前端 bundle + +## 15.4 限流与熔断 + +建议至少做两层: + +### Nginx 层 + +- IP 限流 +- 突发连接限制 + +### API 层 + +- 用户 / Token 限流 +- 按接口限流 +- 上游失败熔断 + +这对图片生成和大模型请求尤其关键。 + +--- + +## 16. 存储设计建议 + +## 16.1 当前最容易出问题的点 + +当前开发期接口里存在: + +- 直接读写 JSON 文件 +- 直接写入 `public/generated-*` + +这在单机开发是方便的,但上线后会遇到: + +1. 多实例之间文件不同步 +2. 发布新版本时生成文件可能丢失 +3. 容器重启后本地文件丢失 +4. 无法做版本与审计 + +## 16.2 推荐存储分层 + +### 结构化数据 + +用 PostgreSQL: + +- 用户 +- 存档 +- override 元数据 +- 任务记录 +- 审计日志 + +### 大文件 / 资源文件 + +用对象存储: + +- 场景图 +- 角色图 +- 动画帧 +- 导出资源 + +### 短生命周期状态 + +用 Redis: + +- 任务队列 +- 限流计数 +- 短期缓存 +- 会话 + +--- + +## 17. 日志与可观测性 + +至少记录这些字段: + +- `requestId` +- `userId` / `editorId` +- `route` +- `origin` +- `upstreamVendor` +- `model` +- `statusCode` +- `latencyMs` +- `timeoutMs` +- `errorCode` +- `errorMessage` + +对大模型/图片接口尤其要记录: + +1. 请求耗时 +2. 上游状态码 +3. 失败正文摘要 +4. 重试次数 +5. 任务 ID + +这样以后排查时,才不会再次回到“只看到浏览器报 CORS,不知道真因”的状态。 + +--- + +## 18. 推荐实施顺序 + +## 阶段 1:先解决正式上线 + +目标: + +- 前端可部署 +- API Key 不暴露 +- 不再浏览器直连第三方 +- 当前 CORS 问题彻底解决 + +动作: + +1. 新建独立 `server/` 服务 +2. 把 `/api/llm/chat/completions` 与 `/api/custom-world/scene-image` 迁过去 +3. Nginx 统一反代 `/api/*` +4. 前端保持原有 `/api/*` 调用方式 +5. 先在同域部署,尽量不碰 CORS + +## 阶段 2:把编辑器接口接入正式权限 + +动作: + +1. editor API 独立命名空间 +2. 增加登录、角色、审计 +3. 把“写源码文件”改成“写业务数据目录或数据库” + +## 阶段 3:接入异步任务与对象存储 + +动作: + +1. 图片生成改为 job queue +2. 结果回写数据库与对象存储 +3. API 只返回任务状态与资源地址 + +## 阶段 4:补齐平台化能力 + +动作: + +1. 用户系统 +2. 云存档 +3. 限流配额 +4. 后台审计 +5. 监控告警 + +--- + +## 19. 最终推荐结论 + +针对这个项目,最稳、最适合当前现状、也兼顾未来的方案是: + +1. **不要尝试让浏览器直接跨域访问第三方模型服务** +2. **新增独立 Node API 服务,替代当前生产环境对 Vite middleware 的依赖** +3. **通过 Nginx 把前端和 `/api/*` 统一到同一域名下,优先从架构层消灭大部分 CORS** +4. **把 API 分成 runtime / editor / internal 三层** +5. **把图片生成、资源发布逐步迁移到对象存储 + 异步任务** +6. **把未来的鉴权、限流、审计、日志、数据库能力预留在 BFF 层** + +一句话总结: + +**真正解决 CORS 的最佳方式,不是给浏览器更多跨域权限,而是让浏览器只访问你自己的服务;同时把当前开发期代理能力升级成正式的生产 API 架构。** + +--- + +## 20. 适合立即执行的落地清单 + +如果现在就要开始做,建议按下面顺序推进: + +1. 新建 `server/` 目录,先迁出 LLM 代理与场景图片代理 +2. 把线上部署改成 `Nginx -> dist + Node API` +3. 保持前端仍然请求 `/api/llm` 与 `/api/custom-world/scene-image` +4. 先使用同域部署,不主动引入跨子域 CORS +5. editor 写接口上线前先加鉴权,不要裸开放 +6. 生成文件先写挂载目录,下一步再迁对象存储 +7. 为流式接口补 `proxy_buffering off` +8. 为 API 层补 `requestId + latency + upstream error` 日志 + +如果后续需要,我可以继续把这份文档下一步直接细化成: + +- `server/` 目录结构草案 +- `Express/Fastify` 的接口骨架 +- `Nginx` 正式可用配置 +- `Dockerfile + docker-compose` 部署方案 +- 当前仓库对应的迁移 checklist diff --git a/docs/prd/AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md b/docs/prd/AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md new file mode 100644 index 00000000..4ef947df --- /dev/null +++ b/docs/prd/AI_NATIVE_CLASSIC_RPG_EXPERIENCE_BENCHMARK_PRD_2026-04-06.md @@ -0,0 +1,498 @@ +# AI 原生经典 RPG 体验对标引擎 PRD + +更新时间:`2026-04-06` + +## 0. 目标 + +这份 PRD 建立在已有的: + +- `AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md` +- `AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md` + +之上,进一步回答一个更明确的问题: + +**如何让当前仓库里的 AI 原生剧情引擎,不只是“会生成剧情”,而是能在现有游戏框架中,驱动出对标《仙剑》《轩辕剑》《古剑》《黑神话:悟空》《博德之门》这类经典作品的角色扮演体验。** + +这里的“对标”不是复制题材、桥段或美术风格,而是抽取这些作品背后的体验能力: + +1. 让玩家记住角色,而不只是记住设定。 +2. 让世界里的地点、旧物、传闻、队友、任务彼此互相讲同一件事。 +3. 让玩家的选择、立场、关系、误判、探索真正改变后续体验。 +4. 让剧情推进同时拥有“作者性”和“系统性”。 + +一句话目标: + +**把当前项目的 AI 原生叙事,升级成一种能稳定产出“经典单机 RPG 体验质感”的剧情引擎。** + +## 1. 研究结论先说 + +综合参考对象后,可以把这几类经典体验抽象成 5 个核心方向: + +1. `仙剑` 型体验 + - 强角色记忆点 + - “人”和“情”优先 + - 主线、支线、传记、碎片化叙事共同塑造角色 + +2. `轩辕剑` 型体验 + - 历史 / 神话 /文化意象共同构成宏大冲突 + - 大时代与个人成长同时成立 + - 系统玩法本身带文化与世界观意味 + +3. `古剑` 型体验 + - 世界观先行 + - 主题先行 + - 角色命题、支线补完、衍生叙事共同构成厚世界 + +4. `黑神话` 型体验 + - 空间本身讲故事 + - 物件、残痕、命名、演出都带文化锚点 + - 旅程像一次带试炼感的穿行,而不是纯任务导航 + +5. `博德之门` 型体验 + - 队友强反应 + - 选择有代价 + - 关系状态、立场差异、任务分支和系统规则一起驱动叙事 + +对当前项目来说,这意味着剧情引擎必须补的,不是更多文案,而是下面 10 项能力: + +1. 世界线程图谱 +2. 题材适配层 +3. 角色命题与秘密档案 +4. 队友反应与关系矩阵 +5. 信息可见性裁剪 +6. 地点 / 物件 / 文书 / 残痕的叙事编译 +7. 情境导演与节奏控制 +8. 线程 -> 合约 -> 信号推进 +9. 回响与长期记忆 +10. 当前框架可落地的 orchestration 主链 + +## 2. 经典作品拆解 + +## 2.1 从《仙剑》系列提炼什么 + +《仙剑》最值得提炼的,不是仙侠题材,而是: + +1. 角色与情感先于设定说明 +2. 玩家会因为“人”和“情”记住剧情 +3. 主线之外的角色剧情、人物传记、碎片信息都在持续加深理解 +4. 角色魅力是分阶段投放,不是一口气灌输 + +对引擎的要求就是: + +1. 每个重点角色都必须有“高光入口” +2. 角色认知要通过多载体逐步加深 +3. 情感关系不能只挂在恋爱线,要覆盖友情、亲情、师承、旧债、牺牲、错过 +4. 碎片化叙事必须和主线、支线互文,而不是散落设定 + +## 2.2 从《轩辕剑》系列提炼什么 + +《轩辕剑》最值得提炼的,不是具体朝代,而是: + +1. 历史与神话共存 +2. 宏观时代与个人命运互相牵引 +3. 每一代往往有明确中心主题 +4. 世界里的神器、法宝、炼妖壶、符鬼等系统并不是纯玩法道具,而是世界观的一部分 + +对引擎的要求就是: + +1. 每个世界必须有明确的大时代冲突 +2. 每段主线必须能同时看到“苍生线”和“个人线” +3. 每一轮内容生成都要知道自己服务的是哪条主题轴 +4. 系统物件、战斗能力、资源名称、任务机制都要与世界观绑定 + +## 2.3 从《古剑》系列提炼什么 + +《古剑》最值得提炼的,不是台词风格,而是: + +1. 世界观不是背景板,而是叙事发动机 +2. 主题是预先确定的,比如“重生”“问道” +3. 游戏中只展示世界的一角,但能感到背后有更大的历史层 +4. 支线、小说、传记、远古设定共同构成世界厚度 + +对引擎的要求就是: + +1. 先生成“世界故事图谱”,再生成角色和剧情 +2. 每个世界必须有自己的主题母题 +3. 重点角色都要有“命题” +4. 支线不能只是奖励入口,而要承担世界观展开和人物补完 + +## 2.4 从《黑神话:悟空》提炼什么 + +《黑神话:悟空》最值得提炼的,不是神话题材本身,而是: + +1. 空间叙事强 +2. 旅程感强 +3. 文化意象直接进入地点、敌人、物件、建筑和命名 +4. 玩家不是靠大段讲解理解世界,而是靠穿行、观察、搏斗和残痕去感受 + +对引擎的要求就是: + +1. 场景必须有“故事残痕层” +2. 路线推进要像试炼 / 朝圣 / 追索 / 深入禁地,而不是只弹任务文本 +3. 重点物件必须是文化锚点和旧事证人 +4. 敌人与地标也应属于叙事载体,而不只是战斗内容 + +## 2.5 从《博德之门》提炼什么 + +《博德之门》最值得提炼的,不是西式奇幻题材,而是: + +1. 队友是强叙事引擎 +2. 不同角色对同一行为会有不同立场反应 +3. 关系推进有信任门槛 +4. 玩家选择会改变关系、任务理解和队伍结构 +5. 系统层会用可控的“认可 / 不认可”“信任 / 不信任”来表达复杂反应,而不是无限写分支 + +对引擎的要求就是: + +1. 每个重点队友都要有可追踪的立场轴和信任轴 +2. 对同一选择,队友必须出现差异化反应 +3. 关系状态要进入后续任务、聊天、协战、赠礼、剧情揭示 +4. 系统必须允许“有限分支 + 强反馈”,而不是追求不可控的全分支写作 + +## 3. 对标能力矩阵 + +## 3.1 引擎必须支持的体验支柱 + +建议把“经典 RPG 体验”拆成下面 8 根支柱: + +1. 角色羁绊支柱 +2. 主题表达支柱 +3. 世界厚度支柱 +4. 路线试炼支柱 +5. 选择后果支柱 +6. 队友反应支柱 +7. 叙事载体支柱 +8. 回响记忆支柱 + +## 3.2 支柱与经典作品的对照 + +| 支柱 | 仙剑 | 轩辕剑 | 古剑 | 黑神话 | 博德之门 | +| --- | --- | --- | --- | --- | --- | +| 角色羁绊 | 强 | 中 | 强 | 中 | 强 | +| 主题表达 | 强 | 强 | 强 | 中到强 | 强 | +| 世界厚度 | 中到强 | 强 | 强 | 强 | 强 | +| 路线试炼 | 中 | 中 | 中 | 强 | 中 | +| 选择后果 | 中 | 中 | 中 | 轻 | 强 | +| 队友反应 | 中 | 中 | 中 | 轻 | 强 | +| 叙事载体 | 中 | 强 | 强 | 强 | 强 | +| 回响记忆 | 中 | 强 | 强 | 中 | 强 | + +结论不是“每个游戏都做一样多”,而是: + +**要想对标这些经典作品的总和体验,引擎不能只强其中一项。** + +## 4. 面向当前项目的引擎能力设计 + +## 4.1 世界线程图谱 + +用于承载: + +- 《轩辕剑》式的大时代冲突 +- 《古剑》式的远古 / 旧史 / 深层设定 +- 《仙剑》式的主线与碎片化叙事互文 + +最低要求: + +1. 每个世界有 `明线线程` +2. 每个世界有 `暗线线程` +3. 每个世界有 `旧事伤痕` +4. 每个世界有 `主题母题` + +## 4.2 角色命题与秘密档案 + +用于承载: + +- 《仙剑》式角色高光与感情曲线 +- 《古剑》式角色命题与主题性成长 +- 《博德之门》式角色秘密与立场差异 + +每个重点角色必须有: + +1. 外显身份 +2. 当前压力 +3. 表面目标 +4. 真实目标 +5. 已付代价 +6. 关系负债 +7. 禁区 +8. 可触发反应的关键词 + +## 4.3 队友反应与关系矩阵 + +这是当前项目对标《博德之门》最需要补的一块。 + +建议每个重点队友都至少维护: + +```ts +interface CompanionStanceProfile { + trust: number; + warmth: number; + ideologicalFit: number; + fearOrGuard: number; + loyalty: number; + currentConflictTag?: string | null; + recentApprovals: string[]; + recentDisapprovals: string[]; +} +``` + +作用: + +1. `trust` + - 决定是否愿意跟随你深入风险。 + +2. `warmth` + - 决定情感表达密度。 + +3. `ideologicalFit` + - 决定在价值观选择上的反应强度。 + +4. `fearOrGuard` + - 决定低关系阶段是不是更容易误会、回避、抗拒。 + +5. `loyalty` + - 决定关键节点是否站队。 + +## 4.4 信息可见性层 + +这是对标《古剑》的世界层深度、对标《仙剑》的分段投放、对标《博德之门》的可控分支,以及修复当前自定义世界 prompt 泄露问题的共同底座。 + +核心要求: + +1. 角色知道什么 +2. 玩家知道什么 +3. 角色此刻愿意说什么 +4. 当前场景允许模型看到什么 + +必须分开建模。 + +## 4.5 叙事载体编译层 + +这是对标《黑神话》《轩辕剑》《古剑》最关键的一层。 + +载体不能只限于装备物品,应统一抽象为: + +1. 遗物 +2. 证物 +3. 文书 +4. 材料 +5. 禁物 +6. 装置 +7. 记忆碎片 +8. 残痕场景 + +每个载体都要有: + +1. 可见线索 +2. 见证痕 +3. 未完成问题 +4. 当前出现理由 +5. 后续反应钩子 + +## 4.6 情境导演与节奏层 + +这是对标《仙剑》的情感推进、《黑神话》的旅程压迫、《博德之门》的分支张力时最重要的“节奏控制器”。 + +导演层要先判断: + +1. 当前是情感深化回合,还是冲突升级回合 +2. 当前应推进明线、暗线、关系线、还是主题线 +3. 当前需要推前台的是角色、地点、还是物件 +4. 当前披露预算应该是低、中还是高 + +没有导演层,AI 文本会持续飘散。 + +## 4.7 线程 -> 合约 -> 信号推进 + +这是把经典单机 RPG 的作者性故事,转成 AI 原生可持续运行结构的关键。 + +统一抽象如下: + +1. 线程 + - 这段故事在讲哪条线。 + +2. 合约 + - 当前线的阶段目标、参与者、条件、失败态与回报。 + +3. 信号 + - 玩家做了什么,导致哪个关系、地点、物件、真相片段发生变化。 + +## 4.8 回响与长期记忆 + +这是决定剧情是否像“经典作品”的最后一层。 + +玩家之所以会觉得这些作品有余味,不是因为文本多,而是因为: + +1. 旧事会回来 +2. 旧物会再被提起 +3. 旧误解会被翻案 +4. 旧角色会因为你做过的事改变口风 + +因此系统必须维护: + +1. 事件记忆 +2. 关系记忆 +3. 线索记忆 +4. 误解记忆 +5. 已揭示真相记忆 + +## 5. 当前框架接入方案 + +## 5.1 `src/services/customWorld.ts` + +从“批量生成 NPC 和地标”升级为: + +1. 先生成 `ThemePack` +2. 再生成 `WorldStoryGraph` +3. 再生成 `ActorNarrativeProfile` +4. 最后补 `backstoryReveal / skills / initialItems` + +它将成为当前项目里最接近“古剑式世界观先行 + 轩辕剑式宏大主题”的入口。 + +## 5.2 `src/services/prompt.ts` + +从“组装上下文”升级为: + +1. 读取 `VisibilitySlice` +2. 读取 `SceneNarrativeDirective` +3. 只注入当前阶段可见、可说、可推的最小剧情上下文 + +它将成为: + +- 对标《博德之门》强反应又可控 +- 对标《仙剑》分阶段角色理解 +- 修复当前全知视角泄露 + +的核心模块。 + +## 5.3 `src/data/npcInteractions.ts` + +从“关系规则函数集合”升级为: + +1. 首遇状态机 +2. 队友立场矩阵 +3. 认可 / 不认可反馈 +4. 关系冲突 tag +5. 私聊 / 队伍 / 营地事件触发 + +它将承担当前项目里最接近《博德之门》队友系统和《仙剑》角色关系成长的部分。 + +## 5.4 `src/services/questDirector.ts` + +从“任务导演”升级为: + +1. 剧情线程导演 +2. 合约生成器 +3. 信号推进器 +4. 阶段揭示器 + +它将承担对标《轩辕剑》宏观主线推进和《博德之门》分支任务反馈的主链。 + +## 5.5 `src/data/runtimeItemDirector.ts` / `src/data/runtimeItemNarrative.ts` + +从“运行时奖励导演”升级为: + +1. 通用叙事载体编译器 +2. 重点物件故事指纹生成器 +3. 角色 / 场景 / 势力 / 旧事的回响载体 + +它将承担对标《黑神话》物件文化锚点、对标《轩辕剑》器物世界观、对标《古剑》旧史残痕的能力。 + +## 5.6 `src/hooks/useStoryGeneration.ts` + +从“剧情总 orchestrator”收束为: + +1. 读取导演结果 +2. 发起对应 contract +3. 驱动生成 +4. 回写记忆与信号 + +不要继续把越来越多叙事细节直接塞进这个 hook,而是让它只做总线协调。 + +## 6. 体验验收标准 + +如果要说“能对标经典单机 RPG 体验”,至少要达到下面这些结果。 + +## 6.1 角色体验 + +1. 玩家在一次完整体验后,能明确记住至少 `3~5` 个队友 / 核心角色的个性、矛盾和关键旧事。 +2. 低好感角色不会只是“冷淡”,而是会带明确压力、错位说辞和暗线钩子。 +3. 高关系角色会在聊天、任务、协战、赠礼、事件节点中显著改变表达与立场。 + +## 6.2 世界体验 + +1. 玩家能感到世界背后有更深层的旧史与暗线,而不是所有信息都在主线上直接交代。 +2. 场景、地点、支线、人物传记、物件描述之间会互相印证。 +3. 世界主题在命名、系统、任务、人物冲突上持续一致。 + +## 6.3 选择体验 + +1. 玩家选择能被至少一名队友明确认可或反对。 +2. 至少一部分选择会影响后续任务理解、关系推进、额外剧情或可见信息。 +3. 系统既要让选择有重量,又不能因为分支爆炸而失控。 + +## 6.4 旅程体验 + +1. 场景推进要有“试炼 / 追索 / 深入 / 回望”的旅程感。 +2. 不是所有叙事都靠对话框完成,空间和载体也必须承担讲故事的职责。 +3. 玩家会因为一个地标、一个旧物、一次反应,主动去拼出暗线。 + +## 7. 推荐落地顺序 + +## 阶段 A:先补对标经典作品的共同底座 + +优先做: + +1. `ThemePack` +2. `WorldStoryGraph` +3. `ActorNarrativeProfile` +4. `VisibilitySlice` + +## 阶段 B:优先把当前项目最能出效果的两条链做强 + +1. 队友 / NPC 关系反应链 +2. 运行时物件 / 场景残痕叙事链 + +这是当前最容易直接提升“像经典 RPG”的地方。 + +## 阶段 C:把任务和主线推进改成线程化 + +重点补: + +1. 线程 +2. 合约 +3. 信号 +4. 阶段揭示 + +## 阶段 D:补营地 / 旅途中队友事件 + +这是当前项目对标《仙剑》与《博德之门》体验非常关键的一步。 + +建议新增: + +1. 营地对话 +2. 旅途中短反应 +3. 关键选择后的队友插话 +4. 队友之间的互相评价 + +## 阶段 E:做经典体验压力测试 + +至少要用 5 类体验场景去压测引擎: + +1. 情感型主线 +2. 历史 / 神话型大事件 +3. 世界观层层揭示型流程 +4. 旅程试炼型场景链 +5. 队友强反应分支型流程 + +## 8. 一句话结论 + +要让当前项目的 AI 原生剧情引擎真正对标《仙剑》《轩辕剑》《古剑》《黑神话》《博德之门》这些经典作品,关键不是去模仿哪一种题材,而是让引擎同时具备: + +- 让人记住角色的能力 +- 让世界互相说话的能力 +- 让选择产生后果的能力 +- 让地点与物件承担叙事的能力 +- 让长线回响沉淀下来的能力 + +只有这些能力一起成立,当前框架里跑出来的体验,才会从“AI 会写剧情”,真正跨到“AI 能驱动经典 RPG 质感”。 diff --git a/docs/prd/AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md b/docs/prd/AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md new file mode 100644 index 00000000..9439774e --- /dev/null +++ b/docs/prd/AI_NATIVE_CROSS_GENRE_STORY_ENGINE_PRD_2026-04-06.md @@ -0,0 +1,554 @@ +# AI 原生跨题材剧情引擎 PRD + +更新时间:`2026-04-06` + +## 0. 定位 + +这份 PRD 的目标不是为某一种题材写一套“更会编故事的文案系统”,而是设计一套: + +**可适配奇幻、武侠、仙侠、科幻、悬疑、恐怖、末世、都市、校园、神话等多种题材的 AI 原生游戏剧情引擎。** + +它应该解决的不是单一题材里的句子风格,而是更底层的问题: + +1. 世界的明线、暗线如何被系统化组织。 +2. 角色、地点、物件、文书、怪物、装置、尸体、遗迹这些叙事载体如何共同讲故事。 +3. 玩家当前能知道什么、误以为知道什么、还不能知道什么,如何被稳定控制。 +4. AI 如何负责叙事生成,本地规则如何负责边界、状态、可见性、推进信号与玩法编译。 + +一句话定位: + +**它是一个“剧情引擎层”,不是一个“某题材内容包”。** + +## 1. 设计目标 + +这套引擎要同时满足 6 个目标: + +1. 跨题材 + - 核心语义不写死在武侠、奇幻、修仙、蒸汽朋克等具体题材上。 + +2. AI 原生 + - 剧情文本、现场张力、角色表述、线索回响由 AI 参与生成,而不是只做静态模板替换。 + +3. 规则可控 + - 世界状态、信息泄露边界、关系推进、任务推进、奖励发放仍由本地规则约束。 + +4. 叙事网状化 + - 故事不只存在于主线任务里,而是分布在角色、物件、地点、事件余波与传闻中。 + +5. 可扩展 + - 新增题材时,优先新增“题材适配层”,而不是推翻剧情引擎本体。 + +6. 可验证 + - 能明确验收“是否有故事感”“是否埋得住暗线”“是否越权泄露”“是否跨题材仍成立”。 + +## 2. 参考方法抽象 + +本次设计参考的是成熟叙事游戏的方法,不是照搬具体剧情。 + +## 2.1 可借鉴的方法来源 + +1. CRPG 方法 + - 代表思路:角色秘密、阵营立场、任务分支、物件与角色的反应联动。 + - 可借鉴点:角色不是独立设定卡,而是世界冲突中的活节点。 + +2. 沉浸式侦查 / 推理方法 + - 代表思路:线索不是一次性交代,而是靠地点、证物、口供、误导与缺失共同成立。 + - 可借鉴点:信息差、误判、再解释,是故事感的重要来源。 + +3. 系统叙事方法 + - 代表思路:事件、状态、关系、资源变化会自然生成“像故事”的结果。 + - 可借鉴点:引擎应先保证状态与因果,再让 AI 把它叙事化。 + +4. Roguelike / 重复游玩叙事方法 + - 代表思路:角色关系、旧伤、遗物、失败记录、阶段揭示会在多轮体验中叠加意义。 + - 可借鉴点:故事不是一次性讲完,而是通过回响与累积形成。 + +5. 强氛围题材方法 + - 代表思路:名字、物件、俗称、禁忌称呼、残损意象本身就携带故事。 + - 可借鉴点:叙事载体不只靠大段说明,也能靠命名与残痕表达。 + +## 2.2 抽象结论 + +综合这些方法后,这套引擎应固定采用下面这 5 个叙事原则: + +1. 故事必须网状分布,不能只挂在主线任务上。 +2. 信息披露必须分层,不能让模型默认全知。 +3. 低关系、低信任、低理解阶段,不能减少故事密度,只能减少披露深度。 +4. 物件与地点必须是故事证人,而不只是功能容器。 +5. 题材差异应该主要落在“词汇、意象、制度、冲突形式”上,而不是改变剧情引擎基本语法。 + +## 3. 引擎核心原则 + +1. AI 负责叙事表达,本地负责规则裁决。 +2. 世界先于角色,角色先于对白,状态先于文案。 +3. 所有剧情都必须能回到“谁知道什么、谁想隐藏什么、谁正在承受什么”。 +4. 明线、暗线、代价线、回响线是所有题材共通的最小叙事单元。 +5. 信息可见性必须被数据化,而不能只靠 prompt 口头提醒。 +6. 引擎关注“剧情语法”,题材包只负责“表现词汇”。 + +## 4. 引擎总架构 + +建议把 AI 原生剧情引擎拆成 8 个层: + +1. 世界语义层 +2. 题材适配层 +3. 角色与阵营层 +4. 信息可见性层 +5. 情境导演层 +6. 合约与信号推进层 +7. 叙事载体编译层 +8. 记忆与回响层 + +关系如下: + +```text +世界语义层 +-> 题材适配层 +-> 角色与阵营层 +-> 信息可见性层 +-> 情境导演层 +-> 合约与信号推进层 +-> 叙事载体编译层 +-> 记忆与回响层 +``` + +## 5. 世界语义层 + +## 5.1 目标 + +让所有题材都先被翻译成一套统一的世界叙事骨架,而不是直接开始生成角色和对白。 + +## 5.2 建议的数据结构 + +```ts +interface StoryThread { + id: string; + title: string; + visibility: 'visible' | 'hidden'; + summary: string; + conflictType: string; + stakes: string; + involvedFactionIds: string[]; + involvedActorIds: string[]; + relatedLocationIds: string[]; +} + +interface StoryScar { + id: string; + title: string; + pastEvent: string; + publicResidue: string; + hiddenTruth: string; + relatedActorIds: string[]; + relatedLocationIds: string[]; +} + +interface StoryMotif { + id: string; + label: string; + semanticRole: 'institution' | 'ritual' | 'technology' | 'taboo' | 'ruin' | 'memory' | 'resource' | 'creature'; + lexicalHints: string[]; +} + +interface WorldStoryGraph { + visibleThreads: StoryThread[]; + hiddenThreads: StoryThread[]; + scars: StoryScar[]; + motifs: StoryMotif[]; +} +``` + +## 5.3 为什么它必须在最前面 + +没有这层图谱,就会出现这类问题: + +1. 角色各自有设定,但彼此没有共享暗线。 +2. 物件命名很酷,但和世界冲突没有关系。 +3. 场景有氛围,但和主要矛盾不互相印证。 +4. 每次 AI 输出都像重新发明一个宇宙。 + +## 6. 题材适配层 + +## 6.1 目标 + +让题材差异变成一个可替换的“表现层”,而不是把剧情引擎本体写死成某一类世界观。 + +## 6.2 建议的数据结构 + +```ts +interface ThemePack { + id: string; + displayName: string; + toneRange: string[]; + institutionLexicon: string[]; + tabooLexicon: string[]; + artifactClasses: string[]; + actorArchetypes: string[]; + conflictForms: string[]; + clueForms: string[]; + namingPatterns: string[]; + revealStyles: string[]; +} +``` + +## 6.3 题材适配层负责什么 + +它只负责: + +1. 把“机构”翻译成宗门、财团、学会、调查局、帮派、舰队、公司、教团等。 +2. 把“禁忌”翻译成邪术、封印、机密协议、校园旧规、污染区准则等。 +3. 把“叙事载体”翻译成遗物、枪械、芯片、病例、证物、祭器、录像带、样本、航图等。 +4. 把“冲突形式”翻译成宫廷斗争、公司内斗、调查失踪、阵营战争、神话追索、生存竞争等。 + +它不负责: + +1. 决定角色到底知道什么。 +2. 决定剧情推进是否合法。 +3. 决定哪些信息此刻允许披露。 + +## 7. 角色与阵营层 + +## 7.1 角色不是“背景文本”,而是“叙事立场体” + +每个角色都必须被拆成下面几个面向: + +1. 外显身份 +2. 当前处境 +3. 表面目标 +4. 真实目标 +5. 隐藏关系 +6. 已付代价 +7. 不愿被碰的禁区 +8. 会触发反应的关键词 + +## 7.2 建议的数据结构 + +```ts +interface ActorNarrativeProfile { + publicMask: string; + firstContactMask: string; + visibleLine: string; + hiddenLine: string; + contradiction: string; + debtOrBurden: string; + taboo: string; + immediatePressure: string; + relatedThreadIds: string[]; + relatedScarIds: string[]; + reactionHooks: string[]; +} +``` + +## 7.3 低关系角色的引擎规则 + +低关系、低好感、低信任角色必须满足: + +1. 有压力 +2. 有保留 +3. 有错位 +4. 有反应钩子 + +不能只是: + +1. 说得少 +2. 更冷淡 +3. 更短句 + +正确做法应该是: + +- 披露深度更低 +- 戏剧张力更高 +- 错误说辞更多 +- 观察与试探更明显 + +## 8. 信息可见性层 + +## 8.1 这是 AI 原生剧情引擎的核心 + +如果不把“可见性”数据化,AI 叙事会天然滑向全知视角。 + +因此必须明确区分: + +1. 事实是否存在 +2. 玩家是否知道 +3. 当前角色是否愿意说 +4. 当前 prompt 是否允许注入 +5. 玩家是否只是误以为知道 + +## 8.2 建议的数据结构 + +```ts +interface KnowledgeFact { + id: string; + content: string; + ownerActorIds: string[]; + relatedThreadIds: string[]; + visibility: 'public' | 'discoverable' | 'private' | 'forbidden'; +} + +interface VisibilitySlice { + factIds: string[]; + sayableFactIds: string[]; + inferredFactIds: string[]; + forbiddenFactIds: string[]; + misdirectionHints: string[]; +} +``` + +## 8.3 运行时规则 + +1. prompt 只吃 `VisibilitySlice` +2. 未解锁章节不等于不存在,但不能进当前 prompt +3. 角色知道某事,不等于此刻愿意承认 +4. 玩家接触到线索,不等于系统要直接盖章真相 + +## 9. 情境导演层 + +## 9.1 目标 + +每一轮剧情生成都不是“让 AI 自由写”,而是先由导演层判断: + +1. 此刻最重要的压力是什么 +2. 谁在主导场面 +3. 当前最适合推进的是明线、暗线还是关系线 +4. 哪些叙事载体应该被推到前台 + +## 9.2 导演层输入 + +- 当前场景 +- 当前实体 +- 当前关系状态 +- 当前可见信息 +- 最近信号变化 +- 玩家上一步行动 +- 尚未回响的故事线程 + +## 9.3 导演层输出 + +```ts +interface SceneNarrativeDirective { + primaryPressure: string; + activeThreadIds: string[]; + foregroundActorIds: string[]; + foregroundCarrierIds: string[]; + revealBudget: 'low' | 'medium' | 'high'; + emotionalCadence: 'tense' | 'curious' | 'hostile' | 'intimate' | 'tragic' | 'mysterious'; +} +``` + +## 10. 合约与信号推进层 + +## 10.1 目标 + +让剧情推进不依赖纯脚本,而是依赖“意图 -> 合约 -> 信号”。 + +## 10.2 统一抽象 + +1. 意图 + - 当前想推动什么关系、冲突、调查或获取。 + +2. 合约 + - 把意图翻译成可追踪的步骤、条件、参与者与回报。 + +3. 信号 + - 玩家行动、地点变化、物件获取、关系变化、战斗结果、情报拼接后触发推进。 + +## 10.3 为什么这层跨题材都成立 + +因为不管是: + +- 武侠寻仇 +- 科幻调查 +- 校园秘密 +- 末世生存 +- 神话追索 + +它们最终都能抽象成: + +- 某种线索被拿到 +- 某个误会被确认或打破 +- 某种关系被推进或撕裂 +- 某个真相片段被解锁 + +## 11. 叙事载体编译层 + +## 11.1 不要只把“物品”当成装备 + +跨题材剧情引擎里,叙事载体不应仅仅是“装备 / 道具”,而应统一抽象成: + +```ts +type NarrativeCarrierType = + | 'artifact' + | 'document' + | 'evidence' + | 'device' + | 'resource' + | 'corpse' + | 'sample' + | 'relic' + | 'ritual_object' + | 'memory_fragment'; +``` + +## 11.2 每个载体必须包含的叙事指纹 + +```ts +interface CarrierStoryFingerprint { + visibleClue: string; + witnessMark: string; + unresolvedQuestion: string; + currentAppearanceReason: string; + relatedThreadIds: string[]; + relatedScarIds: string[]; + reactionHooks: string[]; +} +``` + +## 11.3 编译规则 + +每个叙事载体都至少要能回答: + +1. 它是谁、哪处、哪次事留下的痕迹? +2. 它为什么现在出现? +3. 它之后能让谁产生反应? +4. 它和哪条线程有关系? + +这层的作用,是让: + +- 奇幻里的遗物 +- 武侠里的旧兵器 +- 科幻里的芯片 +- 悬疑里的口供 +- 恐怖里的录像带 +- 校园题材里的匿名纸条 + +都能进入同一套剧情引擎。 + +## 12. 记忆与回响层 + +## 12.1 目标 + +让世界对玩家行动和已获取的叙事载体产生长期回响,而不是每轮都像第一次发生。 + +## 12.2 记忆分层 + +建议至少拆成: + +1. 事件记忆 +2. 关系记忆 +3. 线索记忆 +4. 误解记忆 +5. 已揭示真相记忆 + +## 12.3 回响规则 + +一个故事线程真正成立,不是因为它被写过一次,而是因为它能在后续这些地方重新出现: + +1. 别的角色说法里 +2. 新地点残痕里 +3. 新载体命名里 +4. 新任务前提里 +5. 关系变化反应里 + +## 13. Prompt Contract 设计 + +## 13.1 建议拆成 6 类 contract + +1. 世界图谱 contract +2. 角色叙事档案 contract +3. 章节解锁 contract +4. 场景导演 contract +5. 叙事载体意图 contract +6. 回响总结 contract + +## 13.2 contract 总原则 + +1. AI 只拿当前阶段需要的最小上下文 +2. AI 不直接决定数值、库存、状态迁移、任务合法性 +3. AI 输出优先是“意图 / 钩子 / 视角 / 叙事指纹”,不是庞大成品对象 +4. 所有未解锁信息都不能被默认注入 + +## 14. 与当前仓库的接入方式 + +这套引擎并不是脱离当前项目另起炉灶,而是可以沿着已有骨架往前升级。 + +## 14.1 可直接复用的现有基础 + +1. `customWorld.ts` + - 已有世界生成骨架,可升级成“世界图谱 + 角色叙事档案”生成入口。 + +2. `prompt.ts` + - 已有上下文组织能力,可升级成“基于可见性切片”的 prompt 裁剪器。 + +3. `questDirector.ts` + - 已有任务导演方向,可升级成“线程 -> 合约 -> 信号”的推进器。 + +4. `runtimeItemDirector.ts` / `runtimeItemNarrative.ts` + - 已有运行时奖励与叙事包装能力,可升级成“叙事载体编译器”。 + +5. `npcInteractions.ts` + - 已有关系状态和首遇逻辑,可升级成“关系与披露双轴控制器”。 + +## 14.2 建议新增的模块 + +- `src/services/storyEngine/themePack.ts` +- `src/services/storyEngine/worldStoryGraph.ts` +- `src/services/storyEngine/visibilityEngine.ts` +- `src/services/storyEngine/actorNarrativeDossier.ts` +- `src/services/storyEngine/sceneNarrativeDirector.ts` +- `src/services/storyEngine/carrierNarrativeCompiler.ts` +- `src/services/storyEngine/echoMemory.ts` + +## 15. 验收标准 + +这套引擎至少要满足下面这些标准,才能算“跨题材 AI 原生剧情引擎”而不是“某一类题材文案增强器”。 + +1. 同一套引擎在至少 3 种明显不同的题材包里都能产出结构稳定的世界线程、角色秘密与叙事载体。 +2. 低关系角色在不同题材下都能做到“有压力、有错位、有暗线钩子”,而不是只会变冷淡。 +3. 未解锁信息不会在首遇、低披露或无关场景中提前进入 prompt。 +4. 至少 `80%` 的重点叙事载体都能被玩家看出与某条故事线程、某个旧伤或某个角色关系有关。 +5. 玩家在不看后台数据的情况下,仍能通过角色、物件、地点、任务描述拼出世界里的明线与暗线。 +6. 新增一个题材时,主要工作量集中在 `ThemePack` 与词汇适配,而不是重写剧情主逻辑。 + +## 16. 推荐落地顺序 + +## 阶段 A:先做引擎底层,不先卷文案 + +先补: + +- 世界图谱 +- 可见性切片 +- 角色叙事档案 + +## 阶段 B:再接当前项目最需要的两个落点 + +1. 大世界 NPC 背景与首遇表达 +2. 运行时物件名称、描述与回响 + +## 阶段 C:再把任务、地点、关系、载体接成一张网 + +重点做: + +- 线程推进 +- 合约生成 +- 信号触发 +- 回响回写 + +## 阶段 D:最后做多题材验证 + +至少选 3 种差异足够大的题材做压力测试: + +1. 高奇幻 / 武侠神话类 +2. 科幻 / 调查类 +3. 悬疑 / 恐怖 / 校园 / 现代类 + +## 17. 最后结论 + +真正可复用的 AI 原生剧情引擎,不应该先问“这句像不像某个题材”,而应该先问: + +1. 世界有哪些正在运行的明线和暗线? +2. 谁知道什么?谁不肯说什么?谁在承受什么? +3. 哪些角色、地点、物件、证物正在共同讲同一件事? +4. 当前这一轮,玩家应该感到的是压力、怀疑、诱惑、误导,还是揭示? + +只有当这些问题被引擎层回答清楚之后,不同题材的外观、词汇和风格,才会真正长在同一套 AI 原生剧情框架之上。 diff --git a/docs/prd/AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md b/docs/prd/AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md new file mode 100644 index 00000000..21c2b5a0 --- /dev/null +++ b/docs/prd/AI_NATIVE_NARRATIVE_THREAD_ITEM_AND_WORLD_NPC_PRD_2026-04-06.md @@ -0,0 +1,734 @@ +# AI 原生叙事线索驱动的物品命名与大世界 NPC 背景系统 PRD + +更新时间:`2026-04-06` + +## 0. 目标 + +这份 PRD 面向当前仓库,解决两个直接影响“故事感”的核心问题: + +1. 实时生成的物品名称、描述虽然已经能贴合 build 和来源,但整体仍偏模板拼接,缺少“它背后发生过什么”的故事重量。 +2. 大世界生成的 NPC 背景目前更像设定摘要,尤其是初始好感度低的角色,容易只剩“冷淡 / 戒备 / 不说”,却没有真正的戏剧张力和暗线钩子。 + +本次设计参考《博德之门 3》《黑神话:悟空》这类 RPG 的叙事组织方式,但不照搬具体内容,而是提炼出适合当前项目的 4 个目标: + +1. 每个 NPC 都不仅有“公开设定”,还要有能被逐步识别的明线、暗线、代价线与回响线。 +2. 每个运行时物品都不仅是“当前 build 的奖励”,还要像某件旧事留下的证物、遗物、债物或禁物。 +3. 初始好感度低不等于故事薄;低好感角色要更像“有东西不肯说”,而不是“没有东西可说”。 +4. AI 继续负责叙事表达与意图,本地规则继续负责数值、合法性、状态迁移与编译。 + +一句话目标: + +**让角色背景、物品名称、物品描述都成为世界主线与暗线的载体,而不是玩法奖励外面包的一层随机文案。** + +## 1. 当前问题定位 + +## 1.1 运行时物品叙事目前仍偏模板拼接 + +当前实现已经有 `runtimeItemContext -> runtimeItemDirector -> runtimeItemCompiler -> runtimeItemNarrative` 这条主链,玩法层是成立的,但叙事密度仍然不够。 + +主要问题有 3 个: + +1. `src/data/runtimeItemNarrative.ts` + - 名称仍然主要依赖 `来源词 + 关系词 + 功能词` 的固定拼接。 + - 描述仍然主要是“谁留下的什么 + 为什么出现 + 偏向什么 build”的单模板句式。 + +2. `src/data/runtimeItemNarrative.ts` + - fallback 的 `reasonToAppear` 仍是“与最近局势把它推到了你面前”这一类通用解释。 + - 这能解释“为什么它不是纯随机”,但解释不了“它到底见证过什么”。 + +3. `src/services/runtimeItemAiPrompt.ts` + - 当前 AI contract 仍聚焦 `shortNameSeed / sourcePhrase / reasonToAppear / relationHooks / desiredBuildTags`。 + - 它能产出“贴合当前局势”的轻量意图,但还不够支撑“证物感、传闻感、宿命感、旧债感”。 + +结果就是: + +- 物品已经不再是纯随机装备,但还没有成为能埋故事线索的叙事节点。 +- 玩家会知道“它适合当前 build”,但不一定会对“它从哪段旧事里来”产生兴趣。 + +## 1.2 自定义世界 NPC 生成仍偏“字段补齐”,不够像故事角色 + +当前 `src/services/customWorld.ts` 的自定义世界生成已经能产出完整的: + +- `backstory` +- `personality` +- `motivation` +- `combatStyle` +- `backstoryReveal` +- `skills` +- `initialItems` + +但问题在于,它更像一份“角色设定卡 JSON”,而不是“能在游玩里逐步显影的故事人物”。 + +当前的主要限制有: + +1. 单次生成字段很多,但每个字段长度被压得很短。 +2. `backstoryReveal` 固定 4 章,但更像摘要切片,而不是围绕世界冲突组织的叙事章节。 +3. 生成要求强调“不要改定位、不要超字段、字符串尽量简洁”,这对结构稳定有帮助,但会明显牺牲戏剧性。 + +结果就是: + +- NPC 有设定,但缺少真正的事件痕迹。 +- NPC 有动机,但缺少真正的秘密、债务、误认、旧案、禁忌、未完成关系。 +- NPC 有章节,但章节之间没有足够强的剧情递进与回响。 + +## 1.3 自定义世界 NPC 的信息边界仍在越权泄露 + +这部分是当前“低好感角色仍然不神秘”的一个关键根因。 + +目前在 `src/services/prompt.ts` 中,自定义世界 NPC 遭遇描述仍会注入: + +1. 完整 `backstory` +2. 所有 `backstoryReveal.chapters` +3. 技能与初始物品细节 + +同时 `describeCustomWorldSection(...)` 还会把多名 NPC 的: + +- 公开背景 +- 完整背景 +- 动机 +- 技能 +- 初始物品 +- 章节 teaser + +打成“自定义世界补充档案”整体注入。 + +这会直接导致两个问题: + +1. 模型在第一次见面、低好感、甚至仅仅“面前遭遇”阶段,就已经站在“全知视角”上写角色。 +2. 角色表面上虽然还在说得很少,但模型其实已经知道太多,所以写出来的话会天然带着“设定卡背书感”,而不是“此刻只肯露一角”。 + +## 1.4 初始低好感角色目前更多是“收口”,不是“施压” + +低好感角色无聊,核心不在于他们说得少,而在于他们缺少下面这些东西: + +1. 面前局势里的压力 +2. 说辞与真实动机之间的错位 +3. 对某个旧事件、旧物、旧人、旧地的条件反射 +4. 让玩家感觉“这人知道点什么,但现在不肯给”的钩子 + +也就是说: + +**当前低好感角色更多是信息减少了,但戏剧密度没有同步提高。** + +## 2. 设计参考转译 + +本次设计参考的不是具体桥段,而是两类成熟 RPG 的叙事组织方法。 + +## 2.1 来自《博德之门 3》的可借鉴点 + +1. 角色秘密不是独立文本,而是会影响初见印象、后续关系、任务走向与物品反应。 +2. 物品、书信、遗物、口供、尸体、地标都在一起构成线索网,而不是各说各话。 +3. 真正有意思的角色,往往不是“设定很多”,而是“表面、欲望、隐瞒、代价”彼此错位。 + +## 2.2 来自《黑神话:悟空》的可借鉴点 + +1. 名称本身就带旧事与异感,不只是功能标签。 +2. 很多信息不是直接说明,而是通过残缺线索、旧痕、俗称、误传、传闻去显影。 +3. 角色和物件都会带一种“事已经发生过,但后劲还在”的叙事余震。 + +## 2.3 对当前项目的转译原则 + +转成当前仓库可落地的做法后,所有重点 NPC 与重点物品都必须携带四条线: + +1. 明线 + - 玩家当前就能感知到的表层目标、来意、用途、冲突位置。 + +2. 暗线 + - 与世界冲突、旧事件、秘密关系、禁忌知识有关,但此刻不完全说透的线。 + +3. 代价线 + - 角色或物品背后已经失去过什么、欠着什么、被谁盯上、为什么不能轻易松口。 + +4. 回响线 + - 能与其他 NPC、场景、任务、物品互相印证的共享线索、意象、事件伤痕或势力痕迹。 + +## 3. 设计原则 + +1. AI 负责叙事层,本地负责规则层。 + - 不能让模型直接决定数值、掉落预算、好感变化和状态迁移。 + +2. 首次接触只给“可见的一角”,不给“全量设定”。 + - 低好感阶段更要严格限制 prompt 注入范围。 + +3. 低好感降低的是披露深度,不是故事密度。 + - 对方可以不坦白,但必须有压力、矛盾、误导、观察和反应。 + +4. 物品必须先是叙事证物,再是功能容器。 + - build 倾向依然重要,但需要嵌在“它是谁留下的、为什么出现在这里”之中。 + +5. 世界里的 NPC、物品、场景要共享同一套叙事词根与事件节点。 + - 不允许每次生成都像一个独立小宇宙。 + +6. UI 展示保持克制。 + - 不在面板里默认堆规则说明,只展示结果与线索感,符合项目已有的清爽游戏 UI 要求。 + +## 4. 核心系统结论 + +建议把当前“物品叙事 + 大世界 NPC 背景”升级为: + +**世界叙事图谱 -> NPC 叙事档案 -> 运行时物品叙事指纹 -> prompt 可见性裁剪 -> 反应与回响回写** + +它不是新起一套独立玩法,而是补在当前这些模块之上: + +- `src/services/customWorld.ts` +- `src/services/prompt.ts` +- `src/data/runtimeItemContext.ts` +- `src/data/runtimeItemDirector.ts` +- `src/data/runtimeItemNarrative.ts` +- `src/services/runtimeItemAiPrompt.ts` + +## 5. 世界级叙事图谱设计 + +## 5.1 目标 + +给自定义世界补一层比 `summary / tone / factions / landmarks` 更强的叙事骨架,让 NPC 和物品都不是凭空生出来,而是从同一套世界秘密与旧事件里长出来。 + +## 5.2 建议新增的数据结构 + +```ts +interface WorldNarrativeThread { + id: string; + title: string; + lineType: 'visible' | 'shadow'; + summary: string; + involvedFactions: string[]; + involvedNpcIds: string[]; + relatedLandmarkIds: string[]; + motifIds: string[]; +} + +interface WorldNarrativeScar { + id: string; + title: string; + pastEvent: string; + visibleResidue: string; + hiddenTruth: string; + relatedNpcIds: string[]; + relatedLandmarkIds: string[]; +} + +interface WorldNarrativeMotif { + id: string; + label: string; + usage: 'name' | 'item' | 'dialogue' | 'landmark'; + examples: string[]; +} + +interface CustomWorldNarrativeGraph { + visibleThreads: WorldNarrativeThread[]; + shadowThreads: WorldNarrativeThread[]; + scars: WorldNarrativeScar[]; + motifs: WorldNarrativeMotif[]; +} +``` + +并挂到: + +```ts +interface CustomWorldProfile { + narrativeGraph?: CustomWorldNarrativeGraph; +} +``` + +## 5.3 叙事图谱最少产出要求 + +每个自定义世界最少应生成: + +1. `3` 条明线线程 +2. `4~6` 条暗线线程 +3. `6~10` 个旧事件伤痕 +4. `12~20` 个世界词根 / 意象母题 + +这些词根不是为了写百科,而是为了让: + +- NPC 名字外的说话习惯 +- 物品名称中的来源词 +- 物品描述中的事件痕迹 +- 地标描述中的遗留痕迹 + +能够反复互相印证。 + +## 6. 大世界 NPC 叙事档案设计 + +## 6.1 当前 `backstoryReveal` 的问题 + +当前 `backstoryReveal` 已经有“按好感解锁”的结构,但更接近“把背景拆成 4 段”,还不够像“有明线暗线的角色档案”。 + +建议保留现有外层结构,但新增一层更强的叙事骨架。 + +## 6.2 建议新增的数据结构 + +```ts +interface NpcNarrativeDossier { + publicMask: string; + firstContactMask: string; + visibleLine: string; + shadowLine: string; + contradiction: string; + debtOrOath: string; + hiddenFearOrTaboo: string; + scenePressure: string; + relatedThreadIds: string[]; + relatedScarIds: string[]; + motifIds: string[]; + reactionHooks: string[]; + linkedItemSeeds: string[]; +} +``` + +建议挂到: + +```ts +interface CustomWorldRoleProfile { + narrativeDossier?: NpcNarrativeDossier; +} +``` + +字段含义如下: + +- `publicMask` + - 外人最容易听到或看到的版本。 + +- `firstContactMask` + - 第一次接触时这人会先拿出来挡在前面的那层说辞。 + +- `visibleLine` + - 当前出现在此地、此刻最能被玩家感知到的表层线。 + +- `shadowLine` + - 背后真正连着哪条暗线,但还不会直接说透。 + +- `contradiction` + - 这个角色最值得被玩家察觉的“说辞与事实错位”。 + +- `debtOrOath` + - 让角色更像活在故事中的关键债务、誓言、旧命令或未结关系。 + +- `hiddenFearOrTaboo` + - 这个角色不愿被提起的人、事、地、物或称谓。 + +- `scenePressure` + - 这个角色此刻为什么紧绷、拖延、转移、误导。 + +- `reactionHooks` + - 未来哪些人名、物名、伤痕、势力称呼会触发反应。 + +## 6.3 低初始好感角色的写法规则 + +低好感不是“少写”,而是换成“斜着写、卡着写、顶着写”。 + +建议按初始好感分 4 档处理。 + +| 初始好感区间 | 角色手感 | 必须出现的内容 | 禁止出现的内容 | +| --- | --- | --- | --- | +| `<= -10` | 敌意、误认、警惕、试图抢占叙事主导权 | 现场威胁、对玩家的判断、一个错误说辞、一个破绽 | 上来完整交代来历 | +| `-9 ~ 14` | 戒备、带压力的克制、把话题往表层拖 | 当前局势、表面理由、一个不愿明说的对象、一个观察细节 | 平铺直叙式自我介绍 | +| `15 ~ 39` | 正常交流但不交底 | 表层目标、与场景关联、一个旧事余波 | 直接说最终动机 | +| `>= 40` | 有合作空间但仍保留底牌 | 合作可能、旧债或旧誓、阶段性真相 | 一次性摊牌全部秘密 | + +关键规则: + +1. 低好感角色的首轮输出必须带“错位感”。 +2. 至少要有一个让玩家觉得“这句话不全对”的缝隙。 +3. 至少要有一个和此地事件、旧痕、物件、人物有关的具体钩子。 + +## 6.4 背景章节的重组方式 + +当前仍可保留 4 章,但不建议继续把它们仅仅当成“摘要 1、摘要 2、摘要 3、摘要 4”。 + +建议把 4 章改成稳定的功能分层: + +1. `surface` + - 表层来意 / 当下伪装 / 公开身份切口 + +2. `scar` + - 旧事裂痕 / 已经失去的东西 / 不愿再提的一次失败 + +3. `bind` + - 关系债务 / 誓言 / 阵营绑缚 / 必须维护的错误 + +4. `truth` + - 真正目标 / 最终底牌 / 真相代价 + +保留 `id` 稳定,标题允许按世界主题动态生成,而不是固定文案。 + +## 6.5 章节文本的写法要求 + +每一章都必须同时回答两件事: + +1. 这段旧事和世界哪条明线 / 暗线有关? +2. 它为什么会影响这个 NPC 今天的说话方式、站位、物品、关系或敌意? + +也就是说,章节正文不该只是“以前发生了什么”,而要能连回“现在为什么会这样”。 + +## 7. 运行时物品叙事指纹设计 + +## 7.1 当前问题 + +当前运行时物品已经能贴合: + +- 场景 +- 遭遇 +- build 缺口 +- 关系锚点 + +但它更像“上下文化奖励”,还不够像“故事痕迹”。 + +## 7.2 建议新增的数据结构 + +```ts +interface RuntimeItemStoryFingerprint { + visibleClue: string; + witnessMark: string; + unfinishedBusiness: string; + hiddenHook: string; + relatedThreadIds: string[]; + relatedScarIds: string[]; + motifIds: string[]; + reactionHooks: string[]; + namingPattern: + | 'npc_relic' + | 'scene_relic' + | 'faction_issue' + | 'monster_trophy' + | 'quest_evidence' + | 'forbidden_object'; +} +``` + +并挂到: + +```ts +interface RuntimeItemMetadata { + storyFingerprint?: RuntimeItemStoryFingerprint; +} +``` + +## 7.3 每件重点物品必须携带的叙事要素 + +对 `rare` 及以上,或者 `narrativeWeight = medium / heavy` 的物品,至少要同时有: + +1. 一个可见线索 + - 玩家光看名字、描述或出处就能捕到的痕迹。 + +2. 一个见证痕 + - 它像谁留下的、从哪次旧事里滚出来的、带着什么使用痕迹。 + +3. 一个未完成问题 + - 这件物品背后还有什么没结掉。 + +4. 一个当前出现理由 + - 为什么偏偏是现在、是这里、是你拿到它。 + +5. 一个可回响对象 + - 哪个 NPC / 场景 / 势力 / 任务之后可能对它起反应。 + +## 7.4 命名系统升级 + +当前的三段式命名方向是对的,但需要从“词块拼装”升级为“叙事指纹编译”。 + +建议按来源分 6 种命名范式: + +| 命名范式 | 适用来源 | 推荐结构 | 示例风格 | +| --- | --- | --- | --- | +| 人物遗物 | NPC 奖励、遗失物 | 旧称 / 誓约 / 功能物 | `断旗旧誓护心佩` | +| 场景遗物 | 宝藏、废墟、秘境 | 地标 / 灾痕 / 品类 | `沉炉灰纹短刃` | +| 势力制式 | 商店、军需、黑市 | 势力 / 制式 / 用途 | `巡河司缉印符` | +| 怪物战利 | 怪物掉落、生态素材 | 生态 / 异化 / 精粹 | `雾骨猎印精粹` | +| 任务证物 | 委托、追查、交付 | 事件 / 口供 / 信物 | `沉港失契信物` | +| 禁忌物 | 关键宝藏、暗线物 | 禁名 / 封痕 / 器类 | `烬名封缄骨匣` | + +要求不是字数更长,而是: + +- 名字里至少有一个世界词根 +- 至少有一个事件痕或关系痕 +- 最后才落到功能词或器类词 + +## 7.5 描述文案升级 + +当前描述模板需要升级为三层表达: + +1. 第一层:可见痕迹 + - 这件东西看起来像经历过什么。 + +2. 第二层:旧事牵连 + - 它和谁、哪处、哪次旧事有关。 + +3. 第三层:当前局势意义 + - 为什么此刻来到玩家手里,以及它偏向什么战斗或 build 方向。 + +示例结构: + +```text +表面痕迹句。旧事牵连句。当前局势与玩法意义句。 +``` + +要求: + +1. 第二句必须有“谁 / 哪处 / 哪次事”的具体指向之一。 +2. 不能只有“适合当前 build”这种系统性总结。 +3. 允许保留一点空白,不要把暗线直接讲穿。 + +## 8. Prompt Contract 升级 + +## 8.1 自定义世界 NPC 生成 prompt 的问题 + +当前 prompt 的主要问题,不是字段不够,而是: + +1. 生成阶段没有先产“世界叙事图谱”,导致 NPC 和物品共享词根不稳定。 +2. 角色阶段过早要求完整字段,导致模型更像在补设定表。 +3. 文本长度限制过严,压缩掉了故事张力。 + +## 8.2 建议改成三阶段生成 + +### 阶段 A:世界叙事图谱 + +先产出: + +- `visibleThreads` +- `shadowThreads` +- `scars` +- `motifs` + +### 阶段 B:角色骨架 + +只产出: + +- `name` +- `title` +- `role` +- `description` +- `initialAffinity` +- `relationshipHooks` +- `tags` +- `narrativeDossier` + +### 阶段 C:背景章节、技能、初始物品 + +基于前两阶段结果,再补: + +- `backstory` +- `backstoryReveal` +- `skills` +- `initialItems` + +这样做的目的不是把流程变复杂,而是防止: + +- 世界叙事图谱还没稳定,角色就先各自长成独立设定卡。 + +## 8.3 自定义世界 NPC 生成时的必填叙事约束 + +每个 NPC 必须明确写出: + +1. 当前站在哪条明线上 +2. 真正卷入哪条暗线 +3. 一件已经发生过、仍在影响他的旧事 +4. 一个不愿被直问的对象、地点、称呼或物件 +5. 一个与玩家可能建立连接的切入口 + +## 8.4 运行时物品 AI contract 升级 + +建议在当前 `RuntimeItemAiIntent` 外增加叙事指纹字段: + +```ts +interface RuntimeItemAiIntent { + shortNameSeed: string; + sourcePhrase: string; + reasonToAppear: string; + relationHooks: string[]; + desiredBuildTags: string[]; + desiredFunctionalBias: Array<'heal' | 'mana' | 'cooldown' | 'guard' | 'damage'>; + tone: 'grim' | 'mysterious' | 'martial' | 'ritual' | 'survival'; + visibleClue?: string; + witnessMark?: string; + unfinishedBusiness?: string; + hiddenHook?: string; + reactionHooks?: string[]; + namingPattern?: string; +} +``` + +AI 仍然不直接产出成品,只负责: + +- 提供线索 +- 提供见证感 +- 提供未完成之事 +- 提供命名范式建议 + +本地编译层再决定: + +- 最终名称 +- 最终描述 +- metadata 回写 +- 稀有度与 build 预算 + +## 9. Prompt 可见性裁剪规则 + +## 9.1 自定义世界 NPC 遭遇 prompt + +必须修正为: + +1. 首遇或低披露阶段 + - 只注入 `publicSummary` + - 只注入 `firstContactMask / visibleLine / scenePressure` + - 只注入已解锁章节的 `contextSnippet` + +2. 禁止直接注入 + - 完整 `backstory` + - 未解锁 `backstoryReveal.content` + - 全量章节摘要 + +## 9.2 自定义世界总档案注入 + +`buildCustomWorldReferenceText(...)` 不能再把多名 NPC 的完整背景和章节提示整体塞进主 prompt。 + +建议改成: + +1. 世界摘要 +2. 叙事图谱摘要 +3. 与当前场景 / 当前遭遇最相关的少量 NPC 索引 + +每名 NPC 只保留: + +- 名称 +- 身份 +- 公开背景 +- 所在线程 +- 关键反应钩子 + +## 9.3 低好感阶段的 prompt 目标 + +模型应该知道的是: + +- 这个角色此刻为什么不松口 +- 他在盯什么 +- 哪个问题会让他产生反应 + +模型不应该知道的是: + +- 他完整的人生说明书 + +## 10. UI 表达建议 + +UI 侧保持项目当前的清爽方向,不默认堆规则说明文案。 + +建议只做这几种表达: + +1. NPC 档案页 + - 公开背景 + - 已解锁章节 + - 未解锁章节 teaser + - 不额外展示系统术语 + +2. 重点物品 tooltip / 描述区 + - 名称 + - 三层式描述 + - 如需增加额外信息,只显示“来历”或“传闻”一行,不做规则说明板 + +3. 与物品 / 人物的回响 + - 优先通过剧情文本、反应文本、任务追加线索体现 + - 不优先通过 UI 标签硬显示“这是一条暗线” + +## 11. 验收标准 + +做到以下几点,才算这次需求真正成立: + +1. 随机抽样 `20` 个运行时重点物品时,至少 `80%` 的名称能看出明确来源词与事件痕,不再像纯功能拼接词。 +2. 随机抽样 `20` 个运行时重点物品时,`100%` 的描述都能同时回答“它经历过什么”和“为什么此刻出现”。 +3. 随机抽样 `20` 个自定义世界 NPC 时,`100%` 都能指出自己挂在哪条明线、暗线和哪道旧伤上。 +4. 初始好感度低于 `15` 的 NPC,首轮文本中至少有一个“说辞与真实意图错位”的点。 +5. 首遇与低披露阶段的自定义世界 NPC prompt 中,不再直接注入完整 `backstory` 与未解锁章节。 +6. 同一世界内,物品名、场景名、NPC 章节和剧情文本之间,能够复用同一组词根与事件痕,而不是每次随机飘散。 +7. 玩家在不看后台数据的情况下,也能从名字、描述、对话和档案 teaser 中隐约拼出世界里的明线与暗线。 + +## 12. 推荐落地顺序 + +## 阶段 A:先修正信息边界与现状问题 + +优先改: + +- `src/services/prompt.ts` +- `src/services/customWorld.ts` +- `src/services/customWorldReferenceText` 相关逻辑 + +目标: + +- 堵住完整背景与未解锁章节的越权注入 +- 让低好感阶段先重新成立 + +## 阶段 B:补世界叙事图谱 + +新增: + +- `CustomWorldNarrativeGraph` +- 世界线程、旧伤、意象词根 + +目标: + +- 让 NPC 与物品共享同一套故事母题 + +## 阶段 C:补 NPC 叙事档案 + +新增: + +- `NpcNarrativeDossier` + +调整: + +- `backstoryReveal` 的生成逻辑 +- 低初始好感 NPC 的首遇表达规则 + +目标: + +- 让“低好感但有戏”成为稳定产物 + +## 阶段 D:补运行时物品叙事指纹 + +新增: + +- `RuntimeItemStoryFingerprint` + +调整: + +- `runtimeItemAiPrompt` +- `runtimeItemNarrative` + +目标: + +- 让物品名称与描述能承载旧事、见证与未完成问题 + +## 阶段 E:做回响与反应 + +最后接: + +- NPC 对特定物品的反应 +- 任务对物品线索的承接 +- 场景与旧伤的互相印证 + +目标: + +- 让“名字和背景有故事”真正进入游玩闭环,而不是只停在文案层 + +## 13. 涉及文件建议 + +建议优先改动这些区域: + +- `src/types/customWorld.ts` +- `src/types/runtimeItem.ts` +- `src/services/customWorld.ts` +- `src/services/prompt.ts` +- `src/services/runtimeItemAiPrompt.ts` +- `src/data/runtimeItemNarrative.ts` +- `src/data/runtimeItemDirector.ts` + +建议新增这些模块: + +- `src/services/customWorldNarrative.ts` +- `src/services/customWorldNarrativePrompt.ts` +- `src/data/runtimeItemStoryCompiler.ts` + +## 14. 一句话结论 + +这次要做的,不是把物品文案写得更华丽,也不是把 NPC 背景写得更长,而是: + +**让世界里的每个人、每件物都像被主线和暗线真正碰过,名字里有来路,描述里有旧事,对话里有保留,低好感时也能让玩家感觉到背后压着一整段没被说出来的故事。** diff --git a/scripts/dev-server/localApiPlugins.ts b/scripts/dev-server/localApiPlugins.ts index 18c900c9..145dd6a8 100644 --- a/scripts/dev-server/localApiPlugins.ts +++ b/scripts/dev-server/localApiPlugins.ts @@ -8,7 +8,7 @@ import http, { import https from 'node:https'; import path from 'node:path'; -import type { Plugin } from 'vite'; +import { loadEnv, type Plugin } from 'vite'; const LLM_PROXY_PATH = '/api/llm/chat/completions'; const ITEM_CATALOG_PATH = '/api/item-catalog'; @@ -92,6 +92,17 @@ function normalizeDashScopeBaseUrl(value: string) { return value.replace(/\/$/u, ''); } +function resolveRuntimeEnv( + rootDir: string, + mode: string, + env: Record, +) { + return { + ...env, + ...loadEnv(mode, rootDir, ''), + }; +} + function extractApiErrorMessage(responseText: string, fallbackMessage: string) { if (!responseText.trim()) { return fallbackMessage; @@ -327,16 +338,24 @@ function proxyStreamingRequest( }); } -function createLlmProxyPlugin(env: Record): Plugin { - const upstreamBaseUrl = normalizeUpstreamBaseUrl( - env.VITE_LLM_BASE_URL || - env.LLM_BASE_URL || - 'https://ark.cn-beijing.volces.com/api/v3', - ); - const apiKey = - env.LLM_API_KEY || env.ARK_API_KEY || env.VITE_LLM_API_KEY || ''; - +function createLlmProxyPlugin( + rootDir: string, + mode: string, + env: Record, +): Plugin { const handler = async (req: IncomingMessage, res: ServerResponse) => { + const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env); + const upstreamBaseUrl = normalizeUpstreamBaseUrl( + runtimeEnv.VITE_LLM_BASE_URL || + runtimeEnv.LLM_BASE_URL || + 'https://ark.cn-beijing.volces.com/api/v3', + ); + const apiKey = + runtimeEnv.LLM_API_KEY || + runtimeEnv.ARK_API_KEY || + runtimeEnv.VITE_LLM_API_KEY || + ''; + if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; @@ -783,21 +802,23 @@ function createStateFunctionOverridesPlugin(rootDir: string): Plugin { function createCustomWorldSceneImagePlugin( rootDir: string, + mode: string, env: Record, ): Plugin { - const baseUrl = normalizeDashScopeBaseUrl( - env.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, - ); - const apiKey = env.DASHSCOPE_API_KEY || ''; - const defaultModel = - env.DASHSCOPE_IMAGE_MODEL || DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL; - const taskTimeoutMs = Number( - env.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || - env.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS || - DASHSCOPE_TASK_TIMEOUT_MS, - ); - const handler = async (req: IncomingMessage, res: ServerResponse) => { + const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env); + const baseUrl = normalizeDashScopeBaseUrl( + runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, + ); + const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; + const defaultModel = + runtimeEnv.DASHSCOPE_IMAGE_MODEL || DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL; + const taskTimeoutMs = Number( + runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || + runtimeEnv.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS || + DASHSCOPE_TASK_TIMEOUT_MS, + ); + if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; @@ -1451,11 +1472,12 @@ function createCharacterAnimationPublishPlugin(rootDir: string): Plugin { export function createLocalApiPlugins( rootDir: string, + mode: string, env: Record, ): Plugin[] { return [ - createLlmProxyPlugin(env), - createCustomWorldSceneImagePlugin(rootDir, env), + createLlmProxyPlugin(rootDir, mode, env), + createCustomWorldSceneImagePlugin(rootDir, mode, env), createItemCatalogPlugin(rootDir), createItemOverridesPlugin(rootDir), createNpcVisualOverridePlugin(rootDir), diff --git a/src/components/CharacterPanel.tsx b/src/components/CharacterPanel.tsx index 8cafe845..18712cc7 100644 --- a/src/components/CharacterPanel.tsx +++ b/src/components/CharacterPanel.tsx @@ -1,6 +1,10 @@ import { AnimatePresence, motion } from 'motion/react'; import { type CSSProperties, useEffect, useMemo, useState } from 'react'; +import { + resolveRoleCombatStats, + type RoleCombatStats, +} from '../data/attributeCombat'; import { formatAttributeList, resolveAttributeSchema, @@ -260,6 +264,10 @@ function formatAttributeMetricValue(value: number) { return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1); } +function formatAttributePercentValue(value: number) { + return `${formatAttributeMetricValue(value * 100)}%`; +} + function getAttributeBonusPillClassName(bonus: number) { if (bonus >= 0.05) { return 'border-amber-400/25 bg-amber-500/12 text-amber-100'; @@ -270,6 +278,29 @@ function getAttributeBonusPillClassName(bonus: number) { return 'border-white/10 bg-black/20 text-zinc-500'; } +function getAttributeEffectText( + slotId: string, + combatStats: RoleCombatStats, + resourceLabels: ReturnType, +) { + switch (slotId) { + case 'axis_a': + return `攻击倍率 x${formatAttributeMetricValue(combatStats.attackPowerMultiplier)}`; + case 'axis_b': + return `${resourceLabels.maxHp} +${combatStats.maxHpBonus}`; + case 'axis_c': + return `${resourceLabels.hp}恢复 +${combatStats.storyRecovery}`; + case 'axis_d': + return `攻击速度 ${formatAttributeMetricValue(combatStats.turnSpeed)}`; + case 'axis_e': + return `暴击率 ${formatAttributePercentValue(combatStats.critChance)}`; + case 'axis_f': + return `暴击伤害 x${formatAttributeMetricValue(combatStats.critDamageMultiplier)}`; + default: + return '提升战斗表现'; + } +} + function buildLeaderEquipmentRows( playerCharacter: Character, playerEquipment: EquipmentLoadout, @@ -461,19 +492,26 @@ export function CharacterPanel({ ? buildLeaderEquipmentRows(playerCharacter, playerEquipment) : buildCompanionEquipmentRows(selectedMember.character, selectedMember.id) : []; - const selectedAttributeRows = useMemo( + const selectedMemberAttributeProfile = useMemo( () => selectedMember + ? resolveCharacterAttributeProfile( + selectedMember.character, + worldType, + customWorldProfile, + ) + : null, + [customWorldProfile, selectedMember, worldType], + ); + const selectedAttributeRows = useMemo( + () => + selectedMemberAttributeProfile ? formatAttributeList( - resolveCharacterAttributeProfile( - selectedMember.character, - worldType, - customWorldProfile, - ), + selectedMemberAttributeProfile, selectedAttributeSchema, ) : [], - [customWorldProfile, selectedAttributeSchema, selectedMember, worldType], + [selectedAttributeSchema, selectedMemberAttributeProfile], ); const selectedAttributeBonusBySlot = useMemo( () => @@ -493,20 +531,67 @@ export function CharacterPanel({ ) as Record, [selectedAttributeSchema, selectedBuildBreakdown], ); + const selectedBoostedAttributeProfile = useMemo(() => { + if (!selectedMemberAttributeProfile) { + return null; + } + + return { + ...selectedMemberAttributeProfile, + values: { + ...(selectedMemberAttributeProfile.values ?? {}), + ...Object.fromEntries( + selectedAttributeSchema.slots.map((slot) => { + const baseValue = + selectedMemberAttributeProfile.values?.[slot.slotId] ?? 0; + const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0; + + return [ + slot.slotId, + Number((baseValue * (1 + totalBonus)).toFixed(4)), + ]; + }), + ), + }, + }; + }, [ + selectedAttributeBonusBySlot, + selectedAttributeSchema, + selectedMemberAttributeProfile, + ]); + const selectedBoostedCombatStats = useMemo( + () => + selectedMember + ? resolveRoleCombatStats(selectedBoostedAttributeProfile) + : null, + [selectedBoostedAttributeProfile, selectedMember], + ); const selectedDisplayAttributeRows = useMemo( () => selectedAttributeRows.map(({ slot, value }) => { const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0; - const boostedValue = value * (1 + totalBonus); + const boostedValue = Number((value * (1 + totalBonus)).toFixed(4)); return { slot, baseValue: value, boostedValue, totalBonus, + effectText: selectedBoostedCombatStats + ? getAttributeEffectText( + slot.slotId, + selectedBoostedCombatStats, + resourceLabels, + ) + : slot.combatUseText, }; }), - [selectedAttributeBonusBySlot, selectedAttributeRows], + [ + resourceLabels, + selectedAttributeBonusBySlot, + selectedAttributeRows, + selectedBoostedCombatStats, + ], ); const selectedContributionAttributes = selectedContributionRow ? getBuildContributionAttributeRows( @@ -718,7 +803,6 @@ export function CharacterPanel({ -
@@ -877,7 +961,13 @@ export function CharacterPanel({
{selectedDisplayAttributeRows.map( - ({ slot, baseValue, boostedValue, totalBonus }) => ( + ({ + slot, + baseValue, + boostedValue, + totalBonus, + effectText, + }) => (
-
+
{formatAttributeMetricValue(boostedValue)}
-
+
+
+ + 标签加成{' '} + {formatBuildContributionPercent(totalBonus)} + +
原始 {formatAttributeMetricValue(baseValue)}
- - {formatBuildContributionPercent(totalBonus)} -
-
- {slot.definition} +
+ {effectText}
), diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index 4943c5cf..d2d1eaac 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -31,6 +31,7 @@ import { CustomWorldPlayableNpc, CustomWorldProfile, CustomWorldSceneConnection, + type ItemRarity, } from '../types'; import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { CharacterAnimator } from './CharacterAnimator'; @@ -64,6 +65,14 @@ const [ BACKSTORY_UNLOCK_AFFINITY_CLOSE, ] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS; +const ITEM_RARITY_OPTIONS: Array<{ value: ItemRarity; label: string }> = [ + { value: 'common', label: 'common' }, + { value: 'uncommon', label: 'uncommon' }, + { value: 'rare', label: 'rare' }, + { value: 'epic', label: 'epic' }, + { value: 'legendary', label: 'legendary' }, +]; + function slugify(value: string) { const normalized = value .trim() @@ -101,6 +110,48 @@ function clampInitialAffinity(value: string, fallback: number) { return Math.max(-40, Math.min(90, Math.round(parsed))); } +function parseOptionalNumber(value: string) { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function createRoleSkillDraft(seedLabel: string, index: number) { + return { + id: createEntryId('skill', seedLabel, Date.now() + index), + name: `新技能${index + 1}`, + summary: '', + style: '起手压制', + }; +} + +function createRoleInitialItemDraft(seedLabel: string, index: number) { + return { + id: createEntryId('item', seedLabel, Date.now() + index), + name: `新物品${index + 1}`, + category: '材料', + quantity: 1, + rarity: 'rare' as ItemRarity, + description: '', + tags: [], + }; +} + +function createBackstoryChapterDraft(seedLabel: string, index: number) { + return { + id: createEntryId('backstory-chapter', seedLabel, Date.now() + index), + title: `背景片段${index + 1}`, + affinityRequired: + AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS[ + Math.min(index, AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.length - 1) + ] ?? BACKSTORY_UNLOCK_AFFINITY_CLOSE, + teaser: '', + content: '', + contextSnippet: '', + }; +} + function syncLandmarksWithStoryNpcs( landmarks: CustomWorldLandmark[], storyNpcs: CustomWorldProfile['storyNpcs'], @@ -700,7 +751,8 @@ function SaveBar({ onSave: () => void; }) { return ( -
+
+
+ > +
+ 保存修改 + +
+ +
); } +function SectionPanel({ + title, + subtitle, + actions, + children, +}: { + title: string; + subtitle?: string; + actions?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+
+
+ {title} +
+ {subtitle ? ( +
+ {subtitle} +
+ ) : null} +
+ {actions} +
+
{children}
+
+ ); +} + +function BackstoryRevealEditor({ + value, + onChange, +}: { + value: CustomWorldPlayableNpc['backstoryReveal']; + onChange: (value: CustomWorldPlayableNpc['backstoryReveal']) => void; +}) { + const updateChapter = ( + index: number, + updater: ( + chapter: CustomWorldPlayableNpc['backstoryReveal']['chapters'][number], + ) => CustomWorldPlayableNpc['backstoryReveal']['chapters'][number], + ) => { + onChange({ + ...value, + chapters: value.chapters.map((chapter, chapterIndex) => + chapterIndex === index ? updater(chapter) : chapter, + ), + }); + }; + + const addChapter = () => { + onChange({ + ...value, + chapters: [ + ...value.chapters, + createBackstoryChapterDraft('custom-role', value.chapters.length), + ], + }); + }; + + const removeChapter = (index: number) => { + if (value.chapters.length <= 1) { + window.alert('至少保留一个背景章节。'); + return; + } + + onChange({ + ...value, + chapters: value.chapters.filter( + (_chapter, chapterIndex) => chapterIndex !== index, + ), + }); + }; + + return ( + + } + > + +