This commit is contained in:
2026-04-19 20:33:18 +08:00
parent 692643136f
commit 67c584b4df
123 changed files with 11898 additions and 4082 deletions

View File

@@ -92,7 +92,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. # Server-side DashScope endpoint and API key used by the local scene-image proxy.
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1" DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990" DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
# Optional model name for custom-world scene image generation. # Optional model name for custom-world scene image generation.
DASHSCOPE_IMAGE_MODEL="wan2.7-image" DASHSCOPE_IMAGE_MODEL="wan2.7-image"
@@ -100,10 +100,17 @@ DASHSCOPE_IMAGE_MODEL="wan2.7-image"
# Optional model names for character asset studio. # Optional model names for character asset studio.
DASHSCOPE_CHARACTER_VISUAL_MODEL="wan2.7-image-pro" DASHSCOPE_CHARACTER_VISUAL_MODEL="wan2.7-image-pro"
DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL="wan2.7-image-pro" DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL="wan2.7-image-pro"
DASHSCOPE_CHARACTER_VIDEO_MODEL="wan2.7-i2v"
DASHSCOPE_CHARACTER_REFERENCE_VIDEO_MODEL="wan2.7-r2v" DASHSCOPE_CHARACTER_REFERENCE_VIDEO_MODEL="wan2.7-r2v"
DASHSCOPE_CHARACTER_MOTION_TRANSFER_MODEL="wan2.2-animate-move" DASHSCOPE_CHARACTER_MOTION_TRANSFER_MODEL="wan2.2-animate-move"
# Optional Ark Seedance config for character animation image-to-video.
# If omitted, image-to-video will fall back to `ARK_API_KEY` / `LLM_API_KEY`
# and `ARK_BASE_URL` / `LLM_BASE_URL`.
ARK_CHARACTER_VIDEO_BASE_URL="https://ark.cn-beijing.volces.com/api/v3"
ARK_CHARACTER_VIDEO_API_KEY=""
ARK_CHARACTER_VIDEO_MODEL="doubao-seedance-2-0-fast-260128"
ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS="420000"
# Optional: server-side polling timeout for custom-world scene image generation, in milliseconds. # Optional: server-side polling timeout for custom-world scene image generation, in milliseconds.
DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS="150000" DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS="150000"

View File

@@ -1,5 +1,9 @@
VITE_LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3" VITE_LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3"
LLM_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e" LLM_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e"
ARK_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e"
ARK_CHARACTER_VIDEO_BASE_URL="https://ark.cn-beijing.volces.com/api/v3"
ARK_CHARACTER_VIDEO_API_KEY="eb750614-e0b5-402a-bfea-4224862d251e"
ARK_CHARACTER_VIDEO_MODEL="doubao-seedance-2-0-fast-260128"
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1" DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990" DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990"

View File

@@ -81,7 +81,7 @@ docs/
│ ├─ AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md │ ├─ AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md
│ ├─ BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md │ ├─ BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md
│ └─ RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md │ └─ RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md
├─ reference/ ├─ reference/[QwenSpriteSheetTool.tsx](src/tools/QwenSpriteSheetTool.tsx)
│ ├─ README.md │ ├─ README.md
│ └─ FUNCTION_SCRIPT_CATALOG_2026-04-04.md │ └─ FUNCTION_SCRIPT_CATALOG_2026-04-04.md
└─ technical/ └─ technical/

View File

@@ -15,6 +15,7 @@
- [FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md](./FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md)Function 运行时完整测试、服务端承接验证与当前门禁缺口。 - [FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md](./FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md)Function 运行时完整测试、服务端承接验证与当前门禁缺口。
- [ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md](./ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md):物品生成与 Build 标签系统对 PRD 的落地情况。 - [ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md](./ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md):物品生成与 Build 标签系统对 PRD 的落地情况。
- [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。 - [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。
- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。
## 推荐使用方式 ## 推荐使用方式

View File

@@ -0,0 +1,524 @@
# 工程清理与后端边界审计2026-04-19
更新时间:`2026-04-19`
## 0. 审计目标
本次审计只回答四类问题:
1. 项目里哪些内容已经是高置信度的垃圾、临时产物或无入口代码。
2. 哪些实现属于双份真相、重复映射或旧链路残留。
3. 哪些前端代码仍然承担了应迁移到 Express 后端的职责。
4. 哪些文件已经大到会持续拖累迭代效率,需要优先拆分。
---
## 1. 结论先行
当前仓库的主要问题不是“有一些小工具没人用”,而是四类结构性噪音同时存在:
1. **仓库噪音产物仍然很多。**
根目录残留了大量 `.codex-*.log``tmp_*`、旧截图/HTML以及 `temp-build-goal-check/` 这类大体量检查产物,已经不是单个文件层面的脏数据,而是在持续污染工程视野。
2. **旧入口和新入口并存,形成了明显的冗余链路。**
`scripts/dev-server/localApiPlugins.ts` 已经退出当前正式开发入口,但仍保留了 LLM proxy、JSON 写盘、资产发布等整套旧 Vite 本地 API 机制。
3. **前端仍然承载了过多运行时规则与 AI 编排。**
`src/services/ai.ts``src/services/customWorld.ts``src/hooks/story/npcEncounterActions.ts` 这类文件,仍在浏览器里承担 prompt 组装、规则判定、奖励结算、剧情推进等职责。
4. **后端边界还没有真正闭合。**
`server-node` 虽然已经承接了大量路由和运行时动作,但仍直接 import `src/services/customWorld*.ts``src/types.ts`,说明后端领域层还没有完全从前端目录中独立出来。
一句话判断:
**这轮优先级不该再是继续堆功能,而是先清仓库噪音与无入口孤岛,再把前后端双份真相收口,最后拆新的巨型热点文件。**
---
## 2. 本次审计方法与口径
### 2.1 方法
本次审计结合了四类证据:
1. 文档基线:
- `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md`
- `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md`
- `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md`
- `scripts/dev-server/README.md`
2. 当前入口核对:
- `src/main.tsx`
- `src/routing/appRoutes.tsx`
- `src/App.tsx`
- `package.json`
- `server-node/package.json`
3. 静态依赖扫描:
-`src/``server-node/src/``packages/shared/src/``scripts/``650` 个 TS/JS 文件做本地依赖图扫描。
4. 定向 grep
- 核对旧 dev 插件入口、后端跨层 import、localStorage 使用、运行时快照双写、重复映射代码。
### 2.2 口径说明
为避免误判,本次审计明确排除了两类对象:
1. **包脚本入口**:例如 `scripts/build-gate.mjs``scripts/check-encoding.mjs``server-node/build.mjs` 这类由 `package.json` 直接执行的脚本,不因“无 import”而判为垃圾。
2. **字符串路径消费的资源**:例如 `src/data/itemOverrides.json``src/data/monsterOverrides.json` 会被校验脚本和 editor route 以文件路径读取,不按“无 import”处理。
另外,当前工作区存在未提交改动,因此本次结论以**已纳入当前主链且能确认未接线/重复/越界的内容**为主,不把明显的当日 WIP 文件计入垃圾结论。
---
## 3. 高置信度垃圾、临时产物与无入口代码
## 3.1 仓库噪音产物已经到了需要集中清理的程度
### 证据
| 项目 | 当前证据 | 判断 |
| --- | --- | --- |
| 根目录日志/临时文件 | 根目录命中 `60``.codex-*.log``.preview.*``tmp_*``npc-editor-*``temp-write-check.txt`,合计约 `52.36 MB` | 已经不是偶发临时文件,而是长期堆积的开发残留 |
| `temp-build-goal-check/` | 当前包含 `15099` 个文件,合计约 `166.56 MB` | 大体量检查产物,应该移出主工程视野 |
| Python 缓存 | 当前存在 `scripts/__pycache__/` | 纯缓存产物,不应长期留在仓库工作区中 |
### 影响
1. 根目录信噪比明显下降,真实工程文件被大量一次性产物淹没。
2. `temp-build-goal-check/` 虽然已被 `.gitignore``vite.config.ts` 的 watch 忽略模式覆盖,但 `.eslintrc.cjs``ignorePatterns` 里没有对应口径,仍存在工具口径不一致问题。
3. 这类目录会持续干扰检索、review、lint 判断和本地扫描速度。
### 建议
1. 把根目录临时日志、扫描 txt/html、旧截图统一迁到单独的 `tmp/` 或本地缓存目录,默认不留在仓库根目录。
2.`temp-build-goal-check/` 改成真正的外置检查产物目录,或者在 lint/脚本口径上一起排除。
3. 清理 `scripts/__pycache__/`,并统一补上 Python 缓存忽略规则。
---
## 3.2 旧 Vite 本地 API 插件链已经退出主入口,但仍保留整套旧实现
### 证据
1. `scripts/dev-server/README.md` 已明确写明:`scripts/dev-server/**` 不再是当前开发入口,只保留为迁移参考。
2. `scripts/dev-server/localApiPlugins.ts` 当前仍有 `1664` 行。
3. 仓库内已经找不到 `localApiPlugins` 的实际代码入口引用,当前只剩文档引用。
4. 该文件内部仍然同时定义和拼装:
- `createLlmProxyPlugin`
- `createJsonFileEditorPlugin`
- `createCustomWorldSceneImagePlugin`
- `createCharacterVisualPublishPlugin`
- `createCharacterAnimationPublishPlugin`
- `createCharacterAssetStudioPlugins`
- `createQwenSpriteSheetToolPlugins`
### 判断
这不是“一个小工具暂时没用”,而是**整条旧 editor/assets 本地 API 链路仍然完整保留在仓库里**。它在工程上已经属于高置信度的历史残留。
### 建议
1. 如果只保留迁移证据,建议把 `scripts/dev-server/localApiPlugins.ts` 和相关说明迁到 `docs/reference/` 或单独的 `archive/` 目录。
2. 如果确实还要保留参考代码,至少要在文件顶部加更强的“只读参考、禁止继续扩展”标识,并从主工程扫描面上进一步隔离。
3. 不建议继续在这条旧链路里新增任何 `/api/*` 能力。
---
## 3.3 当前存在一批“无运行时入口”或“仅测试引用”的孤岛模块
### 高置信度无入口/仅测试引用清单
| 模块 | 证据 | 判断 |
| --- | --- | --- |
| `src/components/GameShell.tsx` | 文件体量 `761` 行;当前 `src/App.tsx` 只接入 `components/game-shell/GameShellRuntime.tsx`;仓库内无其它 import | 旧版壳层残留 |
| `src/components/custom-world-home/CustomWorldCreationHub.tsx` | 仅被 `CustomWorldCreationHub.test.tsx``CustomWorldCreationHub.interaction.test.tsx` 引用;`src/routing/appRoutes.tsx` 只有 `game``qwen-sprite-tool` 两条路由 | 已做出 UI但未进入正式入口 |
| `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx` | 当前无运行时引用 | 同属未接线入口壳层 |
| `src/components/custom-world-agent/*``9` 个子模块 | 当前合计约 `826` 行;典型文件包括 `CustomWorldAgentLauncherModal.tsx``CustomWorldAgentDraftDrawer.tsx``CustomWorldAgentLockBar.tsx``CustomWorldAgentQuickActions.tsx``CustomWorldAgentSummaryPanel.tsx`;部分文件完全无引用,部分仅被测试引用 | 处于“做了一部分 UI但未进入主链”的孤岛状态 |
| `src/hooks/story/storyBootstrap.ts` | `250` 行,仓库内只定义不消费 | 已被新流程替代的可能性高 |
| `src/hooks/useEquipmentFlow.ts` / `useForgeFlow.ts` / `useInventoryFlow.ts` | 合计约 `393` 行,当前无运行时引用 | 旧流转层残留 |
| `src/editor/shared/cloneValue.ts` / `EditorEmptyState.tsx` / `EditorSelectionCard.tsx` / `useJsonSave.ts` | 当前无运行时引用 | editor 旧共享层碎片 |
| `src/services/customWorldPresentation.stub.ts` | 当前无引用,且文件本身就是 stub | 高置信度占位残留 |
| `src/services/typewriter.ts` | 当前无引用,仅提供一个 `getTypewriterDelay` | 已被其它链路内联实现替代 |
| `src/data/buildTagSimilarity.generated.ts` | 当前 `823` 行,仅能被生成脚本自身检索到,没有消费方 | 生成产物未接入任何业务链路 |
| `src/data/customWorldCharacterLoadout.stub.ts` | 当前无引用,且实现只返回空数组 | 占位残留 |
| `src/components/DeveloperTeamModal.tsx` / `src/components/LazySkillEffectPreview.tsx` | 当前无运行时引用 | 小体量零散孤岛 |
### 判断
这批文件不一定都应该“立刻删除”,但它们已经满足两个至少其一:
1. 当前正式入口完全不消费。
2. 只剩测试在消费,本体没有真实运行时位置。
所以它们至少都应该进入以下三选一处理:
1. 立即归档/删除。
2. 明确接回正式入口。
3. 改名或迁目录,标明“实验稿/参考稿/未接线”身份。
### 特别提醒
`src/components/custom-world-home/``src/components/custom-world-agent/` 这两组文件里,存在**已经有一定 UI 完成度、但没有进入真实路由/流程**的情况。
这类文件最危险的点不是体量,而是会让后来者误以为“这块功能已经在主链上”。
---
## 4. 冗余实现与双份真相
## 4.1 Story option interaction 映射在前后端各维护了一份
### 证据
1. 前端 `src/services/runtimeStoryService.ts``buildRuntimeOptionInteraction` 维护了 `npcActionMap``treasureActionMap`
2. 后端 `server-node/src/modules/story/storyActionService.ts``buildStoryOptionInteraction` 维护了几乎同构的一份 `npcActionMap``treasureActionMap`
### 风险
1. 任何一个 functionId 增删改,前后端都要同步。
2. 一边先改、一边漏改时,表现层和运行时层会出现静默漂移。
### 建议
把 interaction/view model 映射收口到后端,前端只消费后端返回的结构,不再根据 `functionId` 本地重建一遍交互语义。
---
## 4.2 浏览历史已经有后端接口,但前端仍维护本地真相与迁移状态
### 证据
1. `src/components/game-shell/PreGameSelectionFlow.tsx` 中,`appendBrowseHistoryEntry` 先调用 `writePlatformBrowseHistory` 写本地,再调用 `upsertProfileBrowseHistory` 写后端。
2. 同文件启动阶段又会先读 `readPlatformBrowseHistory`,再根据 `hasPendingPlatformBrowseHistoryMigration` 把本地历史同步回后端。
3. 后端 `server-node/src/routes/runtimeRoutes.ts` 已经提供了 `/profile/browse-history` 路由,而前端 `src/services/storageService.ts` 也已有对应 API SDK。
### 判断
当前浏览历史并不是单纯的“本地缓存”,而是**本地存储 + 远端持久化 + 迁移标记**三套状态并存。
### 建议
1. 后端结果作为唯一真相源。
2. 前端如果要保留缓存,只保留一个明确的 cache wrapper不再把它做成独立状态系统。
3. `markPlatformBrowseHistoryMigrated` 这种迁移标记应尽量在后端一次性收口,而不是长期停留在正式前端逻辑里。
---
## 4.3 运行时快照依然由前端先落本地,再与后端会话互相回填
### 证据
1. `src/hooks/story/runtimeStoryCoordinator.ts` 在读状态和提交 action 前都会先调用 `putSaveSnapshot`
2. 同文件以及 `src/services/runtimeStoryService.ts` 又会在响应后多次 `rehydrateSavedSnapshot`
3. 这意味着浏览器仍然在“后端 action 之前”先写一份自己的快照解释。
### 判断
这条链路说明当前运行时还处在**前端快照解释权没有完全退出**的过渡状态。
### 建议
1. 前端逐步退化为 view model 消费层。
2. 运行时快照、版本迁移、恢复解释权继续往后端收口。
3. 前端保留最小必要的离线展示缓存,但不再成为正式运行时状态真相来源。
---
## 4.4 旧 Vite 本地 API 与正式 Express 路由仍然形成重复能力面
### 证据
1. `scripts/dev-server/localApiPlugins.ts` 里仍有 JSON 编辑、场景图生成、角色视觉发布、角色动作发布等插件。
2. 当前正式路径已经迁到:
- `server-node/src/modules/editor/**`
- `server-node/src/modules/assets/**`
3. `scripts/dev-server/README.md` 已明确说明旧链路只保留为迁移参考。
### 判断
这属于典型的**旧能力未删除,新能力已落地,双链路长期并存**。
### 建议
尽快把旧 Vite 本地 API 参考实现移出主工程扫描面,避免后续继续被误用或被误认为正式入口。
---
## 5. 需要迁移到后端的代码
## 5.1 `src/services/ai.ts` 仍然承担了过多正式运行时职责
### 当前职责
`src/services/ai.ts` 当前约 `2632` 行,仍然同时承担:
1. function 可用性与 option 构造相关逻辑。
2. NPC 对话 / 招募 prompt 构造。
3. 自定义世界生成 prompt 与 JSON 修复请求。
4. 直接调用 `requestPlainTextCompletion` / `streamPlainTextCompletion`
5. 浏览器内 fallback 与响应解析。
### 判断
这不是单纯的“前端请求 SDK”而是**前端仍在承担正式运行时 AI orchestration**。
### 建议迁移方向
1. prompt 组装、模型调用、超时重试、JSON repair 继续收口到 `server-node/src/modules/ai/**`
2. 前端只保留轻量 SDK 和展示态拼装。
3. fallback 如果必须保留,也应明确区分“开发兜底”与“正式运行时”。
---
## 5.2 `src/services/customWorld.ts` 仍然是前端侧的大型规则中心
### 当前职责
`src/services/customWorld.ts` 当前约 `2413` 行,仍然承担:
1. 世界框架与角色/地标 outline 归一化。
2. 世界属性 schema 生成。
3. `ownedSettingLayers` 归一化。
4. 最终世界 profile 校验。
5. fallback story graph/theme pack 生成。
### 当前越界证据
后端目前直接从以下文件 import 这些能力:
1. `server-node/src/modules/ai/customWorldOrchestrator.ts`
2. `server-node/src/services/customWorldAgentFoundationDraftService.ts`
它们仍直接引用:
1. `src/services/customWorld.js`
2. `src/services/customWorldBuilder.js`
3. `src/services/customWorldCreatorIntent.js`
4. `src/types.js`
### 判断
这说明自定义世界的核心领域规则仍然以**前端目录为事实源**,后端只是在反向复用。
### 建议迁移方向
1. `types/schema/contracts` 抽到 `packages/shared`
2. 规则编译、校验、fallback 与 AI 编排迁到 `server-node`
3. 前端只保留编辑器表现层和字段草稿态。
---
## 5.3 `src/hooks/story/npcEncounterActions.ts` 仍在浏览器里做任务、奖励、战斗与招募结算
### 当前职责
`src/hooks/story/npcEncounterActions.ts` 当前约 `1623` 行,仍然直接编排:
1. `quest_accept` / `quest_turn_in`
2. 招募、切磋、离开、帮助奖励
3. 掉落/背包写入
4. HP / MP / cooldown 奖励变化
5. NPC 亲和度变化
6. 战斗场景切换与遭遇状态推进
### 判断
这条链已经明显超出“前端表现协调层”的边界,仍属于**正式运行时规则在前端执行**。
### 建议迁移方向
1. quest 信号推进 -> `server-node/src/modules/quest/**`
2. 奖励与背包变更 -> `server-node/src/modules/inventory/**`
3. 招募/关系变化 -> `server-node/src/modules/npc/**`
4. 战斗结算 -> `server-node/src/modules/combat/**`
前端应该只保留选项触发、加载态、动画态和最终结果展示。
---
## 5.4 `src/services/apiClient.ts` 仍保留了本地 token 与自动登录凭证存储
### 证据
`src/services/apiClient.ts` 当前仍把以下内容放在 `window.localStorage`
1. access token
2. 自动登录用户名
3. 自动登录密码
### 判断
这既是安全面问题,也是边界问题。
在“后端负责鉴权、前端只做表现”的目标下,正式凭证体系不应长期依赖浏览器本地保存账号密码。
### 建议迁移方向
1. 正式态优先走服务端 session / HttpOnly cookie。
2. 自动登录不要继续保存明文用户名/密码。
3. 前端仅保留最小必要的登录态感知,不保留额外认证真相。
---
## 6. 需要优先优化和拆分的代码
## 6.1 `src/components/CustomWorldEntityEditorModal.tsx`
### 当前状态
文件体量约 `4487` 行,已同时吞下:
1. 世界营地编辑
2. playable NPC 编辑
3. story NPC 编辑
4. 地标与世界地图布局
5. 场景图生成
6. 技能编辑
7. 初始物品编辑
8. 资产工作台串联
9. 多层 modal 开关与保存逻辑
### 判断
这是当前前端最明显的“巨型工作台单体文件”。
### 建议拆分方向
1. 按实体拆:营地 / playable NPC / story NPC / 地标。
2. 按能力拆:基础信息 / 关系 / 技能 / 初始物品 / 视觉资产。
3. 把 AI 生成与资产工作流进一步外置成独立 coordinator。
---
## 6.2 `server-node/src/modules/assets/characterAssetRoutes.ts`
### 当前状态
文件体量约 `3579` 行,已同时承担:
1. route 注册
2. 请求解析
3. LLM prompt bundle 生成
4. JSON 解析与修复
5. 文件系统写盘
6. visual publish
7. animation publish
8. 资产目录管理
### 直接证据
文件内同时存在:
1. `mkdir` / `writeFile`
2. `UpstreamLlmClient`
3. `parseJsonResponseText`
4. 多条 publish 路径
5. 大量本地文件落盘逻辑
### 建议拆分方向
1. route 层
2. prompt bundle service
3. file publish service
4. animation persistence service
5. asset metadata service
---
## 6.3 `src/services/ai.ts`
### 当前状态
文件体量约 `2632` 行,同时承载运行时 story、自定义世界、NPC 对话、招募等多条链路。
### 建议
即使短期内不能全部迁后端,也应该先按职责拆成:
1. runtime story client
2. npc dialogue client
3. recruit dialogue client
4. custom world generation client
5. parser / fallback / error helpers
---
## 6.4 `src/services/customWorld.ts`
### 当前状态
文件体量约 `2413`已经变成世界生成、校验、归一化、fallback 的综合体。
### 建议
至少拆成:
1. 世界框架与 outline schema
2. profile normalize / validate
3. role / landmark 编译器
4. fallback builder
5. world rule helpers
---
## 6.5 `src/hooks/story/npcEncounterActions.ts`
### 当前状态
文件体量约 `1623` 行,已经不是单纯 hook而是前端运行时 action resolver。
### 建议
按动作域拆开:
1. npc chat / recruit
2. npc help / affinity
3. quest accept / turn-in
4. battle entry / exit
5. async streaming / typewriter / presentation glue
---
## 7. 推荐执行顺序
### 第一阶段:先清仓库噪音和旧入口残留
1. 清根目录日志、扫描文件、旧截图、`__pycache__`
2. 迁出 `temp-build-goal-check/`
3. 明确处置 `scripts/dev-server/localApiPlugins.ts`
### 第二阶段:再处理无入口孤岛模块
1. 逐个确认 `GameShell.tsx`、custom-world-home、custom-world-agent、旧 flow hooks 是要接回还是归档
2. 对确认不再使用的 stub / helper / generated dead file 直接清理
### 第三阶段:把双份真相收口
1. runtime option interaction 映射只保留一份
2. 浏览历史以后端为真相源
3. 运行时快照解释权继续后移
4. 清理 `server-node -> src/**` 的反向依赖
### 第四阶段:最后拆巨型热点文件
1. `CustomWorldEntityEditorModal.tsx`
2. `characterAssetRoutes.ts`
3. `ai.ts`
4. `customWorld.ts`
5. `npcEncounterActions.ts`
---
## 8. 本文依据
文档依据:
1. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md`
2. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md`
3. `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md`
4. `scripts/dev-server/README.md`
当前仓库扫描依据:
1. `src/main.tsx`
2. `src/routing/appRoutes.tsx`
3. `src/App.tsx`
4. `package.json`
5. `server-node/package.json`
6. `vite.config.ts`
7. `.eslintrc.cjs`
8. `git grep` 对关键模块引用、后端跨层 import、localStorage、旧 dev 插件入口的扫描结果

View File

@@ -4,16 +4,21 @@
## 当前推荐入口 ## 当前推荐入口
1. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) 1. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md)
这一版聚焦当前仓库里的垃圾/冗余代码、旧入口残留、前后端边界未闭合点,以及下一步最该清什么、迁什么、拆什么。
2. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md)
这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。 这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。
2. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) 3. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md)
适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。 适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。
3. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 4. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md)
适合看第一轮系统性工程扫描,了解最早的问题基线。 适合看第一轮系统性工程扫描,了解最早的问题基线。
## 融合结论 ## 融合结论
- 当前仓库的新重点已经从“单纯补门禁”进一步演进到“清历史残留、清无入口模块、收前后端双份真相”。
- 三轮结论是一致收敛的:问题不在“有没有开始工程化”,而在“工程化是否真正覆盖了最危险的主链路”。 - 三轮结论是一致收敛的:问题不在“有没有开始工程化”,而在“工程化是否真正覆盖了最危险的主链路”。
- 最新一轮已经把关注点集中到质量门禁、真实绿色基线、关键模块豁免和 build warning 上。 - 最新一轮已经把关注点集中到质量门禁、真实绿色基线、关键模块豁免和 build warning 上。
- `2026-04-19` 这一轮进一步把问题压实到了四类:仓库噪音、旧 dev 入口残留、前端越界运行时逻辑、巨型热点文件。
- 如果只是为了判断现在先做什么,直接从 `2026-04-01` 开始即可。 - 如果只是为了判断现在先做什么,直接从 `2026-04-01` 开始即可。
- 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01` 的顺序回看演进。 - 如果是要做当前清理和边界收口,优先看 `2026-04-19`
- 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01 -> 2026-04-19` 的顺序回看演进。

View File

@@ -0,0 +1,182 @@
# 平台首页公开浏览与登录弹窗拦截设计
更新时间:`2026-04-19`
## 0. 背景
当前仓库里的账号 PRD 默认要求“未登录先登录,再进入平台”。
这次产品策略调整为:
- 用户进入平台后,默认可以直接浏览首页
- 只有在尝试进入作品、进入世界、开始创作等受保护动作时,才检查登录
- 登录界面不再是完整页面,而是覆盖在当前平台上的轻量弹窗
这份设计只覆盖当前一次前台入口改造,目标是把边界写清楚到可以直接编码,不再让登录策略和平台首页互相冲突。
---
## 1. 本次目标
1. 未登录用户可以正常进入平台首页并浏览公开内容。
2. 点击作品卡片时,若未登录,弹出登录弹窗;登录成功后继续进入刚才点击的作品。
3. 打开创作类型选择后,点击具体游戏类型开始创作时,若未登录,弹出登录弹窗;登录成功后继续刚才的创作动作。
4. 登录 UI 改成极简弹窗,只保留窗口标题、必要输入框、必要按钮、错误态与关闭能力。
5. 未登录态下不要继续请求“我的作品 / 个人看板 / 云端浏览历史 / 云端存档列表”这类受保护数据,避免首页公开态出现无意义报错。
---
## 2. 公开态与受保护动作边界
## 2.1 未登录允许访问
- 平台首页主视图
- 精选推荐
- 最新发布
- 创作类型选择弹窗本身的展示
- 本地浏览历史展示(若存在)
说明:
- “允许访问”只代表允许看,不代表允许进入作品详情、开始世界或创建内容。
- 首页公开态必须保持可读,不因账号接口 401 出现整屏报错。
## 2.2 未登录必须拦截
- 点击任意作品卡片
- 点击作品详情中的“开始游戏”
- 点击作品详情中的“继续创作 / 发布 / 下架 / 删除”等作者动作
- 点击创作类型卡片,开始进入具体创作工作台
- 其他后续新增的“进入世界 / 开始正式创作”入口
拦截方式统一为:
- 保持当前页面上下文不跳走
- 直接弹出登录弹窗
- 登录成功后自动继续刚才被拦截的动作
---
## 3. 登录弹窗设计
## 3.1 展示形态
- 使用居中的 modal 覆盖层
- 背景保留平台当前页面,只加遮罩和轻微模糊
- 移动端优先,弹窗宽度贴近屏幕边缘,底部和顶部留出安全边距
- 桌面端保持紧凑,不做双栏 hero不再单独占满整页
## 3.2 内容约束
弹窗内默认只保留:
- 标题:`登录账号`
- 手机号输入框
- 验证码输入框
- 获取验证码按钮
- 登录主按钮
- 微信登录按钮(当后端开放时)
- 图形验证码输入区(仅后端要求时出现)
- 错误提示
- 关闭按钮
明确不再保留:
- 品牌副标题
- 功能介绍段落
- 规则说明卡片
- “先登录再同步进度”这类描述性文案
- 占据视觉主体的装饰信息块
## 3.3 登录成功后的行为
- 手机号登录成功后,关闭弹窗
- 当前平台页面不刷新
- 若用户是被某个受保护动作拦截进入登录,则自动恢复该动作
- 若用户只是主动点“登录”按钮,则关闭弹窗并停留在当前页面
## 3.4 关闭行为
- 用户主动关闭弹窗时,只关闭弹窗,不改变当前平台页面
- 不清空首页浏览状态
- 不自动跳转到其他 tab
---
## 4. 前端状态约束
## 4.1 AuthGate
`AuthGate` 需要从“未登录整页拦截器”调整为“平台级账号状态提供器”:
- `checking / recovering`:仍可显示加载态,避免首屏闪烁
- `unauthenticated`:渲染平台内容,同时允许按需打开登录弹窗
- `ready`:渲染平台内容和账号能力
- `pending_bind_phone`:继续保留当前绑定手机号流程,不在这次入口改造里拆散
同时需要在 context 中提供:
- 当前用户
- 打开登录弹窗
- 打开账号面板
- `requireAuth(action)` 能力
`requireAuth(action)` 约束:
- 已登录:直接执行 `action`
- 未登录:弹出登录弹窗,并缓存 `action`
- 登录成功:自动执行缓存的 `action`
## 4.2 平台首页数据加载
`PreGameSelectionFlow` 在未登录时只读取:
- 公开作品广场
- 本地浏览历史
未登录时不读取:
- 自定义世界库
- 个人看板
- 云端浏览历史
- 云端运行时设置
- 云端存档快照
- 云端存档列表
未登录态的对应前台表现:
- “我的创作”显示空态,不显示账号接口错误
- “个人页”显示未登录态入口,可手动打开登录弹窗
- 音量等运行时设置继续使用本地缓存,不触发 `/api/runtime/settings`
- 未登录态不显示“继续远端存档”能力,也不触发 `/api/runtime/save/snapshot`
- 未登录态的“存档”Tab 只展示登录引导,不触发 `/api/runtime/profile/save-archives`
---
## 5. 代码落点
本次实现最少要覆盖:
- `src/components/auth/AuthGate.tsx`
- `src/components/auth/AuthUiContext.ts`
- `src/components/auth/LoginScreen.tsx`
- `src/components/game-shell/PreGameSelectionFlow.tsx`
- `src/components/game-shell/PlatformHomeView.tsx`
- `src/components/game-shell/PlatformCreationTypeModal.tsx`
测试至少覆盖:
- 未登录时平台首页仍能渲染
- 未登录点击作品卡片会打开登录弹窗
- 未登录点击创作类型卡片会打开登录弹窗
- 登录成功后会继续刚才被拦截的动作
---
## 6. 验收标准
1. 用户首次进入平台时,不会先看到整页登录页,而是能看到首页内容。
2. 未登录点击作品时,直接弹出登录弹窗,登录后自动进入对应作品流。
3. 未登录选择 RPG 创作类型时,直接弹出登录弹窗,登录后自动进入创作工作台。
4. 登录弹窗内没有介绍性大段文字,只剩必要输入与按钮。
5. 未登录态首页不会因个人接口失败而出现“读取个人看板失败”“读取作品库失败”之类报错。

View File

@@ -0,0 +1,103 @@
# 平台层 UI 去像素化刷新设计
更新时间:`2026-04-19`
## 1. 目标
本次刷新只覆盖平台层功能 UI不改游戏内 HUD、战斗、地图、剧情面板等像素风界面。
目标有 5 个:
1. 平台层正文与功能信息不再使用像素字体
2. 平台层不再使用像素九宫格边框、像素图标、像素背景纹理这类平台 chrome
3. 原有紫蓝深色方案沉淀为平台暗色主题
4. 新参考图沉淀为平台亮色主题:白色主面板、粉橘主强调、暖白背景、高亮图卡
5. 平台默认使用亮色主题,移动端保持现有布局结构不变,桌面端允许在不改变业务入口的前提下重组为控制台式平台壳层
## 2. 覆盖范围
本次统一按 `!gameState.worldType` 的平台态处理,覆盖:
- 平台首页 `PlatformHomeView`
- 作品详情 `PlatformWorldDetailView`
- 创作类型弹窗 `PlatformCreationTypeModal`
- 平台创作链路中的生成页、结果页、目录页、编辑弹窗
明确不覆盖:
- 进入世界后的游戏内 UI
- 地图、战斗、剧情面板、角色面板、背包面板等像素 RPG 界面
- 世界内容本身的数据图片、角色主图、场景图等作品内容素材
说明:
- “不再引用像素素材”指平台 chrome 不再依赖像素框、像素按钮、像素关闭图标、像素底纹等 UI 资源
- 作品内容图仍可展示,但平台层不再用 `image-rendering: pixelated` 强化像素感
## 3. 视觉原则
### 3.1 风格来源
直接对齐现有登录页和绑定手机号页的成熟样式,并吸收本次参考图的桌面端气质:
- 暗色主题:顶部与边缘的紫蓝径向高光 + 深色纵向渐变背景
- 亮色主题:暖白控制台外壳 + 粉橘主强调 + 轻紫细节高光
- 大圆角卡片
- 半透明玻璃质感
- 平台正文与功能信息统一使用 `Inter + Noto Serif SC`
主题基准:
- 暗色主题:
底色以深靛蓝、深紫黑为主,高光以亮紫、蓝青为主
- 亮色主题:
底色以暖白、浅粉白、浅橘白为主,强调色以高饱和粉色、橘粉色为主,局部可带少量紫色作装饰
- 平台默认主题使用亮色主题;暗色主题保留为可切换方案,不作为当前默认展示
### 3.2 排版
- 平台层正文、按钮、说明、功能标签统一使用非像素字体
- 主标题保留明显层级,但不再做像素描边效果
- 微型标签维持高字距英文/中文短标签,用来保留产品感和秩序感
### 3.3 组件约束
- 面板:使用玻璃卡片,不再用九宫格像素框
- 按钮:使用圆角胶囊按钮或渐变主按钮,不再用像素按钮框
- 图标:优先使用 `lucide-react`
- Tab移动端底部结构不变但图标与底座改成非像素风桌面端切换为左侧纵向导航轨道
- 弹窗:沿用登录页的圆角浮层和半透明遮罩,不再使用像素弹窗边框
- 桌面壳层:首页允许增加顶部工具栏、左侧导航轨、中央内容舞台与右侧趋势面板的组合
- 登录页、绑定手机号、账户弹窗、平台详情、创作生成页、结果页、编辑弹窗都必须共享同一套平台主题 token禁止再各自写一套独立旧色板
- 平台“我的”页中的“设置”入口必须打开真正的设置面板;账号信息、设备管理、安全状态属于设置面板中的分区,不允许再把账号信息弹层直接充当设置页
- 设置面板必须支持平台亮色 / 暗色主题切换,并复用同一套平台 token 驱动登录页、首页、详情页与二三级面板
## 4. 交互与布局约束
- 移动端保持原有页面布局层级、区块顺序、操作入口位置不变
- 桌面端首页允许参考图示重组为“顶部工具栏 + 左侧纵向导航 + 主 Hero 卡 + 右侧趋势列表 + 下方内容卡组”
- 桌面端的重组只改变视觉排布;自 `2026-04-19` 起平台主入口调整为“首页 / 创作 / 存档 / 我的”,四个入口的操作路径都必须保持清晰稳定
- 移动端优先,底部 tab 与主卡片点击区域不能缩小
- 不在平台 UI 面板里额外堆砌规则说明
- 所有视觉替换必须是局部补丁,不做无必要的大规模结构重写
## 5. 实现约束
- 平台态从 `fusion-pixel-app` 中隔离,避免被全局像素字体覆盖
- 平台态背景不再使用 `/UI/Background_fill.png`
- 新样式优先沉淀为平台专用 class / theme token避免把游戏内像素 class 改坏
- 平台默认挂载亮色主题 class旧紫蓝方案保留为暗色主题 class
- 亮色主题需要补齐统一的 overlay、progress track、status pill token登录弹层与二三级功能面板禁止继续沿用旧深色遮罩与紫蓝强调残留
- 编辑弹窗保留业务结构与表单逻辑,只替换壳层样式
## 6. 验收标准
达到以下结果才算完成:
1. 平台首页、详情、登录、绑定手机号、账户弹窗、创作入口、创作结果页不再出现像素字体
2. 平台层按钮、面板、关闭按钮、底部 tab 不再依赖像素 UI 素材
3. 平台默认展示亮色主题,暗色主题保留为独立主题方案
4. 平台层二三级面板、表单、状态卡、弹窗与登录体系不再残留旧金橙 / 青蓝 / 深黑混搭方案
5. 平台层世界封面与角色预览不再使用 `pixelated` 渲染
6. 游戏内像素 UI 保持原样,不出现误改
7. 手机端布局保持稳定,桌面端在参考图方向下完成控制台化重组

View File

@@ -113,4 +113,12 @@
--- ---
## 8. 2026-04-18 补充记录
- `GameShellRuntime` 进入游戏壳时,会主动隐藏认证层提供的右上角全局账号信息条。
- 原因不是账号功能下线,而是这个悬浮条会遮挡冒险主场景内容,移动端更明显。
- 账号相关入口保留在平台首页 / 个人页内部按钮与账号弹窗,不再占用游戏 HUD 区域。
---
*文档目的:交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。* *文档目的:交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。*

View File

@@ -89,6 +89,12 @@
- 在底部工具区,队伍/背包改成 icon 后更紧凑。 - 在底部工具区,队伍/背包改成 icon 后更紧凑。
- 但必须保留 `aria-label`,保证语义清晰、后续也方便测试。 - 但必须保留 `aria-label`,保证语义清晰、后续也方便测试。
### 4.5 冒险主场景不要挂右上角账号悬浮条
- 冒险页右上角属于画面演出和战斗/剧情信息的高频观察区。
- 全局账号信息条挂在这里,会直接压住场景、敌人血条或顶部提示,手机端尤其明显。
- 结论:
账号入口应收回平台首页、个人页或设置面板,不要在实际冒险主场景常驻悬浮显示。
## 5. 队伍面板经验 ## 5. 队伍面板经验
### 5.1 移动端成员列表不能太“卡片化” ### 5.1 移动端成员列表不能太“卡片化”

View File

@@ -76,6 +76,19 @@
- 流程层优先按“职责”拆,不按“文件长度”拆。 - 流程层优先按“职责”拆,不按“文件长度”拆。
- 状态修改逻辑尽量集中到 hook 内,不要散落在多个组件按钮回调里。 - 状态修改逻辑尽量集中到 hook 内,不要散落在多个组件按钮回调里。
## 3.1 AI 草稿数据进列表前,要先补本地稳定标识
自定义世界、角色草稿、澄清问题、生成结果卡片这类数据,在草稿态或兼容旧数据时,`id` 可能为空。
经验:
- React 列表的 `key` 不要直接裸用这类可能为空的 `id`
- 当前选中态、草稿缓存、轮播焦点也不要直接绑空 `id`,否则会出现“点了第二张卡,结果还是第一张卡被选中”的错位。
- 更稳的做法是:
- 业务数据层尽量补齐真实 id
- UI 层再补一层本地稳定 `selectionKey` / fallback render key
- fallback 至少带上 `index + 名称种子`,保证当前列表内唯一
## 4. AI 只适合生成叙事,不适合决定关键规则 ## 4. AI 只适合生成叙事,不适合决定关键规则
实践中最稳定的策略是: 实践中最稳定的策略是:

View File

@@ -1,6 +1,13 @@
# 账号系统与登录入口重构 PRD # 账号系统与登录入口重构 PRD
更新时间:`2026-04-09` 更新时间:`2026-04-19`
> 2026-04-19 入口策略补充:
> 平台首页现调整为“未登录也可浏览公开首页”,不再要求用户先登录才能进入平台。
> 登录拦截点改为“点击作品进入详情/世界”与“选择游戏类型开始创作”等受保护动作触发时再弹出登录弹窗。
> 本次入口策略与弹窗约束以
> [`docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md`](../design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md)
> 为准;本 PRD 中“先登录再进入开始界面”的旧表述不再作为当前前台入口实现依据。
## 0. 目标 ## 0. 目标
@@ -175,7 +182,7 @@ MVP 阶段建议采用最稳妥规则:
4. 微信后强制绑定手机号 4. 微信后强制绑定手机号
5. 账号会话管理 5. 账号会话管理
6. 账号与存档/自定义世界/运行时设置统一绑定 6. 账号与存档/自定义世界/运行时设置统一绑定
7. 基础账号中心与退出登录 7. 基础账号中心、平台设置面板与退出登录
## 3.2 本期不做 ## 3.2 本期不做
@@ -460,6 +467,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含:
- 已绑定手机号(脱敏展示) - 已绑定手机号(脱敏展示)
- 微信绑定状态 - 微信绑定状态
- 最近登录时间 - 最近登录时间
- 平台设置面板中的亮色 / 暗色主题切换
- 退出登录 - 退出登录
二期可以再补: 二期可以再补:

View File

@@ -1,6 +1,6 @@
# AI 角色形象与角色动画 MVP PRD # AI 角色形象与角色动画 MVP PRD
更新时间:`2026-04-04` 更新时间:`2026-04-19`
## 0. 一句话结论 ## 0. 一句话结论
@@ -254,6 +254,15 @@ MVP 支持三种主形象输入方式:
MVP 必须与当前项目可扮演角色动作槽位对齐。 MVP 必须与当前项目可扮演角色动作槽位对齐。
当前落地实现补充约束(`2026-04-19`
- 角色资产工坊默认固定生成入口收敛为 `idle / run / attack / die`
- `hurt` 不再作为固定按钮动作
- 图生视频默认走火山方舟 `Seedance` 首尾帧方案
- 接口请求体中的两张参考图分别固定为 `first_frame / last_frame`
- 固定参数为 `1:1``480p``4 秒`、单次 `1` 个视频
- 提示词中的动作名统一传英文动作名
第一版要求以下基础动作槽位不能为空: 第一版要求以下基础动作槽位不能为空:
| 动作槽位 | 是否必填 | 备注 | | 动作槽位 | 是否必填 | 备注 |
@@ -345,7 +354,6 @@ MVP 支持两种方式:
- `attack` - `attack`
- `jump_attack` - `jump_attack`
- `hurt`
- `die` - `die`
要求末帧清晰,不与下一动作切换冲突。 要求末帧清晰,不与下一动作切换冲突。
@@ -634,4 +642,3 @@ type GeneratedCharacterAnimationAsset = {
- 路径清晰 - 路径清晰
- 能真正进入当前仓库 - 能真正进入当前仓库
- 后续可以在此基础上再加技能动作、剧情演出和多供应商增强路线 - 后续可以在此基础上再加技能动作、剧情演出和多供应商增强路线

View File

@@ -0,0 +1,226 @@
# AI Native 战斗单行为 Function PRD2026-04-18
## 1. 目标
本次迭代把战斗中的 function 从“战术风格 function”收敛为“单次可直接结算的原子行为 function”。
核心目标:
1. 战斗中一次点击只完成一个明确行为,不再做连续多轮击打、连续多 actor 轮转的 function 设计。
2. 战斗中除逃跑外,不再为每次动作额外触发剧情推理,而是直接结算数值并刷新下一轮战斗选项。
3. 只有在逃跑成功或战斗正式结束后,才触发一次剧情推理,生成脱战后的 storyText 与后续剧情选项。
---
## 2. 新战斗 option 池
`inBattle = true` 时,默认战斗选项池固定收敛为以下结构,顺序按下列规则输出:
1. `battle_attack_basic`
2. `battle_recover_breath`
3. `inventory_use`
4. `battle_use_skill`
5. `battle_escape_breakout`
其中第 4 项不是单个 option而是“每个技能一个 option 实例”。
### 2.1 普通攻击
- functionId`battle_attack_basic`
- 含义:不消耗灵力的基础攻击。
- 结算:直接结算一次基础伤害。
- 不触发剧情推理。
### 2.2 恢复
- functionId`battle_recover_breath`
- 含义:本回合做恢复与节奏调整。
- 结算:直接恢复血量/灵力,并推进技能冷却。
- 不触发剧情推理。
### 2.3 使用物品
- functionId`inventory_use`
- 含义:战斗中直接使用一个可结算的消耗品。
- 本期战斗选项池只给一个“推荐可用物品” option不展开整包物品列表。
- option 必须携带 `runtimePayload.itemId`
- 若当前没有可用消耗品,则仍保留该项,但以 disabled 态展示。
- 不触发剧情推理。
### 2.4 使用技能
- functionId`battle_use_skill`
- 每个角色技能都生成一个独立 option。
- option 文案直接对应技能名,不再包装成“稳扎试探 / 破架 / 终结窗口”这类抽象战术文案。
- option 必须携带 `runtimePayload.skillId`
- 若技能因蓝量不足或冷却中不可用,仍保留该 option但以 disabled 态展示。
- 点击后直接结算该技能本次效果,不触发剧情推理。
### 2.5 逃跑
- functionId`battle_escape_breakout`
- 含义:立即尝试脱离当前战斗。
- 结算:直接处理脱战结果。
- 逃跑成功后必须触发剧情推理。
---
## 3. 旧战斗 function 的处理
以下旧 function 不再进入默认战斗选项池:
- `battle_all_in_crush`
- `battle_guard_break`
- `battle_probe_pressure`
- `battle_feint_step`
- `battle_finisher_window`
兼容规则:
- 后端仍允许解析这些旧 functionId避免旧存档 / 旧 currentStory 点击时报错。
- 兼容结算统一按“单次攻击型行为”处理,不再保留旧的战术风格分支。
- 新生成的新选项、新 currentStory、新 viewModel 不再继续下发这些旧 function。
---
## 4. 单行为结算规则
### 4.1 单次点击的边界
一次点击只允许完成一次玩家声明行为:
- 普通攻击
- 恢复
- 使用物品
- 使用某个具体技能
- 逃跑
不再允许一次点击里继续串:
- 多轮连续攻击
- 多个技能连续释放
- 多个角色依次轮转
- 为了“表现完整”再补一整串额外战斗回合
### 4.2 回合感保留
虽然不再做连续多轮击打,但每个战斗动作仍然视为消耗了一个战斗回合,因此:
- 技能冷却要按“本次动作结束后”推进
- 恢复类动作可额外提供冷却推进收益
- 物品动作在战斗态下也算一次战斗回合
### 4.3 结果文本
ongoing battle 的本地/后端结果文本只负责说明这一次动作结算结果,不负责续写新的剧情段落。
例如:
- 你挥出一记普通攻击,命中前方敌人。
- 你稳住呼吸,恢复了部分气血与灵力。
- 你立刻服下疗伤药,当前状态回升。
- 你释放了【试锋斩】,直接压低了对方血线。
---
## 5. 剧情推理触发边界
### 5.1 不触发剧情推理的情况
当动作执行后仍处于战斗中时,以下 function 不触发剧情推理:
- `battle_attack_basic`
- `battle_recover_breath`
- `inventory_use`
- `battle_use_skill`
- 旧攻击类兼容 function
此时系统行为为:
1. 直接结算动作
2. 更新 HP / MP / CD / 物品 / 战斗状态
3. 直接刷新新一轮战斗选项
4. `storyText` 直接使用本次结算结果文本,不请求 AI 续写
### 5.2 必须触发剧情推理的情况
以下情况必须触发剧情推理:
1. `battle_escape_breakout` 执行后成功脱战
2. 任意战斗动作执行后,战斗正式结束
战斗正式结束包括:
- 敌方被击败
- 切磋结束
- 玩家被系统判定为本轮战斗已断开
此时系统行为为:
1. 先完成数值结算与状态落地
2. 再以“本次动作 + 本次战斗结果”为上下文触发一次剧情推理
3. 生成脱战后的 `storyText` 与非战斗态 options
---
## 6. 前后端数据约束
### 6.1 Option 扩展字段
为了支持“单 functionId + 多实例技能/物品 option”战斗 option 允许携带以下运行时字段:
- `runtimePayload`
- `skillId?: string`
- `itemId?: string`
- `disabled?: boolean`
- `disabledReason?: string`
### 6.2 前端职责
- 前端只负责展示 option、透传 `runtimePayload`、展示 disabled 态
- 前端不再自己推导战斗中“是否需要剧情推理”
- 前端不再把技能 option 重写成抽象战术描述
### 6.3 后端职责
- 后端负责生成战斗 option 池
- 后端负责解析 `skillId / itemId`
- 后端负责决定 battle ongoing / battle end / escape 后是否触发剧情推理
---
## 7. 本次落地范围
本期必须落地:
1. 后端 runtime 战斗 option 池切换到单行为模型
2. 后端 combat resolution 支持普通攻击 / 指定技能 / 恢复 / 战斗物品 / 逃跑
3. 后端只在逃跑或战斗结束后做剧情推理
4. 前端支持透传战斗 option 的 `runtimePayload`
5. 前端支持 disabled battle option 展示
6. 文档、测试同步更新
本期不做:
1. 新增复杂目标选择 UI
2. 一次展开完整背包的战斗 item 子面板
3. 重做整套战斗演出系统
4. 把所有旧本地 battle plan 彻底删除到只剩后端一条链
---
## 8. 验收口径
满足以下条件视为本次需求完成:
1. 战斗中不再出现 `battle_all_in_crush / battle_guard_break / battle_probe_pressure / battle_feint_step / battle_finisher_window` 作为默认候选项。
2. 战斗默认候选项能看到:
- 普通攻击
- 恢复
- 使用物品
- 每个技能一个独立技能项
- 逃跑
3. 点击普通攻击 / 恢复 / 使用物品 / 技能时,不请求新的剧情推理,直接返回结算结果并刷新下一轮战斗 options。
4. 点击逃跑成功后,会请求一次剧情推理并切回脱战后的剧情 options。
5. 任意攻击或技能把敌人打死后,会请求一次剧情推理并切回脱战后的剧情 options。
6. 旧存档里残留旧 battle functionId 时,不会因为 function 不识别而报错。

View File

@@ -484,6 +484,29 @@ interface ListCustomWorldWorksResponse {
- 当前用户作品量预计不大 - 当前用户作品量预计不大
- 先把结构做稳,比先做分页更重要 - 先把结构做稳,比先做分页更重要
### 公开浏览与登录边界
创作首页与世界选择页必须拆分两类数据:
1. 公开浏览数据
2. 当前用户私有数据
其中以下接口必须定义为公开只读:
- `GET /api/runtime/custom-world-gallery`
- `GET /api/runtime/custom-world-gallery/:ownerUserId/:profileId`
明确约束:
1. 未登录用户进入世界选择页时,也必须能读取公开作品广场
2. 公开作品广场读取不能依赖 access token也不能因为 refresh 失败返回 401
3. 已发布作品详情允许未登录用户查看
4. 只有“继续创作 / 发布 / 下架 / 删除 / 查看我的草稿 / 查看我的统计”等私有能力必须要求登录
也就是说:
**平台首页要支持“先浏览公开作品,再决定是否登录进入世界或开始创作”。**
## 9.3 数据来源 ## 9.3 数据来源
### 草稿来源 ### 草稿来源

View File

@@ -17,17 +17,20 @@
## 1. 当前“我的”Tab 功能拆分 ## 1. 当前“我的”Tab 功能拆分
当前页面可拆成以下 `9` 个独立功能 说明
-`2026-04-19` 起,“最近游玩 / 历史浏览”已从“我的”页迁出,改为平台一级主 Tab“存档”。
- 对应母文档见 [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md)。
当前“我的”页保留以下 `7` 个独立功能:
1. 账号资料与身份卡 1. 账号资料与身份卡
2. 会员中心与充值 2. 会员中心与充值
3. 我的数据看板 3. 我的数据看板
4. 最近游玩 4. 邀请好友
5. 历史浏览 5. 填邀请码
6. 邀请好友 6. 玩家社区
7. 填邀请码 7. 设置与账号安全
8. 玩家社区
9. 设置与账号安全
--- ---
@@ -36,12 +39,11 @@
1. [MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md) 1. [MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PROFILE_IDENTITY_CARD_PRD_2026-04-16.md)
2. [MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md) 2. [MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_MEMBERSHIP_CENTER_PRD_2026-04-16.md)
3. [MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md) 3. [MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md)
4. [MY_TAB_RECENT_PLAY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_RECENT_PLAY_PRD_2026-04-16.md) 4. [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md)
5. [MY_TAB_BROWSE_HISTORY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_BROWSE_HISTORY_PRD_2026-04-16.md) 5. [MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md)
6. [MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md) 6. [MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md)
7. [MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md) 7. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md)
8. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md) 8. [MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md)
9. [MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md)
--- ---
@@ -51,20 +53,19 @@
1. 账号资料与身份卡 1. 账号资料与身份卡
2. 设置与账号安全 2. 设置与账号安全
3. 最近游玩 3. 我的数据看板
4. 历史浏览 4. 平台存档 Tab
5. 我的数据看板 5. 会员中心与充值
6. 会员中心与充值 6. 邀请好友
7. 邀请好友 7. 邀请
8. 填邀请码 8. 玩家社区
9. 玩家社区
原因: 原因:
- `1 + 2` 复用现有账号系统最多,最容易先落地 - `1 + 2` 复用现有账号系统最多,最容易先落地
- `3 + 4 + 5` 直接增强“我的”页内容密度,短期收益高 - `3 + 4` 直接增强账号资产与回流体验,短期收益高
- `6 + 7` 涉及商业化和关系绑定,依赖结算与奖励台账 - `5 + 6` 涉及商业化和关系绑定,依赖结算与奖励台账
- `8` 最适合放在平台内容层能力稳定后再做 - `7` 最适合放在平台内容层能力稳定后再做
--- ---

View File

@@ -0,0 +1,252 @@
# 平台“存档”Tab PRD
更新时间:`2026-04-19`
## 0. 目标
把原本堆在“我的”页中的“最近游玩 / 历史浏览”移出,新增平台一级主 Tab“存档”用于承载当前账号在平台里玩过的所有游戏留下的最近可恢复存档。
这次改动的核心目标不是做复杂多槽位存档系统,而是先落地一个稳定、可跨设备同步、可直接继续游玩的账号级存档入口。
---
## 1. 信息架构调整
## 1.1 平台主导航
平台主导航从:
- 首页
- 创作
- 我的
调整为:
- 首页
- 创作
- 存档
- 我的
移动端底部导航与桌面端左侧纵向导航都必须同步调整。
## 1.2 “我的”页调整
“我的”页删除以下内容块:
- 最近游玩
- 历史浏览
“我的”页保留:
- 账号资料与身份卡
- 数据看板
- 常用功能
- 设置与账号安全
说明:
- 历史浏览本期直接从“我的”页移除,不再占据个人页首屏空间。
- 存档能力统一收口到平台一级“存档”Tab不再同时在“我的”页重复展示。
---
## 2. 存档定义
## 2.1 本期存档口径
本期“存档”Tab 展示的是:
- 当前账号在每个已游玩游戏 / 世界下保留的最近一个可恢复存档
不是:
- 同一游戏下的完整多槽位存档管理页
- 手动重命名 / 置顶 / 删除存档系统
## 2.2 世界唯一键
服务端必须按 `worldKey` 聚合最近存档:
- 自定义世界:`custom:<profileId>`
- 内建世界:`builtin:<worldType>`
同一账号、同一 `worldKey` 只保留最近一次成功保存的可恢复存档。
## 2.3 生命周期
1. 玩家每次成功写入运行时快照时,同步刷新该世界的最近存档记录。
2. 删除当前活动快照时,不删除历史存档归档。
3. 点击“继续游玩”时,从该世界最近存档恢复为当前活动快照,再进入游戏。
---
## 3. 界面设计
## 3.1 存档 Tab 首屏结构
页面由两部分组成:
1. 顶部摘要卡
2. 存档列表
顶部摘要卡用于表达:
- 当前共有多少个可恢复存档
- 最近一次更新的存档是谁
不要在 UI 中默认堆规则说明文案,只保留简洁的状态表达。
## 3.2 列表排序
列表按 `lastPlayedAt` 倒序。
最近更新的存档始终在最前面。
## 3.3 列表项字段
每个列表项必须展示:
- 游戏名称
- 最后游玩时间
- 游戏信息
其中“游戏信息”优先级如下:
1. `continueGameDigest`
2. 当前故事文本摘要
3. 世界简介 / 场景简介
4. 若都没有则给出简洁兜底文案
可附带展示封面,但封面不是必填验收项。
## 3.4 点击行为
点击列表项后:
1. 调用后端恢复接口
2. 将所选存档切换为当前活动快照
3. 直接进入游戏继续游玩
前端不允许自行拼装恢复上下文。
## 3.5 空状态
### 已登录但无存档
- 展示轻量空态
- 引导去首页开始游玩
### 未登录
- 展示登录态空壳
- 不请求受保护的云端存档列表接口
---
## 4. 默认进入逻辑
当满足以下条件时玩家进入网站后的平台首页默认进入“存档”Tab
1. 当前处于登录状态
2. 当前账号至少存在一个存档
否则:
- 仍默认进入“首页”Tab
注意:
- 这个默认进入逻辑只在平台首屏初始化时执行,不能覆盖用户手动切换后的选择。
---
## 5. 后端设计
## 5.1 新增数据表
建议新增 `profile_save_archives`
- `user_id`
- `world_key`
- `owner_user_id`
- `profile_id`
- `world_type`
- `world_name`
- `world_subtitle`
- `summary_text`
- `cover_image_src`
- `saved_at`
- `bottom_tab`
- `game_state_json`
- `current_story_json`
- `updated_at`
约束:
- 主键:`user_id + world_key`
- 排序索引:`user_id + saved_at desc`
## 5.2 写入规则
每次 `/api/runtime/save/snapshot` 成功写入后:
1. 正常更新当前活动快照
2. 同步 upsert 对应 `world_key` 的存档归档
3. 继续保留原有个人看板 / 已玩作品同步逻辑
## 5.3 列表接口
### `GET /api/runtime/profile/save-archives`
返回:
- 当前账号全部最近存档
字段至少包含:
- `worldKey`
- `worldName`
- `worldSubtitle`
- `summaryText`
- `coverImageSrc`
- `lastPlayedAt`
- `worldType`
- `profileId`
- `ownerUserId`
## 5.4 恢复接口
### `POST /api/runtime/profile/save-archives/:worldKey/resume`
用途:
- 将指定存档归档恢复为当前活动快照
- 返回恢复后的快照
限制:
- 恢复动作不能重复记账,不得再次累计个人资产流水
- 恢复动作不能重复累计已玩时长
- 恢复动作不能破坏现有快照水合逻辑
---
## 6. 前端实现要求
1. `PlatformHomeView` 新增 `存档` 主 Tab。
2. `PreGameSelectionFlow` 在平台数据加载时同时拉取存档列表。
3. 已登录且有存档时,平台首屏默认选中 `存档` Tab。
4. “我的”页删除“最近游玩 / 历史浏览”两个区块。
5. 点击存档列表项时必须经过后端恢复接口,恢复成功后直接进入游戏。
6. 移动端优先,列表项点击区域不能过小。
---
## 7. 验收标准
1. 已登录账号可以在“存档”Tab 看到所有已玩过世界的最近存档。
2. 列表按最近更新时间倒序。
3. 列表项可看到游戏名称、最后游玩时间和游戏信息。
4. 点击列表项后可直接继续对应游戏。
5. 已登录且至少有一个存档时进入网站默认打开“存档”Tab。
6. 未登录时不请求云端存档列表,也不会出现受保护接口报错。

View File

@@ -265,6 +265,15 @@
但对“第一版角色动作资产生产”来说,它更适合作为增强通道,不建议先做成唯一主依赖。 但对“第一版角色动作资产生产”来说,它更适合作为增强通道,不建议先做成唯一主依赖。
实现更新(`2026-04-19`
- 当前仓库的 `image-to-video` 角色动作生成入口已切到火山方舟 `Seedance`
- 采用双参考图首尾帧方案:图片 1 约束首帧,图片 2 约束尾帧
- 当前请求体中的两张参考图角色分别固定为 `first_frame / last_frame`
- 当前固定参数为 `1:1``480p``4 秒`、单次 `1` 个视频
- 当前固定动作入口收敛为 `idle / run / attack / die`,不再内置固定 `hurt`
- 提示词里传给视频模型的动作名统一使用英文动作名
## 5.3 补充路线:腾讯云相关能力 ## 5.3 补充路线:腾讯云相关能力
腾讯云相关接口里,`提交图片跳舞任务` 提供了: 腾讯云相关接口里,`提交图片跳舞任务` 提供了:
@@ -422,12 +431,12 @@
- `idle` - `idle`
- `run` - `run`
- `attack` - `attack`
- `jump`
- `hurt`
- `die` - `die`
系统自动选择对应参考视频模板。 系统自动选择对应参考视频模板。
`jump``hurt` 这类扩展动作不再作为当前编辑器固定按钮,改为后续扩展动作槽位或手动补齐。
### B. 视频驱动 ### B. 视频驱动
用户上传参考动作视频,系统抽姿态后再生成角色动作。 用户上传参考动作视频,系统抽姿态后再生成角色动作。
@@ -498,6 +507,8 @@ flowchart LR
- 文生图时,优先生成与当前项目角色素材视角一致的单人全身图 - 文生图时,优先生成与当前项目角色素材视角一致的单人全身图
- 有参考图时,优先做“角色指定 + 风格收敛 + 视角纠偏” - 有参考图时,优先做“角色指定 + 风格收敛 + 视角纠偏”
- 用户直接上传素材时,先做校验、裁切、背景清理和尺寸标准化 - 用户直接上传素材时,先做校验、裁切、背景清理和尺寸标准化
- 编辑器未上传参考图时,主形象阶段默认附加一张由项目内可扮演角色 idle 帧拼成的风格参考板,用来锁定像素动作角色的轮廓语言、右朝向、体型比例与配色组织,避免模型只放大 Q 版比例却丢掉像素感
- 风格约束优先级里“像素动作角色感”高于“Q 版比例提示”;比例只允许轻度偏大头,不允许退化成普通软萌插画或儿童绘本风
### 角色视角要求 ### 角色视角要求
@@ -623,6 +634,16 @@ flowchart LR
8. 生成 Sprite Sheet 8. 生成 Sprite Sheet
9. 输出动画元数据 9. 输出动画元数据
### 当前工程的抠像补充策略
针对角色动作视频抽帧后常见的“后段帧出现白底”“角色轮廓残留绿幕像素点”问题,当前工程内的背景清理不再只依赖单一绿幕阈值,而是统一改为以下顺序:
1. 先识别边界连通的可移除背景区域,同时覆盖纯绿色绿幕和高亮低色差白底。
2. 再向主体边缘的半透明软边做一轮有限扩张,把压缩后残留的白边、绿边纳入透明化处理。
3. 最后对贴近透明边缘的像素做去污,优先压掉绿色溢色,并把白边/绿边颜色拉回附近前景主体颜色,减少抽帧后的轮廓发白、发绿。
这样可以避免把角色内部的白色高光、白色装备整体误删,同时能更稳定地清理视频模型在末段帧里偶发的白背景和压缩噪点。
### 像素化策略 ### 像素化策略
推荐做法: 推荐做法:
@@ -703,7 +724,14 @@ export interface GeneratedCharacterAnimationAsset {
id: string; id: string;
characterId: string; characterId: string;
visualAssetId: string; visualAssetId: string;
action: BaseAnimationSlot | 'cast' | 'talk' | 'skill1' | 'skill2' | 'skill3' | 'skill4'; action:
| BaseAnimationSlot
| 'cast'
| 'talk'
| 'skill1'
| 'skill2'
| 'skill3'
| 'skill4';
sourceProvider: 'aliyun-wan' | 'volc-seedance' | 'tencent' | 'local'; sourceProvider: 'aliyun-wan' | 'volc-seedance' | 'tencent' | 'local';
sourceMode: 'template' | 'video-drive' | 'audio-drive'; sourceMode: 'template' | 'video-drive' | 'audio-drive';
frameCount: number; frameCount: number;
@@ -914,20 +942,26 @@ draft
第一版要求以下基础动作槽位全部有内容: 第一版要求以下基础动作槽位全部有内容:
| 动作槽位 | 是否必填 | 建议来源 | 当前编辑器固定生成入口补充说明(`2026-04-19`
| --- | --- | --- |
| `idle` | 必填 | 模板生成 | - 固定按钮只保留 `idle / run / attack / die`
| `acquire` | 必填 | 可由短持物 / 抬手动作生成 | - `hurt` 不再作为固定生成按钮
| `attack` | 必填 | 模板生成 | - 如果运行时仍需 `hurt` 资源,应通过后续扩展动作槽位或手动补齐
| `run` | 必填 | 模板生成 |
| `jump` | 必填 | 模板生成 | | 动作槽位 | 是否必填 | 建议来源 |
| `double_jump` | 必填 | 可由跳跃二次变体生成 | | ------------- | -------- | ------------------------- |
| `jump_attack` | 必填 | 跳跃攻击模板 | | `idle` | 必填 | 模板生成 |
| `dash` | 必填 | 冲刺模板 | | `acquire` | 必填 | 可由短持物 / 抬手动作生成 |
| `hurt` | 必填 | 受击模板 | | `attack` | 必填 | 模板生成 |
| `die` | 必填 | 倒地 / 消散模板 | | `run` | 必填 | 模板生成 |
| `climb` | 必填 | 攀爬模板 | | `jump` | 必填 | 模板生成 |
| `wall_slide` | 必填 | 可由攀爬或停滞帧变体生成 | | `double_jump` | 必填 | 可由跳跃二次变体生成 |
| `jump_attack` | 必填 | 跳跃攻击模板 |
| `dash` | 必填 | 冲刺模板 |
| `hurt` | 必填 | 受击模板 |
| `die` | 必填 | 倒地 / 消散模板 |
| `climb` | 必填 | 攀爬模板 |
| `wall_slide` | 必填 | 可由攀爬或停滞帧变体生成 |
这里“不能为空”指的是: 这里“不能为空”指的是:
@@ -961,14 +995,13 @@ draft
先优先做这些高价值模板: 先优先做这些高价值模板:
| 模板 | 推荐时长 | 是否循环 | 说明 | | 模板 | 推荐时长 | 是否循环 | 说明 |
| --- | --- | --- | --- | | -------- | -------- | -------- | -------------- |
| `idle` | 2s-4s | 是 | 微动作、呼吸 | | `idle` | 2s-4s | 是 | 微动作、呼吸 |
| `run` | 2s-3s | 是 | 固定侧向 | | `run` | 2s-3s | 是 | 固定侧向 |
| `attack` | 2s-4s | 否 | 近战基础攻击 | | `attack` | 2s-4s | 否 | 近战基础攻击 |
| `jump` | 1s-2s | 否 | 起跳与空中姿态 | | `jump` | 1s-2s | 否 | 起跳与空中姿态 |
| `hurt` | 1s-2s | 否 | 受击短动作 | | `die` | 2s-4s | 否 | 倒下或消散 |
| `die` | 2s-4s | 否 | 倒下或消散 |
### 12.4 不建议第一阶段就重投入的动作 ### 12.4 不建议第一阶段就重投入的动作

View File

@@ -0,0 +1,143 @@
# Prompt 目录收口方案2026-04-19
## 1. 这次调整解决什么问题
此前后端提示词分散在多个业务模块里:
- `server-node/src/modules/ai/**`
- `server-node/src/modules/quest/**`
- `server-node/src/modules/runtime-item/**`
- `server-node/src/services/customWorld*.ts`
问题主要有三类:
1. 业务逻辑和 prompt 文本混写,改提示词时容易顺手改坏运行时逻辑。
2. 同一类 prompt 缺少集中入口,排查系统 prompt / user prompt / repair prompt 成本高。
3. 老桥接层、测试和新业务链路同时依赖时,迁移成本高,容易出现导出断裂。
这次收口目标不是“重写全部 AI 链路”,而是先把当前后端主链 prompt 收到单独目录,业务模块退化成“准备上下文 + 调用 prompt 脚本”。
## 2. 新目录
本轮新增目录:
```text
server-node/src/prompts/
├─ chatPromptBuilders.ts
├─ customWorldAgentPrompts.ts
├─ customWorldEntityPrompts.ts
├─ customWorldOrchestratorPrompts.ts
├─ customWorldSceneNpcPrompts.ts
├─ questPrompts.ts
├─ runtimeItemPrompts.ts
├─ storyOrchestratorPrompts.ts
└─ storyPromptBuilders.ts
```
当前职责划分:
- `chatPromptBuilders.ts`
- 角色私聊 / NPC 聊天 / 招募对话 prompt
- `storyPromptBuilders.ts`
- 主剧情 system prompt 与 user prompt builder
- `storyOrchestratorPrompts.ts`
- 剧情语言修复 prompt
- `questPrompts.ts`
- 任务意图 system prompt 与 user prompt builder
- `runtimeItemPrompts.ts`
- 运行时物品意图 system prompt 与 user prompt 文本装配
- `customWorldOrchestratorPrompts.ts`
- 自定义世界主编排 JSON 生成与 repair prompt
- `customWorldAgentPrompts.ts`
- 世界草稿 JSON prompt、补角色 / 补地点 prompt
- `customWorldEntityPrompts.ts`
- 世界编辑器角色 / 场景实体生成 prompt
- `customWorldSceneNpcPrompts.ts`
- 世界编辑器场景 NPC 生成 prompt
## 3. 落地规则
### 3.1 业务模块只做两件事
1. 整理运行时上下文
2. 调用 `server-node/src/prompts/**` 下的脚本输出 prompt
不要在业务模块里继续直接内联大段 system prompt / repair prompt / user prompt 模板文本。
### 3.2 Prompt 文件只放文本相关职责
允许放:
- system prompt 常量
- user prompt builder
- repair prompt builder
- prompt 专用的文本摘要函数
不建议放:
- 运行时状态 mutation
- 仓储读写
- HTTP 处理
- 与 prompt 无关的领域推导
### 3.3 兼容层保留旧导出
本轮对已有纯 prompt builder 文件采取了兼容迁移:
- `server-node/src/modules/ai/chatPromptBuilders.ts`
- `server-node/src/modules/ai/storyPromptBuilders.ts`
旧路径现在作为薄 re-export 保留,避免测试、桥接层和旧引用一次性全部断掉。
对于 `runtimeQuestModule.ts``runtimeItemModule.ts` 这类被桥接层直接引用的模块,本轮保留原导出名,通过 re-export 指向新 prompt 文件,保证兼容性。
## 4. 后续新增 prompt 的写法
新增提示词时按下面顺序处理:
1. 先判断是否属于已有领域。
2. 如果属于已有领域,优先补到对应 `server-node/src/prompts/*.ts`
3. 如果是新领域,再新增一个独立 prompt 脚本文件。
4. 业务模块只传入已经整理好的上下文字段,不在模块内部继续拼长文本。
5. 至少补一条该 prompt 的调用链测试或现有测试断言。
建议命名:
- system prompt`XXX_SYSTEM_PROMPT`
- repair prompt`buildXXXRepairPrompt`
- user prompt`buildXXXPrompt`
- 纯文本装配:`buildXXXPromptText`
## 5. 本轮范围与剩余事项
本轮已经收口:
- Story
- Chat
- Quest
- Runtime Item
- Custom World 主编排
- Custom World Agent 草稿增补
- Custom World 编辑器角色 / 场景 / 场景 NPC 生成
本轮尚未完全收口的内联 prompt 聚集区:
- `server-node/src/modules/assets/characterAssetRoutes.ts`
- `server-node/src/services/eightAnchorPromptBuilder.ts`
这两块后续继续沿用同一规则:
- 先抽出 prompt 文本与 builder
- 再让业务文件只保留参数整理与调用
## 6. 验证方式
本轮调整后建议至少执行:
- `npm run check:encoding`
- `npm run server-node:test`
- `npm --prefix server-node run build`
说明:
- 当前仓库里 `server-node/src/db.test.ts` 仍有一条与新增迁移版本号相关的既有失败,不属于本次 prompt 目录改造引入的问题。

View File

@@ -4,6 +4,7 @@
## 文档列表 ## 文档列表
- [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。
- [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 - [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
- [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md)Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。 - [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md)Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。
- [EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md](./EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md):按并行工作流文档逐项核对后的完成度审计与剩余收口点。 - [EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md](./EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md):按并行工作流文档逐项核对后的完成度审计与剩余收口点。

View File

@@ -0,0 +1,478 @@
export type MutableRgbaBuffer = Uint8Array | Uint8ClampedArray;
const SOFT_EDGE_ALPHA_THRESHOLD = 224;
const FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD = 96;
function clamp01(value: number) {
return Math.max(0, Math.min(1, value));
}
function lerp(from: number, to: number, t: number) {
return from + (to - from) * clamp01(t);
}
function computeGreenBackgroundScore(
red: number,
green: number,
blue: number,
alpha: number,
) {
if (alpha === 0) {
return 1;
}
const greenLead = green - Math.max(red, blue);
if (green < 52 || greenLead <= 8) {
return 0;
}
const greenRatio = green / Math.max(1, red + blue);
if (greenRatio <= 0.52) {
return 0;
}
return clamp01(
((green - 52) / 168) * 0.22 +
((greenLead - 8) / 96) * 0.53 +
((greenRatio - 0.52) / 0.82) * 0.25,
);
}
function computeWhiteBackgroundScore(
red: number,
green: number,
blue: number,
alpha: number,
) {
if (alpha === 0) {
return 1;
}
const maxChannel = Math.max(red, green, blue);
const minChannel = Math.min(red, green, blue);
const average = (red + green + blue) / 3;
if (average < 188 || minChannel < 168) {
return 0;
}
const spread = maxChannel - minChannel;
const neutrality = 1 - clamp01((spread - 6) / 34);
const brightness = clamp01((average - 188) / 55);
const floor = clamp01((minChannel - 168) / 60);
return clamp01(neutrality * (brightness * 0.85 + floor * 0.15));
}
function collectForegroundNeighborColor(
pixels: MutableRgbaBuffer,
width: number,
height: number,
x: number,
y: number,
backgroundMask: Uint8Array,
backgroundHints: Float32Array,
) {
let totalWeight = 0;
let totalRed = 0;
let totalGreen = 0;
let totalBlue = 0;
for (let offsetY = -2; offsetY <= 2; offsetY += 1) {
for (let offsetX = -2; offsetX <= 2; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
continue;
}
const nextPixelIndex = nextY * width + nextX;
if (backgroundMask[nextPixelIndex]) {
continue;
}
if ((backgroundHints[nextPixelIndex] ?? 0) >= 0.18) {
continue;
}
const nextOffset = nextPixelIndex * 4;
const nextAlpha = pixels[nextOffset + 3] ?? 0;
if (nextAlpha < FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) {
continue;
}
const distance = Math.abs(offsetX) + Math.abs(offsetY);
const weight =
(nextAlpha / 255) *
(distance <= 1 ? 1.8 : distance === 2 ? 1.2 : 0.7);
totalWeight += weight;
totalRed += (pixels[nextOffset] ?? 0) * weight;
totalGreen += (pixels[nextOffset + 1] ?? 0) * weight;
totalBlue += (pixels[nextOffset + 2] ?? 0) * weight;
}
}
if (totalWeight <= 0) {
return null;
}
return {
red: Math.round(totalRed / totalWeight),
green: Math.round(totalGreen / totalWeight),
blue: Math.round(totalBlue / totalWeight),
};
}
export function removeBackgroundFromRgba(
pixels: MutableRgbaBuffer,
width: number,
height: number,
) {
const pixelCount = width * height;
if (pixelCount <= 0) {
return false;
}
const backgroundMask = new Uint8Array(pixelCount);
const greenScores = new Float32Array(pixelCount);
const whiteScores = new Float32Array(pixelCount);
const backgroundHints = new Float32Array(pixelCount);
const queue: number[] = [];
let queueIndex = 0;
let changed = false;
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
const offset = pixelIndex * 4;
const red = pixels[offset] ?? 0;
const green = pixels[offset + 1] ?? 0;
const blue = pixels[offset + 2] ?? 0;
const alpha = pixels[offset + 3] ?? 0;
const greenScore = computeGreenBackgroundScore(red, green, blue, alpha);
const whiteScore = computeWhiteBackgroundScore(red, green, blue, alpha);
const transparencyHint = clamp01((56 - alpha) / 56) * 0.75;
greenScores[pixelIndex] = greenScore;
whiteScores[pixelIndex] = whiteScore;
backgroundHints[pixelIndex] = Math.max(
greenScore,
whiteScore,
transparencyHint,
);
}
const trySeedBackground = (pixelIndex: number) => {
if (backgroundMask[pixelIndex]) {
return;
}
const offset = pixelIndex * 4;
const alpha = pixels[offset + 3] ?? 0;
const strongCandidate =
alpha < 40 ||
(greenScores[pixelIndex] ?? 0) > 0.12 ||
(whiteScores[pixelIndex] ?? 0) > 0.32;
if (!strongCandidate) {
return;
}
backgroundMask[pixelIndex] = 1;
queue.push(pixelIndex);
};
for (let x = 0; x < width; x += 1) {
trySeedBackground(x);
trySeedBackground((height - 1) * width + x);
}
for (let y = 1; y < height - 1; y += 1) {
trySeedBackground(y * width);
trySeedBackground(y * width + width - 1);
}
while (queueIndex < queue.length) {
const pixelIndex = queue[queueIndex]!;
queueIndex += 1;
const x = pixelIndex % width;
const y = Math.floor(pixelIndex / width);
const neighborIndexes = [
x > 0 ? pixelIndex - 1 : -1,
x + 1 < width ? pixelIndex + 1 : -1,
y > 0 ? pixelIndex - width : -1,
y + 1 < height ? pixelIndex + width : -1,
];
for (const nextPixelIndex of neighborIndexes) {
if (nextPixelIndex < 0 || backgroundMask[nextPixelIndex]) {
continue;
}
const nextOffset = nextPixelIndex * 4;
const nextAlpha = pixels[nextOffset + 3] ?? 0;
const nextGreenScore = greenScores[nextPixelIndex] ?? 0;
const nextWhiteScore = whiteScores[nextPixelIndex] ?? 0;
const nextHint = backgroundHints[nextPixelIndex] ?? 0;
const reachableSoftEdge =
nextHint > 0.08 &&
nextAlpha < SOFT_EDGE_ALPHA_THRESHOLD &&
(nextGreenScore > 0.04 || nextWhiteScore > 0.08 || nextAlpha < 180);
if (
nextAlpha < 40 ||
nextGreenScore > 0.12 ||
nextWhiteScore > 0.32 ||
reachableSoftEdge
) {
backgroundMask[nextPixelIndex] = 1;
queue.push(nextPixelIndex);
}
}
}
for (let iteration = 0; iteration < 2; iteration += 1) {
const expandedMask = new Uint8Array(backgroundMask);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixelIndex = y * width + x;
if (expandedMask[pixelIndex]) {
continue;
}
const alpha = pixels[pixelIndex * 4 + 3] ?? 0;
const hint = backgroundHints[pixelIndex] ?? 0;
if (alpha >= SOFT_EDGE_ALPHA_THRESHOLD || hint <= 0.06) {
continue;
}
let adjacentBackgroundCount = 0;
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
continue;
}
if (backgroundMask[nextY * width + nextX]) {
adjacentBackgroundCount += 1;
}
}
}
if (
adjacentBackgroundCount >= 2 ||
(adjacentBackgroundCount >= 1 && hint > 0.18)
) {
expandedMask[pixelIndex] = 1;
}
}
}
backgroundMask.set(expandedMask);
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixelIndex = y * width + x;
if (!backgroundMask[pixelIndex]) {
continue;
}
const offset = pixelIndex * 4;
const alpha = pixels[offset + 3] ?? 0;
if (alpha === 0) {
continue;
}
const matteScore = Math.max(
backgroundHints[pixelIndex] ?? 0,
greenScores[pixelIndex] ?? 0,
whiteScores[pixelIndex] ?? 0,
);
let foregroundSupport = 0;
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
continue;
}
const nextPixelIndex = nextY * width + nextX;
if (backgroundMask[nextPixelIndex]) {
continue;
}
const nextAlpha = pixels[nextPixelIndex * 4 + 3] ?? 0;
if (nextAlpha >= FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD) {
foregroundSupport += 1;
}
}
}
let nextAlpha = alpha;
if (matteScore > 0.9 || foregroundSupport === 0) {
nextAlpha = 0;
} else if (matteScore > 0.72 && foregroundSupport <= 1) {
nextAlpha = Math.min(alpha, Math.round(alpha * 0.08));
} else {
nextAlpha = Math.min(
alpha,
Math.round(alpha * Math.max(0.08, 1 - matteScore * 0.95)),
);
}
if (foregroundSupport >= 3 && matteScore < 0.55) {
nextAlpha = Math.max(nextAlpha, Math.round(alpha * 0.22));
}
if (nextAlpha < 10) {
nextAlpha = 0;
}
if (nextAlpha !== alpha) {
pixels[offset + 3] = nextAlpha;
changed = true;
}
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const pixelIndex = y * width + x;
const offset = pixelIndex * 4;
const alpha = pixels[offset + 3] ?? 0;
if (alpha === 0) {
continue;
}
let touchesTransparentEdge = false;
for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const nextX = x + offsetX;
const nextY = y + offsetY;
if (nextX < 0 || nextX >= width || nextY < 0 || nextY >= height) {
touchesTransparentEdge = true;
continue;
}
const nextPixelIndex = nextY * width + nextX;
if (
backgroundMask[nextPixelIndex] ||
(pixels[nextPixelIndex * 4 + 3] ?? 0) < 16
) {
touchesTransparentEdge = true;
}
}
}
if (!touchesTransparentEdge) {
continue;
}
const greenScore = greenScores[pixelIndex] ?? 0;
const whiteScore = whiteScores[pixelIndex] ?? 0;
const contamination = Math.max(
greenScore,
whiteScore,
backgroundMask[pixelIndex] ? 0.35 : 0,
alpha < 220 ? ((220 - alpha) / 220) * 0.25 : 0,
);
if (contamination < 0.06) {
continue;
}
let red = pixels[offset] ?? 0;
let green = pixels[offset + 1] ?? 0;
let blue = pixels[offset + 2] ?? 0;
const sample = collectForegroundNeighborColor(
pixels,
width,
height,
x,
y,
backgroundMask,
backgroundHints,
);
const blend = clamp01(
Math.max(contamination * 0.82, touchesTransparentEdge ? 0.22 : 0),
);
if (sample) {
red = Math.round(lerp(red, sample.red, blend));
green = Math.round(lerp(green, sample.green, blend));
blue = Math.round(lerp(blue, sample.blue, blend));
if (greenScore > 0.04) {
green = Math.min(green, sample.green + 18);
}
if (whiteScore > 0.1) {
red = Math.min(red, sample.red + 26);
green = Math.min(green, sample.green + 26);
blue = Math.min(blue, sample.blue + 26);
}
} else {
if (greenScore > 0.04) {
green = Math.max(
Math.max(red, blue),
Math.round(green - (green - Math.max(red, blue)) * 0.78),
);
}
if (whiteScore > 0.12) {
const spread = Math.max(red, green, blue) - Math.min(red, green, blue);
if (spread < 20) {
const tonedValue = Math.round(((red + green + blue) / 3) * 0.88);
red = Math.min(red, tonedValue);
green = Math.min(green, tonedValue);
blue = Math.min(blue, tonedValue);
}
}
}
let nextAlpha = alpha;
const edgeFade = Math.max(greenScore * 0.35, whiteScore * 0.28);
if (edgeFade > 0.08) {
nextAlpha = Math.min(alpha, Math.round(alpha * (1 - edgeFade)));
if (nextAlpha < 10) {
nextAlpha = 0;
}
}
if (
red !== (pixels[offset] ?? 0) ||
green !== (pixels[offset + 1] ?? 0) ||
blue !== (pixels[offset + 2] ?? 0) ||
nextAlpha !== alpha
) {
pixels[offset] = red;
pixels[offset + 1] = green;
pixels[offset + 2] = blue;
pixels[offset + 3] = nextAlpha;
changed = true;
}
}
}
return changed;
}

View File

@@ -94,11 +94,10 @@ export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
}, },
]; ];
const CHIBI_STYLE_TEXT = const BODY_RATIO_TEXT =
'Q版大头身动作角色,头身比固定控制在 22.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑,接近经典横版像素动作角色的身体比例,不要写实长身比例。'; '横版像素动作角色体型,头身比优先控制在 34 头身,头部只允许略大于写实比例,保留清楚的头、躯干、双臂和双腿轮廓,不要退化成软萌 Q版大头贴或儿童绘本比例。';
const PIXEL_STYLE_TEXT = const PIXEL_STYLE_TEXT =
'像素风画风,整体像素游戏角色设计方向,深色粗轮廓配合清晰大色块,侧脸五官简化,发型、服装、配饰优先形成醒目剪影,身体始终朝右,适合横版动作 sprite 资产。'; '明确的像素动作角色设定稿气质,整体像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,身体始终朝右,适合横版动作 sprite 资产。';
export function getActionTemplateById(id: QwenSpriteActionTemplateId) { export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
return ( return (
@@ -113,7 +112,7 @@ export function buildMasterPrompt(characterBrief: string) {
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
`画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, `画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
`风格要求:Q版大头身动作角色清爽可爱头身比固定控制在 2 到 2.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑。深色粗轮廓配合清晰大色块,侧脸五官简化,发型、服装、配饰优先形成醒目剪影,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。`, `风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,形体清晰,服装层次明确,优先体现像素动作角色感而不是软萌 Q版插画感,便于后续连续动作生成。`,
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。', '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。',
'主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。', '主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。',
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。', '视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。',
@@ -130,12 +129,12 @@ export function buildVideoActionPrompt(options: {
characterBrief: string; characterBrief: string;
}) { }) {
return [ return [
`单人全身角色动作视频,动作主题${options.actionTemplate.label}`, `单人全身角色动作视频,动作英文名${options.actionTemplate.id}`,
`角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰不要退化成完全 90 度纯右视图。`, `角色固定为图1同一角色保持右向斜侧身动作视角镜头稳定轮廓清晰不要退化成完全 90 度纯右视图。`,
`视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`,
`主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`,
`画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, `画面要求1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`,
`风格要求:${CHIBI_STYLE_TEXT} ${PIXEL_STYLE_TEXT} 清爽可爱,高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`, `风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,优先保证像素动作角色感,不要退化成只剩 Q 版比例的普通插画,便于后续连续动作生成。`,
`动作结构:${options.actionTemplate.sequenceLines.join('')}。结尾要求:${options.actionTemplate.ending}`, `动作结构:${options.actionTemplate.sequenceLines.join('')}。结尾要求:${options.actionTemplate.ending}`,
options.useChromaKey options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。' ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'

View File

@@ -2,6 +2,9 @@ import type { JsonObject } from './common';
export const SAVE_SNAPSHOT_VERSION = 2; export const SAVE_SNAPSHOT_VERSION = 2;
export const DEFAULT_MUSIC_VOLUME = 0.42; export const DEFAULT_MUSIC_VOLUME = 0.42;
export const DEFAULT_PLATFORM_THEME = 'light';
export const PLATFORM_THEMES = ['light', 'dark'] as const;
export type PlatformTheme = (typeof PLATFORM_THEMES)[number];
export type SavedGameSnapshot< export type SavedGameSnapshot<
TGameState = unknown, TGameState = unknown,
@@ -28,6 +31,7 @@ export type SavedGameSnapshotInput<
export type RuntimeSettings = { export type RuntimeSettings = {
musicVolume: number; musicVolume: number;
platformTheme: PlatformTheme;
}; };
export type BasicOkResult = { export type BasicOkResult = {
@@ -73,6 +77,31 @@ export type ProfilePlayStatsResponse = {
updatedAt: string | null; updatedAt: string | null;
}; };
export type ProfileSaveArchiveSummary = {
worldKey: string;
ownerUserId: string | null;
profileId: string | null;
worldType: string | null;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
lastPlayedAt: string;
};
export type ProfileSaveArchiveListResponse = {
entries: ProfileSaveArchiveSummary[];
};
export type ProfileSaveArchiveResumeResponse<
TGameState = unknown,
TBottomTab extends string = string,
TCurrentStory = unknown,
> = {
entry: ProfileSaveArchiveSummary;
snapshot: SavedGameSnapshot<TGameState, TBottomTab, TCurrentStory>;
};
export type CustomWorldPublicationStatus = 'draft' | 'published'; export type CustomWorldPublicationStatus = 'draft' | 'published';
export type CustomWorldThemeMode = export type CustomWorldThemeMode =
| 'martial' | 'martial'

View File

@@ -261,6 +261,8 @@ export const TASK5_RUNTIME_FUNCTION_IDS = [
'idle_observe_signs', 'idle_observe_signs',
'idle_rest_focus', 'idle_rest_focus',
'idle_travel_next_scene', 'idle_travel_next_scene',
'battle_attack_basic',
'battle_use_skill',
'battle_all_in_crush', 'battle_all_in_crush',
'battle_escape_breakout', 'battle_escape_breakout',
'battle_feint_step', 'battle_feint_step',
@@ -326,6 +328,7 @@ export type RuntimeStoryOptionView = {
actionText: string; actionText: string;
detailText?: string; detailText?: string;
scope: Task5RuntimeOptionScope; scope: Task5RuntimeOptionScope;
payload?: RuntimeStoryChoicePayload;
disabled?: boolean; disabled?: boolean;
reason?: string; reason?: string;
}; };

View File

@@ -2030,10 +2030,169 @@ test('profile dashboard aggregates wallet, play time and played works at the acc
}); });
}); });
test('profile save archives list worlds by last played time and can resume a selected archive', async () => {
await withTestServer('profile-save-archives', async ({ baseUrl }) => {
const user = await authEntry(baseUrl, 'archive_user', 'secret123');
const firstSaveResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(user.token, {
method: 'PUT',
body: JSON.stringify({
savedAt: '2026-04-19T08:00:00.000Z',
bottomTab: 'adventure',
currentStory: {
text: '潮声还在旧灯塔下回荡。',
options: [],
},
gameState: {
worldType: 'CUSTOM',
playerCurrency: 120,
runtimeStats: {
playTimeMs: 5400000,
},
storyEngineMemory: {
continueGameDigest: '回到裂潮边城的旧灯塔继续追查假航灯。',
},
customWorldProfile: {
id: 'world-aurora',
name: '裂潮边城',
summary: '潮声与城线之间的冷铁边疆。',
},
},
}),
}),
);
assert.equal(firstSaveResponse.status, 200);
const secondSaveResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(user.token, {
method: 'PUT',
body: JSON.stringify({
savedAt: '2026-04-19T10:15:00.000Z',
bottomTab: 'inventory',
currentStory: {
text: '江湖新章的风雨夜刚刚开始。',
options: [],
},
gameState: {
worldType: 'WUXIA',
playerCurrency: 86,
runtimeStats: {
playTimeMs: 900000,
},
currentScenePreset: {
name: '江湖新章',
summary: '雨夜客栈里的新委托。',
},
},
}),
}),
);
assert.equal(secondSaveResponse.status, 200);
const listResponse = await httpRequest(
`${baseUrl}/api/runtime/profile/save-archives`,
withBearer(user.token),
);
const listPayload = (await listResponse.json()) as {
entries: Array<{
worldKey: string;
worldName: string;
summaryText: string;
lastPlayedAt: string;
}>;
};
assert.equal(listResponse.status, 200);
assert.deepEqual(
listPayload.entries.map((entry) => entry.worldKey),
['builtin:WUXIA', 'custom:world-aurora'],
);
assert.equal(listPayload.entries[0]?.worldName, '江湖新章');
assert.equal(
listPayload.entries[1]?.summaryText,
'回到裂潮边城的旧灯塔继续追查假航灯。',
);
assert.equal(
listPayload.entries[0]?.lastPlayedAt,
'2026-04-19T10:15:00.000Z',
);
const resumeResponse = await httpRequest(
`${baseUrl}/api/runtime/profile/save-archives/${encodeURIComponent('custom:world-aurora')}`,
withBearer(user.token, {
method: 'POST',
}),
);
const resumePayload = (await resumeResponse.json()) as {
entry: {
worldKey: string;
};
snapshot: {
bottomTab: string;
gameState: {
playerCurrency: number;
customWorldProfile: {
id: string;
name: string;
} | null;
};
};
};
assert.equal(resumeResponse.status, 200);
assert.equal(resumePayload.entry.worldKey, 'custom:world-aurora');
assert.equal(resumePayload.snapshot.bottomTab, 'adventure');
assert.equal(resumePayload.snapshot.gameState.playerCurrency, 120);
assert.equal(
resumePayload.snapshot.gameState.customWorldProfile?.id,
'world-aurora',
);
const currentSnapshotResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(user.token),
);
const currentSnapshotPayload = (await currentSnapshotResponse.json()) as {
bottomTab: string;
gameState: {
playerCurrency: number;
customWorldProfile: {
id: string;
} | null;
};
};
assert.equal(currentSnapshotResponse.status, 200);
assert.equal(currentSnapshotPayload.bottomTab, 'adventure');
assert.equal(currentSnapshotPayload.gameState.playerCurrency, 120);
assert.equal(
currentSnapshotPayload.gameState.customWorldProfile?.id,
'world-aurora',
);
const dashboardResponse = await httpRequest(
`${baseUrl}/api/runtime/profile/dashboard`,
withBearer(user.token),
);
const dashboardPayload = (await dashboardResponse.json()) as {
walletBalance: number;
totalPlayTimeMs: number;
playedWorldCount: number;
};
assert.equal(dashboardResponse.status, 200);
assert.equal(dashboardPayload.walletBalance, 86);
assert.equal(dashboardPayload.totalPlayTimeMs, 6300000);
assert.equal(dashboardPayload.playedWorldCount, 2);
});
});
test('custom worlds stay private until published and then appear in the public gallery', async () => { test('custom worlds stay private until published and then appear in the public gallery', async () => {
await withTestServer('custom-world-gallery', async ({ baseUrl }) => { await withTestServer('custom-world-gallery', async ({ baseUrl }) => {
const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123'); const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123');
const viewer = await authEntry(baseUrl, 'gallery_viewer', 'secret123');
const upsertResponse = await httpRequest( const upsertResponse = await httpRequest(
`${baseUrl}/api/runtime/custom-world-library/world-a`, `${baseUrl}/api/runtime/custom-world-library/world-a`,
@@ -2084,15 +2243,11 @@ test('custom worlds stay private until published and then appear in the public g
const galleryBeforePublish = await httpRequest( const galleryBeforePublish = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery`, `${baseUrl}/api/runtime/custom-world-gallery`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
); );
const galleryBeforePayload = (await galleryBeforePublish.json()) as { const galleryBeforePayload = (await galleryBeforePublish.json()) as {
entries: unknown[]; entries: unknown[];
}; };
assert.equal(galleryBeforePublish.status, 200);
assert.deepEqual(galleryBeforePayload.entries, []); assert.deepEqual(galleryBeforePayload.entries, []);
const publishResponse = await httpRequest( const publishResponse = await httpRequest(
@@ -2114,11 +2269,6 @@ test('custom worlds stay private until published and then appear in the public g
const galleryAfterPublish = await httpRequest( const galleryAfterPublish = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery`, `${baseUrl}/api/runtime/custom-world-gallery`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
); );
const galleryAfterPayload = (await galleryAfterPublish.json()) as { const galleryAfterPayload = (await galleryAfterPublish.json()) as {
entries: Array<{ entries: Array<{
@@ -2139,11 +2289,6 @@ test('custom worlds stay private until published and then appear in the public g
const galleryDetail = await httpRequest( const galleryDetail = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`, `${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryAfterPayload.entries[0]?.ownerUserId || '')}/${encodeURIComponent(galleryAfterPayload.entries[0]?.profileId || '')}`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
); );
const galleryDetailPayload = (await galleryDetail.json()) as { const galleryDetailPayload = (await galleryDetail.json()) as {
entry: { entry: {
@@ -2175,11 +2320,6 @@ test('custom worlds stay private until published and then appear in the public g
const galleryAfterUnpublish = await httpRequest( const galleryAfterUnpublish = await httpRequest(
`${baseUrl}/api/runtime/custom-world-gallery`, `${baseUrl}/api/runtime/custom-world-gallery`,
{
headers: {
Authorization: `Bearer ${viewer.token}`,
},
},
); );
const galleryAfterUnpublishPayload = const galleryAfterUnpublishPayload =
(await galleryAfterUnpublish.json()) as { (await galleryAfterUnpublish.json()) as {

View File

@@ -30,6 +30,7 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
`CREATE TABLE IF NOT EXISTS runtime_settings ( `CREATE TABLE IF NOT EXISTS runtime_settings (
user_id TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,
music_volume REAL NOT NULL, music_volume REAL NOT NULL,
platform_theme TEXT NOT NULL DEFAULT 'light',
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`, )`,
@@ -316,4 +317,38 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
)`, )`,
], ],
}, },
{
id: '20260419_014_profile_save_archives',
name: 'profile save archives',
statements: [
`CREATE TABLE IF NOT EXISTS profile_save_archives (
user_id TEXT NOT NULL,
world_key TEXT NOT NULL,
owner_user_id TEXT,
profile_id TEXT,
world_type TEXT,
world_name TEXT NOT NULL DEFAULT '',
world_subtitle TEXT NOT NULL DEFAULT '',
summary_text TEXT NOT NULL DEFAULT '',
cover_image_src TEXT,
saved_at TEXT NOT NULL,
bottom_tab TEXT NOT NULL,
game_state_json JSONB NOT NULL,
current_story_json JSONB,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, world_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS profile_save_archives_user_saved_idx
ON profile_save_archives (user_id, saved_at DESC)`,
],
},
{
id: '20260419_015_runtime_settings_platform_theme',
name: 'runtime settings platform theme',
statements: [
`ALTER TABLE runtime_settings
ADD COLUMN IF NOT EXISTS platform_theme TEXT NOT NULL DEFAULT 'light'`,
],
},
]; ];

View File

@@ -125,11 +125,13 @@ function describeAffinityShift(affinityDelta: number) {
} }
function buildFallbackNpcChatSuggestions(playerMessage: string) { function buildFallbackNpcChatSuggestions(playerMessage: string) {
const topic = playerMessage.trim() || '刚才那句'; const topic = Array.from(playerMessage.trim() || '刚才那句')
.slice(0, 8)
.join('');
return [ return [
`顺着“${topic}”再追问一句`, '你刚才那句是什么意思',
'先表明你的判断,再看对方反应', `这事和${topic}有关吗`,
'换个更轻一点的语气继续聊下去', '你愿意再说清楚点吗',
]; ];
} }

View File

@@ -1,469 +1 @@
import type { export * from '../../prompts/chatPromptBuilders.js';
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
type JsonRecord = Record<string, unknown>;
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
只回复这名角色此刻会对玩家说的话。
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
只输出纯文本,共 3 行,每行一条。
不要加编号、项目符号、Markdown 或额外说明。
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
只输出一段简洁文字。
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。
你只能输出纯中文对话正文不能输出解释、代码、markdown、JSON 或额外说明。
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段内容只是聊天,不是做决定。
- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。
- 禁止把情报直接写成对玩家的指令。
- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`;
export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。
你只能输出纯中文对话正文不能输出解释、代码、markdown、JSON 或额外说明。
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段对话的目标是把“邀请对方入队”自然谈成。
- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
- 最后一行必须由对方明确答应加入队伍。`;
export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
只输出纯文本,共 3 行,每行 1 条。
不要加编号、项目符号、Markdown、JSON 或额外说明。
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`;
function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as JsonRecord)
: null;
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readStringArray(value: unknown) {
return Array.isArray(value)
? value
.map((item) => readString(item))
.filter((item): item is string => Boolean(item))
: [];
}
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '边城模板';
case 'XIANXIA':
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:
return worldType || '未知世界';
}
}
function describeStats(label: string, record: JsonRecord | null) {
const hp = readNumber(record?.hp);
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
const mana = readNumber(record?.mana);
const maxMana = Math.max(1, readNumber(record?.maxMana, mana));
return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`;
}
function describeCharacter(label: string, value: unknown) {
const record = asRecord(value);
const name = readString(record?.name) ?? '未知角色';
const title = readString(record?.title) ?? '未知称号';
const description = readString(record?.description) ?? '暂无额外描述';
const personality = readString(record?.personality) ?? '性格信息未显式提供';
return [
`${label}姓名:${name}`,
`${label}称号:${title}`,
`${label}描述:${description}`,
`${label}性格:${personality}`,
].join('\n');
}
function describeStoryHistory(history: unknown) {
if (!Array.isArray(history) || history.length === 0) {
return '近期剧情:暂无。';
}
const lines = history
.slice(-4)
.map((item) => readString(asRecord(item)?.text))
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n')
: '近期剧情:暂无。';
}
function describeConversationHistory(history: unknown) {
if (!Array.isArray(history) || history.length === 0) {
return '聊天记录:暂无。';
}
const lines = history
.slice(-12)
.map((item) => {
const record = asRecord(item);
const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色';
const text = readString(record?.text);
return text ? `- ${speaker}${text}` : null;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['聊天记录:', ...lines].join('\n')
: '聊天记录:暂无。';
}
function describeNpcConversationHistory(history: unknown, npcName: string) {
if (!Array.isArray(history) || history.length === 0) {
return '当前聊天记录:暂无。';
}
const lines = history
.slice(-10)
.map((item) => {
const record = asRecord(item);
const speaker = readString(record?.speaker);
const speakerName = readString(record?.speakerName);
const text = readString(record?.text);
if (!text) return null;
if (speaker === 'player') {
return `- 玩家:${text}`;
}
if (speaker === 'npc') {
return `- ${speakerName ?? npcName}${text}`;
}
if (speaker === 'system') {
return `- 系统提示:${text}`;
}
return `- ${speakerName ?? '同伴'}${text}`;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['当前聊天记录:', ...lines].join('\n')
: '当前聊天记录:暂无。';
}
function describeSceneContext(context: unknown) {
const record = asRecord(context);
const sceneName = readString(record?.sceneName) ?? '当前区域';
const sceneDescription =
readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。';
const inBattle = record?.inBattle === true ? '战斗中' : '非战斗';
const customWorldProfile = asRecord(record?.customWorldProfile);
const customWorldName = readString(customWorldProfile?.name);
const customWorldSummary = readString(customWorldProfile?.summary);
return [
`世界补充:${customWorldName ?? '无'}`,
customWorldSummary ? `世界摘要:${customWorldSummary}` : null,
`场景:${sceneName}`,
`场景描述:${sceneDescription}`,
`当前状态:${inBattle}`,
describeStats('玩家', record),
]
.filter(Boolean)
.join('\n');
}
function describeTargetStatus(status: unknown) {
const record = asRecord(status);
const roleLabel = readString(record?.roleLabel) ?? '同行角色';
const affinity = record?.affinity;
return [
`对方身份:${roleLabel}`,
describeStats('对方', record),
typeof affinity === 'number' ? `当前好感:${affinity}` : null,
]
.filter(Boolean)
.join('\n');
}
function describeEncounter(encounter: unknown) {
const record = asRecord(encounter);
const npcName = readString(record?.npcName) ?? '眼前角色';
const contextText =
readString(record?.context) ??
readString(record?.npcDescription) ??
'你们正在当前遭遇里继续对话。';
return {
npcName,
block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'),
};
}
function describeMonsters(monsters: unknown) {
if (!Array.isArray(monsters) || monsters.length === 0) {
return '当前敌对目标:无。';
}
const lines = monsters
.slice(0, 4)
.map((item) => {
const record = asRecord(item);
const name =
readString(record?.name) ??
readString(record?.npcName) ??
readString(record?.id);
const hp = readNumber(record?.hp);
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
return name ? `- ${name}(生命 ${hp}/${maxHp}` : null;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['当前敌对目标:', ...lines].join('\n')
: '当前敌对目标:无。';
}
function describeTargetCharacterName(payload: {
targetCharacter?: unknown;
encounter?: unknown;
}) {
return (
readString(asRecord(payload.targetCharacter)?.name) ??
readString(asRecord(payload.encounter)?.npcName) ??
'对方'
);
}
export function buildCharacterPanelChatPrompt(
payload: CharacterChatReplyRequest,
) {
const targetName = describeTargetCharacterName(payload);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.conversationSummary
? `之前聊天摘要:${payload.conversationSummary}`
: '之前聊天摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
`玩家刚刚对 ${targetName} 说:${payload.playerMessage}`,
`现在请以 ${targetName} 的身份,直接回复玩家。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildCharacterPanelChatSuggestionPrompt(
payload: CharacterChatSuggestionsRequest,
) {
const targetName = describeTargetCharacterName(payload);
const latestCharacterReply = Array.isArray(payload.conversationHistory)
? [...payload.conversationHistory]
.reverse()
.map((item) => asRecord(item))
.find((record) => readString(record?.speaker) === 'character')
: null;
const latestReplyText = readString(latestCharacterReply?.text);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.conversationSummary
? `之前聊天摘要:${payload.conversationSummary}`
: '之前聊天摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
latestReplyText
? `角色刚刚的回复:${latestReplyText}`
: `玩家正准备与 ${targetName} 开始一段新的私聊。`,
`请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildCharacterPanelChatSummaryPrompt(
payload: CharacterChatSummaryRequest,
) {
const targetName = describeTargetCharacterName(payload);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.previousSummary
? `旧摘要:${payload.previousSummary}`
: '旧摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
`请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`,
]
.filter(Boolean)
.join('\n\n');
}
function buildNpcDialoguePromptBase(
payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
const character =
(payload as NpcChatTurnRequest).character ??
(payload as NpcChatTurnRequest).player;
if (!(payload as NpcChatTurnRequest).character && character) {
(payload as NpcChatTurnRequest).character = character;
}
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.character),
encounter.block,
describeMonsters(payload.monsters),
describeStoryHistory(payload.history),
]
.filter(Boolean)
.join('\n\n');
}
export function buildStrictNpcChatDialoguePrompt(
payload: NpcChatDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
const context = asRecord(payload.context);
const openingCampBackground = readString(context?.openingCampBackground);
const openingCampDialogue = readString(context?.openingCampDialogue);
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
return [
buildNpcDialoguePromptBase(payload),
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
allowedTopics.length > 0
? `当前更适合谈的内容:${allowedTopics.join('、')}`
: null,
blockedTopics.length > 0
? `当前避免直接说破:${blockedTopics.join('、')}`
: null,
`当前聊天主题:${payload.topic}`,
payload.resultSummary
? `这段聊天希望带来的变化:${payload.resultSummary}`
: '这段聊天要让气氛、情报或关系出现一层新的变化。',
`请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcRecruitDialoguePrompt(
payload: NpcRecruitDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
return [
buildNpcDialoguePromptBase(payload),
`玩家邀请:${payload.invitationText}`,
payload.recruitSummary
? `招募补充条件:${payload.recruitSummary}`
: '这轮对话已经具备自然邀请对方入队的条件。',
'这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。',
`最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcChatTurnReplyPrompt(
payload: NpcChatTurnRequest,
) {
const encounter = describeEncounter(payload.encounter);
const npcState = asRecord(payload.npcState);
const conversationHistory =
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
? payload.conversationHistory
: payload.dialogue ?? payload.conversationHistory ?? [];
const affinity = readNumber(npcState?.affinity, 0);
const chattedCount = readNumber(npcState?.chattedCount, 0);
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(conversationHistory, encounter.npcName),
`当前关系值:${affinity}`,
`已聊天轮次:${chattedCount}`,
`玩家刚刚说:${payload.playerMessage}`,
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcChatTurnSuggestionPrompt(
payload: NpcChatTurnRequest,
npcReply: string,
) {
const encounter = describeEncounter(payload.encounter);
const conversationHistory =
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
? payload.conversationHistory
: payload.dialogue ?? payload.conversationHistory ?? [];
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(conversationHistory, encounter.npcName),
`玩家刚刚说:${payload.playerMessage}`,
`NPC 刚刚回复:${npcReply}`,
`请围绕刚刚这轮对话,为玩家生成 3 条可以继续和 ${encounter.npcName} 聊下去的中文短句候选。`,
]
.filter(Boolean)
.join('\n\n');
}

View File

@@ -22,17 +22,15 @@ import type {
CustomWorldCreatorIntent, CustomWorldCreatorIntent,
CustomWorldProfile, CustomWorldProfile,
} from '../../../../src/types.js'; } from '../../../../src/types.js';
import {
buildCustomWorldProfilePrompt,
buildCustomWorldProfileRepairPrompt,
CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT,
CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT,
} from '../../prompts/customWorldOrchestratorPrompts.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js';
type GeneratedProfile = Record<string, unknown>; type GeneratedProfile = Record<string, unknown>;
const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你会收到一段本应为单个 JSON 对象的文本。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
不要输出 Markdown、代码块、解释、注释或额外文字。
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = 180000; const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = 180000;
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3; const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
@@ -278,59 +276,6 @@ function createCustomWorldGenerationReporter(
}; };
} }
function buildCustomWorldProfilePrompt(params: {
settingText: string;
generationSeedText: string;
creatorIntent: CustomWorldCreatorIntent | null;
generationMode: CustomWorldGenerationMode;
}) {
const targets = getCustomWorldGenerationTargets(params.generationMode);
const creatorIntentText =
params.creatorIntent && hasMeaningfulCustomWorldCreatorIntent(params.creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(params.creatorIntent)
: '';
return [
'请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。',
'必须严格输出单个 JSON 对象,不要 Markdown不要解释。',
'',
`生成模式:${params.generationMode}`,
`可扮演角色数量:${targets.playableCount}`,
`场景角色数量:${targets.storyCount}`,
`关键场景数量:${targets.landmarkCount}`,
'',
'创作者输入:',
params.generationSeedText,
creatorIntentText ? `\n结构化创作锚点\n${creatorIntentText}` : '',
'',
'输出 JSON 字段要求:',
'- name, subtitle, summary, tone, playerGoal, templateWorldType',
'- majorFactions: string[]coreConflicts: string[]',
'- camp: { name, description, dangerLevel }',
'- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections',
'- connections 每项包含 targetLandmarkName, relativePosition, summarytargetLandmarkName 必须指向本次输出的其他场景名',
'',
'约束:',
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
'- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。',
'- templateWorldType 只能是 WUXIA 或 XIANXIA。',
'- dangerLevel 使用 low、medium、high、extreme 之一。',
'- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。',
'- 不要预生成物品档案items 如需输出,必须为空数组。',
].filter(Boolean).join('\n');
}
function buildCustomWorldProfileRepairPrompt(responseText: string) {
return [
'请修复下面的自定义世界 JSON。',
'只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。',
responseText,
].join('\n\n');
}
async function parseCustomWorldJsonStage(params: { async function parseCustomWorldJsonStage(params: {
llmClient: UpstreamLlmClient; llmClient: UpstreamLlmClient;
responseText: string; responseText: string;
@@ -424,16 +369,21 @@ export async function generateCustomWorldProfileFromOrchestrator(
creatorIntent, creatorIntent,
generationMode, generationMode,
} = resolveCustomWorldGenerationInput(input); } = resolveCustomWorldGenerationInput(input);
const targets = getCustomWorldGenerationTargets(generationMode);
const creatorIntentText =
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
: '';
const reporter = createCustomWorldGenerationReporter(options.onProgress); const reporter = createCustomWorldGenerationReporter(options.onProgress);
try { try {
throwIfCustomWorldGenerationAborted(options.signal); throwIfCustomWorldGenerationAborted(options.signal);
reporter.begin('prepare', '正在整理创作者输入与结构化锚点。'); reporter.begin('prepare', '正在整理创作者输入与结构化锚点。');
const userPrompt = buildCustomWorldProfilePrompt({ const userPrompt = buildCustomWorldProfilePrompt({
settingText,
generationSeedText, generationSeedText,
creatorIntent,
generationMode, generationMode,
creatorIntentText,
targets,
}); });
reporter.complete('prepare', '设定上下文已整理,开始请求大模型推理。'); reporter.complete('prepare', '设定上下文已整理,开始请求大模型推理。');

View File

@@ -1,6 +1,10 @@
import { hasMixedNarrativeLanguage } from '../../../../packages/shared/src/llm/narrativeLanguage.js'; import { hasMixedNarrativeLanguage } from '../../../../packages/shared/src/llm/narrativeLanguage.js';
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js'; import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js';
import {
buildStoryLanguageRepairPrompt,
STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT,
} from '../../prompts/storyOrchestratorPrompts.js';
import { buildUserPrompt, SYSTEM_PROMPT } from './storyPromptBuilders.js'; import { buildUserPrompt, SYSTEM_PROMPT } from './storyPromptBuilders.js';
type JsonRecord = Record<string, unknown>; type JsonRecord = Record<string, unknown>;
@@ -64,12 +68,6 @@ type RawOptionItem = {
actionText?: string; actionText?: string;
}; };
const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
你会收到一个已经解析过的剧情 JSON 对象。
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
const DEFAULT_VISUALS = { const DEFAULT_VISUALS = {
playerAnimation: 'idle' as const, playerAnimation: 'idle' as const,
playerMoveMeters: 0, playerMoveMeters: 0,
@@ -83,6 +81,8 @@ const STATIC_FALLBACK_OPTION_MAP: Record<
string, string,
Partial<PromptStoryOption> & { actionText: string } Partial<PromptStoryOption> & { actionText: string }
> = { > = {
battle_attack_basic: { actionText: '普通攻击' },
battle_use_skill: { actionText: '释放技能' },
battle_all_in_crush: { actionText: '正面强压敌人' }, battle_all_in_crush: { actionText: '正面强压敌人' },
battle_escape_breakout: { actionText: '先脱离眼前追杀' }, battle_escape_breakout: { actionText: '先脱离眼前追杀' },
battle_feint_step: { actionText: '借假动作切进身位' }, battle_feint_step: { actionText: '借假动作切进身位' },
@@ -334,11 +334,9 @@ function resolveOptionsFromOptionCatalog(
function getFallbackFunctionIds(context: PromptContext, encounter?: SceneEncounterResult) { function getFallbackFunctionIds(context: PromptContext, encounter?: SceneEncounterResult) {
if (context.inBattle === true) { if (context.inBattle === true) {
return [ return [
'battle_probe_pressure', 'battle_attack_basic',
'battle_guard_break',
'battle_recover_breath', 'battle_recover_breath',
'battle_feint_step', 'battle_use_skill',
'battle_finisher_window',
'battle_escape_breakout', 'battle_escape_breakout',
]; ];
} }
@@ -381,25 +379,6 @@ function getFallbackOptions(
); );
} }
function buildStoryLanguageRepairPrompt(response: AIResponse) {
return [
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
JSON.stringify(
{
storyText: response.storyText,
encounter: response.encounter ?? null,
options: response.options.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
})),
},
null,
2,
),
].join('\n\n');
}
function needsStoryLanguageRepair(response: AIResponse) { function needsStoryLanguageRepair(response: AIResponse) {
return hasMixedNarrativeLanguage(response.storyText); return hasMixedNarrativeLanguage(response.storyText);
} }

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildUserPrompt } from './storyPromptBuilders.js';
test('buildUserPrompt adds post-chat reevaluation guidance for npc option catalogs', () => {
const prompt = buildUserPrompt({
worldType: 'WUXIA',
character: {
name: '沈行',
title: '试剑客',
description: '测试角色',
personality: '谨慎',
},
monsters: [],
history: [
{ text: '你:刚才那句话是什么意思?' },
{ text: '山道客:你最好别继续深究。' },
],
context: {
sceneName: '山道',
sceneDescription: '风声贴着碎石一路往前卷。',
encounterName: '山道客',
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
playerMaxMana: 30,
inBattle: false,
pendingSceneEncounter: false,
lastFunctionId: 'npc_chat',
},
choice: '结束与山道客的这轮交谈,重新观察当前局势',
requestOptions: {
optionCatalog: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
},
{
functionId: 'npc_help',
actionText: '请求援手',
},
{
functionId: 'npc_trade',
actionText: '看看能交换什么',
},
],
},
});
assert.match(prompt, / NPC /u);
assert.match(prompt, /退/u);
assert.match(prompt, / function /u);
});

View File

@@ -1,163 +1 @@
type JsonRecord = Record<string, unknown>; export * from '../../prompts/storyPromptBuilders.js';
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '边城模板';
case 'XIANXIA':
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:
return worldType || '未知世界';
}
}
function describeCharacter(character: JsonRecord) {
return [
`主角:${readString(character.name) ?? '未知角色'}`,
`称号:${readString(character.title) ?? '未知称号'}`,
`描述:${readString(character.description) ?? '暂无'}`,
`性格:${readString(character.personality) ?? '未显式提供'}`,
].join('\n');
}
function describeMonsters(monsters: JsonRecord[]) {
if (monsters.length <= 0) {
return '当前敌对目标:无。';
}
return [
'当前敌对目标:',
...monsters.slice(0, 4).map((monster) => {
const name = readString(monster.name) ?? readString(monster.id) ?? '未知目标';
const hp = readNumber(monster.hp);
const maxHp = Math.max(1, readNumber(monster.maxHp, hp));
return `- ${name}(生命 ${hp}/${maxHp}`;
}),
].join('\n');
}
function describeStoryHistory(history: JsonRecord[]) {
if (history.length <= 0) {
return '近期剧情:暂无。';
}
return [
'近期剧情:',
...history.slice(-4).map((moment) => `- ${readString(moment.text) ?? '(空白)'}`),
].join('\n');
}
function describeRequestOptions(options: {
availableOptions?: Array<Record<string, unknown>>;
optionCatalog?: Array<Record<string, unknown>>;
}) {
const available = options.availableOptions ?? [];
const catalog = options.optionCatalog ?? [];
if (available.length > 0) {
return [
'固定可选项列表:',
...available.map((option, index) => {
const functionId = readString(option.functionId) ?? 'unknown';
const actionText =
readString(option.actionText) ??
readString(option.text) ??
'未提供文案';
return `- 第 ${index + 1} 项 / ${functionId}${actionText}`;
}),
'必须保持数量不变functionId 不变,可以重写 actionText。'.trim(),
].join('\n');
}
if (catalog.length > 0) {
return [
'当前局面可调用的交互选项目录:',
...catalog.map((option, index) => {
const functionId = readString(option.functionId) ?? 'unknown';
const actionText =
readString(option.actionText) ??
readString(option.text) ??
'未提供文案';
return `- 第 ${index + 1} 项 / ${functionId}${actionText}`;
}),
'functionId 只能从上面目录里选择。'.trim(),
].join('\n');
}
return '当前没有固定目录,请根据局势生成合理选项。';
}
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象不能输出解释、markdown 或代码块。
输出格式必须严格符合:
{
"storyText": "剧情文本",
"encounter": null,
"options": [
{
"functionId": "预定义功能ID",
"actionText": "选项显示文本"
}
]
}
严格规则:
- 所有文本必须是中文。
- 如果提示语给出了固定可选项或可选目录,必须遵守给定的 functionId 范围。
- storyText 必须直接承接当前场景、最近剧情和玩家刚刚的动作。
- options 只允许输出 functionId 和 actionText。
- 如果当前不是“继续推进后下一刻会遇到什么”的场景encounter 必须保持为 null。`;
export function buildUserPrompt(params: {
worldType: string;
character: JsonRecord;
monsters: JsonRecord[];
history: JsonRecord[];
context: JsonRecord;
choice?: string;
requestOptions?: {
availableOptions?: Array<Record<string, unknown>>;
optionCatalog?: Array<Record<string, unknown>>;
};
}) {
const sceneName = readString(params.context.sceneName) ?? '当前区域';
const sceneDescription = readString(params.context.sceneDescription) ?? '暂无场景附加描述。';
const encounterName = readString(params.context.encounterName);
const playerHp = readNumber(params.context.playerHp);
const playerMaxHp = Math.max(1, readNumber(params.context.playerMaxHp, playerHp));
const playerMana = readNumber(params.context.playerMana);
const playerMaxMana = Math.max(1, readNumber(params.context.playerMaxMana, playerMana));
const inBattle = params.context.inBattle === true ? '战斗中' : '非战斗';
const pendingSceneEncounter =
params.context.pendingSceneEncounter === true ? '是' : '否';
return [
`世界:${describeWorld(params.worldType)}`,
`场景:${sceneName}`,
`场景描述:${sceneDescription}`,
encounterName ? `当前面前对象:${encounterName}` : null,
`当前状态:${inBattle}`,
`玩家生命:${playerHp}/${playerMaxHp}`,
`玩家灵力:${playerMana}/${playerMaxMana}`,
`是否需要判断下一刻遭遇:${pendingSceneEncounter}`,
describeCharacter(params.character),
describeMonsters(params.monsters),
describeStoryHistory(params.history),
params.choice ? `玩家刚刚选择:${params.choice}` : '玩家刚进入当前局面。',
describeRequestOptions(params.requestOptions ?? {}),
params.context.pendingSceneEncounter === true
? '如果这一轮明确是在判断继续推进后下一刻会遇到什么,可以填写 encounter否则 encounter 必须为 null。'
: '当前这一步不是新的遭遇生成流程encounter 必须为 null。',
]
.filter(Boolean)
.join('\n\n');
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,17 @@
import type { import type {
RuntimeBattlePresentation, RuntimeBattlePresentation,
RuntimeStoryChoicePayload,
RuntimeStoryPatch, RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js'; } from '../../../../packages/shared/src/contracts/story.js';
import { conflict } from '../../errors.js'; import { conflict } from '../../errors.js';
import {
appendBuildBuffs,
resolvePlayerOutgoingDamageResult,
} from '../runtime/runtimeBuildModule.js';
import { import {
getEncounterNpcState, getEncounterNpcState,
getPlayerCharacter,
getPlayerSkillCooldowns,
setEncounterNpcState, setEncounterNpcState,
type RuntimeSession, type RuntimeSession,
} from '../story/runtimeSession.js'; } from '../story/runtimeSession.js';
@@ -16,6 +23,15 @@ type CombatActionConfig = {
counterMultiplier: number; counterMultiplier: number;
heal?: number; heal?: number;
manaRestore?: number; manaRestore?: number;
cooldownBonus?: number;
selectedSkillId?: string | null;
appliedCooldownTurns?: number;
buildBuffs?: Array<{
id: string;
name: string;
tags: string[];
durationTurns: number;
}>;
}; };
export type CombatResolution = { export type CombatResolution = {
@@ -26,46 +42,21 @@ export type CombatResolution = {
storyText?: string; storyText?: string;
}; };
const COMBAT_ACTIONS: Record<string, CombatActionConfig> = { const LEGACY_ATTACK_FUNCTION_IDS = new Set<string>([
battle_all_in_crush: { 'battle_all_in_crush',
actionText: '正面强压', 'battle_guard_break',
manaCost: 14, 'battle_probe_pressure',
baseDamage: 22, 'battle_feint_step',
counterMultiplier: 1.25, 'battle_finisher_window',
}, ]);
battle_feint_step: {
actionText: '虚晃切步', function isObject(value: unknown): value is Record<string, unknown> {
manaCost: 8, return typeof value === 'object' && value !== null && !Array.isArray(value);
baseDamage: 16, }
counterMultiplier: 0.7,
}, function readString(value: unknown) {
battle_finisher_window: { return typeof value === 'string' && value.trim() ? value.trim() : '';
actionText: '抓破绽终结', }
manaCost: 10,
baseDamage: 18,
counterMultiplier: 0.9,
},
battle_guard_break: {
actionText: '破架重击',
manaCost: 9,
baseDamage: 17,
counterMultiplier: 0.95,
},
battle_probe_pressure: {
actionText: '稳步试探',
manaCost: 5,
baseDamage: 12,
counterMultiplier: 0.8,
},
battle_recover_breath: {
actionText: '边守边调息',
manaCost: 0,
baseDamage: 0,
counterMultiplier: 0.55,
heal: 12,
manaRestore: 9,
},
};
function getAliveTarget(session: RuntimeSession) { function getAliveTarget(session: RuntimeSession) {
return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null; return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null;
@@ -124,19 +115,120 @@ function finishBattle(
} }
} }
function buildBasicAttackBaseDamage(session: RuntimeSession) {
const character = getPlayerCharacter(session);
if (!character) {
return 10;
}
return Math.max(
8,
Math.round(
character.attributes.strength * 0.85 +
character.attributes.agility * 0.45,
),
);
}
function tickCooldownMap(
cooldowns: Record<string, number>,
turns: number,
) {
let nextCooldowns = cooldowns;
for (let index = 0; index < Math.max(0, Math.floor(turns)); index += 1) {
nextCooldowns = Object.fromEntries(
Object.entries(nextCooldowns).map(([skillId, value]) => [
skillId,
Math.max(0, Math.floor(value) - 1),
]),
);
}
return nextCooldowns;
}
function resolveCombatActionConfig(params: {
session: RuntimeSession;
functionId: string;
payload?: RuntimeStoryChoicePayload;
}) {
const { session, functionId, payload } = params;
if (functionId === 'battle_recover_breath') {
return {
actionText: '恢复',
manaCost: 0,
baseDamage: 0,
counterMultiplier: 0.55,
heal: 12,
manaRestore: 9,
cooldownBonus: 1,
selectedSkillId: null,
} satisfies CombatActionConfig;
}
if (functionId === 'battle_attack_basic' || LEGACY_ATTACK_FUNCTION_IDS.has(functionId)) {
return {
actionText: '普通攻击',
manaCost: 0,
baseDamage: buildBasicAttackBaseDamage(session),
counterMultiplier: 1,
selectedSkillId: null,
} satisfies CombatActionConfig;
}
if (functionId === 'battle_use_skill') {
const character = getPlayerCharacter(session);
if (!character) {
throw conflict('缺少玩家角色,无法结算技能动作');
}
const skillId = readString(isObject(payload) ? payload.skillId : '');
if (!skillId) {
throw conflict('battle_use_skill 缺少 skillId');
}
const skill = character.skills.find((candidate) => candidate.id === skillId);
if (!skill) {
throw conflict(`未找到技能:${skillId}`);
}
const cooldowns = getPlayerSkillCooldowns(session);
if ((cooldowns[skill.id] ?? 0) > 0) {
throw conflict(`${skill.name} 仍在冷却中`);
}
return {
actionText: skill.name,
manaCost: skill.manaCost,
baseDamage: skill.damage,
counterMultiplier: 0.95,
selectedSkillId: skill.id,
appliedCooldownTurns: skill.cooldownTurns,
buildBuffs: skill.buildBuffs ?? [],
} satisfies CombatActionConfig;
}
throw conflict(`暂不支持的战斗动作:${functionId}`);
}
export function resolveCombatAction( export function resolveCombatAction(
session: RuntimeSession, session: RuntimeSession,
functionId: string, params: {
functionId: string;
payload?: RuntimeStoryChoicePayload;
},
): CombatResolution { ): CombatResolution {
const target = getAliveTarget(session); const target = getAliveTarget(session);
if (!session.inBattle || !target) { if (!session.inBattle || !target) {
throw conflict('当前不在可结算战斗态,不能执行该战斗动作'); throw conflict('当前不在可结算战斗态,不能执行该战斗动作');
} }
if (functionId === 'battle_escape_breakout') { if (params.functionId === 'battle_escape_breakout') {
finishBattle(session, 'escaped'); finishBattle(session, 'escaped');
return { return {
actionText: '强行脱离战斗', actionText: '逃跑',
resultText: `你抓住空当摆脱了${target.name}的压制,先把这轮正面冲突拉开了。`, resultText: `你抓住空当摆脱了${target.name}的压制,先把这轮正面冲突拉开了。`,
battle: { battle: {
targetId: target.id, targetId: target.id,
@@ -146,7 +238,7 @@ export function resolveCombatAction(
patches: [ patches: [
{ {
type: 'battle_resolved', type: 'battle_resolved',
functionId, functionId: params.functionId,
targetId: target.id, targetId: target.id,
outcome: 'escaped', outcome: 'escaped',
}, },
@@ -165,27 +257,66 @@ export function resolveCombatAction(
}; };
} }
const action = COMBAT_ACTIONS[functionId]; const action = resolveCombatActionConfig({
if (!action) { session,
throw conflict(`暂不支持的战斗动作:${functionId}`); functionId: params.functionId,
} payload: params.payload,
});
if (action.manaCost > session.playerMana) { if (action.manaCost > session.playerMana) {
throw conflict('当前灵力不足,无法执行这个战斗动作'); throw conflict('当前灵力不足,无法执行这个战斗动作');
} }
const character = getPlayerCharacter(session);
if (!character) {
throw conflict('缺少玩家角色,无法结算战斗动作');
}
const isSpar = session.currentNpcBattleMode === 'spar'; const isSpar = session.currentNpcBattleMode === 'spar';
const targetHpRatio = target.hp / Math.max(target.maxHp, 1); const damageResult =
const damageBonus = action.baseDamage > 0
functionId === 'battle_finisher_window' && targetHpRatio <= 0.4 ? 8 : 0; ? resolvePlayerOutgoingDamageResult(
const damageDealt = isSpar ? 1 : action.baseDamage + damageBonus; session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
character,
action.baseDamage,
1,
`${params.functionId}:${action.selectedSkillId ?? 'default'}:${target.id}:${session.runtimeVersion}`,
)
: null;
const damageDealt = isSpar
? action.baseDamage > 0
? 1
: 0
: damageResult?.damage ?? 0;
session.playerMana -= action.manaCost; session.playerMana -= action.manaCost;
session.playerHp += action.heal ?? 0; session.playerHp += action.heal ?? 0;
session.playerMana += action.manaRestore ?? 0; session.playerMana += action.manaRestore ?? 0;
let nextCooldowns = tickCooldownMap(getPlayerSkillCooldowns(session), 1);
if ((action.cooldownBonus ?? 0) > 0) {
nextCooldowns = tickCooldownMap(nextCooldowns, action.cooldownBonus ?? 0);
}
if (action.selectedSkillId && (action.appliedCooldownTurns ?? 0) > 0) {
nextCooldowns = {
...nextCooldowns,
[action.selectedSkillId]: action.appliedCooldownTurns,
};
}
session.rawGameState.playerSkillCooldowns = nextCooldowns;
if (action.buildBuffs?.length) {
session.rawGameState.activeBuildBuffs = appendBuildBuffs(
(session.rawGameState.activeBuildBuffs as Parameters<typeof appendBuildBuffs>[0]) ??
[],
action.buildBuffs as Parameters<typeof appendBuildBuffs>[1],
);
}
clampPlayerVitals(session); clampPlayerVitals(session);
target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt); if (damageDealt > 0) {
target.hp = Math.max(isSpar ? 1 : 0, target.hp - damageDealt);
}
const patches: RuntimeStoryPatch[] = []; const patches: RuntimeStoryPatch[] = [];
let resultText = ''; let resultText = '';
@@ -204,12 +335,15 @@ export function resolveCombatAction(
} else { } else {
finishBattle(session, 'victory'); finishBattle(session, 'victory');
outcome = 'victory'; outcome = 'victory';
resultText = `你彻底压垮了${target.name}的节奏,这场战斗已经收口`; resultText = `这一手彻底压垮了${target.name},眼前战斗已经正式结束`;
} }
} else { } else {
const baseCounter = isSpar const baseCounter = isSpar
? 1 ? 1
: Math.max(4, Math.round(target.maxHp * 0.16 * action.counterMultiplier)); : Math.max(
4,
Math.round(target.maxHp * 0.14 * action.counterMultiplier),
);
damageTaken = baseCounter; damageTaken = baseCounter;
session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken); session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken);
@@ -220,7 +354,7 @@ export function resolveCombatAction(
patches.push(affinityPatch); patches.push(affinityPatch);
} }
outcome = 'spar_complete'; outcome = 'spar_complete';
resultText = `${target.name}也逼到了你的极限,这场切磋点到为止,双方都默认收手。`; resultText = `${target.name}把你逼到了极限,这场切磋点到为止,双方都默认收手。`;
} else if (!isSpar && session.playerHp <= 0) { } else if (!isSpar && session.playerHp <= 0) {
session.playerHp = 0; session.playerHp = 0;
session.inBattle = false; session.inBattle = false;
@@ -230,15 +364,19 @@ export function resolveCombatAction(
session.currentEncounter = null; session.currentEncounter = null;
outcome = 'escaped'; outcome = 'escaped';
resultText = `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`; resultText = `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`;
} else if (params.functionId === 'battle_recover_breath') {
resultText = `你先把伤势和气息稳住了一轮,但${target.name}仍在持续逼近。`;
} else if (params.functionId === 'battle_use_skill') {
resultText = `${action.actionText}命中了${target.name},这一轮技能效果已经直接结算。`;
} else { } else {
resultText = `${action.actionText}命中了${target.name}但对方仍然顶住并回敬了一轮压力`; resultText = `${action.actionText}命中了${target.name}本次攻击已经完成结算`;
} }
} }
patches.push( patches.push(
{ {
type: 'battle_resolved', type: 'battle_resolved',
functionId, functionId: params.functionId,
targetId: target.id, targetId: target.id,
damageDealt, damageDealt,
damageTaken, damageTaken,

View File

@@ -5,8 +5,14 @@ import {
QUEST_REWARD_THEMES, QUEST_REWARD_THEMES,
QUEST_URGENCY_LEVELS, QUEST_URGENCY_LEVELS,
} from '../../../../packages/shared/src/contracts/story.js'; } from '../../../../packages/shared/src/contracts/story.js';
import {
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,
} from '../../prompts/questPrompts.js';
import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js'; import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js';
export { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT };
export type QuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number]; export type QuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number];
export type QuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number]; export type QuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number];
export type QuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number]; export type QuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number];
@@ -669,169 +675,6 @@ function getSignalProgressIncrement(signal: QuestProgressSignal) {
return signal.kind === 'item_delivered' ? Math.max(1, signal.quantity) : 1; return signal.kind === 'item_delivered' ? Math.max(1, signal.quantity) : 1;
} }
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
const moments = context.recentStoryMoments
.slice(-4)
.map((moment) => `- ${moment.text}`)
.join('\n');
return moments || '- 暂无近期剧情记录';
}
function summarizeCurrentQuests(context: QuestGenerationContext) {
const summary = context.currentQuestSummary
?.map(
(quest) =>
`- ${quest.title}${quest.status}),发布者 ${quest.issuerNpcId}`,
)
.join('\n');
return summary || '- 当前没有进行中的任务';
}
function summarizeCompanions(context: QuestGenerationContext) {
const active =
context.activeCompanions?.map((companion) => companion.characterId).join('、') ||
'无';
const roster =
context.rosterCompanions?.map((companion) => companion.characterId).join('、') ||
'无';
return `当前同行角色:${active}\n队伍名册${roster}`;
}
function summarizePlayerState(context: QuestGenerationContext) {
const playerName = context.playerCharacter?.name ?? '未知角色';
const playerTitle = context.playerCharacter?.title ?? '未知称号';
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
const inventory =
context.playerInventory?.slice(0, 8).map((item) => item.name).join('、') || '无';
return [
`玩家:${playerName}${playerTitle}`,
`生命:${hp}`,
`灵力:${mana}`,
`背包快照:${inventory}`,
].join('\n');
}
function summarizeScene(
scene: QuestSceneSnapshot | null,
context: QuestGenerationContext,
) {
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
return [
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
`敌对角色 ID${hostileNpcIds}`,
`宝藏线索数量:${treasureHintCount}`,
].join('\n');
}
function summarizeActiveThreads(context: QuestGenerationContext) {
return context.activeThreadIds?.length
? context.activeThreadIds.join('、')
: '暂无明确激活线程';
}
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
const profile = context.issuerNarrativeProfile;
if (!profile) {
return '暂无额外叙事档案';
}
return [
`公开面:${profile.publicMask ?? '暂无'}`,
`表层线:${profile.visibleLine ?? '暂无'}`,
`当前压力:${profile.immediatePressure ?? '暂无'}`,
profile.reactionHooks?.length
? `反应钩子:${profile.reactionHooks.join('、')}`
: null,
]
.filter(Boolean)
.join('\n');
}
function describeWorld(worldType: QuestGenerationContext['worldType']) {
switch (worldType) {
case 'WUXIA':
return '边城模板';
case 'XIANXIA':
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:
return '未知世界';
}
}
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
只返回 JSON不要输出 Markdown。
输出结构:
{
"intent": {
"title": "中文任务标题",
"description": "中文任务描述",
"summary": "中文短摘要",
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
"dramaticNeed": "string",
"issuerGoal": "string",
"playerHook": "string",
"worldReason": "string",
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
"urgency": "low|medium|high",
"intimacy": "transactional|cooperative|trust_based",
"rewardTheme": "currency|resource|relationship|intel|rare_item",
"followupHooks": ["string"]
}
}
规则:
- 所有自然语言字段都必须使用中文。
- 任务必须扎根于当前场景、发布者和近期剧情。
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
export function buildQuestIntentPrompt(params: {
context: QuestGenerationContext;
scene: QuestSceneSnapshot | null;
opportunity: QuestOpportunity;
}) {
const { context, scene, opportunity } = params;
const customWorldSummary = context.customWorldProfile
? `${context.customWorldProfile.name ?? '自定义世界'}: ${
context.customWorldProfile.summary ?? '暂无摘要'
}`
: '无';
return [
`世界:${describeWorld(context.worldType)}`,
`自定义世界摘要:${customWorldSummary}`,
`发布角色:${context.issuerNpcName}${context.issuerNpcId}`,
`发布者身份:${context.issuerNpcContext || '暂无'}`,
`发布者好感:${context.issuerAffinity ?? 0}`,
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
`当前激活线程:${summarizeActiveThreads(context)}`,
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
`当前遭遇类型:${context.encounterKind ?? '无'}`,
summarizeScene(scene, context),
summarizePlayerState(context),
summarizeCompanions(context),
`当前任务机会:${opportunity.reason}`,
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
].join('\n\n');
}
export function buildQuestGenerationContextFromState(params: { export function buildQuestGenerationContextFromState(params: {
state: RuntimeStateLike; state: RuntimeStateLike;
encounter: RuntimeEncounterLike; encounter: RuntimeEncounterLike;

View File

@@ -2,6 +2,12 @@ import {
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES, RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
RUNTIME_ITEM_TONE_VALUES, RUNTIME_ITEM_TONE_VALUES,
} from '../../../../packages/shared/src/contracts/story.js'; } from '../../../../packages/shared/src/contracts/story.js';
import {
buildRuntimeItemIntentPromptText,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
} from '../../prompts/runtimeItemPrompts.js';
export { RUNTIME_ITEM_INTENT_SYSTEM_PROMPT };
export type RuntimeItemFunctionalBias = export type RuntimeItemFunctionalBias =
(typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number]; (typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number];
@@ -573,48 +579,16 @@ function describePlan(
].join('\n'); ].join('\n');
} }
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
你只返回 JSON不要输出 Markdown、解释或代码块。
输出结构:
{
"intents": [
{
"shortNameSeed": "中文短种子",
"sourcePhrase": "中文来源短语",
"reasonToAppear": "中文出现理由",
"relationHooks": ["中文关系钩子"],
"desiredBuildTags": ["中文 build 标签"],
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
"tone": "grim|mysterious|martial|ritual|survival",
"visibleClue": "玩家第一眼能抓到的痕迹",
"witnessMark": "它见证过什么的使用痕",
"unfinishedBusiness": "背后仍未结清的问题",
"hiddenHook": "更深一层但别直接讲穿的钩子",
"reactionHooks": ["以后谁会对它起反应"],
"namingPattern": "命名范式建议"
}
]
}
规则:
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
- 所有自然语言字段都必须使用中文。
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
export function buildRuntimeItemIntentPrompt(params: { export function buildRuntimeItemIntentPrompt(params: {
context: RuntimeItemGenerationContext; context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[]; plans: RuntimeItemPlan[];
}) { }) {
return [ return buildRuntimeItemIntentPromptText({
`生成渠道:${params.context.generationChannel}`, generationChannel: params.context.generationChannel,
`以下每个物品都需要给出一条可编译的运行时物品意图。`, planBlocks: params.plans.map((plan, index) =>
...params.plans.map((plan, index) => describePlan(params.context, plan, index)), describePlan(params.context, plan, index),
'请严格返回 JSON。', ),
].join('\n\n'); });
} }
function buildBaseRuntimeContext(params: { function buildBaseRuntimeContext(params: {

View File

@@ -1,11 +1,17 @@
import type { import type {
RuntimeStoryEncounterViewModel, RuntimeStoryEncounterViewModel,
RuntimeStoryChoicePayload,
RuntimeStoryOptionView, RuntimeStoryOptionView,
RuntimeStoryViewModel, RuntimeStoryViewModel,
Task5RuntimeOptionScope, Task5RuntimeOptionScope,
} from '../../../../packages/shared/src/contracts/story.js'; } from '../../../../packages/shared/src/contracts/story.js';
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js'; import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js';
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js'; import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
import { resolvePlayerOutgoingDamageResult } from '../runtime/runtimeBuildModule.js';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../runtime/runtimeInventoryEffectsModule.js';
type JsonRecord = Record<string, unknown>; type JsonRecord = Record<string, unknown>;
type StoryHistoryRole = 'action' | 'result'; type StoryHistoryRole = 'action' | 'result';
@@ -62,6 +68,58 @@ export type RuntimeCompanion = {
joinedAtAffinity: number; joinedAtAffinity: number;
}; };
type RuntimePlayerAttributes = {
strength: number;
agility: number;
intelligence: number;
spirit: number;
};
type RuntimePlayerSkill = {
id: string;
name: string;
damage: number;
manaCost: number;
cooldownTurns: number;
buildBuffs?: Array<{
id: string;
sourceType: 'skill' | 'item' | 'forge';
sourceId: string;
name: string;
tags: string[];
durationTurns: number;
maxStacks?: number;
}>;
};
type RuntimePlayerCharacter = {
attributes: RuntimePlayerAttributes;
skills: RuntimePlayerSkill[];
};
type RuntimeBattleItemUseProfile = {
hpRestore?: number;
manaRestore?: number;
cooldownReduction?: number;
buildBuffs?: Array<{
id: string;
sourceType: 'item';
sourceId: string;
name: string;
tags: string[];
durationTurns: number;
}>;
};
type RuntimeBattleInventoryItem = {
id: string;
name: string;
quantity: number;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
tags: string[];
useProfile?: RuntimeBattleItemUseProfile;
};
export type RuntimeSession = { export type RuntimeSession = {
sessionId: string; sessionId: string;
runtimeVersion: number; runtimeVersion: number;
@@ -97,6 +155,8 @@ const STORY_FUNCTION_IDS = new Set<string>([
]); ]);
const COMBAT_FUNCTION_IDS = new Set<string>([ const COMBAT_FUNCTION_IDS = new Set<string>([
'battle_attack_basic',
'battle_use_skill',
'battle_all_in_crush', 'battle_all_in_crush',
'battle_escape_breakout', 'battle_escape_breakout',
'battle_feint_step', 'battle_feint_step',
@@ -164,6 +224,16 @@ const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
detailText: '收束当前遭遇并切往下一段场景流程。', detailText: '收束当前遭遇并切往下一段场景流程。',
scope: 'story', scope: 'story',
}, },
battle_attack_basic: {
actionText: '普通攻击',
detailText: '本回合执行一次不耗蓝的基础攻击。',
scope: 'combat',
},
battle_use_skill: {
actionText: '释放技能',
detailText: '直接执行一个具体技能,不再包装成抽象战术动作。',
scope: 'combat',
},
battle_all_in_crush: { battle_all_in_crush: {
actionText: '正面强压', actionText: '正面强压',
detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。', detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。',
@@ -195,8 +265,13 @@ const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
scope: 'combat', scope: 'combat',
}, },
battle_recover_breath: { battle_recover_breath: {
actionText: '边守边调息', actionText: '恢复',
detailText: '优先回稳资源,但仍可能吃到轻量反击。', detailText: '直接恢复资源,并推进本回合冷却。',
scope: 'combat',
},
inventory_use: {
actionText: '使用物品',
detailText: '战斗中优先执行一个可立即结算的消耗品。',
scope: 'combat', scope: 'combat',
}, },
npc_chat: { npc_chat: {
@@ -430,6 +505,344 @@ function normalizeHostileNpcs(value: unknown) {
.filter((entry): entry is RuntimeHostileNpc => Boolean(entry)); .filter((entry): entry is RuntimeHostileNpc => Boolean(entry));
} }
function normalizePlayerSkill(value: unknown): RuntimePlayerSkill | null {
const rawSkill = isObject(value) ? value : null;
if (!rawSkill) {
return null;
}
const id = readString(rawSkill.id);
const name = readString(rawSkill.name, id);
if (!id || !name) {
return null;
}
return {
id,
name,
damage: Math.max(1, Math.round(readNumber(rawSkill.damage, 1))),
manaCost: Math.max(0, Math.round(readNumber(rawSkill.manaCost, 0))),
cooldownTurns: Math.max(
0,
Math.round(readNumber(rawSkill.cooldownTurns, 0)),
),
buildBuffs: readArray(rawSkill.buildBuffs)
.map((entry) => {
const rawBuff = isObject(entry) ? entry : null;
if (!rawBuff) {
return null;
}
const buffId = readString(rawBuff.id);
const sourceId = readString(rawBuff.sourceId);
const name = readString(rawBuff.name, buffId);
if (!buffId || !sourceId || !name) {
return null;
}
const sourceType = readString(rawBuff.sourceType, 'skill');
return {
id: buffId,
sourceType:
sourceType === 'item' || sourceType === 'forge'
? sourceType
: 'skill',
sourceId,
name,
tags: readArray(rawBuff.tags).filter(
(tag): tag is string =>
typeof tag === 'string' && tag.trim().length > 0,
),
durationTurns: Math.max(
1,
Math.round(readNumber(rawBuff.durationTurns, 1)),
),
maxStacks:
typeof rawBuff.maxStacks === 'number' &&
Number.isFinite(rawBuff.maxStacks)
? Math.max(1, Math.round(rawBuff.maxStacks))
: undefined,
} satisfies NonNullable<RuntimePlayerSkill['buildBuffs']>[number];
})
.filter(
(
entry,
): entry is NonNullable<RuntimePlayerSkill['buildBuffs']>[number] =>
Boolean(entry),
),
};
}
function normalizePlayerCharacter(
value: unknown,
): RuntimePlayerCharacter | null {
const rawCharacter = isObject(value) ? value : null;
const rawAttributes = isObject(rawCharacter?.attributes)
? rawCharacter.attributes
: null;
if (!rawCharacter || !rawAttributes) {
return null;
}
return {
attributes: {
strength: Math.max(0, Math.round(readNumber(rawAttributes.strength, 0))),
agility: Math.max(0, Math.round(readNumber(rawAttributes.agility, 0))),
intelligence: Math.max(
0,
Math.round(readNumber(rawAttributes.intelligence, 0)),
),
spirit: Math.max(0, Math.round(readNumber(rawAttributes.spirit, 0))),
},
skills: readArray(rawCharacter.skills)
.map((entry) => normalizePlayerSkill(entry))
.filter((entry): entry is RuntimePlayerSkill => Boolean(entry)),
};
}
function normalizeBattleInventoryItem(
value: unknown,
): RuntimeBattleInventoryItem | null {
const rawItem = isObject(value) ? value : null;
if (!rawItem) {
return null;
}
const id = readString(rawItem.id);
const name = readString(rawItem.name, id);
if (!id || !name) {
return null;
}
const rarity = readString(rawItem.rarity, 'common');
const normalizedRarity =
rarity === 'legendary' ||
rarity === 'epic' ||
rarity === 'rare' ||
rarity === 'uncommon'
? rarity
: 'common';
const useProfile = isObject(rawItem.useProfile)
? (cloneJson(rawItem.useProfile) as RuntimeBattleItemUseProfile)
: undefined;
return {
id,
name,
quantity: Math.max(0, Math.round(readNumber(rawItem.quantity, 0))),
rarity: normalizedRarity,
tags: readArray(rawItem.tags).filter(
(tag): tag is string => typeof tag === 'string' && tag.trim().length > 0,
),
useProfile,
};
}
export function getPlayerCharacter(session: RuntimeSession) {
return normalizePlayerCharacter(session.rawGameState.playerCharacter);
}
export function getPlayerSkillCooldowns(session: RuntimeSession) {
const rawCooldowns = isObject(session.rawGameState.playerSkillCooldowns)
? session.rawGameState.playerSkillCooldowns
: {};
return Object.fromEntries(
Object.entries(rawCooldowns).map(([skillId, turns]) => [
skillId,
Math.max(0, Math.round(readNumber(turns, 0))),
]),
) as Record<string, number>;
}
function getBattleInventoryItems(session: RuntimeSession) {
return readArray(session.rawGameState.playerInventory)
.map((entry) => normalizeBattleInventoryItem(entry))
.filter((entry): entry is RuntimeBattleInventoryItem => Boolean(entry));
}
function buildBasicAttackBaseDamage(character: RuntimePlayerCharacter) {
return Math.max(
8,
Math.round(
character.attributes.strength * 0.85 +
character.attributes.agility * 0.45,
),
);
}
function buildBattleDisabledOption(params: {
functionId: string;
actionText?: string;
detailText?: string;
reason: string;
payload?: RuntimeStoryChoicePayload;
}) {
return buildOptionView(params.functionId, {
actionText: params.actionText,
detailText: params.detailText,
payload: params.payload,
disabled: true,
reason: params.reason,
});
}
function buildBattleItemSummary(
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
) {
const parts = [
effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null,
effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null,
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
effect.buildBuffs.length > 0
? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
: null,
].filter(Boolean);
return parts.join(' / ') || '立即结算一次物品效果';
}
function pickPreferredBattleItem(session: RuntimeSession) {
const character = getPlayerCharacter(session);
if (!character) {
return null;
}
const cooldowns = getPlayerSkillCooldowns(session);
const hasCoolingSkill = Object.values(cooldowns).some((turns) => turns > 0);
const playerHpRatio = session.playerHp / Math.max(session.playerMaxHp, 1);
const playerManaRatio = session.playerMana / Math.max(session.playerMaxMana, 1);
return getBattleInventoryItems(session)
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
.map((item) => {
const effect = resolveInventoryItemUseEffect(item, character);
if (!effect) {
return null;
}
const score =
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
effect.buildBuffs.length * 8;
return {
item,
effect,
score,
};
})
.filter(
(
candidate,
): candidate is {
item: RuntimeBattleInventoryItem;
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
score: number;
} => Boolean(candidate),
)
.sort(
(left, right) =>
right.score - left.score ||
right.effect.hpRestore - left.effect.hpRestore ||
right.effect.manaRestore - left.effect.manaRestore ||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
)[0] ?? null;
}
function buildBattleSkillOptions(session: RuntimeSession) {
const character = getPlayerCharacter(session);
if (!character) {
return [];
}
const cooldowns = getPlayerSkillCooldowns(session);
return character.skills.map((skill) => {
const remainingCooldown = cooldowns[skill.id] ?? 0;
const damage = resolvePlayerOutgoingDamageResult(
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
character,
skill.damage,
1,
`runtime-skill-preview:${skill.id}`,
).damage;
const detailText = [
`耗蓝 ${skill.manaCost}`,
`伤害 ${damage}`,
`冷却 ${skill.cooldownTurns}`,
].join(' / ');
if (remainingCooldown > 0) {
return buildBattleDisabledOption({
functionId: 'battle_use_skill',
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
reason: `冷却中,还需 ${remainingCooldown} 回合`,
});
}
if (skill.manaCost > session.playerMana) {
return buildBattleDisabledOption({
functionId: 'battle_use_skill',
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
reason: '灵力不足',
});
}
return buildOptionView('battle_use_skill', {
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
});
});
}
function buildBattleActionOptions(session: RuntimeSession) {
const character = getPlayerCharacter(session);
const itemCandidate = pickPreferredBattleItem(session);
const basicAttackDamage = character
? resolvePlayerOutgoingDamageResult(
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
character,
buildBasicAttackBaseDamage(character),
1,
'runtime-basic-attack-preview',
).damage
: 0;
return [
buildOptionView('battle_attack_basic', {
detailText:
basicAttackDamage > 0
? `不耗蓝 / 伤害 ${basicAttackDamage}`
: '不耗蓝的基础攻击',
}),
buildOptionView('battle_recover_breath', {
actionText: '恢复',
detailText: '回血 12 / 回蓝 9 / 冷却 -1',
}),
itemCandidate
? buildOptionView('inventory_use', {
actionText: `使用物品:${itemCandidate.item.name}`,
detailText: buildBattleItemSummary(itemCandidate.effect),
payload: { itemId: itemCandidate.item.id },
})
: buildBattleDisabledOption({
functionId: 'inventory_use',
actionText: '使用物品',
detailText: '当前没有可直接结算的战斗消耗品',
reason: '暂无可用物品',
}),
...buildBattleSkillOptions(session),
buildOptionView('battle_escape_breakout'),
] satisfies RuntimeStoryOptionView[];
}
export function getEncounterKey(encounter: RuntimeEncounter) { export function getEncounterKey(encounter: RuntimeEncounter) {
return encounter.id || encounter.npcName; return encounter.id || encounter.npcName;
} }
@@ -613,15 +1026,7 @@ function hasGiftablePlayerInventory(session: RuntimeSession) {
export function buildAvailableOptions(session: RuntimeSession) { export function buildAvailableOptions(session: RuntimeSession) {
if (session.inBattle) { if (session.inBattle) {
return [ return buildBattleActionOptions(session);
'battle_probe_pressure',
'battle_guard_break',
'battle_feint_step',
'battle_finisher_window',
'battle_all_in_crush',
'battle_recover_breath',
'battle_escape_breakout',
].map((functionId) => buildOptionView(functionId));
} }
if (session.currentEncounter?.kind === 'npc') { if (session.currentEncounter?.kind === 'npc') {
@@ -784,6 +1189,9 @@ export function buildLegacyCurrentStory(
text: option.actionText, text: option.actionText,
detailText: option.detailText, detailText: option.detailText,
priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1, priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1,
runtimePayload: option.payload,
disabled: option.disabled,
disabledReason: option.reason,
visuals: { visuals: {
playerAnimation: 'idle', playerAnimation: 'idle',
playerMoveMeters: 0, playerMoveMeters: 0,

View File

@@ -378,46 +378,48 @@ test('runtime story actions resolve combat finishers on the server and collapse
await withTestServer('combat-finisher', async ({ baseUrl }) => { await withTestServer('combat-finisher', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_combat_finisher', 'secret123'); const entry = await authEntry(baseUrl, 'story_combat_finisher', 'secret123');
await putSnapshot(baseUrl, entry.token, { await putSnapshot(
worldType: 'WUXIA', baseUrl,
storyHistory: [], entry.token,
currentEncounter: { createTask6GameState({
kind: 'npc', currentEncounter: {
id: 'npc_bandit_01', kind: 'npc',
npcName: '断桥匪首',
npcDescription: '手提短刀的拦路匪徒',
context: '桥口劫匪',
hostile: true,
},
npcInteractionActive: false,
sceneHostileNpcs: [
{
id: 'npc_bandit_01', id: 'npc_bandit_01',
name: '断桥匪首', npcName: '断桥匪首',
hp: 12, npcDescription: '手提短刀的拦路匪徒',
maxHp: 28, context: '桥口劫匪',
description: '桥口劫匪', hostile: true,
}, },
], npcInteractionActive: false,
inBattle: true, sceneHostileNpcs: [
playerHp: 42, {
playerMaxHp: 50, id: 'npc_bandit_01',
playerMana: 20, name: '断桥匪首',
playerMaxMana: 20, hp: 12,
npcStates: { maxHp: 28,
npc_bandit_01: { description: '桥口劫匪',
affinity: -12, },
chattedCount: 0, ],
helpUsed: false, inBattle: true,
giftsGiven: 0, playerHp: 42,
inventory: [], playerMaxHp: 50,
recruited: false, playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
npcStates: {
npc_bandit_01: {
affinity: -12,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
}, },
}, currentNpcBattleMode: 'fight',
companions: [], currentNpcBattleOutcome: null,
currentNpcBattleMode: 'fight', }),
currentNpcBattleOutcome: null, );
});
const response = await httpRequest( const response = await httpRequest(
`${baseUrl}/api/runtime/story/actions/resolve`, `${baseUrl}/api/runtime/story/actions/resolve`,
@@ -486,6 +488,313 @@ test('runtime story actions resolve combat finishers on the server and collapse
}); });
}); });
test('runtime story state exposes the single-action combat option pool with runtime payload metadata', async () => {
await withTestServer('combat-state-options', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_combat_state', 'secret123');
const playerCharacter = {
...requirePlayerCharacter(),
skills: [
{
id: 'slash',
name: '试锋斩',
animation: 'attack',
damage: 18,
manaCost: 4,
cooldownTurns: 2,
range: 1,
style: 'steady',
},
{
id: 'wind-step',
name: '断风步',
animation: 'attack',
damage: 12,
manaCost: 2,
cooldownTurns: 0,
range: 1,
style: 'steady',
},
],
};
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
playerCharacter,
currentEncounter: {
kind: 'npc',
id: 'npc_bandit_01',
npcName: '断桥匪首',
npcDescription: '手提短刀的拦路匪徒',
context: '桥口劫匪',
hostile: true,
},
npcInteractionActive: false,
sceneHostileNpcs: [
{
id: 'npc_bandit_01',
name: '断桥匪首',
hp: 36,
maxHp: 36,
description: '桥口劫匪',
},
],
inBattle: true,
playerMana: 6,
playerMaxMana: 16,
playerSkillCooldowns: {
slash: 2,
'wind-step': 0,
},
playerInventory: [
{
id: 'focus-tonic',
category: '消耗品',
name: '凝神灵液',
quantity: 1,
rarity: 'rare',
tags: ['mana'],
useProfile: {
manaRestore: 6,
},
},
],
npcStates: {
npc_bandit_01: {
affinity: -12,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
currentNpcBattleMode: 'fight',
}),
);
const response = await httpRequest(
`${baseUrl}/api/runtime/story/state/runtime-main`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
);
const payload = (await response.json()) as {
viewModel: {
status: {
inBattle: boolean;
};
availableOptions: Array<{
functionId: string;
actionText: string;
payload?: {
skillId?: string;
itemId?: string;
};
disabled?: boolean;
reason?: string;
}>;
};
};
assert.equal(response.status, 200);
assert.equal(payload.viewModel.status.inBattle, true);
assert.deepEqual(
payload.viewModel.availableOptions.map((option) => option.functionId),
[
'battle_attack_basic',
'battle_recover_breath',
'inventory_use',
'battle_use_skill',
'battle_use_skill',
'battle_escape_breakout',
],
);
const itemOption = payload.viewModel.availableOptions[2];
assert.equal(itemOption?.functionId, 'inventory_use');
assert.equal(itemOption?.payload?.itemId, 'focus-tonic');
assert.equal(itemOption?.disabled, undefined);
const slashOption = payload.viewModel.availableOptions[3];
assert.equal(slashOption?.actionText, '试锋斩');
assert.equal(slashOption?.payload?.skillId, 'slash');
assert.equal(slashOption?.disabled, true);
assert.match(slashOption?.reason ?? '', //u);
const windStepOption = payload.viewModel.availableOptions[4];
assert.equal(windStepOption?.actionText, '断风步');
assert.equal(windStepOption?.payload?.skillId, 'wind-step');
assert.equal(windStepOption?.disabled, undefined);
});
});
test('runtime story actions resolve battle_use_skill as a single ongoing combat turn', async () => {
await withTestServer('combat-use-skill', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_combat_use_skill', 'secret123');
const playerCharacter = {
...requirePlayerCharacter(),
skills: [
{
id: 'slash',
name: '试锋斩',
animation: 'attack',
damage: 18,
manaCost: 4,
cooldownTurns: 2,
range: 1,
style: 'steady',
buildBuffs: [
{
id: 'slash:buff',
sourceType: 'skill',
sourceId: 'slash',
name: '试锋余势',
tags: ['快剑'],
durationTurns: 2,
},
],
},
],
};
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
playerCharacter,
currentEncounter: {
kind: 'npc',
id: 'npc_bandit_01',
npcName: '断桥匪首',
npcDescription: '手提短刀的拦路匪徒',
context: '桥口劫匪',
hostile: true,
},
npcInteractionActive: false,
sceneHostileNpcs: [
{
id: 'npc_bandit_01',
name: '断桥匪首',
hp: 80,
maxHp: 80,
description: '桥口劫匪',
},
],
inBattle: true,
playerHp: 32,
playerMaxHp: 40,
playerMana: 9,
playerMaxMana: 16,
playerSkillCooldowns: {},
activeBuildBuffs: [],
npcStates: {
npc_bandit_01: {
affinity: -12,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
currentNpcBattleMode: 'fight',
}),
);
const response = await httpRequest(
`${baseUrl}/api/runtime/story/actions/resolve`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 0,
action: {
type: 'story_choice',
functionId: 'battle_use_skill',
payload: {
skillId: 'slash',
},
},
}),
}),
);
const payload = (await response.json()) as {
serverVersion: number;
viewModel: {
player: {
mana: number;
};
status: {
inBattle: boolean;
};
availableOptions: Array<{
functionId: string;
actionText: string;
payload?: {
skillId?: string;
};
disabled?: boolean;
reason?: string;
}>;
};
presentation: {
resultText: string;
storyText: string;
battle: {
outcome: string;
damageDealt: number;
} | null;
};
snapshot: {
gameState: {
playerMana: number;
playerSkillCooldowns: Record<string, number>;
activeBuildBuffs: Array<{
id: string;
}>;
};
};
patches: Array<{
type: string;
functionId?: string;
}>;
};
assert.equal(response.status, 200);
assert.equal(payload.serverVersion, 1);
assert.equal(payload.presentation.battle?.outcome, 'ongoing');
assert.ok((payload.presentation.battle?.damageDealt ?? 0) > 0);
assert.equal(payload.presentation.storyText, payload.presentation.resultText);
assert.match(payload.presentation.storyText, //u);
assert.equal(payload.viewModel.status.inBattle, true);
assert.equal(payload.viewModel.player.mana, 5);
assert.equal(payload.snapshot.gameState.playerMana, 5);
assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 2);
assert.equal(payload.snapshot.gameState.activeBuildBuffs[0]?.id, 'slash:buff');
assert.ok(
payload.patches.some(
(patch) =>
patch.type === 'battle_resolved' &&
patch.functionId === 'battle_use_skill',
),
);
const skillOption = payload.viewModel.availableOptions.find(
(option) =>
option.functionId === 'battle_use_skill' &&
option.payload?.skillId === 'slash',
);
assert.ok(skillOption);
assert.equal(skillOption.actionText, '试锋斩');
assert.equal(skillOption.disabled, true);
assert.match(skillOption.reason ?? '', //u);
});
});
test('runtime story actions resolve inventory_use and persist updated resources', async () => { test('runtime story actions resolve inventory_use and persist updated resources', async () => {
await withTestServer('task6-inventory-use', async ({ baseUrl }) => { await withTestServer('task6-inventory-use', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_task6_inventory', 'secret123'); const entry = await authEntry(baseUrl, 'story_task6_inventory', 'secret123');

View File

@@ -155,17 +155,16 @@ function buildStoryOptionFromRuntimeOption(
session: RuntimeSession, session: RuntimeSession,
option: RuntimeStoryOptionView, option: RuntimeStoryOptionView,
) { ) {
const detailParts = [option.detailText, option.disabled ? option.reason : null]
.filter(Boolean)
.join(' ');
return { return {
functionId: option.functionId, functionId: option.functionId,
actionText: option.actionText, actionText: option.actionText,
text: option.actionText, text: option.actionText,
detailText: detailParts || undefined, detailText: option.detailText,
visuals: DEFAULT_STORY_OPTION_VISUALS, visuals: DEFAULT_STORY_OPTION_VISUALS,
interaction: buildStoryOptionInteraction(session, option), interaction: buildStoryOptionInteraction(session, option),
runtimePayload: option.payload,
disabled: option.disabled,
disabledReason: option.reason,
} satisfies JsonRecord; } satisfies JsonRecord;
} }
@@ -173,9 +172,7 @@ function buildStoryOptionsFromRuntimeOptions(
session: RuntimeSession, session: RuntimeSession,
options: RuntimeStoryOptionView[], options: RuntimeStoryOptionView[],
) { ) {
return options return options.map((option) => buildStoryOptionFromRuntimeOption(session, option));
.filter((option) => !option.disabled)
.map((option) => buildStoryOptionFromRuntimeOption(session, option));
} }
function escapeRegExp(value: string) { function escapeRegExp(value: string) {
@@ -460,6 +457,22 @@ function normalizeStatusPatch(session: RuntimeSession) {
} satisfies RuntimeStoryPatch; } satisfies RuntimeStoryPatch;
} }
function shouldGenerateReasonedCombatStory(
functionId: string,
resolution: StoryResolution,
) {
if (!isCombatFunctionId(functionId)) {
return false;
}
const outcome = resolution.battle?.outcome;
return (
outcome === 'victory' ||
outcome === 'spar_complete' ||
outcome === 'escaped'
);
}
function clearEncounterState(session: RuntimeSession) { function clearEncounterState(session: RuntimeSession) {
session.currentEncounter = null; session.currentEncounter = null;
session.npcInteractionActive = false; session.npcInteractionActive = false;
@@ -778,7 +791,12 @@ export async function resolveRuntimeStoryAction(params: {
? { ...session.currentEncounter } ? { ...session.currentEncounter }
: null; : null;
if (isCombatFunctionId(functionId)) { if (isCombatFunctionId(functionId)) {
resolution = resolveCombatAction(session, functionId); resolution = resolveCombatAction(session, {
functionId,
payload: isObject(params.request.action.payload)
? params.request.action.payload
: undefined,
});
} else if (isNpcFunctionId(functionId)) { } else if (isNpcFunctionId(functionId)) {
resolution = resolveNpcInteraction(session, functionId); resolution = resolveNpcInteraction(session, functionId);
} else if (isSupportedInventoryStoryFunctionId(functionId)) { } else if (isSupportedInventoryStoryFunctionId(functionId)) {
@@ -840,7 +858,10 @@ export async function resolveRuntimeStoryAction(params: {
} catch { } catch {
savedCurrentStory = buildLegacyCurrentStory(storyText, options); savedCurrentStory = buildLegacyCurrentStory(storyText, options);
} }
} else if (params.llmClient && isCombatFunctionId(functionId)) { } else if (
params.llmClient &&
shouldGenerateReasonedCombatStory(functionId, resolution)
) {
try { try {
const generatedPayload = await generateReasonedStoryPayload({ const generatedPayload = await generateReasonedStoryPayload({
llmClient: params.llmClient, llmClient: params.llmClient,

View File

@@ -0,0 +1,471 @@
import type {
CharacterChatReplyRequest,
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../packages/shared/src/contracts/story.js';
type JsonRecord = Record<string, unknown>;
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
只回复这名角色此刻会对玩家说的话。
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
只输出纯文本,共 3 行,每行一条。
不要加编号、项目符号、Markdown 或额外说明。
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
只输出一段简洁文字。
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG 的角色对话编剧。
你只能输出纯中文对话正文不能输出解释、代码、markdown、JSON 或额外说明。
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段内容只是聊天,不是做决定。
- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。
- 禁止把情报直接写成对玩家的指令。
- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`;
export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招募剧情对话编剧。
你只能输出纯中文对话正文不能输出解释、代码、markdown、JSON 或额外说明。
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段对话的目标是把“邀请对方入队”自然谈成。
- 不允许出现拒绝入队、继续观望、以后再说、条件未满足等结果。
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
- 最后一行必须由对方明确答应加入队伍。`;
export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
只输出纯文本,共 3 行,每行 1 条。
不要加编号、项目符号、Markdown、JSON 或额外说明。
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`;
function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as JsonRecord)
: null;
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readStringArray(value: unknown) {
return Array.isArray(value)
? value
.map((item) => readString(item))
.filter((item): item is string => Boolean(item))
: [];
}
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '边城模板';
case 'XIANXIA':
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:
return worldType || '未知世界';
}
}
function describeStats(label: string, record: JsonRecord | null) {
const hp = readNumber(record?.hp);
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
const mana = readNumber(record?.mana);
const maxMana = Math.max(1, readNumber(record?.maxMana, mana));
return `${label}生命 ${hp}/${maxHp},灵力 ${mana}/${maxMana}`;
}
function describeCharacter(label: string, value: unknown) {
const record = asRecord(value);
const name = readString(record?.name) ?? '未知角色';
const title = readString(record?.title) ?? '未知称号';
const description = readString(record?.description) ?? '暂无额外描述';
const personality = readString(record?.personality) ?? '性格信息未显式提供';
return [
`${label}姓名:${name}`,
`${label}称号:${title}`,
`${label}描述:${description}`,
`${label}性格:${personality}`,
].join('\n');
}
function describeStoryHistory(history: unknown) {
if (!Array.isArray(history) || history.length === 0) {
return '近期剧情:暂无。';
}
const lines = history
.slice(-4)
.map((item) => readString(asRecord(item)?.text))
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['近期剧情:', ...lines.map((line) => `- ${line}`)].join('\n')
: '近期剧情:暂无。';
}
function describeConversationHistory(history: unknown) {
if (!Array.isArray(history) || history.length === 0) {
return '聊天记录:暂无。';
}
const lines = history
.slice(-12)
.map((item) => {
const record = asRecord(item);
const speaker = readString(record?.speaker) === 'player' ? '玩家' : '角色';
const text = readString(record?.text);
return text ? `- ${speaker}${text}` : null;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['聊天记录:', ...lines].join('\n')
: '聊天记录:暂无。';
}
function describeNpcConversationHistory(history: unknown, npcName: string) {
if (!Array.isArray(history) || history.length === 0) {
return '当前聊天记录:暂无。';
}
const lines = history
.slice(-10)
.map((item) => {
const record = asRecord(item);
const speaker = readString(record?.speaker);
const speakerName = readString(record?.speakerName);
const text = readString(record?.text);
if (!text) return null;
if (speaker === 'player') {
return `- 玩家:${text}`;
}
if (speaker === 'npc') {
return `- ${speakerName ?? npcName}${text}`;
}
if (speaker === 'system') {
return `- 系统提示:${text}`;
}
return `- ${speakerName ?? '同伴'}${text}`;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['当前聊天记录:', ...lines].join('\n')
: '当前聊天记录:暂无。';
}
function describeSceneContext(context: unknown) {
const record = asRecord(context);
const sceneName = readString(record?.sceneName) ?? '当前区域';
const sceneDescription =
readString(record?.sceneDescription) ?? '周围气氛仍未完全安定。';
const inBattle = record?.inBattle === true ? '战斗中' : '非战斗';
const customWorldProfile = asRecord(record?.customWorldProfile);
const customWorldName = readString(customWorldProfile?.name);
const customWorldSummary = readString(customWorldProfile?.summary);
return [
`世界补充:${customWorldName ?? '无'}`,
customWorldSummary ? `世界摘要:${customWorldSummary}` : null,
`场景:${sceneName}`,
`场景描述:${sceneDescription}`,
`当前状态:${inBattle}`,
describeStats('玩家', record),
]
.filter(Boolean)
.join('\n');
}
function describeTargetStatus(status: unknown) {
const record = asRecord(status);
const roleLabel = readString(record?.roleLabel) ?? '同行角色';
const affinity = record?.affinity;
return [
`对方身份:${roleLabel}`,
describeStats('对方', record),
typeof affinity === 'number' ? `当前好感:${affinity}` : null,
]
.filter(Boolean)
.join('\n');
}
function describeEncounter(encounter: unknown) {
const record = asRecord(encounter);
const npcName = readString(record?.npcName) ?? '眼前角色';
const contextText =
readString(record?.context) ??
readString(record?.npcDescription) ??
'你们正在当前遭遇里继续对话。';
return {
npcName,
block: [`当前对象:${npcName}`, `对象背景:${contextText}`].join('\n'),
};
}
function describeMonsters(monsters: unknown) {
if (!Array.isArray(monsters) || monsters.length === 0) {
return '当前敌对目标:无。';
}
const lines = monsters
.slice(0, 4)
.map((item) => {
const record = asRecord(item);
const name =
readString(record?.name) ??
readString(record?.npcName) ??
readString(record?.id);
const hp = readNumber(record?.hp);
const maxHp = Math.max(1, readNumber(record?.maxHp, hp));
return name ? `- ${name}(生命 ${hp}/${maxHp}` : null;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['当前敌对目标:', ...lines].join('\n')
: '当前敌对目标:无。';
}
function describeTargetCharacterName(payload: {
targetCharacter?: unknown;
encounter?: unknown;
}) {
return (
readString(asRecord(payload.targetCharacter)?.name) ??
readString(asRecord(payload.encounter)?.npcName) ??
'对方'
);
}
export function buildCharacterPanelChatPrompt(
payload: CharacterChatReplyRequest,
) {
const targetName = describeTargetCharacterName(payload);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.conversationSummary
? `之前聊天摘要:${payload.conversationSummary}`
: '之前聊天摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
`玩家刚刚对 ${targetName} 说:${payload.playerMessage}`,
`现在请以 ${targetName} 的身份,直接回复玩家。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildCharacterPanelChatSuggestionPrompt(
payload: CharacterChatSuggestionsRequest,
) {
const targetName = describeTargetCharacterName(payload);
const latestCharacterReply = Array.isArray(payload.conversationHistory)
? [...payload.conversationHistory]
.reverse()
.map((item) => asRecord(item))
.find((record) => readString(record?.speaker) === 'character')
: null;
const latestReplyText = readString(latestCharacterReply?.text);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.conversationSummary
? `之前聊天摘要:${payload.conversationSummary}`
: '之前聊天摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
latestReplyText
? `角色刚刚的回复:${latestReplyText}`
: `玩家正准备与 ${targetName} 开始一段新的私聊。`,
`请围绕当前气氛,为玩家生成 3 条可以直接发送给 ${targetName} 的简短回复候选。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildCharacterPanelChatSummaryPrompt(
payload: CharacterChatSummaryRequest,
) {
const targetName = describeTargetCharacterName(payload);
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.playerCharacter),
describeCharacter('对方 / ', payload.targetCharacter),
describeTargetStatus(payload.targetStatus),
describeStoryHistory(payload.storyHistory),
payload.previousSummary
? `旧摘要:${payload.previousSummary}`
: '旧摘要:暂无。',
describeConversationHistory(payload.conversationHistory),
`请把玩家与 ${targetName} 的旧摘要和最新聊天整理成一段更新后的关系摘要。`,
]
.filter(Boolean)
.join('\n\n');
}
function buildNpcDialoguePromptBase(
payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
const character =
(payload as NpcChatTurnRequest).character ??
(payload as NpcChatTurnRequest).player;
if (!(payload as NpcChatTurnRequest).character && character) {
(payload as NpcChatTurnRequest).character = character;
}
return [
`世界:${describeWorld(payload.worldType)}`,
describeSceneContext(payload.context),
describeCharacter('玩家 / ', payload.character),
encounter.block,
describeMonsters(payload.monsters),
describeStoryHistory(payload.history),
]
.filter(Boolean)
.join('\n\n');
}
export function buildStrictNpcChatDialoguePrompt(
payload: NpcChatDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
const context = asRecord(payload.context);
const openingCampBackground = readString(context?.openingCampBackground);
const openingCampDialogue = readString(context?.openingCampDialogue);
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
return [
buildNpcDialoguePromptBase(payload),
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
allowedTopics.length > 0
? `当前更适合谈的内容:${allowedTopics.join('、')}`
: null,
blockedTopics.length > 0
? `当前避免直接说破:${blockedTopics.join('、')}`
: null,
`当前聊天主题:${payload.topic}`,
payload.resultSummary
? `这段聊天希望带来的变化:${payload.resultSummary}`
: '这段聊天要让气氛、情报或关系出现一层新的变化。',
`请围绕“${payload.topic}”写一段刚刚发生的对话。必须只输出对白正文,每一行都必须以“你:”或“${encounter.npcName}:”开头。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcRecruitDialoguePrompt(
payload: NpcRecruitDialogueRequest,
) {
const encounter = describeEncounter(payload.encounter);
return [
buildNpcDialoguePromptBase(payload),
`玩家邀请:${payload.invitationText}`,
payload.recruitSummary
? `招募补充条件:${payload.recruitSummary}`
: '这轮对话已经具备自然邀请对方入队的条件。',
'这是一段“邀请对方入队”的对话。请让几轮交流逐步导向成功加入队伍,不要写出拒绝、观望或延期答复。',
`最后一行必须由 ${encounter.npcName} 明确答应加入队伍。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcChatTurnReplyPrompt(
payload: NpcChatTurnRequest,
) {
const encounter = describeEncounter(payload.encounter);
const npcState = asRecord(payload.npcState);
const conversationHistory =
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
? payload.conversationHistory
: payload.dialogue ?? payload.conversationHistory ?? [];
const affinity = readNumber(npcState?.affinity, 0);
const chattedCount = readNumber(npcState?.chattedCount, 0);
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(conversationHistory, encounter.npcName),
`当前关系值:${affinity}`,
`已聊天轮次:${chattedCount}`,
`玩家刚刚说:${payload.playerMessage}`,
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcChatTurnSuggestionPrompt(
payload: NpcChatTurnRequest,
npcReply: string,
) {
const encounter = describeEncounter(payload.encounter);
const conversationHistory =
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
? payload.conversationHistory
: payload.dialogue ?? payload.conversationHistory ?? [];
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(conversationHistory, encounter.npcName),
`玩家刚刚说:${payload.playerMessage}`,
`NPC 刚刚回复:${npcReply}`,
`请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。`,
'每条都必须像玩家台词,不能写成行为描述、语气说明或策略建议。',
'每条都必须控制在 20 个字以内,不要加序号、引号、括号或解释。',
]
.filter(Boolean)
.join('\n\n');
}

View File

@@ -0,0 +1,57 @@
export const FOUNDATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的世界草稿 JSON 生成器。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
export const FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你会收到一段本应为单个 JSON 对象的文本。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
不要输出 Markdown、代码块、解释、注释或额外文字。`;
export const CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT =
'你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。';
export const CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT =
'你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。';
export function buildCustomWorldAgentCharacterExpansionPrompt(params: {
worldName: string;
worldSummary: string;
creatorIntentSummary: string;
anchorSummary: string;
existingNames: string[];
count: number;
promptSeed: string;
}) {
return [
`当前世界:${params.worldName}`,
`世界摘要:${params.worldSummary}`,
`创作意图摘要:${params.creatorIntentSummary}`,
`参考锚点:${params.anchorSummary}`,
`已有角色:${params.existingNames.join('、') || '暂无'}`,
`数量:${params.count}`,
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
'返回 JSON 数组。每个对象字段只允许包含name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。',
'threadIds 必须优先引用现有线程 id。',
].join('\n');
}
export function buildCustomWorldAgentLandmarkExpansionPrompt(params: {
worldName: string;
worldSummary: string;
creatorIntentSummary: string;
anchorSummary: string;
existingNames: string[];
count: number;
promptSeed: string;
}) {
return [
`当前世界:${params.worldName}`,
`世界摘要:${params.worldSummary}`,
`创作意图摘要:${params.creatorIntentSummary}`,
`参考锚点:${params.anchorSummary}`,
`已有地点:${params.existingNames.join('、') || '暂无'}`,
`数量:${params.count}`,
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`,
'返回 JSON 数组。每个对象字段只允许包含name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。',
'threadIds / characterIds 必须优先引用现有对象 id。',
].join('\n');
}

View File

@@ -0,0 +1,249 @@
type ParsedRole = {
id: string;
name: string;
title: string;
role: string;
description: string;
visualDescription: string;
actionDescription: string;
sceneVisualDescription: string;
backstory: string;
personality: string;
motivation: string;
tags: string[];
};
type ParsedLandmarkConnection = {
targetLandmarkId: string;
summary: string;
relativePosition: string;
};
type ParsedLandmark = {
id: string;
name: string;
description: string;
visualDescription: string;
dangerLevel: string;
sceneNpcIds: string[];
connections: ParsedLandmarkConnection[];
};
type ParsedProfile = {
name: string;
settingText: string;
summary: string;
tone: string;
playerGoal: string;
playableNpcs: ParsedRole[];
storyNpcs: ParsedRole[];
landmarks: ParsedLandmark[];
};
export const CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT =
'你是游戏世界编辑器的实体生成器。你必须只返回可解析 JSON不要输出解释、前言或 Markdown。';
function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) {
if (roles.length === 0) {
return emptyText;
}
return roles
.slice(0, 12)
.map(
(role, index) =>
`${index + 1}. ${role.name} / ${role.title || role.role} / 身份:${
role.role || '未写'
} / 描述:${role.description || '未写'} / 背景:${
role.backstory || '未写'
} / 性格:${role.personality || '未写'} / 动机:${
role.motivation || '未写'
} / 形象:${role.visualDescription || '未写'} / 动作表现:${
role.actionDescription || '未写'
} / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${
role.tags.join('、') || '暂无'
}`,
)
.join('\n');
}
function buildLandmarkReferenceText(profile: ParsedProfile) {
if (profile.landmarks.length === 0) {
return '当前还没有场景设定。';
}
const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc]));
const landmarkById = new Map(
profile.landmarks.map((landmark) => [landmark.id, landmark]),
);
return profile.landmarks
.slice(0, 12)
.map((landmark, index) => {
const sceneNpcNames = landmark.sceneNpcIds
.map((npcId) => storyNpcById.get(npcId)?.name ?? '')
.filter(Boolean)
.join('、');
const connectionNames = landmark.connections
.map((connection) => {
const targetName =
landmarkById.get(connection.targetLandmarkId)?.name ||
connection.targetLandmarkId;
return `${targetName}${connection.relativePosition} / ${
connection.summary || '无说明'
}`;
})
.join('、');
return `${index + 1}. ${landmark.name} / 危险度:${
landmark.dangerLevel || 'medium'
} / 描述:${landmark.description || '未写'} / 画面:${
landmark.visualDescription || '未写'
} / 场景角色:${
sceneNpcNames || '暂无'
} / 连接:${connectionNames || '暂无'}`;
})
.join('\n');
}
export function buildPlayablePrompt(profile: ParsedProfile) {
return [
`世界名:${profile.name}`,
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
`世界摘要:${profile.summary || '未填写'}`,
`世界基调:${profile.tone || '未填写'}`,
`玩家主线目标:${profile.playerGoal || '未填写'}`,
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
'请基于上面全部上下文,生成 1 名新的“可扮演角色”。',
'要求:',
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
'- 必须保留明确的协作价值、成长空间和入队理由。',
'- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。',
'- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。',
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
'- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:',
'{',
' "playableNpc": {',
' "name": "角色名",',
' "title": "称号",',
' "role": "身份",',
' "description": "一句到两句定位描述",',
' "visualDescription": "角色形象描述",',
' "actionDescription": "动作表现描述",',
' "sceneVisualDescription": "角色关联场景画面描述",',
' "backstory": "背景经历",',
' "personality": "性格特点",',
' "motivation": "当前动机",',
' "combatStyle": "战斗风格",',
' "initialAffinity": 22,',
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
' "tags": ["标签1", "标签2", "标签3"],',
' "publicSummary": "公开背景摘要",',
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
' "skills": [',
' { "name": "技能1", "summary": "说明", "style": "风格" },',
' { "name": "技能2", "summary": "说明", "style": "风格" },',
' { "name": "技能3", "summary": "说明", "style": "风格" }',
' ],',
' "initialItems": [',
' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }',
' ]',
' }',
'}',
].join('\n');
}
export function buildStoryPrompt(profile: ParsedProfile) {
return [
`世界名:${profile.name}`,
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
`世界摘要:${profile.summary || '未填写'}`,
`世界基调:${profile.tone || '未填写'}`,
`玩家主线目标:${profile.playerGoal || '未填写'}`,
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
'请基于上面全部上下文,生成 1 名新的“场景角色”。',
'要求:',
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
'- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。',
'- 角色应与具体场景、关系链或局势变化发生绑定。',
'- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。',
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
'- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:',
'{',
' "storyNpc": {',
' "name": "角色名",',
' "title": "称号",',
' "role": "身份",',
' "description": "一句到两句定位描述",',
' "visualDescription": "角色形象描述",',
' "actionDescription": "动作表现描述",',
' "sceneVisualDescription": "角色关联场景画面描述",',
' "backstory": "背景经历",',
' "personality": "性格特点",',
' "motivation": "当前动机",',
' "combatStyle": "战斗风格",',
' "initialAffinity": 6,',
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
' "tags": ["标签1", "标签2", "标签3"],',
' "publicSummary": "公开背景摘要",',
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
' "skills": [',
' { "name": "技能1", "summary": "说明", "style": "风格" },',
' { "name": "技能2", "summary": "说明", "style": "风格" },',
' { "name": "技能3", "summary": "说明", "style": "风格" }',
' ],',
' "initialItems": [',
' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }',
' ]',
' }',
'}',
].join('\n');
}
export function buildLandmarkPrompt(profile: ParsedProfile) {
return [
`世界名:${profile.name}`,
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
`世界摘要:${profile.summary || '未填写'}`,
`世界基调:${profile.tone || '未填写'}`,
`玩家主线目标:${profile.playerGoal || '未填写'}`,
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
'请基于上面全部上下文,生成 1 个新的“场景”。',
'要求:',
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。',
'- 必须给出适合出现在这个新场景里的 sceneNpcNames且只能从已有场景角色里选择至少 3 个名字。',
'- 必须给出 connections且 targetLandmarkName 只能引用已有场景名,不要连向自己。',
'- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。',
'- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:',
'{',
' "landmark": {',
' "name": "场景名",',
' "description": "场景描述",',
' "visualDescription": "场景画面描述",',
' "dangerLevel": "low|medium|high|extreme",',
' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],',
' "connections": [',
' { "targetLandmarkName": "已有场景名", "relativePosition": "forward", "summary": "通路说明" },',
' { "targetLandmarkName": "已有场景名", "relativePosition": "inside", "summary": "通路说明" }',
' ]',
' }',
'}',
].join('\n');
}

View File

@@ -0,0 +1,61 @@
export const CUSTOM_WORLD_GENERATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的自定义世界 JSON 生成器。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
export const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你会收到一段本应为单个 JSON 对象的文本。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
不要输出 Markdown、代码块、解释、注释或额外文字。
尽量保留原始语义,只修复格式问题;必要时可以补齐缺失的引号、逗号、括号、数组闭合或缺失字段。`;
export function buildCustomWorldProfilePrompt(params: {
generationSeedText: string;
creatorIntentText?: string;
generationMode: string;
targets: {
playableCount: number;
storyCount: number;
landmarkCount: number;
};
}) {
return [
'请根据创作者输入,生成一个可直接进入游戏的自定义世界档案 JSON。',
'必须严格输出单个 JSON 对象,不要 Markdown不要解释。',
'',
`生成模式:${params.generationMode}`,
`可扮演角色数量:${params.targets.playableCount}`,
`场景角色数量:${params.targets.storyCount}`,
`关键场景数量:${params.targets.landmarkCount}`,
'',
'创作者输入:',
params.generationSeedText,
params.creatorIntentText ? `\n结构化创作锚点\n${params.creatorIntentText}` : '',
'',
'输出 JSON 字段要求:',
'- name, subtitle, summary, tone, playerGoal, templateWorldType',
'- majorFactions: string[]coreConflicts: string[]',
'- camp: { name, description, dangerLevel }',
'- playableNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- storyNpcs: 每项包含 name,title,role,description,backstory,personality,motivation,combatStyle,initialAffinity,relationshipHooks,tags',
'- landmarks: 每项包含 name,description,dangerLevel,sceneNpcNames,connections',
'- connections 每项包含 targetLandmarkName, relativePosition, summarytargetLandmarkName 必须指向本次输出的其他场景名',
'',
'约束:',
'- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。',
'- 角色名字、势力名、场景名必须互相区分,避免重复。',
'- sceneNpcNames 只能引用 storyNpcs 中已经输出的 name。',
'- templateWorldType 只能是 WUXIA 或 XIANXIA。',
'- dangerLevel 使用 low、medium、high、extreme 之一。',
'- relativePosition 使用 forward、back、left、right、up、down、inside、outside 之一。',
'- 不要预生成物品档案items 如需输出,必须为空数组。',
]
.filter(Boolean)
.join('\n');
}
export function buildCustomWorldProfileRepairPrompt(responseText: string) {
return [
'请修复下面的自定义世界 JSON。',
'只输出能被 JSON.parse 直接解析的单个 JSON 对象,不要解释。',
responseText,
].join('\n\n');
}

View File

@@ -0,0 +1,104 @@
type ParsedStoryNpc = {
name: string;
title: string;
role: string;
description: string;
personality: string;
motivation: string;
};
type ParsedLandmark = {
name: string;
description: string;
dangerLevel: string;
};
type ParsedProfile = {
name: string;
settingText: string;
storyNpcs: ParsedStoryNpc[];
landmarks: ParsedLandmark[];
};
export const CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT =
'你是游戏世界编辑器的场景 NPC 生成器。你必须只返回可解析 JSON不要输出解释、前言或 Markdown。';
export function buildCustomWorldSceneNpcPrompt(
profile: ParsedProfile,
landmark: ParsedLandmark,
sceneNpcs: ParsedStoryNpc[],
otherNpcs: ParsedStoryNpc[],
) {
const sceneNpcSummary = sceneNpcs.length
? sceneNpcs
.map(
(npc, index) =>
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`,
)
.join('\n')
: '当前场景还没有已加入 NPC。';
const reserveNpcSummary = otherNpcs.length
? otherNpcs
.slice(0, 8)
.map(
(npc, index) =>
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`,
)
.join('\n')
: '暂无其他场景角色参考。';
const landmarkSummary = profile.landmarks
.slice(0, 10)
.map(
(entry, index) =>
`${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`,
)
.join('\n');
return [
`世界名:${profile.name}`,
`世界设定:${profile.settingText || '未提供额外设定文本。'}`,
`当前目标场景:${landmark.name}`,
`场景描述:${landmark.description || '未填写'}`,
`危险度:${landmark.dangerLevel || '中'}`,
`当前场景已加入 NPC\n${sceneNpcSummary}`,
`其他可参考 NPC\n${reserveNpcSummary}`,
`世界内其他场景概览:\n${landmarkSummary}`,
'请生成 1 名适合加入当前场景的新 NPC。',
'要求:',
'- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。',
'- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。',
'- 关系钩子、技能、初始物品都要可直接进入编辑器。',
'- 返回 JSON不要额外解释。',
'JSON 结构:',
'{',
' "npc": {',
' "name": "角色名",',
' "title": "头衔",',
' "role": "身份",',
' "description": "一句到两句角色描述",',
' "backstory": "背景",',
' "personality": "性格",',
' "motivation": "动机",',
' "combatStyle": "战斗风格",',
' "initialAffinity": 6,',
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
' "tags": ["标签1", "标签2", "标签3"],',
' "publicSummary": "公开背景摘要",',
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
' "skills": [',
' { "name": "技能1", "summary": "说明", "style": "风格" },',
' { "name": "技能2", "summary": "说明", "style": "风格" },',
' { "name": "技能3", "summary": "说明", "style": "风格" }',
' ],',
' "initialItems": [',
' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },',
' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }',
' ]',
' }',
'}',
].join('\n');
}

View File

@@ -0,0 +1,168 @@
import type {
QuestGenerationContext,
QuestOpportunity,
QuestSceneSnapshot,
} from '../modules/quest/runtimeQuestModule.js';
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
const moments = context.recentStoryMoments
.slice(-4)
.map((moment) => `- ${moment.text}`)
.join('\n');
return moments || '- 暂无近期剧情记录';
}
function summarizeCurrentQuests(context: QuestGenerationContext) {
const summary = context.currentQuestSummary
?.map(
(quest) =>
`- ${quest.title}${quest.status}),发布者 ${quest.issuerNpcId}`,
)
.join('\n');
return summary || '- 当前没有进行中的任务';
}
function summarizeCompanions(context: QuestGenerationContext) {
const active =
context.activeCompanions?.map((companion) => companion.characterId).join('、') ||
'无';
const roster =
context.rosterCompanions?.map((companion) => companion.characterId).join('、') ||
'无';
return `当前同行角色:${active}\n队伍名册${roster}`;
}
function summarizePlayerState(context: QuestGenerationContext) {
const playerName = context.playerCharacter?.name ?? '未知角色';
const playerTitle = context.playerCharacter?.title ?? '未知称号';
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
const inventory =
context.playerInventory?.slice(0, 8).map((item) => item.name).join('、') || '无';
return [
`玩家:${playerName}${playerTitle}`,
`生命:${hp}`,
`灵力:${mana}`,
`背包快照:${inventory}`,
].join('\n');
}
function summarizeScene(
scene: QuestSceneSnapshot | null,
context: QuestGenerationContext,
) {
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
return [
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
`敌对角色 ID${hostileNpcIds}`,
`宝藏线索数量:${treasureHintCount}`,
].join('\n');
}
function summarizeActiveThreads(context: QuestGenerationContext) {
return context.activeThreadIds?.length
? context.activeThreadIds.join('、')
: '暂无明确激活线程';
}
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
const profile = context.issuerNarrativeProfile;
if (!profile) {
return '暂无额外叙事档案';
}
return [
`公开面:${profile.publicMask ?? '暂无'}`,
`表层线:${profile.visibleLine ?? '暂无'}`,
`当前压力:${profile.immediatePressure ?? '暂无'}`,
profile.reactionHooks?.length
? `反应钩子:${profile.reactionHooks.join('、')}`
: null,
]
.filter(Boolean)
.join('\n');
}
function describeWorld(worldType: QuestGenerationContext['worldType']) {
switch (worldType) {
case 'WUXIA':
return '边城模板';
case 'XIANXIA':
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:
return '未知世界';
}
}
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
只返回 JSON不要输出 Markdown。
输出结构:
{
"intent": {
"title": "中文任务标题",
"description": "中文任务描述",
"summary": "中文短摘要",
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
"dramaticNeed": "string",
"issuerGoal": "string",
"playerHook": "string",
"worldReason": "string",
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
"urgency": "low|medium|high",
"intimacy": "transactional|cooperative|trust_based",
"rewardTheme": "currency|resource|relationship|intel|rare_item",
"followupHooks": ["string"]
}
}
规则:
- 所有自然语言字段都必须使用中文。
- 任务必须扎根于当前场景、发布者和近期剧情。
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
export function buildQuestIntentPrompt(params: {
context: QuestGenerationContext;
scene: QuestSceneSnapshot | null;
opportunity: QuestOpportunity;
}) {
const { context, scene, opportunity } = params;
const customWorldSummary = context.customWorldProfile
? `${context.customWorldProfile.name ?? '自定义世界'}: ${
context.customWorldProfile.summary ?? '暂无摘要'
}`
: '无';
return [
`世界:${describeWorld(context.worldType)}`,
`自定义世界摘要:${customWorldSummary}`,
`发布角色:${context.issuerNpcName}${context.issuerNpcId}`,
`发布者身份:${context.issuerNpcContext || '暂无'}`,
`发布者好感:${context.issuerAffinity ?? 0}`,
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
`当前激活线程:${summarizeActiveThreads(context)}`,
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
`当前遭遇类型:${context.encounterKind ?? '无'}`,
summarizeScene(scene, context),
summarizePlayerState(context),
summarizeCompanions(context),
`当前任务机会:${opportunity.reason}`,
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
].join('\n\n');
}

View File

@@ -0,0 +1,43 @@
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
你只返回 JSON不要输出 Markdown、解释或代码块。
输出结构:
{
"intents": [
{
"shortNameSeed": "中文短种子",
"sourcePhrase": "中文来源短语",
"reasonToAppear": "中文出现理由",
"relationHooks": ["中文关系钩子"],
"desiredBuildTags": ["中文 build 标签"],
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
"tone": "grim|mysterious|martial|ritual|survival",
"visibleClue": "玩家第一眼能抓到的痕迹",
"witnessMark": "它见证过什么的使用痕",
"unfinishedBusiness": "背后仍未结清的问题",
"hiddenHook": "更深一层但别直接讲穿的钩子",
"reactionHooks": ["以后谁会对它起反应"],
"namingPattern": "命名范式建议"
}
]
}
规则:
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
- 所有自然语言字段都必须使用中文。
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
export function buildRuntimeItemIntentPromptText(params: {
generationChannel: string;
planBlocks: string[];
}) {
return [
`生成渠道:${params.generationChannel}`,
'以下每个物品都需要给出一条可编译的运行时物品意图。',
...params.planBlocks,
'请严格返回 JSON。',
].join('\n\n');
}

View File

@@ -0,0 +1,33 @@
type StoryRepairResponse = {
storyText: string;
encounter?: unknown;
options: Array<{
functionId: string;
actionText: string;
}>;
};
export const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。
你会收到一个已经解析过的剧情 JSON 对象。
你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。
必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`;
export function buildStoryLanguageRepairPrompt(response: StoryRepairResponse) {
return [
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
JSON.stringify(
{
storyText: response.storyText,
encounter: response.encounter ?? null,
options: response.options.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
})),
},
null,
2,
),
].join('\n\n');
}

View File

@@ -0,0 +1,197 @@
type JsonRecord = Record<string, unknown>;
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
return '边城模板';
case 'XIANXIA':
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:
return worldType || '未知世界';
}
}
function describeCharacter(character: JsonRecord) {
return [
`主角:${readString(character.name) ?? '未知角色'}`,
`称号:${readString(character.title) ?? '未知称号'}`,
`描述:${readString(character.description) ?? '暂无'}`,
`性格:${readString(character.personality) ?? '未显式提供'}`,
].join('\n');
}
function describeMonsters(monsters: JsonRecord[]) {
if (monsters.length <= 0) {
return '当前敌对目标:无。';
}
return [
'当前敌对目标:',
...monsters.slice(0, 4).map((monster) => {
const name = readString(monster.name) ?? readString(monster.id) ?? '未知目标';
const hp = readNumber(monster.hp);
const maxHp = Math.max(1, readNumber(monster.maxHp, hp));
return `- ${name}(生命 ${hp}/${maxHp}`;
}),
].join('\n');
}
function describeStoryHistory(history: JsonRecord[]) {
if (history.length <= 0) {
return '近期剧情:暂无。';
}
return [
'近期剧情:',
...history.slice(-4).map((moment) => `- ${readString(moment.text) ?? '(空白)'}`),
].join('\n');
}
function describeRequestOptions(options: {
availableOptions?: Array<Record<string, unknown>>;
optionCatalog?: Array<Record<string, unknown>>;
}) {
const available = options.availableOptions ?? [];
const catalog = options.optionCatalog ?? [];
if (available.length > 0) {
return [
'固定可选项列表:',
...available.map((option, index) => {
const functionId = readString(option.functionId) ?? 'unknown';
const actionText =
readString(option.actionText) ??
readString(option.text) ??
'未提供文案';
return `- 第 ${index + 1} 项 / ${functionId}${actionText}`;
}),
'必须保持数量不变functionId 不变,可以重写 actionText。'.trim(),
].join('\n');
}
if (catalog.length > 0) {
return [
'当前局面可调用的交互选项目录:',
...catalog.map((option, index) => {
const functionId = readString(option.functionId) ?? 'unknown';
const actionText =
readString(option.actionText) ??
readString(option.text) ??
'未提供文案';
return `- 第 ${index + 1} 项 / ${functionId}${actionText}`;
}),
'functionId 只能从上面目录里选择。'.trim(),
].join('\n');
}
return '当前没有固定目录,请根据局势生成合理选项。';
}
function hasNpcOptionCatalog(options: {
availableOptions?: Array<Record<string, unknown>>;
optionCatalog?: Array<Record<string, unknown>>;
}) {
return (options.optionCatalog ?? []).some((option) =>
(readString(option.functionId) ?? '').startsWith('npc_'),
);
}
function isPostNpcChatReevaluation(params: {
choice?: string;
context: JsonRecord;
requestOptions?: {
availableOptions?: Array<Record<string, unknown>>;
optionCatalog?: Array<Record<string, unknown>>;
};
}) {
return (
readString(params.context.lastFunctionId) === 'npc_chat' &&
hasNpcOptionCatalog(params.requestOptions ?? {}) &&
Boolean(readString(params.choice))
);
}
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象不能输出解释、markdown 或代码块。
输出格式必须严格符合:
{
"storyText": "剧情文本",
"encounter": null,
"options": [
{
"functionId": "预定义功能ID",
"actionText": "选项显示文本"
}
]
}
严格规则:
- 所有文本必须是中文。
- 如果提示语给出了固定可选项或可选目录,必须遵守给定的 functionId 范围。
- storyText 必须直接承接当前场景、最近剧情和玩家刚刚的动作。
- options 只允许输出 functionId 和 actionText。
- 如果当前不是“继续推进后下一刻会遇到什么”的场景encounter 必须保持为 null。`;
export function buildUserPrompt(params: {
worldType: string;
character: JsonRecord;
monsters: JsonRecord[];
history: JsonRecord[];
context: JsonRecord;
choice?: string;
requestOptions?: {
availableOptions?: Array<Record<string, unknown>>;
optionCatalog?: Array<Record<string, unknown>>;
};
}) {
const sceneName = readString(params.context.sceneName) ?? '当前区域';
const sceneDescription = readString(params.context.sceneDescription) ?? '暂无场景附加描述。';
const encounterName = readString(params.context.encounterName);
const playerHp = readNumber(params.context.playerHp);
const playerMaxHp = Math.max(1, readNumber(params.context.playerMaxHp, playerHp));
const playerMana = readNumber(params.context.playerMana);
const playerMaxMana = Math.max(1, readNumber(params.context.playerMaxMana, playerMana));
const inBattle = params.context.inBattle === true ? '战斗中' : '非战斗';
const pendingSceneEncounter =
params.context.pendingSceneEncounter === true ? '是' : '否';
const postNpcChatReevaluation = isPostNpcChatReevaluation(params);
return [
`世界:${describeWorld(params.worldType)}`,
`场景:${sceneName}`,
`场景描述:${sceneDescription}`,
encounterName ? `当前面前对象:${encounterName}` : null,
`当前状态:${inBattle}`,
`玩家生命:${playerHp}/${playerMaxHp}`,
`玩家灵力:${playerMana}/${playerMaxMana}`,
`是否需要判断下一刻遭遇:${pendingSceneEncounter}`,
describeCharacter(params.character),
describeMonsters(params.monsters),
describeStoryHistory(params.history),
params.choice ? `玩家刚刚选择:${params.choice}` : '玩家刚进入当前局面。',
describeRequestOptions(params.requestOptions ?? {}),
postNpcChatReevaluation
? '当前这一步是刚结束一轮 NPC 交谈后对眼前局势的再次判断。storyText 必须先落出刚才那段聊天带来的态度变化、气氛变化或新暴露的信息,再进入下一步局势。'
: null,
postNpcChatReevaluation
? '如果输出 npc_ 开头的选项,这些 actionText 必须直接承接刚才聊到的话题、关系变化或对方态度,写成此刻自然浮现的回应,不要退回“继续交谈”“请求援手”“看看能交换什么”这类通用模板。'
: null,
postNpcChatReevaluation
? '当前目录只是合法 function 范围,不代表都要出现;只保留此刻真正自然浮现、和刚才聊天结果有关的选项。'
: null,
params.context.pendingSceneEncounter === true
? '如果这一轮明确是在判断继续推进后下一刻会遇到什么,可以填写 encounter否则 encounter 必须为 null。'
: '当前这一步不是新的遭遇生成流程encounter 必须为 null。',
]
.filter(Boolean)
.join('\n\n');
}

View File

@@ -9,6 +9,7 @@ import type {
ProfileDashboardSummary, ProfileDashboardSummary,
ProfilePlayedWorkSummary, ProfilePlayedWorkSummary,
ProfilePlayStatsResponse, ProfilePlayStatsResponse,
ProfileSaveArchiveSummary,
ProfileWalletLedgerEntry, ProfileWalletLedgerEntry,
RuntimeSettings, RuntimeSettings,
SavedGameSnapshot, SavedGameSnapshot,
@@ -19,6 +20,7 @@ import {
type CustomWorldPublicationStatus, type CustomWorldPublicationStatus,
type CustomWorldSessionRecord, type CustomWorldSessionRecord,
DEFAULT_MUSIC_VOLUME, DEFAULT_MUSIC_VOLUME,
DEFAULT_PLATFORM_THEME,
SAVE_SNAPSHOT_VERSION, SAVE_SNAPSHOT_VERSION,
} from '../../../packages/shared/src/contracts/runtime.js'; } from '../../../packages/shared/src/contracts/runtime.js';
import type { AppDatabase } from '../db.js'; import type { AppDatabase } from '../db.js';
@@ -39,6 +41,7 @@ type SnapshotRow = QueryResultRow & {
type SettingsRow = QueryResultRow & { type SettingsRow = QueryResultRow & {
musicVolume: number; musicVolume: number;
platformTheme: RuntimeSettings['platformTheme'];
}; };
type CustomWorldEntryRow = QueryResultRow & { type CustomWorldEntryRow = QueryResultRow & {
@@ -127,6 +130,23 @@ type ProfileWorldSnapshotMeta = {
worldSubtitle: string; worldSubtitle: string;
}; };
type ProfileSaveArchiveRow = QueryResultRow & {
worldKey: string;
ownerUserId: string | null;
profileId: string | null;
worldType: string | null;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
savedAt: string;
bottomTab: string;
gameState: unknown;
currentStory: unknown;
};
type ProfileSaveArchiveMeta = Omit<ProfileSaveArchiveSummary, 'lastPlayedAt'>;
export type RuntimeRepositoryPort = { export type RuntimeRepositoryPort = {
getSnapshot(userId: string): Promise<SavedSnapshot | null>; getSnapshot(userId: string): Promise<SavedSnapshot | null>;
putSnapshot( putSnapshot(
@@ -136,6 +156,14 @@ export type RuntimeRepositoryPort = {
getProfileDashboard(userId: string): Promise<ProfileDashboardSummary>; getProfileDashboard(userId: string): Promise<ProfileDashboardSummary>;
listProfileWalletLedger(userId: string): Promise<ProfileWalletLedgerEntry[]>; listProfileWalletLedger(userId: string): Promise<ProfileWalletLedgerEntry[]>;
getProfilePlayStats(userId: string): Promise<ProfilePlayStatsResponse>; getProfilePlayStats(userId: string): Promise<ProfilePlayStatsResponse>;
listProfileSaveArchives(userId: string): Promise<ProfileSaveArchiveSummary[]>;
resumeProfileSaveArchive(
userId: string,
worldKey: string,
): Promise<{
entry: ProfileSaveArchiveSummary;
snapshot: SavedSnapshot;
} | null>;
deleteSnapshot(userId: string): Promise<void>; deleteSnapshot(userId: string): Promise<void>;
getSettings(userId: string): Promise<RuntimeSettings>; getSettings(userId: string): Promise<RuntimeSettings>;
putSettings( putSettings(
@@ -313,6 +341,10 @@ function normalizePlatformBrowseHistoryWriteEntry(
}; };
} }
function readSavedStoryText(value: unknown) {
return readString(asRecord(value)?.text);
}
function readFiniteNumber(value: unknown) { function readFiniteNumber(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
return value; return value;
@@ -600,6 +632,90 @@ function toProfilePlayedWorkSummary(
}; };
} }
function toProfileSaveArchiveSummary(
row: Pick<
ProfileSaveArchiveRow,
| 'worldKey'
| 'ownerUserId'
| 'profileId'
| 'worldType'
| 'worldName'
| 'subtitle'
| 'summaryText'
| 'coverImageSrc'
| 'savedAt'
>,
): ProfileSaveArchiveSummary {
const subtitle = row.subtitle || '';
return {
worldKey: row.worldKey,
ownerUserId: row.ownerUserId,
profileId: row.profileId,
worldType: row.worldType,
worldName: row.worldName || '未命名游戏',
subtitle,
summaryText: row.summaryText || subtitle || '继续推进上一次保存的故事。',
coverImageSrc: row.coverImageSrc || null,
lastPlayedAt: row.savedAt,
};
}
function resolveProfileSaveArchiveMeta(
snapshot: SavedSnapshot,
): ProfileSaveArchiveMeta | null {
const worldMeta = resolveProfileWorldSnapshotMeta(snapshot);
if (!worldMeta) {
return null;
}
const gameState = asRecord(snapshot.gameState);
const continueGameDigest = readString(
asRecord(gameState?.storyEngineMemory)?.continueGameDigest,
);
const currentStoryText = readSavedStoryText(snapshot.currentStory);
const customWorldProfile = asRecord(gameState?.customWorldProfile);
if (customWorldProfile) {
const profileId = readString(customWorldProfile.id) || 'custom-world';
const metadata = extractCustomWorldLibraryMetadata(
normalizeStoredProfile(profileId, customWorldProfile),
);
return {
worldKey: worldMeta.worldKey,
ownerUserId: worldMeta.ownerUserId,
profileId: worldMeta.profileId,
worldType: worldMeta.worldType,
worldName: worldMeta.worldTitle || metadata.worldName || '自定义世界',
subtitle: metadata.subtitle || worldMeta.worldSubtitle || '',
summaryText:
continueGameDigest ||
currentStoryText ||
metadata.summaryText ||
worldMeta.worldSubtitle ||
'继续推进上一次保存的故事。',
coverImageSrc: metadata.coverImageSrc,
};
}
const currentScenePreset = asRecord(gameState?.currentScenePreset);
return {
worldKey: worldMeta.worldKey,
ownerUserId: worldMeta.ownerUserId,
profileId: worldMeta.profileId,
worldType: worldMeta.worldType,
worldName: worldMeta.worldTitle || '未命名游戏',
subtitle: worldMeta.worldSubtitle || '',
summaryText:
continueGameDigest ||
currentStoryText ||
worldMeta.worldSubtitle ||
'继续推进上一次保存的故事。',
coverImageSrc: readString(currentScenePreset?.imageSrc) || null,
};
}
export class RuntimeRepository implements RuntimeRepositoryPort { export class RuntimeRepository implements RuntimeRepositoryPort {
constructor(private readonly db: AppDatabase) {} constructor(private readonly db: AppDatabase) {}
@@ -663,6 +779,29 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
return result.rows[0] ?? null; return result.rows[0] ?? null;
} }
private async findProfileSaveArchive(userId: string, worldKey: string) {
const result = await this.db.query<ProfileSaveArchiveRow>(
`SELECT world_key AS "worldKey",
owner_user_id AS "ownerUserId",
profile_id AS "profileId",
world_type AS "worldType",
world_name AS "worldName",
world_subtitle AS subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
saved_at AS "savedAt",
bottom_tab AS "bottomTab",
game_state_json AS "gameState",
current_story_json AS "currentStory"
FROM profile_save_archives
WHERE user_id = $1
AND world_key = $2`,
[userId, worldKey],
);
return result.rows[0] ?? null;
}
private async upsertProfileDashboardState( private async upsertProfileDashboardState(
userId: string, userId: string,
state: { state: {
@@ -686,6 +825,49 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
); );
} }
private async upsertCurrentSnapshot(
userId: string,
snapshot: SavedSnapshot,
) {
const now = new Date().toISOString();
const result = await this.db.query<SnapshotRow>(
`INSERT INTO save_snapshots (
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id) DO UPDATE SET
version = EXCLUDED.version,
saved_at = EXCLUDED.saved_at,
bottom_tab = EXCLUDED.bottom_tab,
game_state_json = EXCLUDED.game_state_json,
current_story_json = EXCLUDED.current_story_json,
updated_at = EXCLUDED.updated_at
RETURNING version,
saved_at AS "savedAt",
game_state_json AS "gameState",
bottom_tab AS "bottomTab",
current_story_json AS "currentStory"`,
[
userId,
snapshot.version,
snapshot.savedAt,
snapshot.bottomTab,
snapshot.gameState,
snapshot.currentStory,
now,
],
);
const row = result.rows[0];
return {
version: row.version,
savedAt: row.savedAt,
gameState: row.gameState,
bottomTab: row.bottomTab,
currentStory: row.currentStory,
} satisfies SavedSnapshot;
}
private async syncProfileDashboardFromSnapshot( private async syncProfileDashboardFromSnapshot(
userId: string, userId: string,
snapshot: SavedSnapshot, snapshot: SavedSnapshot,
@@ -788,6 +970,67 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
}); });
} }
private async syncProfileSaveArchiveFromSnapshot(
userId: string,
snapshot: SavedSnapshot,
) {
const archiveMeta = resolveProfileSaveArchiveMeta(snapshot);
if (!archiveMeta) {
return;
}
const syncedAt = snapshot.savedAt || new Date().toISOString();
await this.db.query(
`INSERT INTO profile_save_archives (
user_id,
world_key,
owner_user_id,
profile_id,
world_type,
world_name,
world_subtitle,
summary_text,
cover_image_src,
saved_at,
bottom_tab,
game_state_json,
current_story_json,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (user_id, world_key) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
profile_id = EXCLUDED.profile_id,
world_type = EXCLUDED.world_type,
world_name = EXCLUDED.world_name,
world_subtitle = EXCLUDED.world_subtitle,
summary_text = EXCLUDED.summary_text,
cover_image_src = EXCLUDED.cover_image_src,
saved_at = EXCLUDED.saved_at,
bottom_tab = EXCLUDED.bottom_tab,
game_state_json = EXCLUDED.game_state_json,
current_story_json = EXCLUDED.current_story_json,
updated_at = EXCLUDED.updated_at`,
[
userId,
archiveMeta.worldKey,
archiveMeta.ownerUserId,
archiveMeta.profileId,
archiveMeta.worldType,
archiveMeta.worldName,
archiveMeta.subtitle,
archiveMeta.summaryText,
archiveMeta.coverImageSrc,
syncedAt,
snapshot.bottomTab,
snapshot.gameState,
snapshot.currentStory,
syncedAt,
],
);
}
private async syncCustomWorldProfileFromSnapshot( private async syncCustomWorldProfileFromSnapshot(
userId: string, userId: string,
snapshot: SavedSnapshot, snapshot: SavedSnapshot,
@@ -883,45 +1126,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
bottomTab: payload.bottomTab, bottomTab: payload.bottomTab,
currentStory: payload.currentStory, currentStory: payload.currentStory,
} satisfies SavedSnapshot; } satisfies SavedSnapshot;
const now = new Date().toISOString(); const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot);
const result = await this.db.query<SnapshotRow>(
`INSERT INTO save_snapshots (
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id) DO UPDATE SET
version = EXCLUDED.version,
saved_at = EXCLUDED.saved_at,
bottom_tab = EXCLUDED.bottom_tab,
game_state_json = EXCLUDED.game_state_json,
current_story_json = EXCLUDED.current_story_json,
updated_at = EXCLUDED.updated_at
RETURNING version,
saved_at AS "savedAt",
game_state_json AS "gameState",
bottom_tab AS "bottomTab",
current_story_json AS "currentStory"`,
[
userId,
snapshot.version,
snapshot.savedAt,
snapshot.bottomTab,
snapshot.gameState,
snapshot.currentStory,
now,
],
);
const row = result.rows[0];
const persistedSnapshot = {
version: row.version,
savedAt: row.savedAt,
gameState: row.gameState,
bottomTab: row.bottomTab,
currentStory: row.currentStory,
} satisfies SavedSnapshot;
await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot); await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot);
await this.syncProfileSaveArchiveFromSnapshot(userId, persistedSnapshot);
await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot); await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot);
return persistedSnapshot; return persistedSnapshot;
@@ -993,6 +1201,50 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
} satisfies ProfilePlayStatsResponse; } satisfies ProfilePlayStatsResponse;
} }
async listProfileSaveArchives(userId: string) {
const result = await this.db.query<ProfileSaveArchiveRow>(
`SELECT world_key AS "worldKey",
owner_user_id AS "ownerUserId",
profile_id AS "profileId",
world_type AS "worldType",
world_name AS "worldName",
world_subtitle AS subtitle,
summary_text AS "summaryText",
cover_image_src AS "coverImageSrc",
saved_at AS "savedAt",
bottom_tab AS "bottomTab",
game_state_json AS "gameState",
current_story_json AS "currentStory"
FROM profile_save_archives
WHERE user_id = $1
ORDER BY saved_at DESC`,
[userId],
);
return result.rows.map((row) => toProfileSaveArchiveSummary(row));
}
async resumeProfileSaveArchive(userId: string, worldKey: string) {
const archive = await this.findProfileSaveArchive(userId, worldKey);
if (!archive) {
return null;
}
const snapshot = {
version: SAVE_SNAPSHOT_VERSION,
savedAt: archive.savedAt,
gameState: archive.gameState,
bottomTab: archive.bottomTab,
currentStory: archive.currentStory,
} satisfies SavedSnapshot;
const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot);
return {
entry: toProfileSaveArchiveSummary(archive),
snapshot: persistedSnapshot,
};
}
async deleteSnapshot(userId: string) { async deleteSnapshot(userId: string) {
await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [ await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [
userId, userId,
@@ -1001,9 +1253,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
async getSettings(userId: string) { async getSettings(userId: string) {
const result = await this.db.query<SettingsRow>( const result = await this.db.query<SettingsRow>(
`SELECT music_volume AS "musicVolume" `SELECT music_volume AS "musicVolume",
FROM runtime_settings platform_theme AS "platformTheme"
WHERE user_id = $1`, FROM runtime_settings
WHERE user_id = $1`,
[userId], [userId],
); );
const row = result.rows[0]; const row = result.rows[0];
@@ -1013,26 +1266,41 @@ export class RuntimeRepository implements RuntimeRepositoryPort {
typeof row?.musicVolume === 'number' typeof row?.musicVolume === 'number'
? row.musicVolume ? row.musicVolume
: DEFAULT_MUSIC_VOLUME, : DEFAULT_MUSIC_VOLUME,
platformTheme:
row?.platformTheme === 'dark'
? 'dark'
: DEFAULT_PLATFORM_THEME,
} satisfies RuntimeSettings; } satisfies RuntimeSettings;
} }
async putSettings(userId: string, settings: RuntimeSettings) { async putSettings(userId: string, settings: RuntimeSettings) {
const nextSettings = { const nextSettings = {
musicVolume: Math.max(0, Math.min(1, settings.musicVolume)), musicVolume: Math.max(0, Math.min(1, settings.musicVolume)),
platformTheme:
settings.platformTheme === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME,
} satisfies RuntimeSettings; } satisfies RuntimeSettings;
const result = await this.db.query<SettingsRow>( const result = await this.db.query<SettingsRow>(
`INSERT INTO runtime_settings (user_id, music_volume, updated_at) `INSERT INTO runtime_settings (user_id, music_volume, platform_theme, updated_at)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET ON CONFLICT (user_id) DO UPDATE SET
music_volume = EXCLUDED.music_volume, music_volume = EXCLUDED.music_volume,
platform_theme = EXCLUDED.platform_theme,
updated_at = EXCLUDED.updated_at updated_at = EXCLUDED.updated_at
RETURNING music_volume AS "musicVolume"`, RETURNING music_volume AS "musicVolume",
[userId, nextSettings.musicVolume, new Date().toISOString()], platform_theme AS "platformTheme"`,
[
userId,
nextSettings.musicVolume,
nextSettings.platformTheme,
new Date().toISOString(),
],
); );
return { return {
musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume, musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume,
platformTheme:
result.rows[0]?.platformTheme ?? nextSettings.platformTheme,
} satisfies RuntimeSettings; } satisfies RuntimeSettings;
} }

View File

@@ -9,11 +9,14 @@ import type {
CustomWorldGalleryResponse, CustomWorldGalleryResponse,
CustomWorldLibraryMutationResponse, CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse, CustomWorldLibraryResponse,
PLATFORM_THEMES,
PlatformBrowseHistoryBatchSyncRequest, PlatformBrowseHistoryBatchSyncRequest,
PlatformBrowseHistoryResponse, PlatformBrowseHistoryResponse,
PlatformBrowseHistoryWriteEntry, PlatformBrowseHistoryWriteEntry,
ProfileDashboardSummary, ProfileDashboardSummary,
ProfilePlayStatsResponse, ProfilePlayStatsResponse,
ProfileSaveArchiveListResponse,
ProfileSaveArchiveResumeResponse,
ProfileWalletLedgerResponse, ProfileWalletLedgerResponse,
RuntimeSettings, RuntimeSettings,
SavedGameSnapshotInput, SavedGameSnapshotInput,
@@ -89,6 +92,7 @@ const saveSnapshotSchema = z.object({
const settingsSchema = z.object({ const settingsSchema = z.object({
musicVolume: z.number().min(0).max(1), musicVolume: z.number().min(0).max(1),
platformTheme: z.enum(PLATFORM_THEMES),
}); });
const platformBrowseHistoryEntrySchema = z.object({ const platformBrowseHistoryEntrySchema = z.object({
@@ -184,6 +188,41 @@ export function createRuntimeRoutes(context: AppContext) {
}); });
}); });
router.get(
'/runtime/custom-world-gallery',
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
asyncHandler(async (_request, response) => {
sendApiResponse(response, {
entries: await context.runtimeRepository.listPublishedCustomWorldGallery(),
} satisfies CustomWorldGalleryResponse);
}),
);
router.get(
'/runtime/custom-world-gallery/:ownerUserId/:profileId',
routeMeta({ operation: 'runtime.customWorldGallery.detail' }),
asyncHandler(async (request, response) => {
const ownerUserId = readParam(request.params.ownerUserId);
const profileId = readParam(request.params.profileId);
if (!ownerUserId || !profileId) {
throw badRequest('ownerUserId and profileId are required');
}
const entry =
await context.runtimeRepository.getPublishedCustomWorldGalleryDetail(
ownerUserId,
profileId,
);
if (!entry) {
throw notFound('public custom world not found');
}
sendApiResponse(response, {
entry,
} satisfies CustomWorldGalleryDetailResponse);
}),
);
router.use(requireAuth); router.use(requireAuth);
router.use( router.use(
'/runtime/custom-world/agent', '/runtime/custom-world/agent',
@@ -313,6 +352,65 @@ export function createRuntimeRoutes(context: AppContext) {
); );
}); });
routeCompatPaths('/profile/save-archives').forEach((path, index) => {
router.get(
path,
routeMeta({
operation:
index === 0
? 'profile.saveArchives.list'
: 'profile.saveArchives.list.compat',
}),
asyncHandler(async (request, response) => {
sendApiResponse<ProfileSaveArchiveListResponse>(response, {
entries: await context.runtimeRepository.listProfileSaveArchives(
request.userId!,
),
});
}),
);
});
[
'/profile/save-archives/:worldKey',
'/runtime/profile/save-archives/:worldKey',
].forEach((path, index) => {
router.post(
path,
routeMeta({
operation:
index === 0
? 'profile.saveArchives.resume'
: 'profile.saveArchives.resume.compat',
}),
asyncHandler(async (request, response) => {
const worldKey =
typeof request.params.worldKey === 'string'
? request.params.worldKey.trim()
: '';
if (!worldKey) {
throw badRequest('worldKey 不能为空');
}
const resumedArchive =
await context.runtimeRepository.resumeProfileSaveArchive(
request.userId!,
worldKey,
);
if (!resumedArchive) {
throw notFound('指定存档不存在');
}
sendApiResponse<ProfileSaveArchiveResumeResponse>(response, {
entry: resumedArchive.entry,
snapshot: hydrateSavedSnapshot(resumedArchive.snapshot)!,
});
}),
);
});
router.post( router.post(
'/llm/chat/completions', '/llm/chat/completions',
routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }), routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }),
@@ -450,42 +548,6 @@ export function createRuntimeRoutes(context: AppContext) {
}), }),
); );
router.get(
'/runtime/custom-world-gallery',
routeMeta({ operation: 'runtime.customWorldGallery.list' }),
asyncHandler(async (_request, response) => {
sendApiResponse(response, {
entries:
await context.runtimeRepository.listPublishedCustomWorldGallery(),
} satisfies CustomWorldGalleryResponse);
}),
);
router.get(
'/runtime/custom-world-gallery/:ownerUserId/:profileId',
routeMeta({ operation: 'runtime.customWorldGallery.detail' }),
asyncHandler(async (request, response) => {
const ownerUserId = readParam(request.params.ownerUserId);
const profileId = readParam(request.params.profileId);
if (!ownerUserId || !profileId) {
throw badRequest('ownerUserId and profileId are required');
}
const entry =
await context.runtimeRepository.getPublishedCustomWorldGalleryDetail(
ownerUserId,
profileId,
);
if (!entry) {
throw notFound('public custom world not found');
}
sendApiResponse(response, {
entry,
} satisfies CustomWorldGalleryDetailResponse);
}),
);
router.put( router.put(
'/runtime/custom-world-library/:profileId', '/runtime/custom-world-library/:profileId',
routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }), routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }),

View File

@@ -3,6 +3,12 @@ import type {
CustomWorldFoundationDraftLandmark, CustomWorldFoundationDraftLandmark,
} from '../../../packages/shared/src/contracts/customWorldAgent.js'; } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { badRequest } from '../errors.js'; import { badRequest } from '../errors.js';
import {
buildCustomWorldAgentCharacterExpansionPrompt,
buildCustomWorldAgentLandmarkExpansionPrompt,
CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT,
CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT,
} from '../prompts/customWorldAgentPrompts.js';
import { import {
getWorldFoundationCardId, getWorldFoundationCardId,
normalizeFoundationDraftProfile, normalizeFoundationDraftProfile,
@@ -438,22 +444,18 @@ async function requestCharacterSuggestionsFromLlm(params: {
params.profile.summary; params.profile.summary;
const content = await params.llmClient.requestMessageContent({ const content = await params.llmClient.requestMessageContent({
systemPrompt: systemPrompt: CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT,
'你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。', userPrompt: buildCustomWorldAgentCharacterExpansionPrompt({
userPrompt: [ worldName: params.profile.name,
`当前世界:${params.profile.name}`, worldSummary: params.profile.summary,
`世界摘要:${params.profile.summary}`, creatorIntentSummary,
`创作意图摘要:${creatorIntentSummary}`, anchorSummary,
`参考锚点:${anchorSummary}`, existingNames: getAllCharacters(params.profile)
`已有角色:${getAllCharacters(params.profile)
.slice(0, 10) .slice(0, 10)
.map((entry) => entry.name) .map((entry) => entry.name),
.join('、') || '暂无'}`, count: params.count,
`数量:${params.count}`, promptSeed: params.promptSeed,
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`, }),
'返回 JSON 数组。每个对象字段只允许包含name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。',
'threadIds 必须优先引用现有线程 id。',
].join('\n'),
timeoutMs: 45000, timeoutMs: 45000,
debugLabel: 'custom-world-agent-generate-characters', debugLabel: 'custom-world-agent-generate-characters',
}); });
@@ -478,22 +480,18 @@ async function requestLandmarkSuggestionsFromLlm(params: {
params.profile.summary; params.profile.summary;
const content = await params.llmClient.requestMessageContent({ const content = await params.llmClient.requestMessageContent({
systemPrompt: systemPrompt: CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT,
'你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。', userPrompt: buildCustomWorldAgentLandmarkExpansionPrompt({
userPrompt: [ worldName: params.profile.name,
`当前世界:${params.profile.name}`, worldSummary: params.profile.summary,
`世界摘要:${params.profile.summary}`, creatorIntentSummary,
`创作意图摘要:${creatorIntentSummary}`, anchorSummary,
`参考锚点:${anchorSummary}`, existingNames: params.profile.landmarks
`已有地点:${params.profile.landmarks
.slice(0, 10) .slice(0, 10)
.map((entry) => entry.name) .map((entry) => entry.name),
.join('、') || '暂无'}`, count: params.count,
`数量:${params.count}`, promptSeed: params.promptSeed,
`补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`, }),
'返回 JSON 数组。每个对象字段只允许包含name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。',
'threadIds / characterIds 必须优先引用现有对象 id。',
].join('\n'),
timeoutMs: 45000, timeoutMs: 45000,
debugLabel: 'custom-world-agent-generate-landmarks', debugLabel: 'custom-world-agent-generate-landmarks',
}); });

View File

@@ -8,6 +8,10 @@ import type {
EightAnchorContent, EightAnchorContent,
} from '../../../packages/shared/src/contracts/customWorldAgent.js'; } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import {
FOUNDATION_JSON_ONLY_SYSTEM_PROMPT,
FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT,
} from '../prompts/customWorldAgentPrompts.js';
import { import {
buildCustomWorldFrameworkJsonRepairPrompt, buildCustomWorldFrameworkJsonRepairPrompt,
buildCustomWorldFrameworkPrompt, buildCustomWorldFrameworkPrompt,
@@ -770,13 +774,6 @@ function buildChapter(params: {
}; };
} }
const FOUNDATION_JSON_ONLY_SYSTEM_PROMPT = `你是严格的世界草稿 JSON 生成器。
只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。`;
const FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你会收到一段本应为单个 JSON 对象的文本。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
不要输出 Markdown、代码块、解释、注释或额外文字。`;
const FOUNDATION_DRAFT_PLAYABLE_COUNT = 3; const FOUNDATION_DRAFT_PLAYABLE_COUNT = 3;
const FOUNDATION_DRAFT_STORY_COUNT = 6; const FOUNDATION_DRAFT_STORY_COUNT = 6;
const FOUNDATION_DRAFT_LANDMARK_COUNT = 4; const FOUNDATION_DRAFT_LANDMARK_COUNT = 4;

View File

@@ -47,6 +47,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
async getSettings() { async getSettings() {
return { return {
musicVolume: 0.42, musicVolume: 0.42,
platformTheme: 'light',
}; };
}, },
async putSettings(_userId, settings) { async putSettings(_userId, settings) {

View File

@@ -39,6 +39,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
async getSettings() { async getSettings() {
return { return {
musicVolume: 0.42, musicVolume: 0.42,
platformTheme: 'light',
}; };
}, },
async putSettings(_userId, settings) { async putSettings(_userId, settings) {

View File

@@ -40,6 +40,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
async getSettings() { async getSettings() {
return { return {
musicVolume: 0.42, musicVolume: 0.42,
platformTheme: 'light',
}; };
}, },
async putSettings(_userId, settings) { async putSettings(_userId, settings) {

View File

@@ -39,6 +39,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
async getSettings() { async getSettings() {
return { return {
musicVolume: 0.42, musicVolume: 0.42,
platformTheme: 'light',
}; };
}, },
async putSettings(_userId, settings) { async putSettings(_userId, settings) {

View File

@@ -1,5 +1,11 @@
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import { badRequest } from '../errors.js'; import { badRequest } from '../errors.js';
import {
buildLandmarkPrompt,
buildPlayablePrompt,
buildStoryPrompt,
CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT,
} from '../prompts/customWorldEntityPrompts.js';
import type { UpstreamLlmClient } from './llmClient.js'; import type { UpstreamLlmClient } from './llmClient.js';
type CustomWorldEntityKind = 'playable' | 'story' | 'landmark'; type CustomWorldEntityKind = 'playable' | 'story' | 'landmark';
@@ -319,69 +325,6 @@ function normalizeProfile(value: unknown): ParsedProfile {
}; };
} }
function buildRoleReferenceText(roles: ParsedRole[], emptyText: string) {
if (roles.length === 0) {
return emptyText;
}
return roles
.slice(0, 12)
.map(
(role, index) =>
`${index + 1}. ${role.name} / ${role.title || role.role} / 身份:${
role.role || '未写'
} / 描述:${role.description || '未写'} / 背景:${
role.backstory || '未写'
} / 性格:${role.personality || '未写'} / 动机:${
role.motivation || '未写'
} / 形象:${role.visualDescription || '未写'} / 动作表现:${
role.actionDescription || '未写'
} / 场景画面:${role.sceneVisualDescription || '未写'} / 标签:${
role.tags.join('、') || '暂无'
}`,
)
.join('\n');
}
function buildLandmarkReferenceText(profile: ParsedProfile) {
if (profile.landmarks.length === 0) {
return '当前还没有场景设定。';
}
const storyNpcById = new Map(profile.storyNpcs.map((npc) => [npc.id, npc]));
const landmarkById = new Map(
profile.landmarks.map((landmark) => [landmark.id, landmark]),
);
return profile.landmarks
.slice(0, 12)
.map((landmark, index) => {
const sceneNpcNames = landmark.sceneNpcIds
.map((npcId) => storyNpcById.get(npcId)?.name ?? '')
.filter(Boolean)
.join('、');
const connectionNames = landmark.connections
.map((connection) => {
const targetName =
landmarkById.get(connection.targetLandmarkId)?.name ||
connection.targetLandmarkId;
return `${targetName}${connection.relativePosition} / ${
connection.summary || '无说明'
}`;
})
.join('、');
return `${index + 1}. ${landmark.name} / 危险度:${
landmark.dangerLevel || 'medium'
} / 描述:${landmark.description || '未写'} / 画面:${
landmark.visualDescription || '未写'
} / 场景角色:${
sceneNpcNames || '暂无'
} / 连接:${connectionNames || '暂无'}`;
})
.join('\n');
}
function buildUniqueRoleName(existingNames: Set<string>, startIndex: number) { function buildUniqueRoleName(existingNames: Set<string>, startIndex: number) {
for (let attempt = 0; attempt < 120; attempt += 1) { for (let attempt = 0; attempt < 120; attempt += 1) {
const index = startIndex + attempt; const index = startIndex + attempt;
@@ -563,148 +506,6 @@ function buildFallbackLandmarkDraft(profile: ParsedProfile) {
}; };
} }
function buildPlayablePrompt(profile: ParsedProfile) {
return [
`世界名:${profile.name}`,
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
`世界摘要:${profile.summary || '未填写'}`,
`世界基调:${profile.tone || '未填写'}`,
`玩家主线目标:${profile.playerGoal || '未填写'}`,
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
'请基于上面全部上下文,生成 1 名新的“可扮演角色”。',
'要求:',
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
'- 必须保留明确的协作价值、成长空间和入队理由。',
'- 不要生成泛用模板角色,必须让角色与当前世界的具体势力、地点、冲突或禁忌发生绑定。',
'- visualDescription 只写与角色设定相关的外形、服装、材质、武器、体态、色彩和识别特征,禁止写角色以外的周边环境等与角色不想管的设定。',
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
'- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:',
'{',
' "playableNpc": {',
' "name": "角色名",',
' "title": "称号",',
' "role": "身份",',
' "description": "一句到两句定位描述",',
' "visualDescription": "角色形象描述",',
' "actionDescription": "动作表现描述",',
' "sceneVisualDescription": "角色关联场景画面描述",',
' "backstory": "背景经历",',
' "personality": "性格特点",',
' "motivation": "当前动机",',
' "combatStyle": "战斗风格",',
' "initialAffinity": 22,',
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
' "tags": ["标签1", "标签2", "标签3"],',
' "publicSummary": "公开背景摘要",',
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
' "skills": [',
' { "name": "技能1", "summary": "说明", "style": "风格" },',
' { "name": "技能2", "summary": "说明", "style": "风格" },',
' { "name": "技能3", "summary": "说明", "style": "风格" }',
' ],',
' "initialItems": [',
' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }',
' ]',
' }',
'}',
].join('\n');
}
function buildStoryPrompt(profile: ParsedProfile) {
return [
`世界名:${profile.name}`,
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
`世界摘要:${profile.summary || '未填写'}`,
`世界基调:${profile.tone || '未填写'}`,
`玩家主线目标:${profile.playerGoal || '未填写'}`,
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
'请基于上面全部上下文,生成 1 名新的“场景角色”。',
'要求:',
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复定位。',
'- 必须像能直接进入游戏的场景角色,而不是抽象设定条目。',
'- 角色应与具体场景、关系链或局势变化发生绑定。',
'- visualDescription 只写与角色设定匹配的外形、服装、材质、武器、体态、色彩和识别特征,不要写“提示词”、镜头参数或构图规则。',
'- actionDescription 只写这个角色的动作表现与战斗气质,不要写镜头切换或参数。',
'- sceneVisualDescription 只写该角色首次登场或主要活动区域的场景画面感,不要写“提示词”字样。',
'- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:',
'{',
' "storyNpc": {',
' "name": "角色名",',
' "title": "称号",',
' "role": "身份",',
' "description": "一句到两句定位描述",',
' "visualDescription": "角色形象描述",',
' "actionDescription": "动作表现描述",',
' "sceneVisualDescription": "角色关联场景画面描述",',
' "backstory": "背景经历",',
' "personality": "性格特点",',
' "motivation": "当前动机",',
' "combatStyle": "战斗风格",',
' "initialAffinity": 6,',
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
' "tags": ["标签1", "标签2", "标签3"],',
' "publicSummary": "公开背景摘要",',
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
' "skills": [',
' { "name": "技能1", "summary": "说明", "style": "风格" },',
' { "name": "技能2", "summary": "说明", "style": "风格" },',
' { "name": "技能3", "summary": "说明", "style": "风格" }',
' ],',
' "initialItems": [',
' { "name": "物品1", "category": "武器", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
' { "name": "物品2", "category": "专属物品", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
' { "name": "物品3", "category": "消耗品", "quantity": 2, "rarity": "uncommon", "description": "说明", "tags": ["标签"] }',
' ]',
' }',
'}',
].join('\n');
}
function buildLandmarkPrompt(profile: ParsedProfile) {
return [
`世界名:${profile.name}`,
`当前世界设定:${profile.settingText || profile.summary || '未提供额外设定。'}`,
`世界摘要:${profile.summary || '未填写'}`,
`世界基调:${profile.tone || '未填写'}`,
`玩家主线目标:${profile.playerGoal || '未填写'}`,
`当前可扮演角色设定:\n${buildRoleReferenceText(profile.playableNpcs, '当前还没有可扮演角色。')}`,
`当前场景角色设定:\n${buildRoleReferenceText(profile.storyNpcs, '当前还没有场景角色。')}`,
`当前场景设定:\n${buildLandmarkReferenceText(profile)}`,
'请基于上面全部上下文,生成 1 个新的“场景”。',
'要求:',
'- 必须与当前世界设定、已有可扮演角色、已有场景角色、已有场景形成互补,不要重复。',
'- 必须给出适合出现在这个新场景里的 sceneNpcNames且只能从已有场景角色里选择至少 3 个名字。',
'- 必须给出 connections且 targetLandmarkName 只能引用已有场景名,不要连向自己。',
'- visualDescription 只写这个场景的空间层次、地面、主体建筑或自然景观、氛围、色彩和可见装置,不要写“提示词”、镜头参数或构图规则。',
'- 只返回 JSON不要输出解释或 Markdown。',
'JSON 结构:',
'{',
' "landmark": {',
' "name": "场景名",',
' "description": "场景描述",',
' "visualDescription": "场景画面描述",',
' "dangerLevel": "low|medium|high|extreme",',
' "sceneNpcNames": ["场景角色1", "场景角色2", "场景角色3"],',
' "connections": [',
' { "targetLandmarkName": "已有场景名", "relativePosition": "forward", "summary": "通路说明" },',
' { "targetLandmarkName": "已有场景名", "relativePosition": "inside", "summary": "通路说明" }',
' ]',
' }',
'}',
].join('\n');
}
function ensureUniqueName(name: string, existingNames: string[], fallbackName: string) { function ensureUniqueName(name: string, existingNames: string[], fallbackName: string) {
const normalized = name.trim() || fallbackName; const normalized = name.trim() || fallbackName;
if (!existingNames.includes(normalized)) { if (!existingNames.includes(normalized)) {
@@ -1040,8 +841,7 @@ async function requestGeneratedEntity(
: buildLandmarkPrompt(profile); : buildLandmarkPrompt(profile);
const content = await llmClient.requestMessageContent({ const content = await llmClient.requestMessageContent({
systemPrompt: systemPrompt: CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT,
'你是游戏世界编辑器的实体生成器。你必须只返回可解析 JSON不要输出解释、前言或 Markdown。',
userPrompt, userPrompt,
timeoutMs: 45000, timeoutMs: 45000,
debugLabel: `custom-world-generate-${kind}`, debugLabel: `custom-world-generate-${kind}`,

View File

@@ -1,5 +1,9 @@
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import { badRequest } from '../errors.js'; import { badRequest } from '../errors.js';
import {
buildCustomWorldSceneNpcPrompt,
CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT,
} from '../prompts/customWorldSceneNpcPrompts.js';
import type { UpstreamLlmClient } from './llmClient.js'; import type { UpstreamLlmClient } from './llmClient.js';
type SceneNpcGenerationInput = { type SceneNpcGenerationInput = {
@@ -288,86 +292,6 @@ function buildFallbackDraft(
}; };
} }
function buildPrompt(
profile: ParsedProfile,
landmark: ParsedLandmark,
sceneNpcs: ParsedStoryNpc[],
otherNpcs: ParsedStoryNpc[],
) {
const sceneNpcSummary = sceneNpcs.length
? sceneNpcs
.map(
(npc, index) =>
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'} / 性格:${npc.personality || '未写'} / 动机:${npc.motivation || '未写'}`,
)
.join('\n')
: '当前场景还没有已加入 NPC。';
const reserveNpcSummary = otherNpcs.length
? otherNpcs
.slice(0, 8)
.map(
(npc, index) =>
`${index + 1}. ${npc.name} / ${npc.title || npc.role} / ${npc.description || '无描述'}`,
)
.join('\n')
: '暂无其他场景角色参考。';
const landmarkSummary = profile.landmarks
.slice(0, 10)
.map(
(entry, index) =>
`${index + 1}. ${entry.name} / 危险度:${entry.dangerLevel || '中'} / ${entry.description || '无描述'}`,
)
.join('\n');
return [
`世界名:${profile.name}`,
`世界设定:${profile.settingText || '未提供额外设定文本。'}`,
`当前目标场景:${landmark.name}`,
`场景描述:${landmark.description || '未填写'}`,
`危险度:${landmark.dangerLevel || '中'}`,
`当前场景已加入 NPC\n${sceneNpcSummary}`,
`其他可参考 NPC\n${reserveNpcSummary}`,
`世界内其他场景概览:\n${landmarkSummary}`,
'请生成 1 名适合加入当前场景的新 NPC。',
'要求:',
'- 必须与当前场景气质、危险度、已有 NPC 分工互补,不要和已有 NPC 重复。',
'- 角色要像真正可落地到游戏里的场景角色,不要写成抽象设定。',
'- 关系钩子、技能、初始物品都要可直接进入编辑器。',
'- 返回 JSON不要额外解释。',
'JSON 结构:',
'{',
' "npc": {',
' "name": "角色名",',
' "title": "头衔",',
' "role": "身份",',
' "description": "一句到两句角色描述",',
' "backstory": "背景",',
' "personality": "性格",',
' "motivation": "动机",',
' "combatStyle": "战斗风格",',
' "initialAffinity": 6,',
' "relationshipHooks": ["关系钩子1", "关系钩子2", "关系钩子3"],',
' "tags": ["标签1", "标签2", "标签3"],',
' "publicSummary": "公开背景摘要",',
' "chapterTeasers": ["表层来意", "旧事裂痕", "隐藏执念", "最终底牌"],',
' "chapterContents": ["对应正文1", "对应正文2", "对应正文3", "对应正文4"],',
' "skills": [',
' { "name": "技能1", "summary": "说明", "style": "风格" },',
' { "name": "技能2", "summary": "说明", "style": "风格" },',
' { "name": "技能3", "summary": "说明", "style": "风格" }',
' ],',
' "initialItems": [',
' { "name": "物品1", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] },',
' { "name": "物品2", "category": "分类", "quantity": 1, "rarity": "uncommon", "description": "说明", "tags": ["标签"] },',
' { "name": "物品3", "category": "分类", "quantity": 1, "rarity": "rare", "description": "说明", "tags": ["标签"] }',
' ]',
' }',
'}',
].join('\n');
}
function sanitizeGeneratedNpc( function sanitizeGeneratedNpc(
rawValue: unknown, rawValue: unknown,
profile: ParsedProfile, profile: ParsedProfile,
@@ -571,9 +495,13 @@ export async function generateSceneNpcForLandmark(
try { try {
const content = await llmClient.requestMessageContent({ const content = await llmClient.requestMessageContent({
systemPrompt: systemPrompt: CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT,
'你是游戏世界编辑器的角色生成器。你必须只返回可解析 JSON不要输出解释、前言或 markdown 代码块之外的额外内容。', userPrompt: buildCustomWorldSceneNpcPrompt(
userPrompt: buildPrompt(profile, landmark, sceneNpcs, otherNpcs), profile,
landmark,
sceneNpcs,
otherNpcs,
),
debugLabel: 'custom-world-scene-npc', debugLabel: 'custom-world-scene-npc',
}); });
const parsed = parseJsonResponseText(content); const parsed = parseJsonResponseText(content);

View File

@@ -1106,6 +1106,10 @@ export function AdventurePanel({
const isDeferredContinueOption = const isDeferredContinueOption =
hasDeferredAdventureOptions && hasDeferredAdventureOptions &&
isContinueAdventureOption(option); isContinueAdventureOption(option);
const optionDisabled = option.disabled === true;
const compactOptionDetailText = option.disabledReason
? option.disabledReason
: getCompactOptionDetailText(option);
if (isDeferredContinueOption) { if (isDeferredContinueOption) {
return ( return (
@@ -1142,12 +1146,13 @@ export function AdventurePanel({
key={`${option.functionId}-${option.actionText}-${index}`} key={`${option.functionId}-${option.actionText}-${index}`}
type="button" type="button"
onClick={() => handleOptionChoice(option)} onClick={() => handleOptionChoice(option)}
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left" disabled={optionDisabled}
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton)} style={getNineSliceStyle(UI_CHROME.choiceButton)}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span <span
className={`text-xs ${getOptionActionTextClass(option)}`} className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`}
> >
{option.actionText} {option.actionText}
</span> </span>
@@ -1156,9 +1161,9 @@ export function AdventurePanel({
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100" className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/> />
</div> </div>
{!isNpcChatMode && getCompactOptionDetailText(option) && ( {!isNpcChatMode && compactOptionDetailText && (
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500"> <div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{getCompactOptionDetailText(option)} {compactOptionDetailText}
</div> </div>
)} )}
{!isNpcChatMode && option.goalAffordance?.label && ( {!isNpcChatMode && option.goalAffordance?.label && (
@@ -1166,7 +1171,7 @@ export function AdventurePanel({
{option.goalAffordance.label} {option.goalAffordance.label}
</div> </div>
)} )}
{!isNpcChatMode && optionImpactSummary && ( {!isNpcChatMode && optionImpactSummary && !optionDisabled && (
<div className="mt-1 text-[10px] text-zinc-500"> <div className="mt-1 text-[10px] text-zinc-500">
{optionImpactSummary} {optionImpactSummary}
</div> </div>
@@ -1175,7 +1180,7 @@ export function AdventurePanel({
); );
})} })}
{isNpcChatMode ? ( {isNpcChatMode ? (
<div className="pixel-nine-slice pixel-panel mt-1 border border-white/10 bg-black/25 p-2"> <div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<input <input
value={npcChatDraft} value={npcChatDraft}

View File

@@ -1,7 +1,3 @@
import type {
EightAnchorContent,
KeyRelationshipValue,
} from '../../packages/shared/src/contracts/customWorldAgent';
import { import {
type ReactNode, type ReactNode,
useDeferredValue, useDeferredValue,
@@ -11,6 +7,10 @@ import {
useState, useState,
} from 'react'; } from 'react';
import type {
EightAnchorContent,
KeyRelationshipValue,
} from '../../packages/shared/src/contracts/customWorldAgent';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets'; import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph'; import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import { import {
@@ -20,7 +20,6 @@ import {
import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent'; import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types'; import { AnimationState, Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator'; import { CharacterAnimator } from './CharacterAnimator';
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal'; import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor'; import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
@@ -75,10 +74,7 @@ function Section({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<div <div className="platform-surface platform-surface--soft px-3.5 py-3">
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}
>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs font-bold tracking-[0.16em] text-white"> <div className="text-xs font-bold tracking-[0.16em] text-white">
@@ -113,17 +109,17 @@ function SmallButton({
}) { }) {
const toneClassName = const toneClassName =
tone === 'sky' tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white' ? 'platform-button platform-button--primary'
: tone === 'rose' : tone === 'rose'
? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white' ? 'platform-button platform-button--danger'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'; : 'platform-button platform-button--ghost';
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`} className={`${toneClassName} min-h-0 rounded-full px-3 py-1 text-[11px] ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
> >
{children} {children}
</button> </button>
@@ -140,12 +136,12 @@ function SearchBox({
placeholder: string; placeholder: string;
}) { }) {
return ( return (
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2"> <div className="platform-subpanel rounded-2xl px-3 py-2">
<input <input
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
placeholder={placeholder} placeholder={placeholder}
className="w-full bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-500" className="w-full bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/> />
</div> </div>
); );
@@ -164,7 +160,7 @@ function ImageFrame({
}) { }) {
return ( return (
<div <div
className={`overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`} className={`overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%),linear-gradient(180deg,rgba(255,96,147,0.92),rgba(255,146,109,0.84))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
> >
{src ? ( {src ? (
<img src={src} alt={alt} className="h-full w-full object-cover" /> <img src={src} alt={alt} className="h-full w-full object-cover" />
@@ -179,8 +175,8 @@ function ImageFrame({
function EmptyState({ title }: { title: string }) { function EmptyState({ title }: { title: string }) {
return ( return (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-5 py-6 text-center"> <div className="platform-subpanel rounded-2xl border-dashed px-5 py-6 text-center">
<div className="text-sm text-zinc-300">{title}</div> <div className="text-sm text-[var(--platform-text-base)]">{title}</div>
</div> </div>
); );
} }
@@ -195,7 +191,7 @@ function buildFallbackRenderKey(
function NewBadge() { function NewBadge() {
return ( return (
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100"> <span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px] font-semibold">
</span> </span>
); );
@@ -211,21 +207,23 @@ function PendingEntityCard({
progress: number; progress: number;
}) { }) {
return ( return (
<div className="rounded-[1.35rem] border border-sky-300/18 bg-sky-500/10 px-4 py-4"> <div className="platform-banner platform-banner--info rounded-[1.35rem] px-4 py-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<div className="text-sm font-semibold text-white">{title}</div> <div className="text-sm font-semibold text-[var(--platform-text-strong)]">
<div className="mt-1 text-xs leading-6 text-sky-50/90"> {title}
</div>
<div className="mt-1 text-xs leading-6">
{phaseLabel} {phaseLabel}
</div> </div>
</div> </div>
<div className="rounded-full border border-sky-300/20 bg-black/20 px-2.5 py-1 text-[10px] text-sky-100"> <div className="platform-pill platform-pill--cool px-2.5 py-1 text-[10px]">
{Math.round(progress)}% {Math.round(progress)}%
</div> </div>
</div> </div>
<div className="mt-3 h-2.5 overflow-hidden rounded-full border border-white/10 bg-black/30"> <div className="platform-progress-track mt-3 h-2.5 overflow-hidden rounded-full">
<div <div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_100%)] transition-[width] duration-300" className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_100%)] transition-[width] duration-300"
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }} style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
/> />
</div> </div>
@@ -261,7 +259,7 @@ function CatalogCard({
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${ className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
isSelected isSelected
? 'border-rose-300/25 bg-rose-500/14 text-rose-50' ? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
: 'border-white/10 bg-black/20 text-zinc-400' : 'platform-subpanel text-[var(--platform-text-soft)]'
}`} }`}
> >
{isSelected ? '已选' : '选择'} {isSelected ? '已选' : '选择'}
@@ -277,14 +275,12 @@ function CatalogCard({
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${ className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
isSelected isSelected
? 'border-rose-300/35 bg-rose-500/10' ? 'border-rose-300/35 bg-rose-500/10'
: disabled : 'platform-subpanel'
? 'border-white/10 bg-black/20'
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div
className={`shrink-0 overflow-hidden rounded-[1rem] border border-white/8 bg-black/25 ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`} className={`platform-subpanel shrink-0 overflow-hidden rounded-[1rem] ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
> >
{media} {media}
</div> </div>
@@ -315,14 +311,12 @@ function CatalogCard({
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${ className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
isSelected isSelected
? 'border-rose-300/35 bg-rose-500/10' ? 'border-rose-300/35 bg-rose-500/10'
: disabled : 'platform-subpanel'
? 'border-white/10 bg-black/20'
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
}`} }`}
> >
<div className="space-y-3"> <div className="space-y-3">
<div <div
className={`overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25 ${mediaClassName ?? ''}`} className={`platform-subpanel overflow-hidden rounded-[1.1rem] ${mediaClassName ?? ''}`}
> >
{media} {media}
</div> </div>
@@ -1030,7 +1024,7 @@ export function CustomWorldEntityCatalog({
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500"> <div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
</div> </div>
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]"> <div className="mt-2 text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem]">
{profile.name} {profile.name}
</div> </div>
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400"> <div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">
@@ -1038,17 +1032,17 @@ export function CustomWorldEntityCatalog({
</div> </div>
</div> </div>
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(8,10,17,0.98)_0%,rgba(8,10,17,0.95)_75%,rgba(8,10,17,0)_100%)] px-1 pb-3 pt-1"> <div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(255,252,253,0.98)_0%,rgba(255,244,248,0.94)_76%,rgba(255,244,248,0)_100%)] px-1 pb-3 pt-1 backdrop-blur-sm">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide"> <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
{RESULT_TABS.map((tab) => ( {RESULT_TABS.map((tab) => (
<div key={tab.id}> <div key={tab.id}>
<button <button
type="button" type="button"
onClick={() => onActiveTabChange(tab.id)} onClick={() => onActiveTabChange(tab.id)}
className={`rounded-full border px-3 py-2 text-left text-sm transition-colors ${activeTab === tab.id ? 'border-sky-300/25 bg-sky-500/12 text-white' : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'}`} className={`platform-tab px-3 py-2 text-left text-sm ${activeTab === tab.id ? 'platform-tab--active' : ''}`}
> >
<div className="font-semibold">{tab.label}</div> <div className="font-semibold">{tab.label}</div>
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55"> <div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]">
{counts[tab.id]} {counts[tab.id]}
</div> </div>
</button> </button>
@@ -1068,7 +1062,7 @@ export function CustomWorldEntityCatalog({
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
{isBulkDeleteMode ? ( {isBulkDeleteMode ? (
<> <>
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300"> <div className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]">
{selectedBulkIds.length} {selectedBulkIds.length}
</div> </div>
<SmallButton onClick={cancelBulkDelete}></SmallButton> <SmallButton onClick={cancelBulkDelete}></SmallButton>
@@ -1109,19 +1103,19 @@ export function CustomWorldEntityCatalog({
<> <>
<Section title="档案规模"> <Section title="档案规模">
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300"> <div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3"> <div className="platform-subpanel rounded-xl px-2 py-3">
<div className="text-xl font-black text-white"> <div className="text-xl font-black text-white">
{profile.playableNpcs.length} {profile.playableNpcs.length}
</div> </div>
<div></div> <div></div>
</div> </div>
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3"> <div className="platform-subpanel rounded-xl px-2 py-3">
<div className="text-xl font-black text-white"> <div className="text-xl font-black text-white">
{profile.storyNpcs.length} {profile.storyNpcs.length}
</div> </div>
<div></div> <div></div>
</div> </div>
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3"> <div className="platform-subpanel rounded-xl px-2 py-3">
<div className="text-xl font-black text-white"> <div className="text-xl font-black text-white">
{profile.landmarks.length + 1} {profile.landmarks.length + 1}
</div> </div>
@@ -1150,15 +1144,15 @@ export function CustomWorldEntityCatalog({
) )
} }
> >
<div className="space-y-3 text-sm leading-7 text-zinc-300"> <div className="space-y-3 text-sm leading-7 text-zinc-300">
<p>{profile.summary}</p> <p>{profile.summary}</p>
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100"> <div className="platform-banner platform-banner--warning rounded-2xl px-3 py-3">
线{profile.playerGoal} 线{profile.playerGoal}
</div>
<div className="platform-subpanel rounded-2xl px-3 py-3">
{profile.tone}
</div>
</div> </div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
{profile.tone}
</div>
</div>
</Section> </Section>
<Section <Section
@@ -1186,7 +1180,7 @@ export function CustomWorldEntityCatalog({
{structuredFoundationEntries.map((entry) => ( {structuredFoundationEntries.map((entry) => (
<div <div
key={entry.id} key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4" className="platform-subpanel rounded-2xl px-4 py-4"
> >
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500"> <div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label} {entry.label}
@@ -1261,30 +1255,30 @@ export function CustomWorldEntityCatalog({
className="h-full w-full object-cover object-top" className="h-full w-full object-cover object-top"
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center bg-black/30 px-3 text-center text-xs font-semibold tracking-[0.16em] text-zinc-400"> <div className="flex h-full w-full items-center justify-center bg-[rgba(255,255,255,0.64)] px-3 text-center text-xs font-semibold tracking-[0.16em] text-[var(--platform-text-soft)]">
{role.name.slice(0, 4) || '角色'} {role.name.slice(0, 4) || '角色'}
</div> </div>
) )
} }
/> />
<div className="flex flex-wrap items-center gap-2 px-1"> <div className="flex flex-wrap items-center gap-2 px-1">
{lockedCharacterNames.has(role.name.trim()) ? ( {lockedCharacterNames.has(role.name.trim()) ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100"> <span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px]">
</span> </span>
) : null} ) : null}
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"> <span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{role.initialAffinity} {role.initialAffinity}
</span> </span>
{role.generatedVisualAssetId ? ( {role.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100"> <span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
</span> </span>
) : null} ) : null}
{role.tags.slice(0, 2).map((tag) => ( {role.tags.slice(0, 2).map((tag) => (
<span <span
key={`${role.id}-${tag}`} key={`${role.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300" className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]"
> >
{tag} {tag}
</span> </span>

View File

@@ -1,4 +1,5 @@
import type { ChangeEvent } from 'react'; import { X } from 'lucide-react';
import type { ChangeEvent } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { Children, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
@@ -38,13 +39,13 @@ import {
CustomWorldSceneConnection, CustomWorldSceneConnection,
type ItemRarity, type ItemRarity,
} from '../types'; } from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { buildAnimationClipFromVideoSource } from './asset-studio/characterAssetWorkflowModel'; import { buildAnimationClipFromVideoSource } from './asset-studio/characterAssetWorkflowModel';
import { import {
type CharacterAnimationGenerationPayload, type CharacterAnimationGenerationPayload,
generateCharacterAnimationDraft, generateCharacterAnimationDraft,
publishCharacterAnimationAssets, publishCharacterAnimationAssets,
} from './asset-studio/characterAssetWorkflowPersistence'; } from './asset-studio/characterAssetWorkflowPersistence';
import { useAuthUi } from './auth/AuthUiContext';
import { CharacterAnimator } from './CharacterAnimator'; import { CharacterAnimator } from './CharacterAnimator';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults'; import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import { import {
@@ -408,9 +409,15 @@ function ModalShell({
disableClose?: boolean; disableClose?: boolean;
usePixelFont?: boolean; usePixelFont?: boolean;
}) { }) {
const authUi = useAuthUi();
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
return ( return (
<div <div
className={`fixed inset-0 ${overlayClassName} flex items-end justify-center bg-black/78 p-0 backdrop-blur-sm sm:items-center sm:p-4`} className={`platform-overlay fixed inset-0 ${overlayClassName} flex items-end justify-center p-0 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={ onClick={
disableClose disableClose
? undefined ? undefined
@@ -422,8 +429,7 @@ function ModalShell({
} }
> >
<div <div
className={`pixel-nine-slice pixel-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${usePixelFont ? 'fusion-pixel-app' : ''} ${panelClassName} sm:rounded-[1.75rem]`} className={`platform-modal-shell flex h-[92vh] w-full flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.6)] sm:h-auto sm:max-h-[min(92vh,56rem)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`} ${panelClassName} sm:rounded-[1.75rem]`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4 sm:px-5"> <div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4 sm:px-5">
@@ -442,9 +448,9 @@ function ModalShell({
onClick={onClose} onClick={onClose}
disabled={disableClose} disabled={disableClose}
aria-label="关闭" aria-label="关闭"
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`} className={`platform-icon-button ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
> >
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
<div <div
@@ -490,9 +496,15 @@ function CompactDialogShell({
disableClose?: boolean; disableClose?: boolean;
usePixelFont?: boolean; usePixelFont?: boolean;
}) { }) {
const authUi = useAuthUi();
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
return ( return (
<div <div
className={`fixed inset-0 ${overlayClassName} flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm`} className={`platform-overlay fixed inset-0 ${overlayClassName} flex items-center justify-center p-4 backdrop-blur-sm`}
onClick={ onClick={
disableClose disableClose
? undefined ? undefined
@@ -504,8 +516,7 @@ function CompactDialogShell({
} }
> >
<div <div
className={`pixel-nine-slice pixel-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : ''}`} className={`platform-modal-shell w-full max-w-md overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)] ${usePixelFont ? 'fusion-pixel-app' : `platform-ui-shell platform-theme ${platformThemeClass}`}`}
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4"> <div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-4">
@@ -517,9 +528,9 @@ function CompactDialogShell({
onClick={onClose} onClick={onClose}
disabled={disableClose} disabled={disableClose}
aria-label="关闭" aria-label="关闭"
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`} className={`platform-icon-button ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
> >
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>
<div className="p-4">{children}</div> <div className="p-4">{children}</div>
@@ -1712,16 +1723,9 @@ function SaveBar({
<button <button
type="button" type="button"
onClick={onSave} onClick={onSave}
className="pixel-nine-slice pixel-pressable text-left" className="platform-button platform-button--primary text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
> >
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button> </button>
</div> </div>
</div> </div>
@@ -2132,12 +2136,13 @@ function RoleSkillEditorModal({
lastFrameImageDataUrl: role.imageSrc, lastFrameImageDataUrl: role.imageSrc,
frameCount: 8, frameCount: 8,
fps: 10, fps: 10,
durationSeconds: 3, durationSeconds: 4,
loop: false, loop: false,
useChromaKey: true, useChromaKey: true,
resolution: '480P', resolution: '480p',
ratio: '1:1',
imageSequenceModel: 'wan2.7-image-pro', imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.2-kf2v-flash', videoModel: 'doubao-seedance-2-0-fast-260128',
referenceVideoModel: 'wan2.7-r2v', referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move', motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload); } satisfies CharacterAnimationGenerationPayload);

View File

@@ -2,7 +2,6 @@ import { motion } from 'motion/react';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress'; import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
interface CustomWorldGenerationViewProps { interface CustomWorldGenerationViewProps {
settingText: string; settingText: string;
@@ -95,16 +94,16 @@ export function CustomWorldGenerationView({
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]" className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
style={{ WebkitOverflowScrolling: 'touch' }} style={{ WebkitOverflowScrolling: 'touch' }}
> >
<div className="sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.86),rgba(10,12,18,0))] px-3 pb-3 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0"> <div className="sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(255,247,250,0.96),rgba(255,244,248,0.86),rgba(255,244,248,0))] px-3 pb-3 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0">
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}
disabled={isGenerating} disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`} className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
> >
{backLabel} {backLabel}
</button> </button>
<div className="rounded-full border border-sky-300/16 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100"> <div className="platform-pill platform-pill--cool px-3 py-1 text-[10px] tracking-[0.2em]">
{isGenerating {isGenerating
? activeBadgeLabel ? activeBadgeLabel
: error : error
@@ -114,19 +113,13 @@ export function CustomWorldGenerationView({
</div> </div>
<div className="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1"> <div className="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1">
<section <section className="platform-surface platform-surface--soft flex flex-col px-4 py-3.5 xl:min-h-0 xl:flex-1">
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400"> <div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
{progressTitle} {progressTitle}
</div> </div>
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]"> <div className="mt-1 text-xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2rem]">
{progress?.phaseLabel ?? '正在启动世界生成'} {progress?.phaseLabel ?? '正在启动世界生成'}
</div> </div>
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300"> <div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
@@ -137,22 +130,22 @@ export function CustomWorldGenerationView({
<div className="text-[11px] tracking-[0.16em] text-zinc-500"> <div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div> </div>
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl"> <div className="mt-1 text-3xl font-black text-[var(--platform-cool-text)] sm:text-4xl">
{progressValue}% {progressValue}%
</div> </div>
</div> </div>
</div> </div>
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35"> <div className="platform-progress-track mt-4 h-4 overflow-hidden rounded-full">
<motion.div <motion.div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]" className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_52%,#ffd2a6_100%)]"
animate={{ width: `${progressValue}%` }} animate={{ width: `${progressValue}%` }}
transition={{ duration: 0.35, ease: 'easeOut' }} transition={{ duration: 0.35, ease: 'easeOut' }}
/> />
</div> </div>
<div className="mt-4 grid gap-2 sm:grid-cols-3"> <div className="mt-4 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3"> <div className="platform-subpanel rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500"> <div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div> </div>
@@ -160,7 +153,7 @@ export function CustomWorldGenerationView({
{progress?.batchLabel ?? '准备中'} {progress?.batchLabel ?? '准备中'}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3"> <div className="platform-subpanel rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500"> <div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div> </div>
@@ -168,7 +161,7 @@ export function CustomWorldGenerationView({
{estimatedWaitText} {estimatedWaitText}
</div> </div>
</div> </div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3"> <div className="platform-subpanel rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500"> <div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div> </div>
@@ -187,7 +180,7 @@ export function CustomWorldGenerationView({
? 'border-emerald-400/16 bg-emerald-500/8' ? 'border-emerald-400/16 bg-emerald-500/8'
: step.status === 'active' : step.status === 'active'
? 'border-sky-300/22 bg-sky-500/10' ? 'border-sky-300/22 bg-sky-500/10'
: 'border-white/8 bg-black/18' : 'platform-subpanel'
}`} }`}
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -217,25 +210,16 @@ export function CustomWorldGenerationView({
<button <button
type="button" type="button"
onClick={onEditSetting} onClick={onEditSetting}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white" className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
> >
{settingActionLabel} {settingActionLabel}
</button> </button>
<button <button
type="button" type="button"
onClick={onRetry} onClick={onRetry}
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto" className="platform-button platform-button--primary w-full sm:w-auto"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
> >
<div className="flex items-center justify-between gap-4"> {retryLabel}
<span className="text-sm font-semibold text-white">
{retryLabel}
</span>
<span className="text-white/60"></span>
</div>
</button> </button>
</> </>
) : onInterrupt ? ( ) : onInterrupt ? (
@@ -250,16 +234,10 @@ export function CustomWorldGenerationView({
</div> </div>
</section> </section>
<section <section className="platform-surface platform-surface--soft overflow-hidden px-4 py-3.5">
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85"> <div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
{settingTitle} {settingTitle}
</div> </div>
<div className="mt-1 text-sm text-zinc-400"> <div className="mt-1 text-sm text-zinc-400">
@@ -270,7 +248,7 @@ export function CustomWorldGenerationView({
type="button" type="button"
onClick={onEditSetting} onClick={onEditSetting}
disabled={isGenerating} disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`} className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
> >
{settingActionLabel} {settingActionLabel}
</button> </button>
@@ -283,7 +261,7 @@ export function CustomWorldGenerationView({
entry.id, entry.id,
`anchor-entry-${index}`, `anchor-entry-${index}`,
)} )}
className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4" className="platform-subpanel rounded-2xl px-4 py-4"
> >
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500"> <div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label} {entry.label}
@@ -295,7 +273,7 @@ export function CustomWorldGenerationView({
))} ))}
</div> </div>
) : ( ) : (
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto"> <div className="platform-subpanel whitespace-pre-line rounded-2xl px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
{settingText || structuredEmptyText} {settingText || structuredEmptyText}
</div> </div>
)} )}

View File

@@ -13,7 +13,6 @@ import {
CustomWorldPlayableNpc, CustomWorldPlayableNpc,
CustomWorldProfile, CustomWorldProfile,
} from '../types'; } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { import {
CustomWorldEntityCatalog, CustomWorldEntityCatalog,
type ResultTab, type ResultTab,
@@ -71,11 +70,11 @@ function SmallButton({
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={`rounded-full border px-3 py-2 text-sm transition-colors ${ className={`${
tone === 'sky' tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white' ? 'platform-button platform-button--primary'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white' : 'platform-button platform-button--ghost'
} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`} } min-h-0 rounded-full px-3 py-2 text-sm ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
> >
{children} {children}
</button> </button>
@@ -351,15 +350,15 @@ export function CustomWorldResultView({
}; };
const autoSaveBadge = const autoSaveBadge =
autoSaveState === 'saved' ? ( autoSaveState === 'saved' ? (
<div className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100"> <div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div> </div>
) : autoSaveState === 'saving' ? ( ) : autoSaveState === 'saving' ? (
<div className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100"> <div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div> </div>
) : autoSaveState === 'error' ? ( ) : autoSaveState === 'error' ? (
<div className="rounded-full border border-rose-300/20 bg-rose-500/10 px-3 py-1 text-[11px] text-rose-100"> <div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div> </div>
) : null; ) : null;
@@ -371,7 +370,7 @@ export function CustomWorldResultView({
type="button" type="button"
onClick={onBack} onClick={onBack}
disabled={isGenerating} disabled={isGenerating}
className={`self-start rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'opacity-45' : ''}`} className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isGenerating ? 'opacity-45' : ''}`}
> >
{backLabel} {backLabel}
</button> </button>
@@ -418,16 +417,18 @@ export function CustomWorldResultView({
</div> </div>
{isGenerating && ( {isGenerating && (
<div className="mt-3 rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4"> <div className="platform-banner platform-banner--info mt-3 rounded-2xl px-4 py-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white"> <div className="text-sm font-semibold text-[var(--platform-text-strong)]">
{progressLabel} {progressLabel}
</div> </div>
<div className="text-xs text-sky-100">{Math.round(progress)}%</div> <div className="text-xs text-[var(--platform-text-base)]">
{Math.round(progress)}%
</div>
</div> </div>
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35"> <div className="platform-progress-track mt-3 h-3 overflow-hidden rounded-full">
<div <div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_48%,#fef08a_100%)] transition-[width] duration-300" className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_48%,#ffd2a6_100%)] transition-[width] duration-300"
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }} style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
/> />
</div> </div>
@@ -435,19 +436,19 @@ export function CustomWorldResultView({
)} )}
{error ? ( {error ? (
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100"> <div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error} {error}
</div> </div>
) : null} ) : null}
{!error && localGenerationError ? ( {!error && localGenerationError ? (
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100"> <div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{localGenerationError} {localGenerationError}
</div> </div>
) : null} ) : null}
<div className="mt-4 flex flex-col gap-3"> <div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? ( {profile.generationStatus === 'key_only' ? (
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100"> <div className="platform-banner platform-banner--warning rounded-2xl text-sm leading-6">
</div> </div>
) : null} ) : null}
@@ -474,18 +475,9 @@ export function CustomWorldResultView({
type="button" type="button"
onClick={onEnterWorld} onClick={onEnterWorld}
disabled={isGenerating} disabled={isGenerating}
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`} className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
> >
<div className="flex items-center justify-between gap-4"> {enterWorldActionLabel}
<span className="text-sm font-semibold text-white">
{enterWorldActionLabel}
</span>
<span className="text-white/60"></span>
</div>
</button> </button>
) : null} ) : null}
</div> </div>

View File

@@ -1,7 +1,4 @@
import { import { ImagePlus, RefreshCcw } from 'lucide-react';
ImagePlus,
RefreshCcw,
} from 'lucide-react';
import { import {
type ChangeEvent, type ChangeEvent,
type CSSProperties, type CSSProperties,
@@ -34,6 +31,7 @@ import {
saveCharacterWorkflowCache, saveCharacterWorkflowCache,
} from './asset-studio/characterAssetWorkflowPersistence'; } from './asset-studio/characterAssetWorkflowPersistence';
import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRolePromptDefaults'; import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRolePromptDefaults';
import { buildProjectPixelStyleReferenceBoard } from './asset-studio/projectPixelStyleReference';
import { CharacterAnimator } from './CharacterAnimator'; import { CharacterAnimator } from './CharacterAnimator';
type EditableCustomWorldRole = { type EditableCustomWorldRole = {
@@ -92,16 +90,7 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
templateId: 'attack_slash', templateId: 'attack_slash',
fps: 12, fps: 12,
frameCount: 8, frameCount: 8,
durationSeconds: 3, durationSeconds: 4,
loop: false,
},
{
animation: AnimationState.HURT,
label: '受击',
templateId: 'hurt',
fps: 10,
frameCount: 6,
durationSeconds: 3,
loop: false, loop: false,
}, },
{ {
@@ -329,9 +318,7 @@ function ActionButton({
<span className="flex flex-col items-start leading-tight"> <span className="flex flex-col items-start leading-tight">
<span>{label}</span> <span>{label}</span>
{subLabel ? ( {subLabel ? (
<span className="text-[11px] font-medium opacity-70"> <span className="text-[11px] font-medium opacity-70">{subLabel}</span>
{subLabel}
</span>
) : null} ) : null}
</span> </span>
</button> </button>
@@ -351,7 +338,9 @@ function buildRoleCharacterBrief(
role.personality ? `角色性格:${role.personality}` : '', role.personality ? `角色性格:${role.personality}` : '',
role.motivation ? `角色动机:${role.motivation}` : '', role.motivation ? `角色动机:${role.motivation}` : '',
role.combatStyle ? `战斗风格:${role.combatStyle}` : '', role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
role.tags && role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '', role.tags && role.tags.length > 0
? `角色标签:${role.tags.join('、')}`
: '',
templateLabel ? `参考模板:${templateLabel}` : '', templateLabel ? `参考模板:${templateLabel}` : '',
] ]
.filter(Boolean) .filter(Boolean)
@@ -606,6 +595,10 @@ export function CustomWorldRoleAssetStudioModal({
const [referenceImageDataUrls, setReferenceImageDataUrls] = useState< const [referenceImageDataUrls, setReferenceImageDataUrls] = useState<
string[] string[]
>([]); >([]);
const [
projectStyleReferenceBoardSource,
setProjectStyleReferenceBoardSource,
] = useState('');
const [visualDrafts, setVisualDrafts] = useState<CharacterVisualDraft[]>([]); const [visualDrafts, setVisualDrafts] = useState<CharacterVisualDraft[]>([]);
const [selectedVisualDraftId, setSelectedVisualDraftId] = useState(''); const [selectedVisualDraftId, setSelectedVisualDraftId] = useState('');
const [visualStatus, setVisualStatus] = useState<string | null>(null); const [visualStatus, setVisualStatus] = useState<string | null>(null);
@@ -632,9 +625,9 @@ export function CustomWorldRoleAssetStudioModal({
const selectedTemplate = const selectedTemplate =
roleKind === 'playable' && workingRole.templateCharacterId roleKind === 'playable' && workingRole.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find( ? (ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === workingRole.templateCharacterId, (character) => character.id === workingRole.templateCharacterId,
) ?? null ) ?? null)
: null; : null;
const characterBriefText = useMemo( const characterBriefText = useMemo(
() => () =>
@@ -679,7 +672,7 @@ export function CustomWorldRoleAssetStudioModal({
); );
const selectedActionConfig = const selectedActionConfig =
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ?? CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
CORE_ACTIONS[0]; CORE_ACTIONS[0]!;
const previewCharacter = useMemo( const previewCharacter = useMemo(
() => () =>
buildAnimationPreviewCharacter({ buildAnimationPreviewCharacter({
@@ -691,7 +684,8 @@ export function CustomWorldRoleAssetStudioModal({
const selectedAnimationConfig = previewCharacter?.animationMap?.[ const selectedAnimationConfig = previewCharacter?.animationMap?.[
selectedAnimation selectedAnimation
] as CharacterAnimationConfig | undefined; ] as CharacterAnimationConfig | undefined;
const selectedAnimationStatus = animationStatusByKey[selectedAnimation] ?? null; const selectedAnimationStatus =
animationStatusByKey[selectedAnimation] ?? null;
const isSelectedAnimationGenerating = const isSelectedAnimationGenerating =
generatingAnimationMap[selectedAnimation] === true; generatingAnimationMap[selectedAnimation] === true;
const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some( const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some(
@@ -705,9 +699,39 @@ export function CustomWorldRoleAssetStudioModal({
() => getAnimationPreviewViewportStyle(440), () => getAnimationPreviewViewportStyle(440),
[], [],
); );
const effectiveVisualReferenceImageDataUrls = useMemo(() => {
if (!projectStyleReferenceBoardSource) {
return referenceImageDataUrls;
}
if (referenceImageDataUrls.length >= 4) {
return referenceImageDataUrls;
}
return [projectStyleReferenceBoardSource, ...referenceImageDataUrls].slice(
0,
4,
);
}, [projectStyleReferenceBoardSource, referenceImageDataUrls]);
const visualSourceMode = const visualSourceMode =
referenceImageDataUrls.length > 0 ? 'image-to-image' : 'text-to-image'; referenceImageDataUrls.length > 0 ? 'image-to-image' : 'text-to-image';
useEffect(() => {
let cancelled = false;
void buildProjectPixelStyleReferenceBoard()
.then((nextBoardSource) => {
if (!cancelled) {
setProjectStyleReferenceBoardSource(nextBoardSource);
}
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setWorkingRole(baseRole); setWorkingRole(baseRole);
@@ -759,7 +783,9 @@ export function CustomWorldRoleAssetStudioModal({
cache.selectedVisualDraftId || cache.visualDrafts?.[0]?.id || '', cache.selectedVisualDraftId || cache.visualDrafts?.[0]?.id || '',
); );
setSelectedAnimation( setSelectedAnimation(
CORE_ACTIONS.some((item) => item.animation === cache.selectedAnimation) CORE_ACTIONS.some(
(item) => item.animation === cache.selectedAnimation,
)
? (cache.selectedAnimation as AnimationState) ? (cache.selectedAnimation as AnimationState)
: (CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE), : (CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE),
); );
@@ -774,11 +800,7 @@ export function CustomWorldRoleAssetStudioModal({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [ }, [baseRole, initialPromptBundle, roleSnapshotKey]);
baseRole,
initialPromptBundle,
roleSnapshotKey,
]);
useEffect(() => { useEffect(() => {
if (isHydratingCache) { if (isHydratingCache) {
@@ -913,7 +935,7 @@ export function CustomWorldRoleAssetStudioModal({
sourceMode: visualSourceMode, sourceMode: visualSourceMode,
promptText: visualPromptText, promptText: visualPromptText,
characterBriefText, characterBriefText,
referenceImageDataUrls: referenceImageDataUrls, referenceImageDataUrls: effectiveVisualReferenceImageDataUrls,
candidateCount: 1, candidateCount: 1,
imageModel: 'wan2.7-image-pro', imageModel: 'wan2.7-image-pro',
size: '1024*1024', size: '1024*1024',
@@ -940,10 +962,6 @@ export function CustomWorldRoleAssetStudioModal({
throw new Error('请先生成角色形象,再生成动作。'); throw new Error('请先生成角色形象,再生成动作。');
} }
const isLoopAction = config.loop;
const shouldUseLastFrameReference =
!isLoopAction && config.animation !== AnimationState.DIE;
const result = await generateCharacterAnimationDraft({ const result = await generateCharacterAnimationDraft({
characterId: workingRole.id, characterId: workingRole.id,
strategy: 'image-to-video', strategy: 'image-to-video',
@@ -954,17 +972,16 @@ export function CustomWorldRoleAssetStudioModal({
visualSource: workingRole.imageSrc, visualSource: workingRole.imageSrc,
referenceImageDataUrls: [], referenceImageDataUrls: [],
referenceVideoDataUrls: [], referenceVideoDataUrls: [],
lastFrameImageDataUrl: shouldUseLastFrameReference lastFrameImageDataUrl: workingRole.imageSrc,
? workingRole.imageSrc
: undefined,
frameCount: config.frameCount, frameCount: config.frameCount,
fps: config.fps, fps: config.fps,
durationSeconds: config.durationSeconds, durationSeconds: config.durationSeconds,
loop: config.loop, loop: config.loop,
useChromaKey: true, useChromaKey: true,
resolution: isLoopAction ? '720P' : '480P', resolution: '480p',
ratio: '1:1',
imageSequenceModel: 'wan2.7-image-pro', imageSequenceModel: 'wan2.7-image-pro',
videoModel: isLoopAction ? 'wan2.6-i2v-flash' : 'wan2.2-kf2v-flash', videoModel: 'doubao-seedance-2-0-fast-260128',
referenceVideoModel: 'wan2.7-r2v', referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move', motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload); } satisfies CharacterAnimationGenerationPayload);
@@ -1105,7 +1122,9 @@ export function CustomWorldRoleAssetStudioModal({
onClose(); onClose();
} }
} catch (error) { } catch (error) {
setSaveStatus(error instanceof Error ? error.message : '保存角色形象失败。'); setSaveStatus(
error instanceof Error ? error.message : '保存角色形象失败。',
);
} finally { } finally {
setIsSavingToRole(false); setIsSavingToRole(false);
} }
@@ -1188,7 +1207,9 @@ export function CustomWorldRoleAssetStudioModal({
<ActionButton <ActionButton
label="清空参考图" label="清空参考图"
onClick={() => setReferenceImageDataUrls([])} onClick={() => setReferenceImageDataUrls([])}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy} disabled={
isGeneratingVisuals || isApplyingVisual || syncBusy
}
/> />
</div> </div>
</div> </div>
@@ -1230,7 +1251,8 @@ export function CustomWorldRoleAssetStudioModal({
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4"> <div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4">
<div className="flex min-h-[28rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4"> <div className="flex min-h-[28rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
{previewCharacter && hasGeneratedAnimation(workingRole, selectedAnimation) ? ( {previewCharacter &&
hasGeneratedAnimation(workingRole, selectedAnimation) ? (
<div <div
className="flex items-center justify-center" className="flex items-center justify-center"
style={animationPreviewViewportStyle} style={animationPreviewViewportStyle}
@@ -1300,8 +1322,12 @@ export function CustomWorldRoleAssetStudioModal({
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{CORE_ACTIONS.map((item) => { {CORE_ACTIONS.map((item) => {
const isSelected = item.animation === selectedAnimation; const isSelected = item.animation === selectedAnimation;
const isReady = hasGeneratedAnimation(workingRole, item.animation); const isReady = hasGeneratedAnimation(
const isGenerating = generatingAnimationMap[item.animation] === true; workingRole,
item.animation,
);
const isGenerating =
generatingAnimationMap[item.animation] === true;
return ( return (
<button <button
key={item.animation} key={item.animation}
@@ -1327,9 +1353,15 @@ export function CustomWorldRoleAssetStudioModal({
</div> </div>
</div> </div>
<StatusBadge <StatusBadge
tone={isGenerating ? 'amber' : isReady ? 'green' : 'zinc'} tone={
isGenerating ? 'amber' : isReady ? 'green' : 'zinc'
}
> >
{isGenerating ? '生成中' : isReady ? '已生成' : '待生成'} {isGenerating
? '生成中'
: isReady
? '已生成'
: '待生成'}
</StatusBadge> </StatusBadge>
</div> </div>
</button> </button>

View File

@@ -59,7 +59,7 @@ interface GameShellStoryProps {
interface GameShellEntryProps { interface GameShellEntryProps {
hasSavedGame: boolean; hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null; savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void; handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void; handleStartNewGame: () => void;
handleSaveAndExit: () => void; handleSaveAndExit: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;

View File

@@ -1,3 +1,4 @@
import { removeBackgroundFromRgba } from '../../../packages/shared/src/assets/chromaKey';
import { import {
AnimationState, AnimationState,
type Character, type Character,
@@ -718,71 +719,7 @@ function applyGreenScreenAlpha(
height: number, height: number,
) { ) {
const imageData = context.getImageData(0, 0, width, height); const imageData = context.getImageData(0, 0, width, height);
const pixels = imageData.data; removeBackgroundFromRgba(imageData.data, width, height);
for (let index = 0; index < pixels.length; index += 4) {
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const alpha = pixels[index + 3] ?? 0;
const greenLead = green - Math.max(red, blue);
const greenRatio = green / Math.max(1, red + blue);
if (alpha === 0) {
continue;
}
if (green > 72 && greenLead > 20 && greenRatio > 0.72) {
let nextAlpha = Math.min(alpha, Math.max(0, 255 - greenLead * 6));
if (green > 120 && greenLead > 48 && greenRatio > 1.12) {
nextAlpha = 0;
}
pixels[index + 3] = nextAlpha;
if (nextAlpha > 0) {
pixels[index + 1] = Math.min(
green,
Math.max(red, blue) + Math.max(6, Math.round(greenLead * 0.18)),
);
}
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
const alpha = pixels[index + 3] ?? 0;
if (alpha === 0) {
continue;
}
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const neighborAlphaValues = [
x > 0 ? (pixels[index - 1] ?? 255) : 255,
x + 1 < width ? (pixels[index + 7] ?? 255) : 255,
y > 0 ? (pixels[index - width * 4 + 3] ?? 255) : 255,
y + 1 < height ? (pixels[index + width * 4 + 3] ?? 255) : 255,
];
const touchesTransparentEdge = neighborAlphaValues.some(
(value) => value < 16,
);
if (!touchesTransparentEdge) {
continue;
}
if (green > Math.max(red, blue) + 4) {
pixels[index + 1] = Math.max(
Math.max(red, blue),
green - Math.round((green - Math.max(red, blue)) * 0.8),
);
}
}
}
context.putImageData(imageData, 0, 0); context.putImageData(imageData, 0, 0);
} }

View File

@@ -123,6 +123,7 @@ export type CharacterAnimationGenerationPayload = {
loop: boolean; loop: boolean;
useChromaKey: boolean; useChromaKey: boolean;
resolution: string; resolution: string;
ratio: string;
imageSequenceModel: string; imageSequenceModel: string;
videoModel: string; videoModel: string;
referenceVideoModel: string; referenceVideoModel: string;

View File

@@ -0,0 +1,75 @@
const PROJECT_PIXEL_STYLE_REFERENCE_SOURCES = [
'/character/Sword Princess/Original/Hero/idle/Idle01.png',
'/character/Archer Hero/Original/Hero/idle/idle01.png',
'/character/Girl Hero 1/Original/Hero/Idle/Idle01.png',
'/character/Punch Hero 3/Original/Hero/Idle/Idle01.png',
'/character/Fighter 4/original/Hero/idle/idle01.png',
] as const;
function loadImageFromSource(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
image.src = source;
});
}
function drawContainedImage(
context: CanvasRenderingContext2D,
image: HTMLImageElement,
options: {
x: number;
y: number;
width: number;
height: number;
},
) {
const fitScale = Math.min(
options.width / image.width,
options.height / image.height,
);
const drawWidth = image.width * fitScale;
const drawHeight = image.height * fitScale;
const drawX = options.x + (options.width - drawWidth) / 2;
const drawY = options.y + (options.height - drawHeight) / 2;
context.drawImage(image, drawX, drawY, drawWidth, drawHeight);
}
export async function buildProjectPixelStyleReferenceBoard(
sources = PROJECT_PIXEL_STYLE_REFERENCE_SOURCES,
) {
const images = await Promise.all(
sources.map((source) => loadImageFromSource(source)),
);
const cols = 3;
const rows = 2;
const cellSize = 320;
const padding = 24;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建画布上下文');
}
canvas.width = cols * cellSize + padding * 2;
canvas.height = rows * cellSize + padding * 2;
context.fillStyle = '#f6f0dd';
context.fillRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingEnabled = false;
images.forEach((image, index) => {
const colIndex = index % cols;
const rowIndex = Math.floor(index / cols);
drawContainedImage(context, image, {
x: padding + colIndex * cellSize,
y: padding + rowIndex * cellSize,
width: cellSize,
height: cellSize,
});
});
return canvas.toDataURL('image/png');
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,20 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react'; import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest'; import { beforeEach, expect, test, vi } from 'vitest';
import type { AuthUser } from '../../services/authService'; import type { AuthUser } from '../../services/authService';
import { AuthGate } from './AuthGate'; import { AuthGate } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({ const authMocks = vi.hoisted(() => ({
getStoredAccessToken: vi.fn(), getStoredAccessToken: vi.fn(),
ensureAutoAuthUser: vi.fn(), ensureAutoAuthUser: vi.fn(),
getAuthLoginOptions: vi.fn(), getAuthLoginOptions: vi.fn(),
loginWithPhoneCode: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
consumeAuthCallbackResult: vi.fn(), consumeAuthCallbackResult: vi.fn(),
})); }));
@@ -30,12 +35,25 @@ vi.mock('../../services/authService', () => ({
getCaptchaChallengeFromError: vi.fn(() => null), getCaptchaChallengeFromError: vi.fn(() => null),
getCurrentAuthUser: vi.fn(), getCurrentAuthUser: vi.fn(),
liftAuthRiskBlock: vi.fn(), liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: vi.fn(), logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(), logoutAuthUser: vi.fn(),
revokeAuthSession: vi.fn(), revokeAuthSession: vi.fn(),
sendPhoneLoginCode: vi.fn(), sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
startWechatLogin: vi.fn(), startWechatLogin: authMocks.startWechatLogin,
}));
vi.mock('../../hooks/useGameSettings', () => ({
useGameSettings: () => ({
musicVolume: 0.42,
setMusicVolume: () => {},
platformTheme: 'light',
setPlatformTheme: () => {},
hasHydratedSettings: true,
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}),
})); }));
vi.mock('./AccountModal', () => ({ vi.mock('./AccountModal', () => ({
@@ -60,6 +78,12 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
authMocks.getStoredAccessToken.mockReturnValue(null); authMocks.getStoredAccessToken.mockReturnValue(null);
authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
expiresInSeconds: 300,
});
authMocks.startWechatLogin.mockResolvedValue(undefined);
authMocks.ensureAutoAuthUser.mockResolvedValue({ authMocks.ensureAutoAuthUser.mockResolvedValue({
user: mockUser, user: mockUser,
credentials: { credentials: {
@@ -69,7 +93,22 @@ beforeEach(() => {
}); });
}); });
test('auth gate prefers login screen when phone login is available', async () => { function ProtectedActionButton({ onAuthenticated }: { onAuthenticated: () => void }) {
const authUi = useAuthUi();
return (
<button
type="button"
onClick={() => {
authUi?.requireAuth(onAuthenticated);
}}
>
</button>
);
}
test('auth gate keeps platform content visible when phone login is available', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({ authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'], availableLoginMethods: ['phone'],
}); });
@@ -80,7 +119,43 @@ test('auth gate prefers login screen when phone login is available', async () =>
</AuthGate>, </AuthGate>,
); );
expect(await screen.findByText('账号登录')).toBeTruthy(); expect(await screen.findByText('应用内容')).toBeTruthy();
expect(screen.getByText('手机号')).toBeTruthy(); expect(screen.getByRole('button', { name: '登录' })).toBeTruthy();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled(); expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
}); });
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
const user = userEvent.setup();
const onAuthenticated = vi.fn();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={onAuthenticated} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '登录账号' });
expect(dialog).toBeTruthy();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
'13800000000',
'123456',
);
expect(onAuthenticated).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull();
});

View File

@@ -1,5 +1,13 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react'; import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useGameSettings } from '../../hooks/useGameSettings';
import { import {
AUTH_STATE_EVENT, AUTH_STATE_EVENT,
getStoredAccessToken, getStoredAccessToken,
@@ -30,7 +38,10 @@ import {
startWechatLogin, startWechatLogin,
} from '../../services/authService'; } from '../../services/authService';
import { AccountModal } from './AccountModal'; import { AccountModal } from './AccountModal';
import { AuthUiContext } from './AuthUiContext'; import {
AuthUiContext,
type PlatformSettingsSection,
} from './AuthUiContext';
import { BindPhoneScreen } from './BindPhoneScreen'; import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen'; import { LoginScreen } from './LoginScreen';
@@ -61,7 +72,10 @@ export function AuthGate({ children }: AuthGateProps) {
const [loggingIn, setLoggingIn] = useState(false); const [loggingIn, setLoggingIn] = useState(false);
const [bindingPhone, setBindingPhone] = useState(false); const [bindingPhone, setBindingPhone] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false); const [wechatLoading, setWechatLoading] = useState(false);
const [showAccountModal, setShowAccountModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false);
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [initialSettingsSection, setInitialSettingsSection] =
useState<PlatformSettingsSection>('appearance');
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true); const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]); const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false); const [loadingSessions, setLoadingSessions] = useState(false);
@@ -75,6 +89,55 @@ export function AuthGate({ children }: AuthGateProps) {
useState<AuthCaptchaChallenge | null>(null); useState<AuthCaptchaChallenge | null>(null);
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] = const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null); useState<AuthCaptchaChallenge | null>(null);
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const settings = useGameSettings(user?.id ?? null);
const platformThemeClass = `platform-theme--${settings.platformTheme}`;
const readyUser = status === 'ready' ? user : null;
const closeLoginModal = useCallback(() => {
pendingProtectedActionRef.current = null;
setShowLoginModal(false);
setLoginCaptchaChallenge(null);
setError('');
}, []);
const openLoginModal = useCallback(
(postLoginAction?: (() => void) | null) => {
if (readyUser) {
postLoginAction?.();
return;
}
pendingProtectedActionRef.current = postLoginAction ?? null;
setShowLoginModal(true);
},
[readyUser],
);
const requireAuth = useCallback(
(action: () => void) => {
openLoginModal(action);
},
[openLoginModal],
);
const openSettingsModal = useCallback(
(section: PlatformSettingsSection = 'appearance') => {
if (readyUser) {
setInitialSettingsSection(section);
setShowSettingsModal(true);
return;
}
openLoginModal();
},
[openLoginModal, readyUser],
);
const openAccountModal = useCallback(() => {
openSettingsModal('account');
}, [openSettingsModal]);
useEffect(() => { useEffect(() => {
let isActive = true; let isActive = true;
@@ -163,6 +226,7 @@ export function AuthGate({ children }: AuthGateProps) {
const callbackResult = consumeAuthCallbackResult(); const callbackResult = consumeAuthCallbackResult();
if (callbackResult?.error && isActive) { if (callbackResult?.error && isActive) {
setError(callbackResult.error); setError(callbackResult.error);
setShowLoginModal(true);
} }
const token = getStoredAccessToken(); const token = getStoredAccessToken();
@@ -217,7 +281,20 @@ export function AuthGate({ children }: AuthGateProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!showAccountModal || status !== 'ready') { if (!readyUser) {
setShowSettingsModal(false);
return;
}
setShowLoginModal(false);
const pendingAction = pendingProtectedActionRef.current;
pendingProtectedActionRef.current = null;
pendingAction?.();
}, [readyUser]);
useEffect(() => {
if (!showSettingsModal || status !== 'ready') {
return; return;
} }
@@ -299,24 +376,47 @@ export function AuthGate({ children }: AuthGateProps) {
return () => { return () => {
isActive = false; isActive = false;
}; };
}, [showAccountModal, status]); }, [showSettingsModal, status]);
const authUiValue = useMemo( const authUiValue = useMemo(
() => ({ () => ({
user, user: readyUser,
openAccountModal: () => setShowAccountModal(true), openLoginModal,
requireAuth,
openSettingsModal,
openAccountModal,
logout: async () => { logout: async () => {
await logoutAuthUser(); await logoutAuthUser();
setShowAccountModal(false); setShowSettingsModal(false);
}, },
setGlobalAccountActionsVisible: setShowGlobalAccountActions, setGlobalAccountActionsVisible: setShowGlobalAccountActions,
musicVolume: settings.musicVolume,
setMusicVolume: settings.setMusicVolume,
platformTheme: settings.platformTheme,
setPlatformTheme: settings.setPlatformTheme,
isHydratingSettings: settings.isHydratingSettings,
isPersistingSettings: settings.isPersistingSettings,
settingsError: settings.settingsError,
}), }),
[user], [
openAccountModal,
openLoginModal,
openSettingsModal,
readyUser,
requireAuth,
settings.isHydratingSettings,
settings.isPersistingSettings,
settings.musicVolume,
settings.platformTheme,
settings.setMusicVolume,
settings.setPlatformTheme,
settings.settingsError,
],
); );
if (status === 'checking') { if (status === 'checking') {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300"> <div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
... ...
</div> </div>
); );
@@ -324,84 +424,17 @@ export function AuthGate({ children }: AuthGateProps) {
if (status === 'recovering') { if (status === 'recovering') {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300"> <div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] text-sm text-[var(--platform-text-base)]`}>
... ...
</div> </div>
); );
} }
if (status === 'unauthenticated') {
return (
<LoginScreen
availableLoginMethods={availableLoginMethods}
sendingCode={sendingCode}
loggingIn={loggingIn}
wechatLoading={wechatLoading}
error={error}
captchaChallenge={loginCaptchaChallenge}
onSendCode={async (phone, captcha) => {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(phone, 'login', captcha);
setLoginCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setLoginCaptchaChallenge(captchaChallenge);
}
setError(
sendError instanceof Error
? sendError.message
: '发送验证码失败,请稍后再试。',
);
throw sendError;
} finally {
setSendingCode(false);
}
}}
onSubmit={async (phone, code) => {
setLoggingIn(true);
setError('');
try {
const nextUser = await loginWithPhoneCode(phone, code);
setLoginCaptchaChallenge(null);
setUser(nextUser);
setStatus('ready');
} catch (loginError) {
setError(
loginError instanceof Error
? loginError.message
: '登录失败,请稍后再试。',
);
} finally {
setLoggingIn(false);
}
}}
onStartWechatLogin={async () => {
setWechatLoading(true);
setError('');
try {
await startWechatLogin();
} catch (wechatError) {
setError(
wechatError instanceof Error
? wechatError.message
: '微信登录暂不可用,请稍后再试。',
);
} finally {
setWechatLoading(false);
}
}}
/>
);
}
if (status === 'pending_bind_phone' && user) { if (status === 'pending_bind_phone' && user) {
return ( return (
<BindPhoneScreen <BindPhoneScreen
user={user} user={user}
platformTheme={settings.platformTheme}
sendingCode={sendingCode} sendingCode={sendingCode}
binding={bindingPhone} binding={bindingPhone}
error={error} error={error}
@@ -455,17 +488,19 @@ export function AuthGate({ children }: AuthGateProps) {
); );
} }
if (status !== 'ready' || !user) { if (status !== 'ready' && status !== 'unauthenticated') {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] px-6 text-zinc-200"> <div className={`platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}>
<div className="max-w-md rounded-3xl border border-white/10 bg-black/40 px-6 py-7 text-center shadow-[0_20px_60px_rgba(0,0,0,0.35)]"> <div className="platform-auth-card max-w-md rounded-3xl px-6 py-7 text-center">
<div className="text-base font-medium text-zinc-50"></div> <div className="text-base font-medium text-[var(--platform-text-strong)]">
<div className="mt-3 text-sm leading-6 text-zinc-300">
</div>
<div className="mt-3 text-sm leading-6 text-[var(--platform-text-base)]">
{error || '账号恢复失败,请刷新页面后重试。'} {error || '账号恢复失败,请刷新页面后重试。'}
</div> </div>
<button <button
type="button" type="button"
className="mt-5 rounded-full border border-amber-300/30 px-4 py-2 text-sm text-amber-100 transition hover:border-amber-300/60 hover:bg-amber-300/10" className="platform-button platform-button--primary mt-5"
onClick={() => { onClick={() => {
window.location.reload(); window.location.reload();
}} }}
@@ -480,140 +515,226 @@ export function AuthGate({ children }: AuthGateProps) {
return ( return (
<AuthUiContext.Provider value={authUiValue}> <AuthUiContext.Provider value={authUiValue}>
<div className="relative"> <div className="relative">
{showGlobalAccountActions ? ( <div className={`platform-theme ${platformThemeClass}`}>
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end"> {showGlobalAccountActions ? (
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur"> <div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
<button {readyUser ? (
type="button" <div className="platform-auth-card pointer-events-auto flex items-center gap-2 rounded-full px-3 py-2 text-xs text-[var(--platform-text-base)]">
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-white/25 hover:text-white" <button
onClick={() => setShowAccountModal(true)} type="button"
> className="platform-button platform-button--secondary min-h-0 rounded-full px-2.5 py-1 text-[11px]"
{user.displayName} onClick={() => openAccountModal()}
</button> >
<button {readyUser.displayName}
type="button" </button>
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100" <button
onClick={() => { type="button"
void logoutAuthUser(); className="platform-button platform-button--ghost min-h-0 rounded-full px-2.5 py-1 text-[11px]"
}} onClick={() => {
> void logoutAuthUser();
退 }}
</button> >
退
</button>
</div>
) : (
<button
type="button"
className="platform-auth-card pointer-events-auto rounded-full px-3 py-2 text-xs font-medium text-[var(--platform-text-strong)] transition hover:-translate-y-px"
onClick={() => openLoginModal()}
>
</button>
)}
</div> </div>
</div> ) : null}
) : null} {readyUser ? (
<AccountModal <AccountModal
user={user} user={readyUser}
isOpen={showAccountModal} isOpen={showSettingsModal}
riskBlocks={riskBlocks} initialSection={initialSettingsSection}
sessions={sessions} platformTheme={settings.platformTheme}
auditLogs={auditLogs} riskBlocks={riskBlocks}
loadingRiskBlocks={loadingRiskBlocks} sessions={sessions}
loadingSessions={loadingSessions} auditLogs={auditLogs}
loadingAuditLogs={loadingAuditLogs} loadingRiskBlocks={loadingRiskBlocks}
onClose={() => setShowAccountModal(false)} loadingSessions={loadingSessions}
onLogout={async () => { loadingAuditLogs={loadingAuditLogs}
await logoutAuthUser(); isHydratingSettings={settings.isHydratingSettings}
setShowAccountModal(false); isPersistingSettings={settings.isPersistingSettings}
}} settingsError={settings.settingsError}
onRefreshRiskBlocks={async () => { onClose={() => setShowSettingsModal(false)}
setLoadingRiskBlocks(true); onPlatformThemeChange={settings.setPlatformTheme}
try { onLogout={async () => {
setRiskBlocks(await getAuthRiskBlocks()); await logoutAuthUser();
} catch (blockError) { setShowSettingsModal(false);
setError( }}
blockError instanceof Error onRefreshRiskBlocks={async () => {
? blockError.message setLoadingRiskBlocks(true);
: '读取安全状态失败,请稍后再试。', try {
); setRiskBlocks(await getAuthRiskBlocks());
} finally { } catch (blockError) {
setLoadingRiskBlocks(false); setError(
} blockError instanceof Error
}} ? blockError.message
onLiftRiskBlock={async (scopeType) => { : '读取安全状态失败,请稍后再试。',
try { );
await liftAuthRiskBlock(scopeType); } finally {
setRiskBlocks(await getAuthRiskBlocks()); setLoadingRiskBlocks(false);
setAuditLogs(await getAuthAuditLogs()); }
} catch (liftError) { }}
setError( onLiftRiskBlock={async (scopeType) => {
liftError instanceof Error try {
? liftError.message await liftAuthRiskBlock(scopeType);
: '解除保护失败,请稍后再试。', setRiskBlocks(await getAuthRiskBlocks());
); setAuditLogs(await getAuthAuditLogs());
} } catch (liftError) {
}} setError(
onRefreshSessions={async () => { liftError instanceof Error
setLoadingSessions(true); ? liftError.message
try { : '解除保护失败,请稍后再试。',
setSessions(await getAuthSessions()); );
} catch (sessionError) { }
setError( }}
sessionError instanceof Error onRefreshSessions={async () => {
? sessionError.message setLoadingSessions(true);
: '读取登录设备失败,请稍后再试。', try {
); setSessions(await getAuthSessions());
} finally { } catch (sessionError) {
setLoadingSessions(false); setError(
} sessionError instanceof Error
}} ? sessionError.message
onRefreshAuditLogs={async () => { : '读取登录设备失败,请稍后再试。',
setLoadingAuditLogs(true); );
try { } finally {
setAuditLogs(await getAuthAuditLogs()); setLoadingSessions(false);
} catch (auditError) { }
setError( }}
auditError instanceof Error onRefreshAuditLogs={async () => {
? auditError.message setLoadingAuditLogs(true);
: '读取账号操作记录失败,请稍后再试。', try {
); setAuditLogs(await getAuthAuditLogs());
} finally { } catch (auditError) {
setLoadingAuditLogs(false); setError(
} auditError instanceof Error
}} ? auditError.message
onRevokeSession={async (sessionId) => { : '读取账号操作记录失败,请稍后再试。',
try { );
await revokeAuthSession(sessionId); } finally {
setSessions((current) => setLoadingAuditLogs(false);
current.filter((session) => session.sessionId !== sessionId), }
); }}
setAuditLogs(await getAuthAuditLogs()); onRevokeSession={async (sessionId) => {
} catch (revokeError) { try {
setError( await revokeAuthSession(sessionId);
revokeError instanceof Error setSessions((current) =>
? revokeError.message current.filter((session) => session.sessionId !== sessionId),
: '移除登录设备失败,请稍后再试。', );
); setAuditLogs(await getAuthAuditLogs());
} } catch (revokeError) {
}} setError(
onLogoutAll={async () => { revokeError instanceof Error
await logoutAllAuthSessions(); ? revokeError.message
setShowAccountModal(false); : '移除登录设备失败,请稍后再试。',
}} );
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge} }
onSendChangePhoneCode={async (phone, captcha) => { }}
try { onLogoutAll={async () => {
const result = await sendPhoneLoginCode( await logoutAllAuthSessions();
phone, setShowSettingsModal(false);
'change_phone', }}
captcha, changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
); onSendChangePhoneCode={async (phone, captcha) => {
setChangePhoneCaptchaChallenge(null); try {
return result; const result = await sendPhoneLoginCode(
} catch (sendError) { phone,
const captchaChallenge = getCaptchaChallengeFromError(sendError); 'change_phone',
if (captchaChallenge) { captcha,
setChangePhoneCaptchaChallenge(captchaChallenge); );
setChangePhoneCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setChangePhoneCaptchaChallenge(captchaChallenge);
}
throw sendError;
}
}}
onChangePhone={async (phone, code) => {
const nextUser = await changePhoneNumber(phone, code);
setChangePhoneCaptchaChallenge(null);
setUser(nextUser);
}}
/>
) : null}
<LoginScreen
isOpen={showLoginModal}
platformTheme={settings.platformTheme}
availableLoginMethods={availableLoginMethods}
sendingCode={sendingCode}
loggingIn={loggingIn}
wechatLoading={wechatLoading}
error={error}
captchaChallenge={loginCaptchaChallenge}
onClose={closeLoginModal}
onSendCode={async (phone, captcha) => {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(phone, 'login', captcha);
setLoginCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setLoginCaptchaChallenge(captchaChallenge);
}
setError(
sendError instanceof Error
? sendError.message
: '发送验证码失败,请稍后再试。',
);
throw sendError;
} finally {
setSendingCode(false);
} }
throw sendError; }}
} onSubmit={async (phone, code) => {
}} setLoggingIn(true);
onChangePhone={async (phone, code) => { setError('');
const nextUser = await changePhoneNumber(phone, code); try {
setChangePhoneCaptchaChallenge(null); const nextUser = await loginWithPhoneCode(phone, code);
setUser(nextUser); setLoginCaptchaChallenge(null);
}} setUser(nextUser);
/> setStatus('ready');
} catch (loginError) {
setError(
loginError instanceof Error
? loginError.message
: '登录失败,请稍后再试。',
);
} finally {
setLoggingIn(false);
}
}}
onStartWechatLogin={async () => {
setWechatLoading(true);
setError('');
try {
await startWechatLogin();
} catch (wechatError) {
setError(
wechatError instanceof Error
? wechatError.message
: '微信登录暂不可用,请稍后再试。',
);
} finally {
setWechatLoading(false);
}
}}
/>
</div>
{children} {children}
</div> </div>
</AuthUiContext.Provider> </AuthUiContext.Provider>

View File

@@ -1,12 +1,30 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type { AuthUser } from '../../services/authService'; import type { AuthUser } from '../../services/authService';
export type PlatformSettingsSection =
| 'appearance'
| 'account'
| 'security'
| 'devices'
| 'logs';
type AuthUiContextValue = { type AuthUiContextValue = {
user: AuthUser | null; user: AuthUser | null;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void; openAccountModal: () => void;
logout: () => Promise<void>; logout: () => Promise<void>;
setGlobalAccountActionsVisible: (visible: boolean) => void; setGlobalAccountActionsVisible: (visible: boolean) => void;
musicVolume: number;
setMusicVolume: (value: number) => void;
platformTheme: PlatformTheme;
setPlatformTheme: (theme: PlatformTheme) => void;
isHydratingSettings: boolean;
isPersistingSettings: boolean;
settingsError: string | null;
}; };
export const AuthUiContext = createContext<AuthUiContextValue | null>(null); export const AuthUiContext = createContext<AuthUiContextValue | null>(null);

View File

@@ -1,10 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService'; import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField'; import { CaptchaChallengeField } from './CaptchaChallengeField';
type BindPhoneScreenProps = { type BindPhoneScreenProps = {
user: AuthUser; user: AuthUser;
platformTheme: PlatformTheme;
sendingCode: boolean; sendingCode: boolean;
binding: boolean; binding: boolean;
error: string; error: string;
@@ -25,6 +27,7 @@ type BindPhoneScreenProps = {
export function BindPhoneScreen({ export function BindPhoneScreen({
user, user,
platformTheme,
sendingCode, sendingCode,
binding, binding,
error, error,
@@ -54,24 +57,24 @@ export function BindPhoneScreen({
}, [cooldownSeconds]); }, [cooldownSeconds]);
return ( return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.14),_transparent_42%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8"> <div className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}>
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]"> <div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-emerald-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.05fr_0.95fr]"> <div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
<div className="border-b border-emerald-200/10 bg-[linear-gradient(135deg,_rgba(16,185,129,0.14),_rgba(59,130,246,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12"> <div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<div className="selection-hero-brand selection-hero-brand--left"> <div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__title"></div> <div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div> <div className="selection-hero-brand__subtitle"> RPG</div>
</div> </div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-emerald-200/70"> <p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
</p> </p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl"> <h1 className="mt-3 text-3xl font-semibold tracking-tight text-[var(--platform-text-strong)] md:text-4xl">
</h1> </h1>
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300"> <p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]">
</p> </p>
<div className="mt-8 rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300"> <div className="platform-subpanel mt-8 rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
{user.displayName} {user.displayName}
</div> </div>
</div> </div>
@@ -83,10 +86,10 @@ export function BindPhoneScreen({
void onSubmit(phone, code); void onSubmit(phone, code);
}} }}
> >
<label className="grid gap-2 text-sm text-zinc-300"> <label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span> <span></span>
<input <input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40" className="platform-input"
autoComplete="tel" autoComplete="tel"
inputMode="numeric" inputMode="numeric"
value={phone} value={phone}
@@ -95,11 +98,11 @@ export function BindPhoneScreen({
/> />
</label> </label>
<label className="grid gap-2 text-sm text-zinc-300"> <label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span> <span></span>
<div className="flex gap-3"> <div className="flex gap-3">
<input <input
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40" className="platform-input min-w-0 flex-1"
inputMode="numeric" inputMode="numeric"
value={code} value={code}
onChange={(event) => setCode(event.target.value)} onChange={(event) => setCode(event.target.value)}
@@ -108,7 +111,7 @@ export function BindPhoneScreen({
<button <button
type="button" type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()} disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-12 shrink-0 rounded-2xl border border-emerald-300/25 px-4 text-sm font-medium text-emerald-100 transition hover:border-emerald-300/55 hover:bg-emerald-300/10 disabled:cursor-not-allowed disabled:opacity-55" className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => { onClick={() => {
void (async () => { void (async () => {
try { try {
@@ -137,7 +140,7 @@ export function BindPhoneScreen({
</label> </label>
{hint ? ( {hint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100"> <div className="platform-banner platform-banner--success text-sm">
{hint} {hint}
</div> </div>
) : null} ) : null}
@@ -149,7 +152,7 @@ export function BindPhoneScreen({
/> />
{error ? ( {error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100"> <div className="platform-banner platform-banner--danger text-sm">
{error} {error}
</div> </div>
) : null} ) : null}
@@ -157,14 +160,14 @@ export function BindPhoneScreen({
<button <button
type="submit" type="submit"
disabled={binding || !phone.trim() || !code.trim()} disabled={binding || !phone.trim() || !code.trim()}
className="h-12 rounded-2xl bg-[linear-gradient(135deg,_#10b981,_#22c55e)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60" className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
> >
{binding ? '正在绑定...' : '绑定手机号并进入游戏'} {binding ? '正在绑定...' : '绑定手机号并进入游戏'}
</button> </button>
<button <button
type="button" type="button"
className="h-11 rounded-2xl border border-white/10 px-4 text-sm text-zinc-300 transition hover:border-white/25 hover:text-white" className="platform-button platform-button--ghost h-11 px-4 text-sm"
onClick={() => { onClick={() => {
void onLogout(); void onLogout();
}} }}

View File

@@ -16,15 +16,15 @@ export function CaptchaChallengeField({
} }
return ( return (
<div className="grid gap-3 rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-4"> <div className="platform-banner platform-banner--info grid gap-3">
<div className="text-sm leading-6 text-sky-100">{challenge.promptText}</div> <div className="text-sm leading-6">{challenge.promptText}</div>
<img <img
src={challenge.imageDataUrl} src={challenge.imageDataUrl}
alt="图形验证码" alt="图形验证码"
className="h-14 w-40 rounded-2xl border border-white/10 bg-black/20 object-cover" className="platform-subpanel h-14 w-40 rounded-2xl object-cover"
/> />
<input <input
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-sky-300/45 focus:bg-black/40" className="platform-input h-11"
value={answer} value={answer}
placeholder="输入图形验证码" placeholder="输入图形验证码"
onChange={(event) => onAnswerChange(event.target.value)} onChange={(event) => onAnswerChange(event.target.value)}

View File

@@ -1,5 +1,7 @@
import { X } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type { import type {
AuthCaptchaChallenge, AuthCaptchaChallenge,
AuthLoginMethod, AuthLoginMethod,
@@ -7,12 +9,15 @@ import type {
import { CaptchaChallengeField } from './CaptchaChallengeField'; import { CaptchaChallengeField } from './CaptchaChallengeField';
type LoginScreenProps = { type LoginScreenProps = {
isOpen: boolean;
platformTheme: PlatformTheme;
availableLoginMethods: AuthLoginMethod[]; availableLoginMethods: AuthLoginMethod[];
sendingCode: boolean; sendingCode: boolean;
loggingIn: boolean; loggingIn: boolean;
wechatLoading: boolean; wechatLoading: boolean;
error: string; error: string;
captchaChallenge: AuthCaptchaChallenge | null; captchaChallenge: AuthCaptchaChallenge | null;
onClose: () => void;
onSendCode: ( onSendCode: (
phone: string, phone: string,
captcha?: { captcha?: {
@@ -28,12 +33,15 @@ type LoginScreenProps = {
}; };
export function LoginScreen({ export function LoginScreen({
isOpen,
platformTheme,
availableLoginMethods, availableLoginMethods,
sendingCode, sendingCode,
loggingIn, loggingIn,
wechatLoading, wechatLoading,
error, error,
captchaChallenge, captchaChallenge,
onClose,
onSendCode, onSendCode,
onSubmit, onSubmit,
onStartWechatLogin, onStartWechatLogin,
@@ -42,7 +50,6 @@ export function LoginScreen({
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [captchaAnswer, setCaptchaAnswer] = useState(''); const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0); const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
const phoneLoginEnabled = availableLoginMethods.includes('phone'); const phoneLoginEnabled = availableLoginMethods.includes('phone');
const wechatLoginEnabled = availableLoginMethods.includes('wechat'); const wechatLoginEnabled = availableLoginMethods.includes('wechat');
@@ -60,159 +67,146 @@ export function LoginScreen({
}; };
}, [cooldownSeconds]); }, [cooldownSeconds]);
if (!isOpen) {
return null;
}
return ( return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8"> <div
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]"> className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.08fr_0.92fr]"> onClick={onClose}
<div className="border-b border-amber-200/10 bg-[linear-gradient(135deg,_rgba(245,158,11,0.16),_rgba(20,184,166,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12"> >
<div className="selection-hero-brand selection-hero-brand--left"> <div
<div className="selection-hero-brand__title"></div> role="dialog"
<div className="selection-hero-brand__subtitle"> RPG</div> aria-modal="true"
</div> aria-labelledby="auth-login-dialog-title"
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-amber-200/70"> className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
onClick={(event) => {
</p> event.stopPropagation();
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl"> }}
>
</h1> <div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300"> <div
id="auth-login-dialog-title"
</p> className="text-lg font-semibold text-[var(--platform-text-strong)]"
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
使
</div>
</div>
</div>
<form
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
onSubmit={(event) => {
event.preventDefault();
if (!phoneLoginEnabled) {
return;
}
void onSubmit(phone, code);
}}
> >
{phoneLoginEnabled ? (
<> </div>
<label className="grid gap-2 text-sm text-zinc-300"> <button
<span></span> type="button"
<input onClick={onClose}
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40" className="platform-icon-button p-2"
autoComplete="tel" aria-label="关闭登录弹窗"
inputMode="numeric" >
value={phone} <X className="h-4 w-4" />
onChange={(event) => setPhone(event.target.value)} </button>
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<div className="flex gap-3">
<input
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
try {
const result = await onSendCode(phone, {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch {
setHint('');
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{hint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{hint}
</div>
) : null}
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
</>
) : null}
{phoneLoginEnabled || wechatLoginEnabled ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
{phoneLoginEnabled && wechatLoginEnabled
? '手机号可直接登录,也可以先用微信。'
: phoneLoginEnabled
? '当前开放手机号登录。'
: '当前开放微信登录。'}
</div>
) : null}
{error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
</div>
) : null}
{phoneLoginEnabled ? (
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim()}
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '正在进入...' : '登录并进入游戏'}
</button>
) : null}
{wechatLoginEnabled ? (
<button
type="button"
disabled={wechatLoading || sendingCode || loggingIn}
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void onStartWechatLogin();
}}
>
{wechatLoading ? '正在跳转微信...' : '微信登录'}
</button>
) : null}
{!phoneLoginEnabled && !wechatLoginEnabled ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
</div>
) : null}
</form>
</div> </div>
<form
className="flex flex-col gap-4 px-5 py-5"
onSubmit={(event) => {
event.preventDefault();
if (!phoneLoginEnabled) {
return;
}
void onSubmit(phone, code);
}}
>
{phoneLoginEnabled ? (
<>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<div className="flex gap-3">
<input
className="platform-input min-w-0 flex-1"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
try {
const result = await onSendCode(phone, {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setCaptchaAnswer('');
} catch {
// Error state is handled by the parent.
}
})();
}}
>
{sendingCode
? '发送中'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
</>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger text-sm">
{error}
</div>
) : null}
{phoneLoginEnabled ? (
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim()}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '登录中' : '登录'}
</button>
) : null}
{wechatLoginEnabled ? (
<button
type="button"
disabled={wechatLoading || sendingCode || loggingIn}
className="platform-button platform-button--secondary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void onStartWechatLogin();
}}
>
{wechatLoading ? '跳转中' : '微信登录'}
</button>
) : null}
{!phoneLoginEnabled && !wechatLoginEnabled ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
</div>
) : null}
</form>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,150 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import {
buildCustomWorldPlayableCharacters,
} from '../../data/characterPresets';
import {
type Character,
type CustomWorldProfile,
WorldType,
} from '../../types';
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
vi.mock('../../data/characterPresets', () => ({
ROLE_TEMPLATE_CHARACTERS: [],
buildCustomWorldPlayableCharacters: vi.fn(),
}));
vi.mock('../CharacterAnimator', () => ({
CharacterAnimator: ({ character }: { character: Character }) => (
<div>{character.name}</div>
),
}));
vi.mock('../CharacterDetailModal', () => ({
CharacterDetailModal: () => null,
}));
vi.mock('../SelectionCustomizationModals', () => ({
CharacterDraftModal: () => null,
}));
function createCharacter(name: string, title: string): Character {
return {
id: '',
name,
title,
description: `${name}的定位描述`,
backstory: `${name}的背景故事`,
personality: `${name} 冷静 果断`,
gender: 'female',
portrait: `/portraits/${name}.png`,
attributes: {
strength: 10,
agility: 11,
intelligence: 12,
spirit: 13,
},
skills: [],
} as unknown as Character;
}
afterEach(() => {
vi.restoreAllMocks();
});
test('custom world character selection stays stable when character ids are empty', async () => {
const user = userEvent.setup();
const handleConfirm = vi.fn();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
vi.mocked(buildCustomWorldPlayableCharacters).mockReturnValue([
createCharacter('沈砺', '潮锋斥候'),
createCharacter('闻潮', '雾海哨兵'),
]);
HTMLElement.prototype.scrollTo = function scrollTo(
this: HTMLElement,
options?: ScrollToOptions | number,
) {
if (typeof options === 'object' && options) {
if (typeof options.left === 'number') {
this.scrollLeft = options.left;
}
if (typeof options.top === 'number') {
this.scrollTop = options.top;
}
}
this.dispatchEvent(new Event('scroll'));
};
vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function mockGetBoundingClientRect(this: HTMLElement) {
if ((this as HTMLElement).dataset.carouselCard === 'true') {
return {
width: 240,
height: 360,
top: 0,
right: 240,
bottom: 360,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
return {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
});
render(
<CharacterSelectionFlow
worldType={WorldType.CUSTOM}
customWorldProfile={{} as CustomWorldProfile}
onBack={() => {}}
onConfirm={handleConfirm}
/>,
);
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: //u }));
expect(handleConfirm).toHaveBeenCalledWith(
expect.objectContaining({
name: '闻潮',
title: '雾海哨兵',
}),
);
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
call.some(
(arg) =>
typeof arg === 'string'
&& arg.includes('Encountered two children with the same key'),
),
);
expect(duplicateKeyCalls).toHaveLength(0);
});

View File

@@ -63,6 +63,21 @@ function getCharacterMeta(
}; };
} }
function buildSelectionCharacterKey(character: Character, index: number) {
const normalizedId = character.id.trim();
if (normalizedId) {
return normalizedId;
}
const fallbackSeed =
character.name.trim()
|| character.title.trim()
|| character.description.trim()
|| 'character';
return `selection-character-${index}-${fallbackSeed}`;
}
function applyCharacterSelectionDraft( function applyCharacterSelectionDraft(
character: Character | null, character: Character | null,
draft?: CharacterSelectionDraft | null, draft?: CharacterSelectionDraft | null,
@@ -163,7 +178,15 @@ export function CharacterSelectionFlow({
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS), () => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
[customWorldProfile], [customWorldProfile],
); );
const [selectedCharacterId, setSelectedCharacterId] = useState(selectionCharacters[0]?.id ?? ''); const selectionEntries = useMemo(
() =>
selectionCharacters.map((character, index) => ({
character,
selectionKey: buildSelectionCharacterKey(character, index),
})),
[selectionCharacters],
);
const [selectedCharacterKey, setSelectedCharacterKey] = useState(selectionEntries[0]?.selectionKey ?? '');
const [detailCharacter, setDetailCharacter] = useState<Character | null>(null); const [detailCharacter, setDetailCharacter] = useState<Character | null>(null);
const characterCarouselRef = useRef<HTMLDivElement | null>(null); const characterCarouselRef = useRef<HTMLDivElement | null>(null);
const [characterCarouselProgress, setCharacterCarouselProgress] = useState(0); const [characterCarouselProgress, setCharacterCarouselProgress] = useState(0);
@@ -173,11 +196,14 @@ export function CharacterSelectionFlow({
const [characterDraftError, setCharacterDraftError] = useState<string | null>(null); const [characterDraftError, setCharacterDraftError] = useState<string | null>(null);
const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState<Record<string, CharacterSelectionDraft>>({}); const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState<Record<string, CharacterSelectionDraft>>({});
const selectedCharacter = useMemo( const selectedCharacterEntry = useMemo(
() => selectionCharacters.find(character => character.id === selectedCharacterId) ?? selectionCharacters[0] ?? null, () => selectionEntries.find(entry => entry.selectionKey === selectedCharacterKey) ?? selectionEntries[0] ?? null,
[selectedCharacterId, selectionCharacters], [selectedCharacterKey, selectionEntries],
); );
const selectedCharacterDraft = selectedCharacter ? characterSelectionDrafts[selectedCharacter.id] ?? null : null; const selectedCharacter = selectedCharacterEntry?.character ?? null;
const selectedCharacterDraft = selectedCharacterEntry
? characterSelectionDrafts[selectedCharacterEntry.selectionKey] ?? null
: null;
const selectedCharacterPreview = useMemo( const selectedCharacterPreview = useMemo(
() => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft), () => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft),
[selectedCharacter, selectedCharacterDraft], [selectedCharacter, selectedCharacterDraft],
@@ -203,21 +229,21 @@ export function CharacterSelectionFlow({
}, [syncCharacterCarousel]); }, [syncCharacterCarousel]);
useEffect(() => { useEffect(() => {
const focusedCharacter = selectionCharacters[focusedCharacterIndex]; const focusedEntry = selectionEntries[focusedCharacterIndex];
if (focusedCharacter && focusedCharacter.id !== selectedCharacterId) { if (focusedEntry && focusedEntry.selectionKey !== selectedCharacterKey) {
setSelectedCharacterId(focusedCharacter.id); setSelectedCharacterKey(focusedEntry.selectionKey);
} }
}, [focusedCharacterIndex, selectedCharacterId, selectionCharacters]); }, [focusedCharacterIndex, selectedCharacterKey, selectionEntries]);
useEffect(() => { useEffect(() => {
if (selectionCharacters.length === 0) return; if (selectionEntries.length === 0) return;
if (!selectionCharacters.some(character => character.id === selectedCharacterId)) { if (!selectionEntries.some(entry => entry.selectionKey === selectedCharacterKey)) {
const firstCharacter = selectionCharacters[0]; const firstEntry = selectionEntries[0];
if (firstCharacter) { if (firstEntry) {
setSelectedCharacterId(firstCharacter.id); setSelectedCharacterKey(firstEntry.selectionKey);
} }
} }
}, [selectedCharacterId, selectionCharacters]); }, [selectedCharacterKey, selectionEntries]);
const openCharacterDraftEditor = () => { const openCharacterDraftEditor = () => {
if (!selectedCharacterPreview) return; if (!selectedCharacterPreview) return;
@@ -228,7 +254,7 @@ export function CharacterSelectionFlow({
}; };
const saveCharacterDraft = () => { const saveCharacterDraft = () => {
if (!selectedCharacter) return; if (!selectedCharacter || !selectedCharacterEntry) return;
const nextName = characterDraftName.trim(); const nextName = characterDraftName.trim();
const nextBackstory = characterDraftBackstory.trim(); const nextBackstory = characterDraftBackstory.trim();
@@ -243,7 +269,7 @@ export function CharacterSelectionFlow({
setCharacterSelectionDrafts(current => ({ setCharacterSelectionDrafts(current => ({
...current, ...current,
[selectedCharacter.id]: { [selectedCharacterEntry.selectionKey]: {
name: nextName, name: nextName,
backstory: nextBackstory, backstory: nextBackstory,
}, },
@@ -278,17 +304,17 @@ export function CharacterSelectionFlow({
onScroll={syncCharacterCarousel} onScroll={syncCharacterCarousel}
className="character-carousel scrollbar-hide flex-[1_1_auto]" className="character-carousel scrollbar-hide flex-[1_1_auto]"
> >
{selectionCharacters.map((character, index) => { {selectionEntries.map(({ character, selectionKey }, index) => {
const characterDraft = characterSelectionDrafts[character.id]; const characterDraft = characterSelectionDrafts[selectionKey];
const meta = getCharacterMeta(character, {name: characterDraft?.name}); const meta = getCharacterMeta(character, {name: characterDraft?.name});
const selected = character.id === selectedCharacter.id; const selected = selectionKey === selectedCharacterKey;
return ( return (
<button <button
key={character.id} key={selectionKey}
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedCharacterId(character.id); setSelectedCharacterKey(selectionKey);
scrollCarouselToIndex(characterCarouselRef.current, index, 'horizontal'); scrollCarouselToIndex(characterCarouselRef.current, index, 'horizontal');
}} }}
data-carousel-card="true" data-carousel-card="true"

View File

@@ -104,7 +104,7 @@ export function GameShellMainContent({
isCharacterSelectionStage: boolean; isCharacterSelectionStage: boolean;
hasSavedGame: boolean; hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null; savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void; handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void; handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleBackToWorldSelect: () => void; handleBackToWorldSelect: () => void;
@@ -132,15 +132,21 @@ export function GameShellMainContent({
resetForSaveAndExit: () => void; resetForSaveAndExit: () => void;
handleSaveAndExit: () => void; handleSaveAndExit: () => void;
}) { }) {
const isPlatformShell = !gameState.worldType;
return ( return (
<div <div
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`} className={`${isPlatformShell ? 'platform-main-shell' : 'pixel-app-shell'} flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
style={{ style={{
background: isCharacterSelectionStage background: isPlatformShell
? 'transparent'
: isCharacterSelectionStage
? '#0d1016' ? '#0d1016'
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`, : `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: isCharacterSelectionStage ? undefined : 'center', backgroundPosition:
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat', isPlatformShell || isCharacterSelectionStage ? undefined : 'center',
backgroundRepeat:
isPlatformShell || isCharacterSelectionStage ? undefined : 'repeat',
}} }}
> >
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">

View File

@@ -21,6 +21,11 @@ const GameShellCanvasStage = lazy(async () => {
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) { export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
const authUi = useAuthUi(); const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const { const {
gameState, gameState,
isLoading, isLoading,
@@ -99,20 +104,25 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
}); });
useEffect(() => { useEffect(() => {
authUi?.setGlobalAccountActionsVisible(Boolean(gameState.playerCharacter)); authUi?.setGlobalAccountActionsVisible(false);
return () => { return () => {
authUi?.setGlobalAccountActionsVisible(true); authUi?.setGlobalAccountActionsVisible(true);
}; };
}, [authUi, gameState.playerCharacter]); }, [authUi]);
return ( return (
<div <div
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100" className={`${isPlatformShell ? `platform-ui-shell platform-theme ${platformThemeClass} text-[var(--platform-text-strong)]` : 'fusion-pixel-app pixel-root-shell text-zinc-100'} flex h-screen max-h-screen flex-col overflow-hidden font-sans`}
style={{ style={{
backgroundImage: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`, background: isPlatformShell
backgroundPosition: 'center', ? 'var(--platform-body-fill)'
backgroundRepeat: 'repeat', : undefined,
backgroundImage: isPlatformShell
? undefined
: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: isPlatformShell ? undefined : 'center',
backgroundRepeat: isPlatformShell ? undefined : 'repeat',
}} }}
> >
<Suspense fallback={null}> <Suspense fallback={null}>

View File

@@ -1,6 +1,4 @@
import { X } from 'lucide-react'; import { ArrowRight, X } from 'lucide-react';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
type PlatformCreationTypeModalProps = { type PlatformCreationTypeModalProps = {
isOpen: boolean; isOpen: boolean;
@@ -55,25 +53,27 @@ function CreationTypeCard(props: {
type="button" type="button"
disabled={disabled} disabled={disabled}
onClick={onSelect} onClick={onSelect}
className={`relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left transition ${ className={`platform-interactive-card relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
item.locked item.locked
? 'cursor-not-allowed border-white/8 bg-white/5 text-zinc-500' ? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]'
: 'border-emerald-300/18 bg-[radial-gradient(circle_at_top_left,rgba(110,231,183,0.16),transparent_36%),linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0.02))] text-white hover:border-emerald-300/35' : 'border-[var(--platform-cool-border)] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_34%),linear-gradient(135deg,rgba(255,79,139,0.96),rgba(255,145,110,0.9))] text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`} } ${busy && !item.locked ? 'opacity-70' : ''}`}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<span <span
className={`rounded-full px-3 py-1 text-[10px] tracking-[0.18em] ${ className={`platform-pill px-3 ${
item.locked item.locked
? 'border border-white/8 bg-black/18 text-zinc-400' ? 'platform-pill--neutral text-[var(--platform-text-soft)]'
: 'border border-emerald-300/20 bg-emerald-500/10 text-emerald-100' : 'platform-pill--neutral border-white/30 bg-white/18 text-white'
}`} }`}
> >
{item.locked ? item.badge : busy ? '正在开启' : item.badge} {item.locked ? item.badge : busy ? '正在开启' : item.badge}
</span> </span>
<span className="text-lg leading-none text-white/45"> {item.locked ? (
{item.locked ? '·' : '→'} <span className="text-lg leading-none text-white/45">·</span>
</span> ) : (
<ArrowRight className="h-4 w-4 text-white/80" />
)}
</div> </div>
<div className="mt-8 text-xl font-black leading-tight text-inherit"> <div className="mt-8 text-xl font-black leading-tight text-inherit">
{item.title} {item.title}
@@ -101,21 +101,15 @@ export function PlatformCreationTypeModal({
} }
return ( return (
<div className="fixed inset-0 z-[90] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center sm:p-4"> <div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div <div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
className="pixel-nine-slice w-full max-w-3xl" <div className="bg-transparent">
style={getNineSliceStyle(UI_CHROME.modalPanel, { <div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
paddingX: 18,
paddingY: 18,
})}
>
<div className="rounded-[1.8rem] bg-[linear-gradient(180deg,rgba(11,16,22,0.98),rgba(8,10,14,0.98))]">
<div className="flex items-start justify-between gap-3 border-b border-white/8 px-4 py-4 sm:px-5">
<div> <div>
<div className="text-base font-semibold text-white"> <div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div> </div>
<div className="mt-1 text-xs text-zinc-400"> <div className="mt-1 text-xs text-[var(--platform-text-base)]">
</div> </div>
</div> </div>
@@ -123,7 +117,7 @@ export function PlatformCreationTypeModal({
type="button" type="button"
onClick={onClose} onClick={onClose}
disabled={isBusy} disabled={isBusy}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
@@ -146,7 +140,7 @@ export function PlatformCreationTypeModal({
</div> </div>
{error ? ( {error ? (
<div className="mt-4 rounded-[1.25rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100"> <div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error} {error}
</div> </div>
) : null} ) : null}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { ArrowLeft } from 'lucide-react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { import {
buildPlatformWorldTags, buildPlatformWorldTags,
describePlatformThemeLabel, describePlatformThemeLabel,
@@ -23,17 +24,17 @@ function ActionButton({
}) { }) {
const toneClass = const toneClass =
tone === 'primary' tone === 'primary'
? 'border-sky-300/25 bg-sky-500/10 text-sky-100 hover:border-sky-300/45 hover:text-white' ? 'platform-button platform-button--primary'
: tone === 'danger' : tone === 'danger'
? 'border-rose-400/25 bg-rose-500/10 text-rose-100 hover:border-rose-400/45 hover:text-white' ? 'platform-button platform-button--danger'
: 'border-white/10 bg-black/20 text-zinc-200 hover:border-white/20 hover:text-white'; : 'platform-button platform-button--secondary';
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={`rounded-full border px-4 py-2 text-sm transition ${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`} className={`${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
> >
{label} {label}
</button> </button>
@@ -81,30 +82,24 @@ export function PlatformWorldDetailView({
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}
className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white" className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]"
> >
<ArrowLeft className="h-4 w-4" />
广 广
</button> </button>
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300"> <div className="platform-pill platform-pill--neutral px-3 py-1.5 text-[11px] tracking-[0.08em]">
{entry.visibility === 'published' ? '已发布' : '草稿'} {entry.visibility === 'published' ? '已发布' : '草稿'}
</div> </div>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"> <div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
<div className="space-y-4 pb-2"> <div className="space-y-4 pb-2">
<div <div className="platform-surface platform-surface--hero relative overflow-hidden px-[18px] py-4">
className="pixel-nine-slice relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
{coverImage ? ( {coverImage ? (
<img <img
src={coverImage} src={coverImage}
alt={entry.worldName} alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-38" className="absolute inset-0 h-full w-full object-cover opacity-38"
style={{ imageRendering: 'pixelated' }}
/> />
) : null} ) : null}
{leadPortrait ? ( {leadPortrait ? (
@@ -113,19 +108,18 @@ export function PlatformWorldDetailView({
alt="" alt=""
aria-hidden="true" aria-hidden="true"
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25" className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
style={{ imageRendering: 'pixelated' }}
/> />
) : null} ) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.9))]" /> <div className="absolute inset-0 bg-[linear-gradient(125deg,rgba(255,31,111,0.78),rgba(255,138,115,0.52)_48%,rgba(255,255,255,0.08)_100%)]" />
<div className="relative z-10"> <div className="relative z-10">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-100"> <span className="platform-pill platform-pill--warm">
{describePlatformThemeLabel(entry.themeMode)} {describePlatformThemeLabel(entry.themeMode)}
</span> </span>
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100"> <span className="platform-pill platform-pill--neutral px-3">
{entry.authorDisplayName} {entry.authorDisplayName}
</span> </span>
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100"> <span className="platform-pill platform-pill--neutral px-3">
{entry.visibility === 'published' {entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}` ? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '仅自己可见'} : '仅自己可见'}
@@ -146,7 +140,7 @@ export function PlatformWorldDetailView({
{tags.map((tag, index) => ( {tags.map((tag, index) => (
<span <span
key={`world-detail-tag-${index}-${tag || 'empty'}`} key={`world-detail-tag-${index}-${tag || 'empty'}`}
className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100" className="platform-pill platform-pill--neutral px-3"
> >
{tag} {tag}
</span> </span>
@@ -156,18 +150,12 @@ export function PlatformWorldDetailView({
</div> </div>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]"> <div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div <div className="platform-surface platform-surface--soft px-4 py-3.5">
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="text-[10px] tracking-[0.22em] text-zinc-500"> <div className="text-[10px] tracking-[0.22em] text-zinc-500">
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-zinc-100 sm:grid-cols-4"> <div className="mt-3 grid grid-cols-2 gap-3 text-sm text-zinc-100 sm:grid-cols-4">
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3"> <div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div> </div>
@@ -175,7 +163,7 @@ export function PlatformWorldDetailView({
{entry.playableNpcCount} {entry.playableNpcCount}
</div> </div>
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3"> <div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div> </div>
@@ -183,7 +171,7 @@ export function PlatformWorldDetailView({
{entry.landmarkCount} {entry.landmarkCount}
</div> </div>
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3"> <div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div> </div>
@@ -191,7 +179,7 @@ export function PlatformWorldDetailView({
{entry.profile.majorFactions.length} {entry.profile.majorFactions.length}
</div> </div>
</div> </div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3"> <div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"> <div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div> </div>
@@ -209,7 +197,7 @@ export function PlatformWorldDetailView({
{previewCharacters.map((character, index) => ( {previewCharacters.map((character, index) => (
<div <div
key={character.id || `preview-character-${index}`} key={character.id || `preview-character-${index}`}
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3" className="platform-subpanel rounded-2xl px-3 py-3"
> >
<div className="line-clamp-1 text-sm font-bold text-white"> <div className="line-clamp-1 text-sm font-bold text-white">
{character.title} {character.title}
@@ -230,7 +218,7 @@ export function PlatformWorldDetailView({
{previewLandmarks.map((landmark, index) => ( {previewLandmarks.map((landmark, index) => (
<div <div
key={landmark.id || `preview-landmark-${index}`} key={landmark.id || `preview-landmark-${index}`}
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3" className="platform-subpanel rounded-2xl px-3 py-3"
> >
<div className="line-clamp-1 text-sm font-bold text-white"> <div className="line-clamp-1 text-sm font-bold text-white">
{landmark.name} {landmark.name}
@@ -244,13 +232,7 @@ export function PlatformWorldDetailView({
</div> </div>
</div> </div>
<div <div className="platform-surface platform-surface--soft px-4 py-3.5">
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="text-[10px] tracking-[0.22em] text-zinc-500"> <div className="text-[10px] tracking-[0.22em] text-zinc-500">
</div> </div>

View File

@@ -17,15 +17,21 @@ import type { AuthUser } from '../../services/authService';
import { import {
clearProfileBrowseHistory, clearProfileBrowseHistory,
deleteCustomWorldProfile, deleteCustomWorldProfile,
getCustomWorldGalleryDetail,
getProfileDashboard, getProfileDashboard,
listCustomWorldGallery, listCustomWorldGallery,
listCustomWorldLibrary, listCustomWorldLibrary,
listProfileBrowseHistory, listProfileBrowseHistory,
listProfileSaveArchives,
resumeProfileSaveArchive,
upsertCustomWorldProfile, upsertCustomWorldProfile,
upsertProfileBrowseHistory, upsertProfileBrowseHistory,
} from '../../services/storageService'; } from '../../services/storageService';
import type { GameState } from '../../types'; import type { GameState } from '../../types';
import { AuthUiContext } from '../auth/AuthUiContext'; import {
type PlatformSettingsSection,
AuthUiContext,
} from '../auth/AuthUiContext';
import { import {
PreGameSelectionFlow, PreGameSelectionFlow,
type SelectionStage, type SelectionStage,
@@ -48,7 +54,9 @@ vi.mock('../../services/storageService', () => ({
listCustomWorldGallery: vi.fn(), listCustomWorldGallery: vi.fn(),
listCustomWorldLibrary: vi.fn(), listCustomWorldLibrary: vi.fn(),
listProfileBrowseHistory: vi.fn(), listProfileBrowseHistory: vi.fn(),
listProfileSaveArchives: vi.fn(),
publishCustomWorldProfile: vi.fn(), publishCustomWorldProfile: vi.fn(),
resumeProfileSaveArchive: vi.fn(),
syncProfileBrowseHistory: vi.fn(), syncProfileBrowseHistory: vi.fn(),
unpublishCustomWorldProfile: vi.fn(), unpublishCustomWorldProfile: vi.fn(),
upsertProfileBrowseHistory: vi.fn(), upsertProfileBrowseHistory: vi.fn(),
@@ -179,7 +187,32 @@ const mockAuthUser: AuthUser = {
wechatBound: false, wechatBound: false,
}; };
function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) { type TestAuthValue = {
user: AuthUser | null;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
logout: () => Promise<void>;
setGlobalAccountActionsVisible: (visible: boolean) => void;
musicVolume: number;
setMusicVolume: (value: number) => void;
platformTheme: 'light' | 'dark';
setPlatformTheme: (theme: 'light' | 'dark') => void;
isHydratingSettings: boolean;
isPersistingSettings: boolean;
settingsError: string | null;
};
function TestWrapper({
withAuth = false,
authValue,
onContinueGame,
}: {
withAuth?: boolean;
authValue?: TestAuthValue;
onContinueGame?: (snapshot?: unknown) => void;
} = {}) {
const [selectionStage, setSelectionStage] = const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform'); useState<SelectionStage>('platform');
@@ -190,24 +223,36 @@ function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) {
gameState={{} as GameState} gameState={{} as GameState}
hasSavedGame={false} hasSavedGame={false}
savedSnapshot={null} savedSnapshot={null}
handleContinueGame={() => {}} handleContinueGame={onContinueGame ?? (() => {})}
handleStartNewGame={() => {}} handleStartNewGame={() => {}}
handleCustomWorldSelect={() => {}} handleCustomWorldSelect={() => {}}
/> />
); );
if (!withAuth) { if (!withAuth && !authValue) {
return content; return content;
} }
return ( return (
<AuthUiContext.Provider <AuthUiContext.Provider
value={{ value={
user: mockAuthUser, authValue ?? {
openAccountModal: () => {}, user: mockAuthUser,
logout: async () => {}, openLoginModal: () => {},
setGlobalAccountActionsVisible: () => {}, requireAuth: (action) => action(),
}} openSettingsModal: () => {},
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
musicVolume: 0.42,
setMusicVolume: () => {},
platformTheme: 'light',
setPlatformTheme: () => {},
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}
}
> >
{content} {content}
</AuthUiContext.Provider> </AuthUiContext.Provider>
@@ -228,6 +273,27 @@ beforeEach(() => {
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]); vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
vi.mocked(listCustomWorldGallery).mockResolvedValue([]); vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]); vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(listProfileSaveArchives).mockResolvedValue([]);
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
entry: {
worldKey: 'custom:world-archive-1',
ownerUserId: null,
profileId: 'world-archive-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T12:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {} as GameState,
},
});
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]); vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]); vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]); vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
@@ -309,6 +375,75 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
).toBeTruthy(); ).toBeTruthy();
}); });
test('clicking a public work while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
vi.mocked(listCustomWorldGallery).mockResolvedValue([
{
ownerUserId: 'author-1',
profileId: 'world-public-1',
visibility: 'published',
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近公开发布的世界。',
coverImageSrc: null,
themeMode: 'tide',
authorDisplayName: '潮汐作者',
playableNpcCount: 3,
landmarkCount: 4,
},
]);
render(
<TestWrapper
authValue={{
user: null,
openLoginModal: () => {},
requireAuth,
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
}}
/>,
);
const workCards = await screen.findAllByRole('button', {
name: //u,
});
await user.click(workCards[0]!);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(getCustomWorldGalleryDetail).not.toHaveBeenCalled();
});
test('selecting RPG creation while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
render(
<TestWrapper
authValue={{
user: null,
openLoginModal: () => {},
requireAuth,
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
}}
/>,
);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: / RPG/u }));
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createCustomWorldAgentSession).not.toHaveBeenCalled();
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => { test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -472,40 +607,78 @@ test('existing draft sessions enter the legacy result layout directly', async ()
expect(screen.getByText('技能')).toBeTruthy(); expect(screen.getByText('技能')).toBeTruthy();
}); });
test('profile tab loads server browse history and can clear it after confirmation', async () => { test('authenticated users with save archives default into the saves tab', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
vi.mocked(listProfileBrowseHistory).mockResolvedValue([ vi.mocked(listProfileSaveArchives).mockResolvedValue([
{ {
ownerUserId: 'author-1', worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1', profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛', worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路', subtitle: '旧灯塔与失控航路',
summaryText: '最近浏览过的公开作品。', summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null, coverImageSrc: null,
themeMode: 'tide', lastPlayedAt: '2026-04-19T12:00:00.000Z',
authorDisplayName: '潮汐作者',
visitedAt: '2026-04-16T12:00:00.000Z',
}, },
]); ]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<TestWrapper withAuth />); render(<TestWrapper withAuth />);
await user.click(screen.getByRole('button', { name: '我的' })); expect(await screen.findByText('全部存档')).toBeTruthy();
expect(await screen.findByText('潮雾列岛')).toBeTruthy(); expect(await screen.findByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('最近更新时间排序')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '清空' })); test('save tab can resume a selected archive directly into the game', async () => {
const user = userEvent.setup();
const handleContinueGame = vi.fn();
await waitFor(() => { vi.mocked(listProfileSaveArchives).mockResolvedValue([
expect(clearProfileBrowseHistory).toHaveBeenCalledTimes(1); {
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
]);
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
entry: {
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T12:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
} as GameState,
},
}); });
expect( render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
screen.getByText('你最近还没有浏览过作品详情,去首页逛一逛吧。'),
).toBeTruthy(); await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
expect(handleContinueGame).toHaveBeenCalledTimes(1);
});
}); });
test('owned world detail can delete a work and return to the create tab list', async () => { test('owned world detail can delete a work and return to the create tab list', async () => {
@@ -544,10 +717,10 @@ test('owned world detail can delete a work and return to the create tab list', a
]); ]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]); vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
render(<TestWrapper />); render(<TestWrapper withAuth />);
await user.click(screen.getByRole('button', { name: '创作' })); await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(await screen.findByText('潮雾列岛')); await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: '删除作品' })); await user.click(await screen.findByRole('button', { name: '删除作品' }));
await waitFor(() => { await waitFor(() => {

View File

@@ -10,8 +10,8 @@ import {
} from 'react'; } from 'react';
import type { import type {
CustomWorldAgentMessage,
CustomWorldAgentActionRequest, CustomWorldAgentActionRequest,
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot, CustomWorldAgentSessionSnapshot,
SendCustomWorldAgentMessageRequest, SendCustomWorldAgentMessageRequest,
@@ -20,6 +20,7 @@ import type {
CustomWorldGalleryCard, CustomWorldGalleryCard,
CustomWorldLibraryEntry, CustomWorldLibraryEntry,
ProfileDashboardSummary, ProfileDashboardSummary,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
@@ -62,7 +63,9 @@ import {
listCustomWorldGallery, listCustomWorldGallery,
listCustomWorldLibrary, listCustomWorldLibrary,
listProfileBrowseHistory, listProfileBrowseHistory,
listProfileSaveArchives,
publishCustomWorldProfile, publishCustomWorldProfile,
resumeProfileSaveArchive,
syncProfileBrowseHistory, syncProfileBrowseHistory,
unpublishCustomWorldProfile, unpublishCustomWorldProfile,
upsertCustomWorldProfile, upsertCustomWorldProfile,
@@ -115,7 +118,7 @@ type PreGameSelectionFlowProps = {
gameState: GameState; gameState: GameState;
hasSavedGame: boolean; hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null; savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void; handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void; handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
}; };
@@ -198,6 +201,9 @@ export function PreGameSelectionFlow({
const [historyEntries, setHistoryEntries] = useState< const [historyEntries, setHistoryEntries] = useState<
PlatformBrowseHistoryEntry[] PlatformBrowseHistoryEntry[]
>([]); >([]);
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>(
[],
);
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home'); const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
const [selectedDetailEntry, setSelectedDetailEntry] = const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null); useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
@@ -225,10 +231,14 @@ export function PreGameSelectionFlow({
useState<ProfileDashboardSummary | null>(null); useState<ProfileDashboardSummary | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null); const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null); const [historyError, setHistoryError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [detailError, setDetailError] = useState<string | null>(null); const [detailError, setDetailError] = useState<string | null>(null);
const [isLoadingPlatform, setIsLoadingPlatform] = useState(false); const [isLoadingPlatform, setIsLoadingPlatform] = useState(false);
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false); const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
const [isClearingHistory, setIsClearingHistory] = useState(false); const [isClearingHistory, setIsClearingHistory] = useState(false);
const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState<
string | null
>(null);
const [isDetailLoading, setIsDetailLoading] = useState(false); const [isDetailLoading, setIsDetailLoading] = useState(false);
const [isMutatingDetail, setIsMutatingDetail] = useState(false); const [isMutatingDetail, setIsMutatingDetail] = useState(false);
const [customWorldAutoSaveState, setCustomWorldAutoSaveState] = const [customWorldAutoSaveState, setCustomWorldAutoSaveState] =
@@ -245,6 +255,9 @@ export function PreGameSelectionFlow({
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null); const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null); const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
const latestAutoSaveRequestIdRef = useRef(0); const latestAutoSaveRequestIdRef = useRef(0);
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
undefined,
);
const previewCustomWorldCharacters = useMemo( const previewCustomWorldCharacters = useMemo(
() => () =>
@@ -258,6 +271,19 @@ export function PreGameSelectionFlow({
() => publishedGalleryEntries.slice(0, 6), () => publishedGalleryEntries.slice(0, 6),
[publishedGalleryEntries], [publishedGalleryEntries],
); );
const isAuthenticated = Boolean(authUi?.user);
const runProtectedAction = useCallback(
(action: () => void) => {
if (!authUi?.requireAuth) {
action();
return;
}
authUi.requireAuth(action);
},
[authUi],
);
const persistAgentUiState = useCallback( const persistAgentUiState = useCallback(
(nextSessionId: string | null, nextOperationId: string | null) => { (nextSessionId: string | null, nextOperationId: string | null) => {
@@ -278,6 +304,13 @@ export function PreGameSelectionFlow({
}, []); }, []);
const refreshProfileDashboard = useCallback(async () => { const refreshProfileDashboard = useCallback(async () => {
if (!authUi?.user) {
setProfileDashboard(null);
setDashboardError(null);
setIsLoadingDashboard(false);
return;
}
setIsLoadingDashboard(true); setIsLoadingDashboard(true);
setDashboardError(null); setDashboardError(null);
@@ -288,7 +321,7 @@ export function PreGameSelectionFlow({
} finally { } finally {
setIsLoadingDashboard(false); setIsLoadingDashboard(false);
} }
}, []); }, [authUi?.user]);
const appendBrowseHistoryEntry = useCallback( const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => { async (entry: PlatformBrowseHistoryWriteEntry) => {
@@ -296,6 +329,10 @@ export function PreGameSelectionFlow({
setHistoryEntries(nextEntries); setHistoryEntries(nextEntries);
setHistoryError(null); setHistoryError(null);
if (!authUi?.user) {
return;
}
try { try {
const syncedEntries = await upsertProfileBrowseHistory(entry); const syncedEntries = await upsertProfileBrowseHistory(entry);
setHistoryEntries(syncedEntries); setHistoryEntries(syncedEntries);
@@ -341,10 +378,16 @@ export function PreGameSelectionFlow({
const localHistoryEntries = readPlatformBrowseHistory(authUi?.user); const localHistoryEntries = readPlatformBrowseHistory(authUi?.user);
setHistoryEntries(localHistoryEntries); setHistoryEntries(localHistoryEntries);
setHistoryError(null); setHistoryError(null);
setSaveError(null);
setIsLoadingPlatform(true); setIsLoadingPlatform(true);
setPlatformError(null); setPlatformError(null);
setIsLoadingDashboard(true); setIsLoadingDashboard(isAuthenticated);
setDashboardError(null); setDashboardError(null);
if (!isAuthenticated) {
setSavedCustomWorldEntries([]);
setSaveEntries([]);
setProfileDashboard(null);
}
try { try {
const [ const [
@@ -352,23 +395,29 @@ export function PreGameSelectionFlow({
galleryEntriesResult, galleryEntriesResult,
dashboardResult, dashboardResult,
historyResult, historyResult,
saveArchivesResult,
] = await Promise.allSettled([ ] = await Promise.allSettled([
listCustomWorldLibrary(), isAuthenticated ? listCustomWorldLibrary() : Promise.resolve([]),
listCustomWorldGallery(), listCustomWorldGallery(),
getProfileDashboard(), isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
(async () => { isAuthenticated
let nextEntries = await listProfileBrowseHistory(); ? (async () => {
let nextEntries = await listProfileBrowseHistory();
if ( if (
hasPendingPlatformBrowseHistoryMigration(authUi?.user) && hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
localHistoryEntries.length > 0 localHistoryEntries.length > 0
) { ) {
nextEntries = await syncProfileBrowseHistory(localHistoryEntries); nextEntries = await syncProfileBrowseHistory(
markPlatformBrowseHistoryMigrated(authUi?.user); localHistoryEntries,
} );
markPlatformBrowseHistoryMigrated(authUi?.user);
}
return nextEntries; return nextEntries;
})(), })()
: Promise.resolve(localHistoryEntries),
isAuthenticated ? listProfileSaveArchives() : Promise.resolve([]),
]); ]);
if (!isActive) { if (!isActive) {
return; return;
@@ -387,7 +436,7 @@ export function PreGameSelectionFlow({
} }
if ( if (
libraryEntriesResult.status === 'rejected' || (isAuthenticated && libraryEntriesResult.status === 'rejected') ||
galleryEntriesResult.status === 'rejected' galleryEntriesResult.status === 'rejected'
) { ) {
const platformFailure = const platformFailure =
@@ -403,7 +452,7 @@ export function PreGameSelectionFlow({
if (dashboardResult.status === 'fulfilled') { if (dashboardResult.status === 'fulfilled') {
setProfileDashboard(dashboardResult.value); setProfileDashboard(dashboardResult.value);
} else { } else if (isAuthenticated) {
setProfileDashboard(null); setProfileDashboard(null);
setDashboardError( setDashboardError(
resolveErrorMessage( resolveErrorMessage(
@@ -415,11 +464,34 @@ export function PreGameSelectionFlow({
if (historyResult.status === 'fulfilled') { if (historyResult.status === 'fulfilled') {
setHistoryEntries(historyResult.value); setHistoryEntries(historyResult.value);
} else { } else if (isAuthenticated) {
setHistoryError( setHistoryError(
resolveErrorMessage(historyResult.reason, '读取浏览历史失败。'), resolveErrorMessage(historyResult.reason, '读取浏览历史失败。'),
); );
} }
if (saveArchivesResult.status === 'fulfilled') {
setSaveEntries(saveArchivesResult.value);
} else if (isAuthenticated) {
setSaveEntries([]);
setSaveError(
resolveErrorMessage(saveArchivesResult.reason, '读取存档列表失败。'),
);
}
const nextPlatformBootstrapUserId = authUi?.user?.id ?? null;
if (platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId) {
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
if (!initialAgentUiStateRef.current.activeSessionId) {
setPlatformTab(
isAuthenticated &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
}
}
} finally { } finally {
if (isActive) { if (isActive) {
setIsLoadingPlatform(false); setIsLoadingPlatform(false);
@@ -431,7 +503,7 @@ export function PreGameSelectionFlow({
return () => { return () => {
isActive = false; isActive = false;
}; };
}, [authUi?.user]); }, [authUi?.user, isAuthenticated]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -990,8 +1062,10 @@ export function PreGameSelectionFlow({
setIsClearingHistory(true); setIsClearingHistory(true);
setHistoryError(null); setHistoryError(null);
try { try {
await clearProfileBrowseHistory();
clearPlatformBrowseHistory(authUi?.user); clearPlatformBrowseHistory(authUi?.user);
if (authUi?.user) {
await clearProfileBrowseHistory();
}
setHistoryEntries([]); setHistoryEntries([]);
} catch (error) { } catch (error) {
setHistoryError(resolveErrorMessage(error, '清空浏览历史失败。')); setHistoryError(resolveErrorMessage(error, '清空浏览历史失败。'));
@@ -1000,6 +1074,34 @@ export function PreGameSelectionFlow({
} }
}; };
const handleResumeSaveEntry = useCallback(
async (entry: ProfileSaveArchiveSummary) => {
if (!authUi?.user || isResumingSaveWorldKey) {
return;
}
setIsResumingSaveWorldKey(entry.worldKey);
setSaveError(null);
try {
const resumedArchive = await resumeProfileSaveArchive(entry.worldKey);
setSaveEntries((currentEntries) =>
currentEntries.map((currentEntry) =>
currentEntry.worldKey === resumedArchive.entry.worldKey
? resumedArchive.entry
: currentEntry,
),
);
handleContinueGame(resumedArchive.snapshot);
} catch (error) {
setSaveError(resolveErrorMessage(error, '恢复存档失败。'));
} finally {
setIsResumingSaveWorldKey(null);
}
},
[authUi?.user, handleContinueGame, isResumingSaveWorldKey],
);
const saveGeneratedCustomWorld = useCallback( const saveGeneratedCustomWorld = useCallback(
async (profile = generatedCustomWorldProfile) => { async (profile = generatedCustomWorldProfile) => {
if (!profile) { if (!profile) {
@@ -1107,7 +1209,9 @@ export function PreGameSelectionFlow({
return; return;
} }
handleCustomWorldSelect(selectedDetailEntry.profile); runProtectedAction(() => {
handleCustomWorldSelect(selectedDetailEntry.profile);
});
}; };
const handlePublishSelectedWorld = async () => { const handlePublishSelectedWorld = async () => {
@@ -1208,6 +1312,8 @@ export function PreGameSelectionFlow({
onTabChange={setPlatformTab} onTabChange={setPlatformTab}
hasSavedGame={hasSavedGame} hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot} savedSnapshot={savedSnapshot}
saveEntries={saveEntries}
saveError={saveError}
featuredEntries={featuredGalleryEntries} featuredEntries={featuredGalleryEntries}
latestEntries={publishedGalleryEntries} latestEntries={publishedGalleryEntries}
myEntries={savedCustomWorldEntries} myEntries={savedCustomWorldEntries}
@@ -1217,20 +1323,30 @@ export function PreGameSelectionFlow({
isLoadingPlatform={isLoadingPlatform} isLoadingPlatform={isLoadingPlatform}
isLoadingDashboard={isLoadingDashboard} isLoadingDashboard={isLoadingDashboard}
isClearingHistory={isClearingHistory} isClearingHistory={isClearingHistory}
isResumingSaveWorldKey={isResumingSaveWorldKey}
platformError={ platformError={
isLoadingPlatform ? null : (platformError ?? creationTypeError) isLoadingPlatform ? null : (platformError ?? creationTypeError)
} }
dashboardError={isLoadingDashboard ? null : dashboardError} dashboardError={isLoadingDashboard ? null : dashboardError}
onContinueGame={handleContinueGame} onContinueGame={handleContinueGame}
onResumeSave={(entry) => {
void handleResumeSaveEntry(entry);
}}
onClearHistory={() => { onClearHistory={() => {
void handleClearBrowseHistory(); void handleClearBrowseHistory();
}} }}
onOpenCreateWorld={openCustomWorldCreator} onOpenCreateWorld={openCustomWorldCreator}
onOpenCreateTypePicker={openCreationTypePicker} onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => { onOpenGalleryDetail={(entry) => {
void openGalleryDetail(entry); runProtectedAction(() => {
void openGalleryDetail(entry);
});
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
openLibraryDetail(entry);
});
}} }}
onOpenLibraryDetail={openLibraryDetail}
onOpenProfileDashboardCard={() => { onOpenProfileDashboardCard={() => {
if (dashboardError) { if (dashboardError) {
void refreshProfileDashboard(); void refreshProfileDashboard();
@@ -1266,23 +1382,41 @@ export function PreGameSelectionFlow({
onStartGame={handleStartSelectedWorld} onStartGame={handleStartSelectedWorld}
onContinueEdit={ onContinueEdit={
isSelectedWorldOwned isSelectedWorldOwned
? () => openSavedCustomWorldEditor(selectedDetailEntry) ? () => {
runProtectedAction(() => {
openSavedCustomWorldEditor(selectedDetailEntry);
});
}
: null : null
} }
onPublish={ onPublish={
selectedDetailEntry.visibility === 'draft' && selectedDetailEntry.visibility === 'draft' &&
isSelectedWorldOwned isSelectedWorldOwned
? handlePublishSelectedWorld ? () => {
runProtectedAction(() => {
void handlePublishSelectedWorld();
});
}
: null : null
} }
onUnpublish={ onUnpublish={
selectedDetailEntry.visibility === 'published' && selectedDetailEntry.visibility === 'published' &&
isSelectedWorldOwned isSelectedWorldOwned
? handleUnpublishSelectedWorld ? () => {
runProtectedAction(() => {
void handleUnpublishSelectedWorld();
});
}
: null : null
} }
onDelete={ onDelete={
isSelectedWorldOwned ? handleDeleteSelectedWorld : null isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void handleDeleteSelectedWorld();
});
}
: null
} }
/> />
)} )}
@@ -1409,7 +1543,9 @@ export function PreGameSelectionFlow({
onRegenerate={undefined} onRegenerate={undefined}
onContinueExpand={undefined} onContinueExpand={undefined}
onEnterWorld={() => { onEnterWorld={() => {
handleCustomWorldSelect(generatedCustomWorldProfile); runProtectedAction(() => {
handleCustomWorldSelect(generatedCustomWorldProfile);
});
}} }}
readOnly={false} readOnly={false}
backLabel={isAgentDraftResultView ? '返回创作' : undefined} backLabel={isAgentDraftResultView ? '返回创作' : undefined}
@@ -1433,7 +1569,9 @@ export function PreGameSelectionFlow({
setShowCreationTypeModal(false); setShowCreationTypeModal(false);
}} }}
onSelectRpg={() => { onSelectRpg={() => {
void openRpgAgentWorkspace(); runProtectedAction(() => {
void openRpgAgentWorkspace();
});
}} }}
/> />
</> </>

View File

@@ -47,7 +47,7 @@ export interface GameShellStoryProps {
export interface GameShellEntryProps { export interface GameShellEntryProps {
hasSavedGame: boolean; hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null; savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void; handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void; handleStartNewGame: () => void;
handleSaveAndExit: () => void; handleSaveAndExit: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;

View File

@@ -210,6 +210,45 @@ describe('npcInteractions', () => {
expect(questOption?.detailText).not.toContain('完成后可获得'); expect(questOption?.detailText).not.toContain('完成后可获得');
}); });
it('builds hostile npc encounters as a direct declaration dialogue with only escape and fight', () => {
const encounter = createEncounter();
const hostileState = {
...buildInitialNpcState(encounter, WorldType.WUXIA),
affinity: -12,
};
const story = buildNpcEncounterStoryMoment({
encounter,
npcState: hostileState,
playerCharacter: createCharacter(),
playerInventory: [],
activeQuests: [],
scene: {
id: 'scene-pass',
name: '断桥口',
npcs: [],
treasureHints: [],
},
worldType: WorldType.WUXIA,
partySize: 0,
});
expect(story.displayMode).toBe('dialogue');
expect(story.dialogue).toEqual([
expect.objectContaining({
speaker: 'npc',
speakerName: 'Trader Lin',
}),
]);
expect(story.options.map((option) => option.functionId)).toEqual([
'battle_escape_breakout',
'npc_fight',
]);
expect(story.options.map((option) => option.actionText)).toEqual([
'逃跑',
'与他对战',
]);
});
it('builds concrete trade action text for story continuation', () => { it('builds concrete trade action text for story continuation', () => {
const encounter = createEncounter(); const encounter = createEncounter();

View File

@@ -84,6 +84,7 @@ import {
import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative'; import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative';
import { import {
getStoryOptionPriority, getStoryOptionPriority,
resolveFunctionOption,
sortStoryOptionsByPriority, sortStoryOptionsByPriority,
} from './stateFunctions'; } from './stateFunctions';
@@ -1392,6 +1393,77 @@ function buildNpcOption(
} as StoryOption; } as StoryOption;
} }
function buildHostileNpcDialogueText(
encounter: Encounter,
affinity: number,
) {
const hostilityText =
affinity <= -20
? '旧账就留到今天一起清。'
: affinity <= -10
? '我们之间已经没什么可谈的了。'
: '你再往前一步,我就当你是在挑衅。';
const contextText = encounter.context?.trim()
? `你居然还敢带着${encounter.context}的事来见我,`
: '';
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
}
function buildHostileNpcEscapeOption(params: {
state?: GameState | null;
worldType: WorldType | null;
playerCharacter: Character;
}) {
const functionContext =
params.worldType
? {
worldType: params.worldType,
playerCharacter: params.playerCharacter,
inBattle: false,
currentSceneId: params.state?.currentScenePreset?.id ?? null,
currentSceneName: params.state?.currentScenePreset?.name ?? null,
monsters: [],
playerHp: params.state?.playerHp ?? 1,
playerMaxHp: params.state?.playerMaxHp ?? 1,
playerMana: params.state?.playerMana ?? 0,
playerMaxMana: params.state?.playerMaxMana ?? 0,
}
: null;
const resolvedOption = functionContext
? resolveFunctionOption(
'battle_escape_breakout',
functionContext,
'逃跑',
)
: null;
if (resolvedOption) {
return {
...resolvedOption,
actionText: '逃跑',
text: '逃跑',
detailText: '',
} satisfies StoryOption;
}
return {
functionId: 'battle_escape_breakout',
actionText: '逃跑',
text: '逃跑',
detailText: '',
priority: getStoryOptionPriority('battle_escape_breakout'),
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: -0.6,
playerOffsetY: 0,
playerFacing: 'left',
scrollWorld: true,
monsterChanges: [],
},
} satisfies StoryOption;
}
function buildQuestAcceptOpportunityDetail(params: { function buildQuestAcceptOpportunityDetail(params: {
issuerNpcId: string; issuerNpcId: string;
issuerNpcName: string; issuerNpcName: string;
@@ -2024,20 +2096,35 @@ export function buildNpcEncounterStoryMoment({
Boolean(encounter.monsterPresetId); Boolean(encounter.monsterPresetId);
if (isHostileEncounter) { if (isHostileEncounter) {
const hostileDialogueText =
overrideText ?? buildHostileNpcDialogueText(encounter, npcState.affinity);
options.push(
buildHostileNpcEscapeOption({
state,
worldType,
playerCharacter,
}),
);
options.push( options.push(
buildNpcOption( buildNpcOption(
NPC_FIGHT_FUNCTION.id, NPC_FIGHT_FUNCTION.id,
`迎战${encounter.npcName}`, '与他对战',
'对方敌意已明确,靠近后就会直接进入战斗。', '',
npcId, npcId,
'fight', 'fight',
), ),
); );
return { return {
text: text: hostileDialogueText,
overrideText ?? displayMode: 'dialogue',
`${scene?.name ?? '当前地界'}里,${encounter.npcName}已将你视为敌人。它一照面就摆出了进攻姿态,当前好感为 ${npcState.affinity}`, dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: hostileDialogueText,
},
],
options: sortStoryOptionsByPriority(options), options: sortStoryOptionsByPriority(options),
}; };
} }

View File

@@ -1,4 +1,8 @@
import { createFallbackOption } from '../data/hostileNpcs'; import { createFallbackOption } from '../data/hostileNpcs';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import { import {
getDefaultFunctionIdsForContext, getDefaultFunctionIdsForContext,
getFunctionById, getFunctionById,
@@ -11,9 +15,9 @@ import { AnimationState, Character, GameState, StoryMoment, StoryOption } from '
const FALLBACK_STORY: StoryMoment = { const FALLBACK_STORY: StoryMoment = {
text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。', text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。',
options: [ options: [
createFallbackOption('battle_all_in_crush', '战斗:全力进攻,压上对手', AnimationState.SKILL1, 0, false), createFallbackOption('battle_attack_basic', '普通攻击', AnimationState.ATTACK, 0, false),
createFallbackOption('battle_probe_pressure', '战斗:稳扎稳打,连番试探', AnimationState.SKILL2, 0, false), createFallbackOption('battle_recover_breath', '恢复', AnimationState.IDLE, 0, false),
createFallbackOption('battle_escape_breakout', '逃跑:抽身后撤,先脱离纠缠', AnimationState.IDLE, -0.6, false), createFallbackOption('battle_escape_breakout', '逃跑', AnimationState.RUN, -0.6, false),
], ],
}; };
@@ -142,7 +146,179 @@ export function normalizeSkillProbabilities(option: StoryOption, character: Char
}; };
} }
function createSingleActionBattleOption(
functionId: string,
actionText: string,
playerAnimation: AnimationState,
detailText?: string,
extras: Partial<StoryOption> = {},
) {
return {
...createFallbackOption(functionId, actionText, playerAnimation, functionId === 'battle_escape_breakout' ? -0.6 : 0, functionId === 'battle_escape_breakout'),
detailText,
...extras,
} satisfies StoryOption;
}
function getBasicAttackDamage(character: Character) {
return Math.max(
8,
Math.round(
character.attributes.strength * 0.85 + character.attributes.agility * 0.45,
),
);
}
function pickPreferredBattleItem(state: GameState, character: Character) {
const hasCoolingSkill = Object.values(state.playerSkillCooldowns).some(
(turns) => turns > 0,
);
const playerHpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
const playerManaRatio = state.playerMana / Math.max(state.playerMaxMana, 1);
return state.playerInventory
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
.map((item) => {
const effect = resolveInventoryItemUseEffect(item, character);
if (!effect) return null;
const score =
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
effect.buildBuffs.length * 8;
return { item, effect, score };
})
.filter(
(
candidate,
): candidate is {
item: GameState['playerInventory'][number];
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
score: number;
} => Boolean(candidate),
)
.sort(
(left, right) =>
right.score - left.score ||
right.effect.hpRestore - left.effect.hpRestore ||
right.effect.manaRestore - left.effect.manaRestore ||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
)[0] ?? null;
}
function buildBattleItemSummary(
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
) {
const parts = [
effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null,
effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null,
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
effect.buildBuffs.length > 0
? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
: null,
].filter(Boolean);
return parts.join(' / ') || '立即结算一次物品效果';
}
function buildSingleActionBattleOptions(state: GameState, character: Character) {
const preferredItem = pickPreferredBattleItem(state, character);
return [
createSingleActionBattleOption(
'battle_attack_basic',
'普通攻击',
AnimationState.ATTACK,
`不耗蓝 / 伤害 ${getBasicAttackDamage(character)}`,
),
createSingleActionBattleOption(
'battle_recover_breath',
'恢复',
AnimationState.IDLE,
'回血 12 / 回蓝 9 / 冷却 -1',
),
preferredItem
? createSingleActionBattleOption(
'inventory_use',
`使用物品:${preferredItem.item.name}`,
AnimationState.ACQUIRE,
buildBattleItemSummary(preferredItem.effect),
{
runtimePayload: { itemId: preferredItem.item.id },
},
)
: createSingleActionBattleOption(
'inventory_use',
'使用物品',
AnimationState.ACQUIRE,
'当前没有可直接结算的战斗消耗品',
{
disabled: true,
disabledReason: '暂无可用物品',
},
),
...character.skills.map((skill) => {
const remainingCooldown = state.playerSkillCooldowns[skill.id] ?? 0;
const detailText = [
`耗蓝 ${skill.manaCost}`,
`伤害 ${skill.damage}`,
`冷却 ${skill.cooldownTurns}`,
].join(' / ');
if (remainingCooldown > 0) {
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
disabled: true,
disabledReason: `冷却中,还需 ${remainingCooldown} 回合`,
},
);
}
if (skill.manaCost > state.playerMana) {
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
disabled: true,
disabledReason: '灵力不足',
},
);
}
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
},
);
}),
createSingleActionBattleOption(
'battle_escape_breakout',
'逃跑',
AnimationState.RUN,
'立刻脱离当前战斗',
),
];
}
export function getFallbackOptionsForState(state: GameState, character: Character) { export function getFallbackOptionsForState(state: GameState, character: Character) {
if (state.inBattle) {
return buildSingleActionBattleOptions(state, character);
}
if (!state.worldType) { if (!state.worldType) {
return FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character)); return FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character));
} }
@@ -191,6 +367,25 @@ export function getOptionImpactSummary(
cooldowns: Record<string, number>, cooldowns: Record<string, number>,
currentNpcBattleMode: GameState['currentNpcBattleMode'] = null, currentNpcBattleMode: GameState['currentNpcBattleMode'] = null,
) { ) {
if (option.functionId === 'battle_attack_basic') {
return currentNpcBattleMode === 'spar'
? '切磋伤害 1'
: `耗蓝 0 / 伤害 ${getBasicAttackDamage(character)}`;
}
if (option.functionId === 'battle_use_skill') {
const skillId =
typeof option.runtimePayload?.skillId === 'string'
? option.runtimePayload.skillId
: '';
const skill = character.skills.find((candidate) => candidate.id === skillId);
if (!skill) return null;
return currentNpcBattleMode === 'spar'
? '切磋伤害 1'
: `耗蓝 ${skill.manaCost} / 伤害 ${skill.damage}`;
}
const functionMeta = getFunctionById(option.functionId); const functionMeta = getFunctionById(option.functionId);
if (!functionMeta) return null; if (!functionMeta) return null;

View File

@@ -0,0 +1,162 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import { readSavedSettings } from '../persistence/gameSettingsStorage';
import type { GameState, StoryMoment } from '../types';
import type { BottomTab } from '../types/navigation';
import { useGamePersistence } from './useGamePersistence';
import { useGameSettings } from './useGameSettings';
const storageMocks = vi.hoisted(() => ({
getSettings: vi.fn(),
putSettings: vi.fn(),
getSaveSnapshot: vi.fn(),
putSaveSnapshot: vi.fn(),
deleteSaveSnapshot: vi.fn(),
}));
vi.mock('../services/storageService', () => ({
getSettings: storageMocks.getSettings,
putSettings: storageMocks.putSettings,
getSaveSnapshot: storageMocks.getSaveSnapshot,
putSaveSnapshot: storageMocks.putSaveSnapshot,
deleteSaveSnapshot: storageMocks.deleteSaveSnapshot,
}));
vi.mock('./story/runtimeStoryCoordinator', () => ({
resumeServerRuntimeStory: vi.fn(),
}));
function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string | null }) {
const settings = useGameSettings(authenticatedUserId);
return (
<div>
<div data-testid="music-volume">{settings.musicVolume.toFixed(2)}</div>
<button
type="button"
onClick={() => {
settings.setMusicVolume(0.6);
}}
>
</button>
</div>
);
}
function PersistenceHarness({
authenticatedUserId,
}: {
authenticatedUserId: string | null;
}) {
const persistence = useGamePersistence({
authenticatedUserId,
gameState: {} as GameState,
bottomTab: 'adventure' as BottomTab,
currentStory: null as StoryMoment | null,
isLoading: false,
setGameState: () => {},
setBottomTab: () => {},
hydrateStoryState: () => {},
resetStoryState: () => {},
});
return (
<div>
<div data-testid="saved-game">{persistence.hasSavedGame ? 'yes' : 'no'}</div>
<div data-testid="hydrating">
{persistence.isHydratingSnapshot ? 'yes' : 'no'}
</div>
</div>
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
window.localStorage.clear();
storageMocks.getSettings.mockResolvedValue({
musicVolume: 0.42,
platformTheme: 'light',
});
storageMocks.putSettings.mockResolvedValue({
musicVolume: 0.6,
platformTheme: 'light',
});
storageMocks.getSaveSnapshot.mockResolvedValue(null);
storageMocks.putSaveSnapshot.mockResolvedValue(null);
storageMocks.deleteSaveSnapshot.mockResolvedValue({
ok: true,
});
});
test('unauthenticated settings use local cache and skip remote runtime settings requests', async () => {
window.localStorage.setItem(
'tavernrealms.settings.v1',
JSON.stringify({
version: 1,
musicVolume: 0.33,
platformTheme: 'dark',
}),
);
render(<SettingsHarness authenticatedUserId={null} />);
expect(screen.getByTestId('music-volume').textContent).toBe('0.33');
expect(storageMocks.getSettings).not.toHaveBeenCalled();
act(() => {
screen.getByRole('button', { name: '设置音量' }).click();
});
expect(storageMocks.putSettings).not.toHaveBeenCalled();
expect(readSavedSettings().musicVolume).toBeCloseTo(0.6);
});
test('authenticated settings hydrate from remote settings and sync later changes back to the server', async () => {
storageMocks.getSettings.mockResolvedValue({
musicVolume: 0.8,
platformTheme: 'dark',
});
render(<SettingsHarness authenticatedUserId="user-1" />);
await waitFor(() => {
expect(storageMocks.getSettings).toHaveBeenCalledTimes(1);
});
expect(screen.getByTestId('music-volume').textContent).toBe('0.80');
vi.useFakeTimers();
act(() => {
screen.getByRole('button', { name: '设置音量' }).click();
});
await act(async () => {
vi.advanceTimersByTime(200);
await Promise.resolve();
});
expect(storageMocks.putSettings).toHaveBeenCalledTimes(1);
expect(storageMocks.putSettings).toHaveBeenCalledWith(
{ musicVolume: 0.6, platformTheme: 'dark' },
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
);
expect(readSavedSettings().musicVolume).toBeCloseTo(0.6);
});
test('unauthenticated runtime skips remote snapshot hydration', async () => {
render(<PersistenceHarness authenticatedUserId={null} />);
await waitFor(() => {
expect(screen.getByTestId('hydrating').textContent).toBe('no');
});
expect(screen.getByTestId('saved-game').textContent).toBe('no');
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
});

View File

@@ -224,7 +224,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState), updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null), getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false), enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction, handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false), handleTreasureInteraction: vi.fn(() => false),
@@ -235,7 +234,6 @@ describe('createStoryChoiceActions', () => {
option.functionId === 'story_continue_adventure', option.functionId === 'story_continue_adventure',
), ),
isCampTravelHomeOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter, isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk', npcPreviewTalkFunctionId: 'npc_preview_talk',
@@ -255,53 +253,14 @@ describe('createStoryChoiceActions', () => {
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled(); expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
}); });
it('routes task5 story choices through the server runtime action endpoint', async () => { it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
const state = createBaseState(); const state = createBaseState();
const option = createBattleOption('npc_chat'); const option = createBattleOption('npc_chat');
const setGameState = vi.fn(); const setGameState = vi.fn();
const setCurrentStory = vi.fn(); const setCurrentStory = vi.fn();
const handleNpcInteraction = vi.fn(() => true);
isServerRuntimeFunctionIdMock.mockReturnValue(true); isServerRuntimeFunctionIdMock.mockReturnValue(true);
resolveServerRuntimeChoiceMock.mockResolvedValue({
hydratedSnapshot: {
gameState: {
...state,
runtimeSessionId: 'runtime-main',
runtimeActionVersion: 1,
npcStates: {
...state.npcStates,
'npc-opponent': {
...state.npcStates['npc-opponent'],
affinity: 6,
chattedCount: 1,
},
},
},
currentStory: {
text: '后端已结算关系变化',
options: [],
},
bottomTab: 'adventure',
},
nextStory: {
text: '后端已结算关系变化',
options: [
{
functionId: 'npc_help',
actionText: '请求援手',
text: '请求援手',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
});
const { handleChoice } = createStoryChoiceActions({ const { handleChoice } = createStoryChoiceActions({
gameState: { gameState: {
@@ -340,15 +299,13 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState), updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null), getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false), enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false), handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false), handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(), commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null), finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false), isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: (encounter): encounter is Encounter => isRegularNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'), Boolean(encounter?.kind === 'npc'),
isNpcEncounter: (encounter): encounter is Encounter => isNpcEncounter: (encounter): encounter is Encounter =>
@@ -360,30 +317,14 @@ describe('createStoryChoiceActions', () => {
await handleChoice(option); await handleChoice(option);
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith({ expect(handleNpcInteraction).toHaveBeenCalledWith(
gameState: expect.objectContaining({
currentEncounter: expect.objectContaining({
id: 'npc-opponent',
}),
}),
currentStory: createFallbackStory('当前故事'),
option,
});
expect(setGameState).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
runtimeActionVersion: 1, functionId: 'npc_chat',
}),
);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '后端已结算关系变化',
options: [
expect.objectContaining({
functionId: 'npc_help',
}),
],
}), }),
); );
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
expect(setGameState).not.toHaveBeenCalled();
expect(setCurrentStory).not.toHaveBeenCalled();
}); });
it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => { it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => {
@@ -447,7 +388,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState), updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null), getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false), enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction, handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false), handleTreasureInteraction: vi.fn(() => false),
@@ -455,7 +395,6 @@ describe('createStoryChoiceActions', () => {
finalizeNpcBattleResult: vi.fn(() => null), finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false), isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: (encounter): encounter is Encounter => isRegularNpcEncounter: (encounter): encounter is Encounter =>
Boolean(encounter?.kind === 'npc'), Boolean(encounter?.kind === 'npc'),
isNpcEncounter: (encounter): encounter is Encounter => isNpcEncounter: (encounter): encounter is Encounter =>
@@ -520,7 +459,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState), updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null), getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false), enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false), handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false), handleTreasureInteraction: vi.fn(() => false),
@@ -537,7 +475,6 @@ describe('createStoryChoiceActions', () => {
})), })),
isContinueAdventureOption: vi.fn(() => false), isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter, isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk', npcPreviewTalkFunctionId: 'npc_preview_talk',
@@ -634,7 +571,6 @@ describe('createStoryChoiceActions', () => {
updateQuestLog: vi.fn((inputState: GameState) => inputState), updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats, incrementRuntimeStats,
getCampCompanionTravelScene: vi.fn(() => null), getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false), enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(() => false), handleNpcInteraction: vi.fn(() => false),
handleTreasureInteraction: vi.fn(() => false), handleTreasureInteraction: vi.fn(() => false),
@@ -642,7 +578,6 @@ describe('createStoryChoiceActions', () => {
finalizeNpcBattleResult: vi.fn(() => null), finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(() => false), isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter, isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk', npcPreviewTalkFunctionId: 'npc_preview_talk',

View File

@@ -90,7 +90,6 @@ export function createStoryChoiceActions({
updateQuestLog, updateQuestLog,
incrementRuntimeStats, incrementRuntimeStats,
getCampCompanionTravelScene, getCampCompanionTravelScene,
startOpeningAdventure,
enterNpcInteraction, enterNpcInteraction,
handleNpcInteraction, handleNpcInteraction,
handleTreasureInteraction, handleTreasureInteraction,
@@ -98,7 +97,6 @@ export function createStoryChoiceActions({
finalizeNpcBattleResult, finalizeNpcBattleResult,
isContinueAdventureOption, isContinueAdventureOption,
isCampTravelHomeOption, isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter, isRegularNpcEncounter,
isNpcEncounter, isNpcEncounter,
npcPreviewTalkFunctionId, npcPreviewTalkFunctionId,
@@ -132,7 +130,6 @@ export function createStoryChoiceActions({
updateQuestLog: UpdateQuestLog; updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats; incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null; getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
startOpeningAdventure: () => Promise<void>;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean; enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>; handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
handleTreasureInteraction: ( handleTreasureInteraction: (
@@ -147,7 +144,6 @@ export function createStoryChoiceActions({
) => { nextState: GameState; resultText: string } | null; ) => { nextState: GameState; resultText: string } | null;
isContinueAdventureOption: (option: StoryOption) => boolean; isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean; isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter; isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter; isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
npcPreviewTalkFunctionId: string; npcPreviewTalkFunctionId: string;
@@ -157,6 +153,7 @@ export function createStoryChoiceActions({
const handleChoice = async (option: StoryOption) => { const handleChoice = async (option: StoryOption) => {
const character = gameState.playerCharacter; const character = gameState.playerCharacter;
if (!gameState.worldType || !character || isLoading) return; if (!gameState.worldType || !character || isLoading) return;
if (option.disabled) return;
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) { if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
setCurrentStory({ setCurrentStory({
@@ -208,16 +205,6 @@ export function createStoryChoiceActions({
return; return;
} }
if (
option.functionId === npcPreviewTalkFunctionId
&& isInitialCompanionEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
) {
setAiError(null);
void startOpeningAdventure();
return;
}
if ( if (
option.functionId === npcPreviewTalkFunctionId option.functionId === npcPreviewTalkFunctionId
&& isRegularNpcEncounter(gameState.currentEncounter) && isRegularNpcEncounter(gameState.currentEncounter)

View File

@@ -0,0 +1,427 @@
import { describe, expect, it, vi } from 'vitest';
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
import { createStoryNpcEncounterActions } from './npcEncounterActions';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试角色',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createEncounter(): Encounter {
return {
id: 'npc-rival',
kind: 'npc',
npcName: '断桥客',
npcDescription: '拦路的旧敌',
npcAvatar: '/npc.png',
context: '断桥旧案',
};
}
function createOption(
functionId: string,
actionText: string,
interaction?: StoryOption['interaction'],
): StoryOption {
return {
functionId,
actionText,
text: actionText,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction,
};
}
function createState(overrides: Partial<GameState> = {}): GameState {
const encounter = createEncounter();
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: encounter,
npcInteractionActive: true,
currentScenePreset: {
id: 'scene-bridge',
name: '断桥口',
description: '风声很紧。',
imageSrc: '/bridge.png',
npcs: [],
treasureHints: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
playerMaxMana: 30,
playerSkillCooldowns: {},
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-rival': {
affinity: 8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as GameState;
}
function createCurrentChatStory(): StoryMoment {
return {
text: '断桥客:你居然还敢来。\n你我只是想把话说清楚。',
options: [
createOption('npc_chat', '先说说你到底在防谁', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '你居然还敢来。',
},
{
speaker: 'player',
text: '我只是想把话说清楚。',
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 1,
customInputPlaceholder: '输入你想对 TA 说的话',
},
};
}
function createNpcEncounterActions(overrides: {
gameState?: GameState;
currentStory?: StoryMoment | null;
generateStoryForState?: ReturnType<typeof vi.fn>;
getAvailableOptionsForState?: ReturnType<typeof vi.fn>;
}) {
const gameState = overrides.gameState ?? createState();
const currentStory = overrides.currentStory ?? createCurrentChatStory();
const setGameState = vi.fn();
const setCurrentStory = vi.fn();
const setAiError = vi.fn();
const setIsLoading = vi.fn();
const actions = createStoryNpcEncounterActions({
gameState,
currentStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
commitGeneratedState: vi.fn(),
commitGeneratedStateWithEncounterEntry: vi.fn(),
appendHistory: vi.fn((state: GameState, actionText: string, resultText: string) => [
...state.storyHistory,
{
text: actionText,
options: [],
historyRole: 'action' as const,
},
{
text: resultText,
options: [],
historyRole: 'result' as const,
},
]),
buildOpeningCampChatContext: vi.fn(() => ({})),
buildStoryContextFromState: vi.fn(() => ({
playerHp: gameState.playerHp,
playerMaxHp: gameState.playerMaxHp,
playerMana: gameState.playerMana,
playerMaxMana: gameState.playerMaxMana,
inBattle: gameState.inBattle,
playerX: gameState.playerX,
playerFacing: gameState.playerFacing,
playerAnimation: gameState.animationState,
skillCooldowns: gameState.playerSkillCooldowns,
})),
buildFallbackStoryForState: vi.fn(() => ({
text: 'fallback',
options: [],
})),
buildDialogueStoryMoment: vi.fn((npcName: string, text: string, options: StoryOption[], streaming = false) => ({
text,
options,
displayMode: 'dialogue',
dialogue: text
? [
{
speaker: 'npc' as const,
speakerName: npcName,
text,
},
]
: [],
streaming,
})),
generateStoryForState:
overrides.generateStoryForState ??
vi.fn().mockResolvedValue({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
options: [createOption('idle_observe_signs', '观察周围动静')],
}),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getTypewriterDelay: vi.fn(() => 0),
getAvailableOptionsForState:
overrides.getAvailableOptionsForState ??
vi.fn(() => [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '问问你到底想和我算哪笔账', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
]),
sanitizeOptions: vi.fn((options: StoryOption[]) => options),
sortOptions: vi.fn((options: StoryOption[]) => options),
buildContinueAdventureOption: vi.fn(() =>
createOption('story_continue_adventure', '继续'),
),
getNpcEncounterKey: vi.fn((encounter: Encounter) => encounter.id ?? encounter.npcName),
getResolvedNpcState: vi.fn((state: GameState, encounter: Encounter) => state.npcStates[encounter.id ?? encounter.npcName]),
updateNpcState: vi.fn((state: GameState) => state),
cloneInventoryItemForOwner: vi.fn(),
resolveNpcInteractionDecision: vi.fn(() => ({ kind: 'default' })),
npcInteractionFlow: {
openTradeModal: vi.fn(),
openGiftModal: vi.fn(),
openRecruitModal: vi.fn(),
startRecruitmentSequence: vi.fn(),
},
});
return {
gameState,
currentStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
...actions,
};
}
describe('npcEncounterActions', () => {
it('re-runs story reasoning after exiting npc chat and appends the new story to history', async () => {
const gameState = createState({
storyHistory: [
{
text: '你先试探了对方的态度。',
options: [],
historyRole: 'action',
},
],
});
const generateStoryForState = vi.fn().mockResolvedValue({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
options: [createOption('idle_observe_signs', '观察周围动静')],
});
const actions = createNpcEncounterActions({
gameState,
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '问问你到底想和我算哪笔账', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
createOption('npc_fight', '直接动手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
]),
});
expect(actions.exitNpcChat()).toBe(true);
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(generateStoryForState).toHaveBeenCalledWith(
expect.objectContaining({
state: gameState,
choice: '结束与断桥客的这轮交谈,重新观察当前局势',
lastFunctionId: 'npc_chat',
optionCatalog: [
expect.objectContaining({
functionId: 'npc_chat',
}),
expect.objectContaining({
functionId: 'npc_help',
}),
expect.objectContaining({
functionId: 'npc_fight',
}),
],
}),
);
const [{ optionCatalog }] = generateStoryForState.mock.calls[0] as [
{ optionCatalog: StoryOption[] },
];
expect(
optionCatalog.filter((option) => option.functionId === 'npc_chat'),
).toHaveLength(1);
expect(actions.setGameState).toHaveBeenCalledWith(
expect.objectContaining({
storyHistory: [
expect.objectContaining({
historyRole: 'action',
text: '你先试探了对方的态度。',
}),
expect.objectContaining({
historyRole: 'action',
text: '结束与断桥客的这轮交谈,重新观察当前局势',
}),
expect.objectContaining({
historyRole: 'result',
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
}),
],
}),
);
expect(actions.setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
}),
);
expect(actions.setIsLoading).toHaveBeenNthCalledWith(1, true);
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
});
it('opens hostile npc encounters as a declaration dialogue with only escape and fight options', () => {
const encounter = createEncounter();
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
npcInteractionActive: false,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
currentStory: {
text: '断桥客停在前方,像是在等你真正回应。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
expect(actions.setGameState).toHaveBeenCalledWith(
expect.objectContaining({
npcInteractionActive: true,
}),
);
expect(actions.setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
displayMode: 'dialogue',
options: [
expect.objectContaining({
functionId: 'battle_escape_breakout',
actionText: '逃跑',
}),
expect.objectContaining({
functionId: 'npc_fight',
actionText: '与他对战',
}),
],
}),
);
});
});

View File

@@ -2,7 +2,9 @@ import type { Dispatch, SetStateAction } from 'react';
import { buildRelationState } from '../../data/attributeResolver'; import { buildRelationState } from '../../data/attributeResolver';
import { hasEncounterEntity } from '../../data/encounterTransition'; import { hasEncounterEntity } from '../../data/encounterTransition';
import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog'; import {
NPC_FIGHT_FUNCTION,
} from '../../data/functionCatalog';
import { import {
addInventoryItems, addInventoryItems,
applyStoryChoiceToStanceProfile, applyStoryChoiceToStanceProfile,
@@ -33,6 +35,7 @@ import {
markQuestTurnedIn, markQuestTurnedIn,
} from '../../data/questFlow'; } from '../../data/questFlow';
import { incrementGameRuntimeStats } from '../../data/runtimeStats'; import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { resolveFunctionOption } from '../../data/stateFunctions';
import { import {
createSceneCallOutEncounter, createSceneCallOutEncounter,
resolveSceneEncounterPreview, resolveSceneEncounterPreview,
@@ -578,7 +581,11 @@ export function createStoryNpcEncounterActions({
encounter: Encounter, encounter: Encounter,
suggestions: string[], suggestions: string[],
): StoryOption[] => ): StoryOption[] =>
suggestions.slice(0, 3).map((suggestion) => ({ suggestions
.map((suggestion) => sanitizeNpcChatSuggestion(suggestion))
.filter(Boolean)
.slice(0, 3)
.map((suggestion) => ({
functionId: 'npc_chat', functionId: 'npc_chat',
actionText: suggestion, actionText: suggestion,
text: suggestion, text: suggestion,
@@ -596,14 +603,57 @@ export function createStoryNpcEncounterActions({
npcId: encounter.id ?? encounter.npcName, npcId: encounter.id ?? encounter.npcName,
action: 'chat', action: 'chat',
}, },
})); }));
const NPC_CHAT_SUGGESTION_LIMIT = 20;
const trimNpcChatSuggestion = (text: string) =>
text.trim().replace(/^["'“”‘’]+|["'“”‘’]+$/g, '');
const clampNpcChatSuggestionLength = (text: string) =>
Array.from(text).slice(0, NPC_CHAT_SUGGESTION_LIMIT).join('');
const isDirectNpcChatSuggestion = (text: string) => {
const normalizedText = trimNpcChatSuggestion(text);
if (!normalizedText) {
return false;
}
const behaviorPrefixes = [
'先',
'再',
'换个',
'顺着',
'试着',
'表明',
'告诉',
'问问',
'追问',
'继续聊',
'继续交谈',
'继续谈',
];
return !behaviorPrefixes.some((prefix) => normalizedText.startsWith(prefix));
};
const sanitizeNpcChatSuggestion = (text: string) => {
const normalizedText = trimNpcChatSuggestion(text);
if (!normalizedText) {
return '';
}
return clampNpcChatSuggestionLength(normalizedText);
};
const buildFallbackNpcChatSuggestions = (playerMessage: string) => { const buildFallbackNpcChatSuggestions = (playerMessage: string) => {
const topic = playerMessage.trim() || '刚才那句话'; const topic = clampNpcChatSuggestionLength(
sanitizeNpcChatSuggestion(playerMessage) || '刚才那句',
);
return [ return [
`顺着“${topic}”继续追问`, sanitizeNpcChatSuggestion(`你刚才那句是什么意思`),
'先表明你的判断,再看对方反应', sanitizeNpcChatSuggestion(`这件事和${topic}有关吗`),
'换个更轻松的语气把话接下去', sanitizeNpcChatSuggestion('你愿意再说清楚点吗'),
]; ];
}; };
@@ -628,9 +678,11 @@ export function createStoryNpcEncounterActions({
const buildNpcChatEntryOptions = ( const buildNpcChatEntryOptions = (
encounter: Encounter, encounter: Encounter,
selectedOption: StoryOption, selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
) => { ) => {
const candidateOptions = [ const candidateOptions = [
selectedOption, selectedOption,
...extraOptions,
...(currentStory?.options ?? []).filter((option) => ...(currentStory?.options ?? []).filter((option) =>
isNpcChatOptionForEncounter(option, encounter), isNpcChatOptionForEncounter(option, encounter),
), ),
@@ -639,12 +691,20 @@ export function createStoryNpcEncounterActions({
const seenActionTexts = new Set<string>(); const seenActionTexts = new Set<string>();
for (const option of candidateOptions) { for (const option of candidateOptions) {
const actionText = option.actionText?.trim(); const actionText = sanitizeNpcChatSuggestion(option.actionText ?? '');
if (!actionText || seenActionTexts.has(actionText)) { if (
!actionText ||
!isDirectNpcChatSuggestion(actionText) ||
seenActionTexts.has(actionText)
) {
continue; continue;
} }
seenActionTexts.add(actionText); seenActionTexts.add(actionText);
dedupedOptions.push(option); dedupedOptions.push({
...option,
actionText,
text: actionText,
});
if (dedupedOptions.length === 3) { if (dedupedOptions.length === 3) {
return dedupedOptions; return dedupedOptions;
} }
@@ -681,28 +741,167 @@ export function createStoryNpcEncounterActions({
}, },
}); });
const collapseNpcChatOptions = (options: StoryOption[]) => {
let hasKeptNpcChat = false;
return options.filter((option) => {
if (option.functionId !== 'npc_chat') {
return true;
}
if (hasKeptNpcChat) {
return false;
}
hasKeptNpcChat = true;
return true;
});
};
const buildNpcChatOpeningDialogue = (encounter: Encounter) =>
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
},
];
const buildHostileNpcDeclarationText = (
encounter: Encounter,
affinity: number,
) => {
const hostilityText =
affinity <= -20
? '旧账就留到今天一起清。'
: affinity <= -10
? '我们之间已经没什么可谈的了。'
: '你再往前一步,我就当你是在挑衅。';
const contextText = encounter.context?.trim()
? `你居然还敢带着${encounter.context}的事来见我,`
: '';
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
};
const buildHostileNpcEscapeOption = (
character: Character,
): StoryOption => {
const functionContext = gameState.worldType
? {
worldType: gameState.worldType,
playerCharacter: character,
inBattle: false,
currentSceneId: gameState.currentScenePreset?.id ?? null,
currentSceneName: gameState.currentScenePreset?.name ?? null,
monsters: [],
playerHp: gameState.playerHp,
playerMaxHp: gameState.playerMaxHp,
playerMana: gameState.playerMana,
playerMaxMana: gameState.playerMaxMana,
}
: null;
const resolvedOption = functionContext
? resolveFunctionOption(
'battle_escape_breakout',
functionContext,
'逃跑',
)
: null;
if (resolvedOption) {
return {
...resolvedOption,
actionText: '逃跑',
text: '逃跑',
detailText: '',
};
}
return {
functionId: 'battle_escape_breakout',
actionText: '逃跑',
text: '逃跑',
detailText: '',
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: -0.6,
playerOffsetY: 0,
playerFacing: 'left',
scrollWorld: true,
monsterChanges: [],
},
};
};
const buildHostileNpcFightOption = (encounter: Encounter): StoryOption => ({
functionId: NPC_FIGHT_FUNCTION.id,
actionText: '与他对战',
text: '与他对战',
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'fight',
},
});
const buildHostileNpcStoryMoment = (
encounter: Encounter,
character: Character,
affinity: number,
): StoryMoment => {
const declarationText = buildHostileNpcDeclarationText(
encounter,
affinity,
);
return {
text: declarationText,
options: [
buildHostileNpcEscapeOption(character),
buildHostileNpcFightOption(encounter),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: declarationText,
},
],
streaming: false,
};
};
const enterNpcChat = ( const enterNpcChat = (
encounter: Encounter, encounter: Encounter,
selectedOption: StoryOption, selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
) => { ) => {
const openingDialogue = const openingDialogue = buildNpcChatOpeningDialogue(encounter);
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}\u770b\u7740\u4f60\uff0c\u50cf\u662f\u5728\u7b49\u4f60\u628a\u8bdd\u63a5\u4e0b\u53bb\u3002`,
},
];
setAiError(null); setAiError(null);
setCurrentStory( setCurrentStory(
buildNpcChatStoryMoment({ buildNpcChatStoryMoment({
encounter, encounter,
dialogue: openingDialogue, dialogue: openingDialogue,
options: buildNpcChatEntryOptions(encounter, selectedOption), options: buildNpcChatEntryOptions(
encounter,
selectedOption,
extraOptions,
),
streaming: false, streaming: false,
turnCount: 0, turnCount: 0,
}), }),
@@ -890,32 +1089,102 @@ export function createStoryNpcEncounterActions({
const exitNpcChat = () => { const exitNpcChat = () => {
const playerCharacter = gameState.playerCharacter; const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) { const encounter = gameState.currentEncounter;
if (!playerCharacter || !isNpcEncounter(encounter)) {
return false; return false;
} }
setAiError(null); setAiError(null);
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter)); setIsLoading(true);
void (async () => {
const choiceText = `结束与${encounter.npcName}的这轮交谈,重新观察当前局势`;
try {
const postChatOptionCatalog = collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const nextStory = await generateStoryForState({
state: gameState,
character: playerCharacter,
history: gameState.storyHistory,
choice: choiceText,
lastFunctionId: 'npc_chat',
optionCatalog: postChatOptionCatalog,
});
const nextHistory = [
...gameState.storyHistory,
createHistoryMoment(choiceText, 'action'),
createHistoryMoment(nextStory.text, 'result', nextStory.options),
];
const recoveredState = applyStoryReasoningRecovery({
...gameState,
storyHistory: nextHistory,
});
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue story after exiting npc chat:', error);
setAiError(
error instanceof Error ? error.message : '退出聊天后的剧情推理失败',
);
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
} finally {
setIsLoading(false);
}
})();
return true; return true;
}; };
const enterNpcInteraction = (encounter: Encounter, actionText: string) => { const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
const playerCharacter = gameState.playerCharacter; const playerCharacter = gameState.playerCharacter;
if (!playerCharacter) return false; if (!playerCharacter) return false;
const npcState = getResolvedNpcState(gameState, encounter);
const nextState: GameState = { const nextState: GameState = {
...gameState, ...gameState,
npcInteractionActive: true, npcInteractionActive: true,
}; };
void commitGeneratedState( setGameState(nextState);
nextState, setAiError(null);
playerCharacter,
actionText, if (npcState.affinity < 0 || encounter.hostile) {
`${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`, setCurrentStory(
NPC_PREVIEW_TALK_FUNCTION.id, buildHostileNpcStoryMoment(encounter, playerCharacter, npcState.affinity),
);
return true;
}
const npcInteractionOptions =
getAvailableOptionsForState(nextState, playerCharacter) ?? [];
const chatOptions = npcInteractionOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
); );
return true; const seedChatOption =
chatOptions[0] ??
({
functionId: 'npc_chat',
actionText: actionText || `${encounter.npcName}搭话`,
text: actionText || `${encounter.npcName}搭话`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc' as const,
npcId: encounter.id ?? encounter.npcName,
action: 'chat' as const,
},
} satisfies StoryOption);
return enterNpcChat(encounter, seedChatOption, chatOptions.slice(1));
}; };
const resolveServerNpcStoryAction = async (params: { const resolveServerNpcStoryAction = async (params: {
@@ -958,17 +1227,51 @@ export function createStoryNpcEncounterActions({
} }
}; };
const inferNpcInteractionFromOption = (
encounter: Encounter,
option: StoryOption,
): StoryOption['interaction'] => {
const npcId = encounter.id ?? encounter.npcName;
const actionByFunctionId: Record<string, StoryOption['interaction']> = {
npc_chat: { kind: 'npc', npcId, action: 'chat' },
npc_help: { kind: 'npc', npcId, action: 'help' },
npc_fight: { kind: 'npc', npcId, action: 'fight' },
npc_leave: { kind: 'npc', npcId, action: 'leave' },
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
npc_spar: { kind: 'npc', npcId, action: 'spar' },
npc_trade: { kind: 'npc', npcId, action: 'trade' },
npc_gift: { kind: 'npc', npcId, action: 'gift' },
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
npc_quest_turn_in: {
kind: 'npc',
npcId,
action: 'quest_turn_in',
questId: option.interaction?.questId,
},
};
return option.interaction ?? actionByFunctionId[option.functionId];
};
const handleNpcInteraction = (option: StoryOption) => { const handleNpcInteraction = (option: StoryOption) => {
const playerCharacter = gameState.playerCharacter; const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) { if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
return false; return false;
} }
const encounter = gameState.currentEncounter; const encounter = gameState.currentEncounter;
const resolvedInteraction = inferNpcInteractionFromOption(encounter, option);
if (!resolvedInteraction || resolvedInteraction.kind !== 'npc') {
return false;
}
const resolvedOption = {
...option,
interaction: resolvedInteraction,
} satisfies StoryOption;
const npcState = getResolvedNpcState(gameState, encounter); const npcState = getResolvedNpcState(gameState, encounter);
const interactionDecision = resolveNpcInteractionDecision( const interactionDecision = resolveNpcInteractionDecision(
gameState, gameState,
option, resolvedOption,
); );
if (interactionDecision.kind === 'trade_modal') { if (interactionDecision.kind === 'trade_modal') {
@@ -994,7 +1297,7 @@ export function createStoryNpcEncounterActions({
return true; return true;
} }
switch (option.interaction.action) { switch (resolvedOption.interaction.action) {
case 'help': { case 'help': {
setAiError(null); setAiError(null);
setIsLoading(true); setIsLoading(true);
@@ -1062,7 +1365,7 @@ export function createStoryNpcEncounterActions({
encounter, encounter,
buildNpcHelpCommitActionText(encounter, reward), buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward), buildNpcHelpResultText(encounter, reward),
option.functionId, resolvedOption.functionId,
{ {
contextNpcStateOverride: contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null, nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
@@ -1133,7 +1436,7 @@ export function createStoryNpcEncounterActions({
encounter, encounter,
buildNpcHelpCommitActionText(encounter, reward), buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward), buildNpcHelpResultText(encounter, reward),
option.functionId, resolvedOption.functionId,
{ {
contextNpcStateOverride: contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null, nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
@@ -1154,23 +1457,23 @@ export function createStoryNpcEncounterActions({
if ( if (
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
) { ) {
void handleNpcChatTurn(encounter, option.actionText); void handleNpcChatTurn(encounter, resolvedOption.actionText);
return true; return true;
} }
return enterNpcChat(encounter, option); return enterNpcChat(encounter, resolvedOption);
} }
case 'quest_accept': { case 'quest_accept': {
void resolveServerNpcStoryAction({ void resolveServerNpcStoryAction({
option, option: resolvedOption,
encounter, encounter,
}); });
return true; return true;
} }
case 'quest_turn_in': { case 'quest_turn_in': {
const questId = option.interaction.questId; const questId = resolvedOption.interaction.questId;
void resolveServerNpcStoryAction({ void resolveServerNpcStoryAction({
option, option: resolvedOption,
encounter, encounter,
payload: questId payload: questId
? { ? {
@@ -1212,9 +1515,9 @@ export function createStoryNpcEncounterActions({
entryState, entryState,
resolvedState, resolvedState,
playerCharacter, playerCharacter,
option.actionText, resolvedOption.actionText,
buildNpcLeaveResultText(encounter), buildNpcLeaveResultText(encounter),
option.functionId, resolvedOption.functionId,
); );
return true; return true;
} }
@@ -1251,9 +1554,9 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState( void commitGeneratedState(
nextState, nextState,
playerCharacter, playerCharacter,
option.actionText, resolvedOption.actionText,
`You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`, `You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`,
option.functionId, resolvedOption.functionId,
); );
return true; return true;
} }
@@ -1297,9 +1600,9 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState( void commitGeneratedState(
nextState, nextState,
playerCharacter, playerCharacter,
option.actionText, resolvedOption.actionText,
`${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`, `${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`,
option.functionId, resolvedOption.functionId,
); );
return true; return true;
} }

View File

@@ -123,19 +123,12 @@ export function buildPreparedOpeningAdventure({
export async function playOpeningAdventureSequence({ export async function playOpeningAdventureSequence({
gameState, gameState,
character,
encounter, encounter,
preparedStory, preparedStory,
setGameState, setGameState,
setCurrentStory, setCurrentStory,
setAiError, setAiError,
setIsLoading, setIsLoading,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
}: { }: {
gameState: GameState; gameState: GameState;
character: Character; character: Character;
@@ -168,160 +161,69 @@ export async function playOpeningAdventureSequence({
) => Promise<StoryOption[]>; ) => Promise<StoryOption[]>;
getTypewriterDelay: (char: string) => number; getTypewriterDelay: (char: string) => number;
}) { }) {
const { const { fallbackText, openingOptions } = preparedStory;
fallbackText,
openingOptions,
resultText: openingBackground,
} = preparedStory;
const actionText = `在营地与 ${encounter.npcName} 交换开场判断`;
const campScene = gameState.worldType const campScene = gameState.worldType
? getWorldCampScenePreset(gameState.worldType) ? getWorldCampScenePreset(gameState.worldType)
: null; : null;
const entryState: GameState = { const storyEncounter: Encounter = {
...gameState,
currentScenePreset: campScene ?? gameState.currentScenePreset,
currentEncounter: {
...encounter,
xMeters: encounter.xMeters ?? CALL_OUT_ENTRY_X_METERS,
},
};
const resolvedEncounter: Encounter = {
...encounter, ...encounter,
xMeters: RESOLVED_ENTITY_X_METERS, xMeters: RESOLVED_ENTITY_X_METERS,
};
const storyEncounter: Encounter = {
...resolvedEncounter,
specialBehavior: 'camp_companion', specialBehavior: 'camp_companion',
}; };
const resolvedState: GameState = { const resolvedState: GameState = {
...gameState, ...gameState,
currentScenePreset: campScene ?? gameState.currentScenePreset, currentScenePreset: campScene ?? gameState.currentScenePreset,
currentEncounter: resolvedEncounter, currentEncounter: storyEncounter,
npcInteractionActive: false, npcInteractionActive: true,
}; };
setGameState(entryState);
setAiError(null); setAiError(null);
setIsLoading(true); setIsLoading(false);
try { try {
if (hasEncounterEntity(resolvedState)) { setGameState(resolvedState);
const runTicks = Math.max( setCurrentStory({
1, text: fallbackText,
Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS), options: sortStoryOptionsByPriority(openingOptions),
); displayMode: 'dialogue',
const tickDurationMs = Math.max( dialogue: [
1,
Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks),
);
for (let tick = 1; tick <= runTicks; tick += 1) {
const progress = tick / runTicks;
setGameState(
interpolateEncounterTransitionState(
entryState,
resolvedState,
progress,
),
);
await new Promise((resolve) =>
window.setTimeout(resolve, tickDurationMs),
);
}
}
const storyState: GameState = {
...resolvedState,
currentEncounter: storyEncounter,
npcInteractionActive: false,
};
setGameState(storyState);
setCurrentStory(buildDialogueStoryMoment(encounter.npcName, '', [], true));
let openingText = fallbackText;
let resolvedOpeningOptions = sortStoryOptionsByPriority(openingOptions);
try {
const response = await generateNextStep(
gameState.worldType!,
character,
getStoryGenerationHostileNpcs(storyState),
gameState.storyHistory,
actionText,
buildStoryContextFromState(storyState, {
lastFunctionId: OPENING_CAMP_DIALOGUE_FUNCTION_ID,
}),
{ {
availableOptions: openingOptions, speaker: 'npc',
speakerName: encounter.npcName,
text: fallbackText,
}, },
); ],
streaming: false,
const generatedText = response.storyText.trim(); npcChatState: {
if ( npcId: storyEncounter.id ?? storyEncounter.npcName,
generatedText && npcName: storyEncounter.npcName,
hasRenderableDialogueTurns(generatedText, encounter.npcName) turnCount: 0,
) { customInputPlaceholder: '输入你想对 TA 说的话',
openingText = generatedText; },
} });
if (response.options.length > 0) {
resolvedOpeningOptions = sortStoryOptionsByPriority(response.options);
}
} catch (error) {
console.error('Failed to infer opening camp dialogue:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
}
const finalHistory = [
...gameState.storyHistory,
createHistoryMoment(actionText, 'action'),
createHistoryMoment(openingText, 'result', openingOptions),
];
const finalState: GameState = {
...storyState,
storyHistory: finalHistory,
};
setGameState(finalState);
const openingOptionsPromise = inferOpeningCampFollowupOptions(
finalState,
character,
resolvedOpeningOptions,
openingBackground,
openingText,
);
let displayedText = '';
for (const nextChar of openingText) {
displayedText += nextChar;
setCurrentStory(
buildDialogueStoryMoment(encounter.npcName, displayedText, [], true),
);
await new Promise((resolve) =>
window.setTimeout(resolve, getTypewriterDelay(nextChar)),
);
}
const finalOpeningOptions = await openingOptionsPromise;
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,
openingText,
finalOpeningOptions,
false,
),
);
} catch (error) { } catch (error) {
console.error('Failed to play opening adventure sequence:', error); console.error('Failed to play opening adventure sequence:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误'); setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory( setGameState(resolvedState);
buildDialogueStoryMoment( setCurrentStory({
encounter.npcName, text: fallbackText,
fallbackText, options: sortStoryOptionsByPriority(openingOptions),
openingOptions, displayMode: 'dialogue',
false, dialogue: [
), {
); speaker: 'npc',
speakerName: encounter.npcName,
text: fallbackText,
},
],
streaming: false,
npcChatState: {
npcId: storyEncounter.id ?? storyEncounter.npcName,
npcName: storyEncounter.npcName,
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
},
});
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -65,12 +65,7 @@ export function buildInitialCompanionDialogueText(
const guardedMotive = const guardedMotive =
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。'; opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
return [ return `${encounter.npcName}看着你,先压低声音开口:“${immediateConcern}${guardedMotive}`;
`你:${surfaceHook}`,
`${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`,
`你:${immediateConcern}`,
`${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`,
].join('\n');
} }
export function buildCampCompanionOpeningResultText( export function buildCampCompanionOpeningResultText(
@@ -132,28 +127,14 @@ export function createCampCompanionStoryHelpers(params: {
character: Character, character: Character,
encounter: Encounter, encounter: Encounter,
) => { ) => {
const targetScene = getCampCompanionTravelScene(state, character);
const baseOptions = params.buildNpcStory( const baseOptions = params.buildNpcStory(
state, state,
character, character,
encounter, encounter,
).options; ).options;
const chatOptions = baseOptions return baseOptions
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id) .filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
.slice(0, 1); .slice(0, 3);
const recruitOption =
baseOptions.find(
(option) => option.functionId === NPC_RECRUIT_FUNCTION.id,
) ?? null;
const openingOptions = recruitOption
? [...chatOptions, recruitOption]
: chatOptions;
if (!targetScene) {
return openingOptions;
}
return [...openingOptions, buildCampTravelHomeOption(targetScene.name)];
}; };
const inferOpeningCampFollowupOptions = async ( const inferOpeningCampFollowupOptions = async (

View File

@@ -75,7 +75,6 @@ describe('storyChoiceCoordinator', () => {
generateStoryForState: vi.fn(), generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(), getAvailableOptionsForState: vi.fn(),
getCampCompanionTravelScene: vi.fn(), getCampCompanionTravelScene: vi.fn(),
startOpeningAdventure: vi.fn(),
commitGeneratedStateWithEncounterEntry: vi.fn(), commitGeneratedStateWithEncounterEntry: vi.fn(),
}; };
const runtimeSupport = { const runtimeSupport = {
@@ -107,7 +106,6 @@ describe('storyChoiceCoordinator', () => {
buildContinueAdventureOption: vi.fn(() => createOption('continue')), buildContinueAdventureOption: vi.fn(() => createOption('continue')),
isContinueAdventureOption: vi.fn(() => false), isContinueAdventureOption: vi.fn(() => false),
isCampTravelHomeOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter, isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk', npcPreviewTalkFunctionId: 'npc_preview_talk',
@@ -126,7 +124,6 @@ describe('storyChoiceCoordinator', () => {
updateQuestLog: runtimeSupport.updateQuestLog, updateQuestLog: runtimeSupport.updateQuestLog,
incrementRuntimeStats: runtimeSupport.updateRuntimeStats, incrementRuntimeStats: runtimeSupport.updateRuntimeStats,
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene, getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
startOpeningAdventure: runtimeController.startOpeningAdventure,
commitGeneratedStateWithEncounterEntry: commitGeneratedStateWithEncounterEntry:
runtimeController.commitGeneratedStateWithEncounterEntry, runtimeController.commitGeneratedStateWithEncounterEntry,
}), }),

View File

@@ -53,7 +53,6 @@ export type ChoiceRuntimeController = {
state: GameState, state: GameState,
character: Character, character: Character,
) => GameState['currentScenePreset'] | null; ) => GameState['currentScenePreset'] | null;
startOpeningAdventure: () => Promise<void>;
commitGeneratedStateWithEncounterEntry: ( commitGeneratedStateWithEncounterEntry: (
entryState: GameState, entryState: GameState,
resolvedState: GameState, resolvedState: GameState,
@@ -113,9 +112,6 @@ export type StoryChoiceCoordinatorParams = {
buildContinueAdventureOption: () => StoryOption; buildContinueAdventureOption: () => StoryOption;
isContinueAdventureOption: (option: StoryOption) => boolean; isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean; isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (
encounter: GameState['currentEncounter'],
) => encounter is Encounter;
isRegularNpcEncounter: ( isRegularNpcEncounter: (
encounter: GameState['currentEncounter'], encounter: GameState['currentEncounter'],
) => encounter is Encounter; ) => encounter is Encounter;
@@ -156,7 +152,6 @@ export function createStoryChoiceCoordinatorConfig(
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats, incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
getCampCompanionTravelScene: getCampCompanionTravelScene:
params.runtimeController.getCampCompanionTravelScene, params.runtimeController.getCampCompanionTravelScene,
startOpeningAdventure: params.runtimeController.startOpeningAdventure,
enterNpcInteraction: params.enterNpcInteraction, enterNpcInteraction: params.enterNpcInteraction,
handleNpcInteraction: params.handleNpcInteraction, handleNpcInteraction: params.handleNpcInteraction,
handleTreasureInteraction: params.handleTreasureInteraction, handleTreasureInteraction: params.handleTreasureInteraction,
@@ -165,7 +160,6 @@ export function createStoryChoiceCoordinatorConfig(
finalizeNpcBattleResult: params.finalizeNpcBattleResult, finalizeNpcBattleResult: params.finalizeNpcBattleResult,
isContinueAdventureOption: params.isContinueAdventureOption, isContinueAdventureOption: params.isContinueAdventureOption,
isCampTravelHomeOption: params.isCampTravelHomeOption, isCampTravelHomeOption: params.isCampTravelHomeOption,
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
isRegularNpcEncounter: params.isRegularNpcEncounter, isRegularNpcEncounter: params.isRegularNpcEncounter,
isNpcEncounter: params.isNpcEncounter, isNpcEncounter: params.isNpcEncounter,
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId, npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,

View File

@@ -157,7 +157,16 @@ describe('storyChoiceRuntime', () => {
]); ]);
}); });
it('identifies npc trade and gift as local runtime modal actions', () => { it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => {
expect(
shouldOpenLocalRuntimeNpcModal(
createOption('npc_chat', {
kind: 'npc',
npcId: 'npc-friend',
action: 'chat',
}),
),
).toBe(true);
expect( expect(
shouldOpenLocalRuntimeNpcModal( shouldOpenLocalRuntimeNpcModal(
createOption('npc_trade', { createOption('npc_trade', {
@@ -177,7 +186,7 @@ describe('storyChoiceRuntime', () => {
), ),
).toBe(true); ).toBe(true);
expect( expect(
shouldOpenLocalRuntimeNpcModal(createOption('npc_chat')), shouldOpenLocalRuntimeNpcModal(createOption('idle_explore_forward')),
).toBe(false); ).toBe(false);
}); });

View File

@@ -104,8 +104,15 @@ export function buildCombatResolutionContextText(params: {
export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) { export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
return ( return (
option.interaction?.kind === 'npc' && (
(option.functionId === 'npc_trade' || option.functionId === 'npc_gift') option.interaction?.kind === 'npc' ||
!option.interaction
) &&
(
option.functionId === 'npc_chat' ||
option.functionId === 'npc_trade' ||
option.functionId === 'npc_gift'
)
); );
} }
@@ -299,6 +306,7 @@ export async function runServerRuntimeChoiceAction(params: {
gameState: params.gameState, gameState: params.gameState,
currentStory: params.currentStory, currentStory: params.currentStory,
option: params.option, option: params.option,
payload: params.option.runtimePayload,
}); });
params.setGameState(hydratedSnapshot.gameState); params.setGameState(hydratedSnapshot.gameState);

Some files were not shown because too many files have changed in this diff Show More