diff --git a/.env.example b/.env.example index ed357201..4a2bcfbe 100644 --- a/.env.example +++ b/.env.example @@ -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. 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. DASHSCOPE_IMAGE_MODEL="wan2.7-image" @@ -100,10 +100,17 @@ DASHSCOPE_IMAGE_MODEL="wan2.7-image" # Optional model names for character asset studio. DASHSCOPE_CHARACTER_VISUAL_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_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. DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS="150000" diff --git a/.env.local b/.env.local index 4dcfe5e5..c858f20a 100644 --- a/.env.local +++ b/.env.local @@ -1,5 +1,9 @@ VITE_LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3" 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" DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1" DASHSCOPE_API_KEY="sk-65a0c6fa5e294b9887ace860f9d65990" diff --git a/AGENTS.md b/AGENTS.md index c69fdd0d..ddd2492e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ docs/ │ ├─ AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md │ ├─ BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md │ └─ RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md -├─ reference/ +├─ reference/[QwenSpriteSheetTool.tsx](src/tools/QwenSpriteSheetTool.tsx) │ ├─ README.md │ └─ FUNCTION_SCRIPT_CATALOG_2026-04-04.md └─ technical/ diff --git a/docs/audits/README.md b/docs/audits/README.md index e7516ac1..75db7b50 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -15,6 +15,7 @@ - [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 的落地情况。 - [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):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。 ## 推荐使用方式 diff --git a/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md new file mode 100644 index 00000000..b6a5ac7f --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md @@ -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 插件入口的扫描结果 + diff --git a/docs/audits/engineering/README.md b/docs/audits/engineering/README.md index 93c44c5f..2bfc211c 100644 --- a/docs/audits/engineering/README.md +++ b/docs/audits/engineering/README.md @@ -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 边界、分层过渡期问题。 -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 上。 +- `2026-04-19` 这一轮进一步把问题压实到了四类:仓库噪音、旧 dev 入口残留、前端越界运行时逻辑、巨型热点文件。 - 如果只是为了判断现在先做什么,直接从 `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` 的顺序回看演进。 diff --git a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md new file mode 100644 index 00000000..4230ada9 --- /dev/null +++ b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md @@ -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. 未登录态首页不会因个人接口失败而出现“读取个人看板失败”“读取作品库失败”之类报错。 diff --git a/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md b/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md new file mode 100644 index 00000000..8d5cf0ed --- /dev/null +++ b/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md @@ -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. 手机端布局保持稳定,桌面端在参考图方向下完成控制台化重组 diff --git a/docs/experience/AGENT_UI_CHANGELOG.md b/docs/experience/AGENT_UI_CHANGELOG.md index 230d4e69..94bf6d96 100644 --- a/docs/experience/AGENT_UI_CHANGELOG.md +++ b/docs/experience/AGENT_UI_CHANGELOG.md @@ -113,4 +113,12 @@ --- +## 8. 2026-04-18 补充记录 + +- `GameShellRuntime` 进入游戏壳时,会主动隐藏认证层提供的右上角全局账号信息条。 +- 原因不是账号功能下线,而是这个悬浮条会遮挡冒险主场景内容,移动端更明显。 +- 账号相关入口保留在平台首页 / 个人页内部按钮与账号弹窗,不再占用游戏 HUD 区域。 + +--- + *文档目的:交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。* diff --git a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md index f36d8343..7288de06 100644 --- a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md +++ b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md @@ -89,6 +89,12 @@ - 在底部工具区,队伍/背包改成 icon 后更紧凑。 - 但必须保留 `aria-label`,保证语义清晰、后续也方便测试。 +### 4.5 冒险主场景不要挂右上角账号悬浮条 +- 冒险页右上角属于画面演出和战斗/剧情信息的高频观察区。 +- 全局账号信息条挂在这里,会直接压住场景、敌人血条或顶部提示,手机端尤其明显。 +- 结论: + 账号入口应收回平台首页、个人页或设置面板,不要在实际冒险主场景常驻悬浮显示。 + ## 5. 队伍面板经验 ### 5.1 移动端成员列表不能太“卡片化” diff --git a/docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md b/docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md index 93a093c2..fb0e209e 100644 --- a/docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md +++ b/docs/experience/PROJECT_DEVELOPMENT_EXPERIENCE.md @@ -76,6 +76,19 @@ - 流程层优先按“职责”拆,不按“文件长度”拆。 - 状态修改逻辑尽量集中到 hook 内,不要散落在多个组件按钮回调里。 +## 3.1 AI 草稿数据进列表前,要先补本地稳定标识 + +自定义世界、角色草稿、澄清问题、生成结果卡片这类数据,在草稿态或兼容旧数据时,`id` 可能为空。 + +经验: + +- React 列表的 `key` 不要直接裸用这类可能为空的 `id`。 +- 当前选中态、草稿缓存、轮播焦点也不要直接绑空 `id`,否则会出现“点了第二张卡,结果还是第一张卡被选中”的错位。 +- 更稳的做法是: + - 业务数据层尽量补齐真实 id + - UI 层再补一层本地稳定 `selectionKey` / fallback render key + - fallback 至少带上 `index + 名称种子`,保证当前列表内唯一 + ## 4. AI 只适合生成叙事,不适合决定关键规则 实践中最稳定的策略是: diff --git a/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md b/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md index 254b09f5..bd6b3451 100644 --- a/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md +++ b/docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md @@ -1,6 +1,13 @@ # 账号系统与登录入口重构 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. 目标 @@ -175,7 +182,7 @@ MVP 阶段建议采用最稳妥规则: 4. 微信后强制绑定手机号 5. 账号会话管理 6. 账号与存档/自定义世界/运行时设置统一绑定 -7. 基础账号中心与退出登录 +7. 基础账号中心、平台设置面板与退出登录 ## 3.2 本期不做 @@ -460,6 +467,7 @@ MVP 阶段建议至少提供一个轻量账号中心,包含: - 已绑定手机号(脱敏展示) - 微信绑定状态 - 最近登录时间 +- 平台设置面板中的亮色 / 暗色主题切换 - 退出登录 二期可以再补: diff --git a/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md b/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md index d66a709e..2686302d 100644 --- a/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md +++ b/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md @@ -1,6 +1,6 @@ # AI 角色形象与角色动画 MVP PRD -更新时间:`2026-04-04` +更新时间:`2026-04-19` ## 0. 一句话结论 @@ -254,6 +254,15 @@ 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` - `jump_attack` -- `hurt` - `die` 要求末帧清晰,不与下一动作切换冲突。 @@ -634,4 +642,3 @@ type GeneratedCharacterAnimationAsset = { - 路径清晰 - 能真正进入当前仓库 - 后续可以在此基础上再加技能动作、剧情演出和多供应商增强路线 - diff --git a/docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md b/docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md new file mode 100644 index 00000000..a78a64b2 --- /dev/null +++ b/docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md @@ -0,0 +1,226 @@ +# AI Native 战斗单行为 Function PRD(2026-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 不识别而报错。 diff --git a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md index 45b66479..b73d82f8 100644 --- a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md +++ b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md @@ -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 数据来源 ### 草稿来源 diff --git a/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md b/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md index 31c947e1..146a247a 100644 --- a/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md +++ b/docs/prd/MY_TAB_FEATURE_PRD_INDEX_2026-04-16.md @@ -17,17 +17,20 @@ ## 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. 账号资料与身份卡 2. 会员中心与充值 3. 我的数据看板 -4. 最近游玩 -5. 历史浏览 -6. 邀请好友 -7. 填邀请码 -8. 玩家社区 -9. 设置与账号安全 +4. 邀请好友 +5. 填邀请码 +6. 玩家社区 +7. 设置与账号安全 --- @@ -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) 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) -4. [MY_TAB_RECENT_PLAY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_RECENT_PLAY_PRD_2026-04-16.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) -6. [MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_FRIENDS_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) -8. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_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) +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_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_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) --- @@ -51,20 +53,19 @@ 1. 账号资料与身份卡 2. 设置与账号安全 -3. 最近游玩 -4. 历史浏览 -5. 我的数据看板 -6. 会员中心与充值 -7. 邀请好友 -8. 填邀请码 -9. 玩家社区 +3. 我的数据看板 +4. 平台存档 Tab +5. 会员中心与充值 +6. 邀请好友 +7. 填邀请码 +8. 玩家社区 原因: - `1 + 2` 复用现有账号系统最多,最容易先落地 -- `3 + 4 + 5` 直接增强“我的”页内容密度,短期收益高 -- `6 + 7` 涉及商业化和关系绑定,依赖结算与奖励台账 -- `8` 最适合放在平台内容层能力稳定后再做 +- `3 + 4` 直接增强账号资产与回流体验,短期收益高 +- `5 + 6` 涉及商业化和关系绑定,依赖结算与奖励台账 +- `7` 最适合放在平台内容层能力稳定后再做 --- diff --git a/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md b/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md new file mode 100644 index 00000000..342844bd --- /dev/null +++ b/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md @@ -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:` +- 内建世界:`builtin:` + +同一账号、同一 `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. 未登录时不请求云端存档列表,也不会出现受保护接口报错。 diff --git a/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md b/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md index a21f9403..d49f6645 100644 --- a/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md +++ b/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md @@ -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 补充路线:腾讯云相关能力 腾讯云相关接口里,`提交图片跳舞任务` 提供了: @@ -422,12 +431,12 @@ - `idle` - `run` - `attack` -- `jump` -- `hurt` - `die` 系统自动选择对应参考视频模板。 +`jump`、`hurt` 这类扩展动作不再作为当前编辑器固定按钮,改为后续扩展动作槽位或手动补齐。 + ### B. 视频驱动 用户上传参考动作视频,系统抽姿态后再生成角色动作。 @@ -498,6 +507,8 @@ flowchart LR - 文生图时,优先生成与当前项目角色素材视角一致的单人全身图 - 有参考图时,优先做“角色指定 + 风格收敛 + 视角纠偏” - 用户直接上传素材时,先做校验、裁切、背景清理和尺寸标准化 +- 编辑器未上传参考图时,主形象阶段默认附加一张由项目内可扮演角色 idle 帧拼成的风格参考板,用来锁定像素动作角色的轮廓语言、右朝向、体型比例与配色组织,避免模型只放大 Q 版比例却丢掉像素感 +- 风格约束优先级里,“像素动作角色感”高于“Q 版比例提示”;比例只允许轻度偏大头,不允许退化成普通软萌插画或儿童绘本风 ### 角色视角要求 @@ -623,6 +634,16 @@ flowchart LR 8. 生成 Sprite Sheet 9. 输出动画元数据 +### 当前工程的抠像补充策略 + +针对角色动作视频抽帧后常见的“后段帧出现白底”“角色轮廓残留绿幕像素点”问题,当前工程内的背景清理不再只依赖单一绿幕阈值,而是统一改为以下顺序: + +1. 先识别边界连通的可移除背景区域,同时覆盖纯绿色绿幕和高亮低色差白底。 +2. 再向主体边缘的半透明软边做一轮有限扩张,把压缩后残留的白边、绿边纳入透明化处理。 +3. 最后对贴近透明边缘的像素做去污,优先压掉绿色溢色,并把白边/绿边颜色拉回附近前景主体颜色,减少抽帧后的轮廓发白、发绿。 + +这样可以避免把角色内部的白色高光、白色装备整体误删,同时能更稳定地清理视频模型在末段帧里偶发的白背景和压缩噪点。 + ### 像素化策略 推荐做法: @@ -703,7 +724,14 @@ export interface GeneratedCharacterAnimationAsset { id: string; characterId: 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'; sourceMode: 'template' | 'video-drive' | 'audio-drive'; frameCount: number; @@ -914,20 +942,26 @@ draft 第一版要求以下基础动作槽位全部有内容: -| 动作槽位 | 是否必填 | 建议来源 | -| --- | --- | --- | -| `idle` | 必填 | 模板生成 | -| `acquire` | 必填 | 可由短持物 / 抬手动作生成 | -| `attack` | 必填 | 模板生成 | -| `run` | 必填 | 模板生成 | -| `jump` | 必填 | 模板生成 | -| `double_jump` | 必填 | 可由跳跃二次变体生成 | -| `jump_attack` | 必填 | 跳跃攻击模板 | -| `dash` | 必填 | 冲刺模板 | -| `hurt` | 必填 | 受击模板 | -| `die` | 必填 | 倒地 / 消散模板 | -| `climb` | 必填 | 攀爬模板 | -| `wall_slide` | 必填 | 可由攀爬或停滞帧变体生成 | +当前编辑器固定生成入口补充说明(`2026-04-19`): + +- 固定按钮只保留 `idle / run / attack / die` +- `hurt` 不再作为固定生成按钮 +- 如果运行时仍需 `hurt` 资源,应通过后续扩展动作槽位或手动补齐 + +| 动作槽位 | 是否必填 | 建议来源 | +| ------------- | -------- | ------------------------- | +| `idle` | 必填 | 模板生成 | +| `acquire` | 必填 | 可由短持物 / 抬手动作生成 | +| `attack` | 必填 | 模板生成 | +| `run` | 必填 | 模板生成 | +| `jump` | 必填 | 模板生成 | +| `double_jump` | 必填 | 可由跳跃二次变体生成 | +| `jump_attack` | 必填 | 跳跃攻击模板 | +| `dash` | 必填 | 冲刺模板 | +| `hurt` | 必填 | 受击模板 | +| `die` | 必填 | 倒地 / 消散模板 | +| `climb` | 必填 | 攀爬模板 | +| `wall_slide` | 必填 | 可由攀爬或停滞帧变体生成 | 这里“不能为空”指的是: @@ -961,14 +995,13 @@ draft 先优先做这些高价值模板: -| 模板 | 推荐时长 | 是否循环 | 说明 | -| --- | --- | --- | --- | -| `idle` | 2s-4s | 是 | 微动作、呼吸 | -| `run` | 2s-3s | 是 | 固定侧向 | -| `attack` | 2s-4s | 否 | 近战基础攻击 | -| `jump` | 1s-2s | 否 | 起跳与空中姿态 | -| `hurt` | 1s-2s | 否 | 受击短动作 | -| `die` | 2s-4s | 否 | 倒下或消散 | +| 模板 | 推荐时长 | 是否循环 | 说明 | +| -------- | -------- | -------- | -------------- | +| `idle` | 2s-4s | 是 | 微动作、呼吸 | +| `run` | 2s-3s | 是 | 固定侧向 | +| `attack` | 2s-4s | 否 | 近战基础攻击 | +| `jump` | 1s-2s | 否 | 起跳与空中姿态 | +| `die` | 2s-4s | 否 | 倒下或消散 | ### 12.4 不建议第一阶段就重投入的动作 diff --git a/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md new file mode 100644 index 00000000..e3580928 --- /dev/null +++ b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md @@ -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 目录改造引入的问题。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 09b8038b..62477f5f 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 - [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):按并行工作流文档逐项核对后的完成度审计与剩余收口点。 diff --git a/packages/shared/src/assets/chromaKey.ts b/packages/shared/src/assets/chromaKey.ts new file mode 100644 index 00000000..7b517cc6 --- /dev/null +++ b/packages/shared/src/assets/chromaKey.ts @@ -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; +} diff --git a/packages/shared/src/assets/qwenSprite.ts b/packages/shared/src/assets/qwenSprite.ts index 24ee57da..f53da868 100644 --- a/packages/shared/src/assets/qwenSprite.ts +++ b/packages/shared/src/assets/qwenSprite.ts @@ -94,11 +94,10 @@ export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [ }, ]; -const CHIBI_STYLE_TEXT = - 'Q版大头身动作角色,头身比固定控制在 2 到 2.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑,接近经典横版像素动作角色的身体比例,不要写实长身比例。'; +const BODY_RATIO_TEXT = + '横版像素动作角色体型,头身比优先控制在 3 到 4 头身,头部只允许略大于写实比例,保留清楚的头、躯干、双臂和双腿轮廓,不要退化成软萌 Q版大头贴或儿童绘本比例。'; const PIXEL_STYLE_TEXT = - '像素风画风,整体是像素游戏角色设计方向,深色粗轮廓配合清晰大色块,侧脸五官简化,发型、服装、配饰优先形成醒目剪影,身体始终朝右,适合横版动作 sprite 资产。'; - + '明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,身体始终朝右,适合横版动作 sprite 资产。'; export function getActionTemplateById(id: QwenSpriteActionTemplateId) { return ( @@ -113,7 +112,7 @@ export function buildMasterPrompt(characterBrief: string) { `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, - `风格要求:Q版大头身动作角色,清爽可爱,头身比固定控制在 2 到 2.5 头身,头部占比明显更大,肩宽和骨盆都更收紧,躯干与四肢短而紧凑。深色粗轮廓配合清晰大色块,侧脸五官简化,发型、服装、配饰优先形成醒目剪影,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。`, + `风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,形体清晰,服装层次明确,优先体现像素动作角色感而不是软萌 Q版插画感,便于后续连续动作生成。`, '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。', '主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。', '视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。', @@ -130,12 +129,12 @@ export function buildVideoActionPrompt(options: { characterBrief: string; }) { return [ - `单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}。`, + `单人全身角色动作视频,动作英文名是 ${options.actionTemplate.id}。`, `角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。`, `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, - `风格要求:${CHIBI_STYLE_TEXT} ${PIXEL_STYLE_TEXT} 清爽可爱,高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`, + `风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,优先保证像素动作角色感,不要退化成只剩 Q 版比例的普通插画,便于后续连续动作生成。`, `动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`, options.useChromaKey ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。' diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 284964e5..e86badb4 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -2,6 +2,9 @@ import type { JsonObject } from './common'; export const SAVE_SNAPSHOT_VERSION = 2; 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< TGameState = unknown, @@ -28,6 +31,7 @@ export type SavedGameSnapshotInput< export type RuntimeSettings = { musicVolume: number; + platformTheme: PlatformTheme; }; export type BasicOkResult = { @@ -73,6 +77,31 @@ export type ProfilePlayStatsResponse = { 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; +}; + export type CustomWorldPublicationStatus = 'draft' | 'published'; export type CustomWorldThemeMode = | 'martial' diff --git a/packages/shared/src/contracts/story.ts b/packages/shared/src/contracts/story.ts index 4b3f2d24..b915471a 100644 --- a/packages/shared/src/contracts/story.ts +++ b/packages/shared/src/contracts/story.ts @@ -261,6 +261,8 @@ export const TASK5_RUNTIME_FUNCTION_IDS = [ 'idle_observe_signs', 'idle_rest_focus', 'idle_travel_next_scene', + 'battle_attack_basic', + 'battle_use_skill', 'battle_all_in_crush', 'battle_escape_breakout', 'battle_feint_step', @@ -326,6 +328,7 @@ export type RuntimeStoryOptionView = { actionText: string; detailText?: string; scope: Task5RuntimeOptionScope; + payload?: RuntimeStoryChoicePayload; disabled?: boolean; reason?: string; }; diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index e65a6d69..1fa33bdb 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -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 () => { await withTestServer('custom-world-gallery', async ({ baseUrl }) => { const owner = await authEntry(baseUrl, 'gallery_owner', 'secret123'); - const viewer = await authEntry(baseUrl, 'gallery_viewer', 'secret123'); const upsertResponse = await httpRequest( `${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( `${baseUrl}/api/runtime/custom-world-gallery`, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }, ); const galleryBeforePayload = (await galleryBeforePublish.json()) as { entries: unknown[]; }; + assert.equal(galleryBeforePublish.status, 200); assert.deepEqual(galleryBeforePayload.entries, []); 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( `${baseUrl}/api/runtime/custom-world-gallery`, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }, ); const galleryAfterPayload = (await galleryAfterPublish.json()) as { entries: Array<{ @@ -2139,11 +2289,6 @@ test('custom worlds stay private until published and then appear in the public g const galleryDetail = await httpRequest( `${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 { entry: { @@ -2175,11 +2320,6 @@ test('custom worlds stay private until published and then appear in the public g const galleryAfterUnpublish = await httpRequest( `${baseUrl}/api/runtime/custom-world-gallery`, - { - headers: { - Authorization: `Bearer ${viewer.token}`, - }, - }, ); const galleryAfterUnpublishPayload = (await galleryAfterUnpublish.json()) as { diff --git a/server-node/src/db/migrations.ts b/server-node/src/db/migrations.ts index b84fd58f..db8d9da5 100644 --- a/server-node/src/db/migrations.ts +++ b/server-node/src/db/migrations.ts @@ -30,6 +30,7 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ `CREATE TABLE IF NOT EXISTS runtime_settings ( user_id TEXT PRIMARY KEY, music_volume REAL NOT NULL, + platform_theme TEXT NOT NULL DEFAULT 'light', updated_at TEXT NOT NULL, 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'`, + ], + }, ]; diff --git a/server-node/src/modules/ai/chatOrchestrator.ts b/server-node/src/modules/ai/chatOrchestrator.ts index 834910c9..5ef2ce8b 100644 --- a/server-node/src/modules/ai/chatOrchestrator.ts +++ b/server-node/src/modules/ai/chatOrchestrator.ts @@ -125,11 +125,13 @@ function describeAffinityShift(affinityDelta: number) { } function buildFallbackNpcChatSuggestions(playerMessage: string) { - const topic = playerMessage.trim() || '刚才那句话'; + const topic = Array.from(playerMessage.trim() || '刚才那句') + .slice(0, 8) + .join(''); return [ - `顺着“${topic}”再追问一句`, - '先表明你的判断,再看对方反应', - '换个更轻一点的语气继续聊下去', + '你刚才那句是什么意思', + `这事和${topic}有关吗`, + '你愿意再说清楚点吗', ]; } diff --git a/server-node/src/modules/ai/chatPromptBuilders.ts b/server-node/src/modules/ai/chatPromptBuilders.ts index c233262f..86bd2775 100644 --- a/server-node/src/modules/ai/chatPromptBuilders.ts +++ b/server-node/src/modules/ai/chatPromptBuilders.ts @@ -1,469 +1 @@ -import type { - CharacterChatReplyRequest, - CharacterChatSuggestionsRequest, - CharacterChatSummaryRequest, - NpcChatDialogueRequest, - NpcChatTurnRequest, - NpcRecruitDialogueRequest, -} from '../../../../packages/shared/src/contracts/story.js'; - -type JsonRecord = Record; - -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'); -} +export * from '../../prompts/chatPromptBuilders.js'; diff --git a/server-node/src/modules/ai/customWorldOrchestrator.ts b/server-node/src/modules/ai/customWorldOrchestrator.ts index 0426ef1a..b1a33fae 100644 --- a/server-node/src/modules/ai/customWorldOrchestrator.ts +++ b/server-node/src/modules/ai/customWorldOrchestrator.ts @@ -22,17 +22,15 @@ import type { CustomWorldCreatorIntent, CustomWorldProfile, } 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'; type GeneratedProfile = Record; - -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 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, summary,targetLandmarkName 必须指向本次输出的其他场景名', - '', - '约束:', - '- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。', - '- 角色名字、势力名、场景名必须互相区分,避免重复。', - '- 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: { llmClient: UpstreamLlmClient; responseText: string; @@ -424,16 +369,21 @@ export async function generateCustomWorldProfileFromOrchestrator( creatorIntent, generationMode, } = resolveCustomWorldGenerationInput(input); + const targets = getCustomWorldGenerationTargets(generationMode); + const creatorIntentText = + creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent) + ? buildCustomWorldCreatorIntentGenerationText(creatorIntent) + : ''; const reporter = createCustomWorldGenerationReporter(options.onProgress); try { throwIfCustomWorldGenerationAborted(options.signal); reporter.begin('prepare', '正在整理创作者输入与结构化锚点。'); const userPrompt = buildCustomWorldProfilePrompt({ - settingText, generationSeedText, - creatorIntent, generationMode, + creatorIntentText, + targets, }); reporter.complete('prepare', '设定上下文已整理,开始请求大模型推理。'); diff --git a/server-node/src/modules/ai/storyOrchestrator.ts b/server-node/src/modules/ai/storyOrchestrator.ts index 54a38690..b29e97c7 100644 --- a/server-node/src/modules/ai/storyOrchestrator.ts +++ b/server-node/src/modules/ai/storyOrchestrator.ts @@ -1,6 +1,10 @@ import { hasMixedNarrativeLanguage } from '../../../../packages/shared/src/llm/narrativeLanguage.js'; import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.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'; type JsonRecord = Record; @@ -64,12 +68,6 @@ type RawOptionItem = { actionText?: string; }; -const STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT = `你是 RPG 中文叙事文本修复器。 -你会收到一个已经解析过的剧情 JSON 对象。 -你的唯一任务是把 storyText 和 options[].actionText 中的英文句子、中英混杂句式、英文解释改写成自然中文。 -必须保持 JSON 结构、encounter、options 数量、options 顺序以及每个 functionId 完全不变。 -只输出一个 JSON 对象,不要输出 Markdown、代码块、解释、注释或额外文字。`; - const DEFAULT_VISUALS = { playerAnimation: 'idle' as const, playerMoveMeters: 0, @@ -83,6 +81,8 @@ const STATIC_FALLBACK_OPTION_MAP: Record< string, Partial & { actionText: string } > = { + battle_attack_basic: { actionText: '普通攻击' }, + battle_use_skill: { actionText: '释放技能' }, battle_all_in_crush: { actionText: '正面强压敌人' }, battle_escape_breakout: { actionText: '先脱离眼前追杀' }, battle_feint_step: { actionText: '借假动作切进身位' }, @@ -334,11 +334,9 @@ function resolveOptionsFromOptionCatalog( function getFallbackFunctionIds(context: PromptContext, encounter?: SceneEncounterResult) { if (context.inBattle === true) { return [ - 'battle_probe_pressure', - 'battle_guard_break', + 'battle_attack_basic', 'battle_recover_breath', - 'battle_feint_step', - 'battle_finisher_window', + 'battle_use_skill', '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) { return hasMixedNarrativeLanguage(response.storyText); } diff --git a/server-node/src/modules/ai/storyPromptBuilders.test.ts b/server-node/src/modules/ai/storyPromptBuilders.test.ts new file mode 100644 index 00000000..c871fb2b --- /dev/null +++ b/server-node/src/modules/ai/storyPromptBuilders.test.ts @@ -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); +}); diff --git a/server-node/src/modules/ai/storyPromptBuilders.ts b/server-node/src/modules/ai/storyPromptBuilders.ts index f1ffcccf..b13bba4d 100644 --- a/server-node/src/modules/ai/storyPromptBuilders.ts +++ b/server-node/src/modules/ai/storyPromptBuilders.ts @@ -1,163 +1 @@ -type JsonRecord = Record; - -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>; - optionCatalog?: Array>; -}) { - 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>; - optionCatalog?: Array>; - }; -}) { - 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'); -} +export * from '../../prompts/storyPromptBuilders.js'; diff --git a/server-node/src/modules/assets/characterAssetRoutes.test.ts b/server-node/src/modules/assets/characterAssetRoutes.test.ts index d6a3f4f0..f7e61d86 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.test.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -8,6 +12,7 @@ import test from 'node:test'; import express from 'express'; import { PNG } from 'pngjs'; +import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js'; import type { AppConfig } from '../../config.js'; import { createCharacterAssetRoutes } from './characterAssetRoutes.js'; @@ -33,6 +38,57 @@ function createGreenScreenFixturePngBuffer() { return PNG.sync.write(png); } +function setPngPixel( + png: PNG, + x: number, + y: number, + rgba: [number, number, number, number], +) { + const offset = (y * png.width + x) * 4; + png.data[offset] = rgba[0]; + png.data[offset + 1] = rgba[1]; + png.data[offset + 2] = rgba[2]; + png.data[offset + 3] = rgba[3]; +} + +function createWhiteBackdropFixturePngBuffer() { + const png = new PNG({ width: 5, height: 5 }); + + for (let y = 0; y < png.height; y += 1) { + for (let x = 0; x < png.width; x += 1) { + setPngPixel(png, x, y, [255, 255, 255, 255]); + } + } + + for (let y = 1; y <= 3; y += 1) { + for (let x = 1; x <= 3; x += 1) { + setPngPixel(png, x, y, [220, 62, 86, 255]); + } + } + + setPngPixel(png, 2, 2, [244, 244, 244, 255]); + + return PNG.sync.write(png); +} + +function createGreenHaloFixturePngBuffer() { + const png = new PNG({ width: 5, height: 5 }); + + for (let y = 0; y < png.height; y += 1) { + for (let x = 0; x < png.width; x += 1) { + setPngPixel(png, x, y, [0, 255, 0, 255]); + } + } + + for (let y = 1; y <= 3; y += 1) { + setPngPixel(png, 1, y, [164, 186, 126, 255]); + setPngPixel(png, 2, y, [220, 60, 82, 255]); + setPngPixel(png, 3, y, [208, 52, 76, 255]); + } + + return PNG.sync.write(png); +} + function readPngAlphaValues(buffer: Buffer) { const png = PNG.sync.read(buffer); return Array.from({ length: png.width * png.height }, (_, index) => { @@ -40,18 +96,38 @@ function readPngAlphaValues(buffer: Buffer) { }); } +function readPngPixel( + buffer: Buffer, + x: number, + y: number, +): { red: number; green: number; blue: number; alpha: number } { + const png = PNG.sync.read(buffer); + const offset = (y * png.width + x) * 4; + + return { + red: png.data[offset] ?? 0, + green: png.data[offset + 1] ?? 0, + blue: png.data[offset + 2] ?? 0, + alpha: png.data[offset + 3] ?? 0, + }; +} + const GREEN_SCREEN_PNG_BUFFER = createGreenScreenFixturePngBuffer(); +const WHITE_BACKDROP_PNG_BUFFER = createWhiteBackdropFixturePngBuffer(); +const GREEN_HALO_PNG_BUFFER = createGreenHaloFixturePngBuffer(); function createTestConfig( projectRoot: string, - dashScopeBaseUrl: string, + upstreamBaseUrl: string, ): AppConfig { return { projectRoot, assetsApiEnabled: true, rawEnv: { - DASHSCOPE_BASE_URL: dashScopeBaseUrl, + DASHSCOPE_BASE_URL: upstreamBaseUrl, DASHSCOPE_API_KEY: 'test-dashscope-key', + ARK_BASE_URL: upstreamBaseUrl, + ARK_API_KEY: 'test-ark-key', }, } as AppConfig; } @@ -74,10 +150,9 @@ function sendJson(res: ServerResponse, payload: unknown) { } async function withHttpServer( - buildHandler: (baseUrl: string) => ( - req: IncomingMessage, - res: ServerResponse, - ) => void | Promise, + buildHandler: ( + baseUrl: string, + ) => (req: IncomingMessage, res: ServerResponse) => void | Promise, run: (baseUrl: string) => Promise, ) { let handler: ( @@ -150,8 +225,38 @@ async function withAssetRouteServer( } } +test('removeBackgroundFromRgba strips border-connected white background and keeps enclosed white highlights', () => { + const png = PNG.sync.read(WHITE_BACKDROP_PNG_BUFFER); + + const changed = removeBackgroundFromRgba(png.data, png.width, png.height); + + assert.equal(changed, true); + assert.equal(png.data[3] ?? 255, 0); + assert.equal(png.data[(2 * png.width + 2) * 4 + 3] ?? 0, 255); +}); + +test('removeBackgroundFromRgba reduces green spill on edge pixels without eroding the foreground core', () => { + const cleanedBuffer = (() => { + const png = PNG.sync.read(GREEN_HALO_PNG_BUFFER); + removeBackgroundFromRgba(png.data, png.width, png.height); + return PNG.sync.write(png); + })(); + + const haloPixel = readPngPixel(cleanedBuffer, 1, 2); + const corePixel = readPngPixel(cleanedBuffer, 2, 2); + + assert.equal(corePixel.alpha, 255); + assert.equal(corePixel.red > corePixel.green, true); + assert.equal( + haloPixel.alpha < 120 || haloPixel.green <= haloPixel.red + 12, + true, + ); +}); + test('character visual generation converts public reference images into data urls before calling DashScope', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-visual-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-visual-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'reference.png'), PNG_BUFFER); @@ -174,7 +279,10 @@ test('character visual generation converts public reference images into data url return; } - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/visual-task-1') { + if ( + req.method === 'GET' && + url.pathname === '/api/v1/tasks/visual-task-1' + ) { sendJson(res, { output: { task_status: 'SUCCEEDED', @@ -233,129 +341,163 @@ test('character visual generation converts public reference images into data url content: Array<{ text?: string; image?: string }>; }>; }; + parameters: { + negative_prompt?: string; + }; }; const content = createPayload.input.messages[0]?.content ?? []; assert.match(content[0]?.text ?? '', /右向斜侧身/u); assert.match(content[0]?.text ?? '', /纯绿色绿幕/u); assert.match(content[0]?.text ?? '', /1:1 正方形画幅|1:1 正方形画布/u); - assert.match(content[0]?.text ?? '', /2 到 2\.5 头身|2 到 3 头身/u); - assert.match(content[0]?.text ?? '', /躯干与四肢短而紧凑/u); - assert.match(content[0]?.text ?? '', /深色粗轮廓配合清晰大色块/u); + assert.match(content[0]?.text ?? '', /3 到 4 头身/u); + assert.match(content[0]?.text ?? '', /像素动作角色/u); + assert.match(content[0]?.text ?? '', /不要退化成软萌 Q版大头贴/u); assert.match(content[0]?.text ?? '', /不要把主题词自动扩写成背景建筑/u); assert.doesNotMatch(content[0]?.text ?? '', /水母国王/u); + assert.match( + createPayload.parameters.negative_prompt ?? '', + /软萌 Q版大头贴/u, + ); assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u); - const savedDraftPath = path.join(tempRoot, 'public', payload.drafts[0]!.imageSrc.slice(1)); + const savedDraftPath = path.join( + tempRoot, + 'public', + payload.drafts[0]!.imageSrc.slice(1), + ); assert.equal(fs.existsSync(savedDraftPath), true); - assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedDraftPath)), [0, 255]); + assert.deepEqual( + readPngAlphaValues(fs.readFileSync(savedDraftPath)), + [0, 255], + ); }); }, ); }); test('character prompt bundle generation falls back to local defaults when llm client is unavailable', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-prompt-bundle-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-prompt-bundle-'), + ); - await withAssetRouteServer(createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => { - const response = await fetch(`${assetBaseUrl}/api/assets/character-prompts/generate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - roleKind: 'story', - characterName: '港口向导', - roleTitle: '潮灯守望者', - roleLabel: '旧港引路人', - description: '熟悉黑潮与暗礁,身上带着潮雾气息。', - backstory: '常年守在废弃灯塔附近,为误入者指路。', - personality: '冷静克制,但会在关键时刻出手。', - motivation: '想守住最后一段仍能靠岸的航道。', - combatStyle: '短刀与信号灯配合,动作利落。', - tags: ['潮雾', '守望', '引路'], - characterBriefText: '角色名称:港口向导\n角色头衔:潮灯守望者\n世界身份:旧港引路人', - }), - }); + await withAssetRouteServer( + createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), + async (assetBaseUrl) => { + const response = await fetch( + `${assetBaseUrl}/api/assets/character-prompts/generate`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + roleKind: 'story', + characterName: '港口向导', + roleTitle: '潮灯守望者', + roleLabel: '旧港引路人', + description: '熟悉黑潮与暗礁,身上带着潮雾气息。', + backstory: '常年守在废弃灯塔附近,为误入者指路。', + personality: '冷静克制,但会在关键时刻出手。', + motivation: '想守住最后一段仍能靠岸的航道。', + combatStyle: '短刀与信号灯配合,动作利落。', + tags: ['潮雾', '守望', '引路'], + characterBriefText: + '角色名称:港口向导\n角色头衔:潮灯守望者\n世界身份:旧港引路人', + }), + }, + ); - assert.equal(response.status, 200); - const payload = (await response.json()) as { - source: string; - visualPromptText: string; - animationPromptText: string; - scenePromptText: string; - }; + assert.equal(response.status, 200); + const payload = (await response.json()) as { + source: string; + visualPromptText: string; + animationPromptText: string; + scenePromptText: string; + }; - assert.equal(payload.source, 'fallback'); - assert.match(payload.visualPromptText, /港口向导/u); - assert.match(payload.visualPromptText, /右向斜侧身/u); - assert.match(payload.visualPromptText, /纯绿色绿幕/u); - assert.match(payload.visualPromptText, /2 到 2\.5 头身/u); - assert.match(payload.visualPromptText, /躯干与四肢短而紧凑/u); - assert.match(payload.visualPromptText, /深色粗轮廓配合清晰大色块/u); - assert.match(payload.animationPromptText, /动作/u); - assert.match(payload.scenePromptText, /场景/u); - }); + assert.equal(payload.source, 'fallback'); + assert.match(payload.visualPromptText, /港口向导/u); + assert.match(payload.visualPromptText, /右向斜侧身/u); + assert.match(payload.visualPromptText, /纯绿色绿幕/u); + assert.match(payload.visualPromptText, /2 到 2\.5 头身/u); + assert.match(payload.visualPromptText, /躯干与四肢短而紧凑/u); + assert.match(payload.visualPromptText, /深色粗轮廓配合清晰大色块/u); + assert.match(payload.animationPromptText, /动作/u); + assert.match(payload.scenePromptText, /场景/u); + }, + ); }); test('character workflow cache persists unsaved studio state', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-workflow-cache-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-workflow-cache-'), + ); - await withAssetRouteServer(createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => { - const saveResponse = await fetch(`${assetBaseUrl}/api/assets/character-workflow-cache`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - visualPromptText: '潮雾港守望者', - animationPromptText: '短刀起手,收招利落', - visualDrafts: [ - { - id: 'draft-1', - label: '候选 1', - imageSrc: '/generated-character-drafts/harbor-guide/draft-1.png', - width: 1024, - height: 1536, - }, - ], - selectedVisualDraftId: 'draft-1', - selectedAnimation: 'idle', - imageSrc: '/generated-characters/harbor-guide/visual/visual-1/master.png', - generatedVisualAssetId: 'visual-1', - generatedAnimationSetId: 'animation-set-1', - animationMap: { - idle: { - basePath: '/generated-animations/harbor-guide/animation-set-1/idle', + await withAssetRouteServer( + createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), + async (assetBaseUrl) => { + const saveResponse = await fetch( + `${assetBaseUrl}/api/assets/character-workflow-cache`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, + body: JSON.stringify({ + characterId: 'harbor-guide', + visualPromptText: '潮雾港守望者', + animationPromptText: '短刀起手,收招利落', + visualDrafts: [ + { + id: 'draft-1', + label: '候选 1', + imageSrc: + '/generated-character-drafts/harbor-guide/draft-1.png', + width: 1024, + height: 1536, + }, + ], + selectedVisualDraftId: 'draft-1', + selectedAnimation: 'idle', + imageSrc: + '/generated-characters/harbor-guide/visual/visual-1/master.png', + generatedVisualAssetId: 'visual-1', + generatedAnimationSetId: 'animation-set-1', + animationMap: { + idle: { + basePath: + '/generated-animations/harbor-guide/animation-set-1/idle', + }, + }, + }), }, - }), - }); + ); - assert.equal(saveResponse.status, 200); + assert.equal(saveResponse.status, 200); - const readResponse = await fetch( - `${assetBaseUrl}/api/assets/character-workflow-cache/harbor-guide`, - ); - assert.equal(readResponse.status, 200); + const readResponse = await fetch( + `${assetBaseUrl}/api/assets/character-workflow-cache/harbor-guide`, + ); + assert.equal(readResponse.status, 200); - const payload = (await readResponse.json()) as { - cache: { - characterId: string; - selectedVisualDraftId: string; - generatedVisualAssetId?: string; - animationMap?: Record; - } | null; - }; + const payload = (await readResponse.json()) as { + cache: { + characterId: string; + selectedVisualDraftId: string; + generatedVisualAssetId?: string; + animationMap?: Record; + } | null; + }; - assert.equal(payload.cache?.characterId, 'harbor-guide'); - assert.equal(payload.cache?.selectedVisualDraftId, 'draft-1'); - assert.equal(payload.cache?.generatedVisualAssetId, 'visual-1'); - assert.equal( - payload.cache?.animationMap?.idle?.basePath, - '/generated-animations/harbor-guide/animation-set-1/idle', - ); - }); + assert.equal(payload.cache?.characterId, 'harbor-guide'); + assert.equal(payload.cache?.selectedVisualDraftId, 'draft-1'); + assert.equal(payload.cache?.generatedVisualAssetId, 'visual-1'); + assert.equal( + payload.cache?.animationMap?.idle?.basePath, + '/generated-animations/harbor-guide/animation-set-1/idle', + ); + }, + ); }); test('character workflow cache skips rewriting unchanged payloads', async () => { @@ -381,7 +523,8 @@ test('character workflow cache skips rewriting unchanged payloads', async () => ], selectedVisualDraftId: 'draft-1', selectedAnimation: 'idle', - imageSrc: '/generated-characters/harbor-guide/visual/visual-1/master.png', + imageSrc: + '/generated-characters/harbor-guide/visual/visual-1/master.png', generatedVisualAssetId: 'visual-1', generatedAnimationSetId: 'animation-set-1', animationMap: { @@ -522,9 +665,18 @@ test('character workflow cache stays isolated for different character ids', asyn } | null; }; - assert.equal(firstReadPayload.cache?.characterId, firstPayload.characterId); - assert.equal(firstReadPayload.cache?.visualPromptText, firstPayload.visualPromptText); - assert.equal(secondReadPayload.cache?.characterId, secondPayload.characterId); + assert.equal( + firstReadPayload.cache?.characterId, + firstPayload.characterId, + ); + assert.equal( + firstReadPayload.cache?.visualPromptText, + firstPayload.visualPromptText, + ); + assert.equal( + secondReadPayload.cache?.characterId, + secondPayload.characterId, + ); assert.equal( secondReadPayload.cache?.visualPromptText, secondPayload.visualPromptText, @@ -534,32 +686,40 @@ test('character workflow cache stays isolated for different character ids', asyn }); test('character animation publish returns frame dimensions in animation map', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-animation-publish-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-animation-publish-'), + ); await withAssetRouteServer( createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => { - const response = await fetch(`${assetBaseUrl}/api/assets/character-animation/publish`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - characterId: 'harbor-guide', - visualAssetId: 'visual-1', - updateCharacterOverride: false, - animations: { - run: { - framesDataUrls: [`data:image/png;base64,${PNG_BUFFER.toString('base64')}`], - fps: 12, - loop: true, - frameWidth: 144, - frameHeight: 192, - previewVideoPath: '/generated-character-drafts/harbor-guide/animation/run/preview.mp4', - }, + const response = await fetch( + `${assetBaseUrl}/api/assets/character-animation/publish`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, - }), - }); + body: JSON.stringify({ + characterId: 'harbor-guide', + visualAssetId: 'visual-1', + updateCharacterOverride: false, + animations: { + run: { + framesDataUrls: [ + `data:image/png;base64,${PNG_BUFFER.toString('base64')}`, + ], + fps: 12, + loop: true, + frameWidth: 144, + frameHeight: 192, + previewVideoPath: + '/generated-character-drafts/harbor-guide/animation/run/preview.mp4', + }, + }, + }), + }, + ); assert.equal(response.status, 200); const payload = (await response.json()) as { @@ -588,7 +748,9 @@ test('character animation publish returns frame dimensions in animation map', as }); test('character visual publish removes green screen before saving master and previews', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-visual-publish-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-visual-publish-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'draft.png'), GREEN_SCREEN_PNG_BUFFER); @@ -596,29 +758,36 @@ test('character visual publish removes green screen before saving master and pre await withAssetRouteServer( createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'), async (assetBaseUrl) => { - const response = await fetch(`${assetBaseUrl}/api/assets/character-visual/publish`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + const response = await fetch( + `${assetBaseUrl}/api/assets/character-visual/publish`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + characterId: 'harbor-guide', + sourceMode: 'image-to-image', + promptText: '潮雾港向导', + selectedPreviewSource: '/draft.png', + previewSources: ['/draft.png'], + width: 1024, + height: 1024, + updateCharacterOverride: false, + }), }, - body: JSON.stringify({ - characterId: 'harbor-guide', - sourceMode: 'image-to-image', - promptText: '潮雾港向导', - selectedPreviewSource: '/draft.png', - previewSources: ['/draft.png'], - width: 1024, - height: 1024, - updateCharacterOverride: false, - }), - }); + ); assert.equal(response.status, 200); const payload = (await response.json()) as { portraitPath: string; }; - const savedMasterPath = path.join(tempRoot, 'public', payload.portraitPath.slice(1)); + const savedMasterPath = path.join( + tempRoot, + 'public', + payload.portraitPath.slice(1), + ); const savedPreviewPath = path.join( tempRoot, 'public', @@ -631,64 +800,52 @@ test('character visual publish removes green screen before saving master and pre assert.equal(fs.existsSync(savedMasterPath), true); assert.equal(fs.existsSync(savedPreviewPath), true); - assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedMasterPath)), [0, 255]); - assert.deepEqual(readPngAlphaValues(fs.readFileSync(savedPreviewPath)), [0, 255]); + assert.deepEqual( + readPngAlphaValues(fs.readFileSync(savedMasterPath)), + [0, 255], + ); + assert.deepEqual( + readPngAlphaValues(fs.readFileSync(savedPreviewPath)), + [0, 255], + ); }, ); }); -test('character animation image-to-video flow uploads a public visual source and submits the resolved oss url', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-video-')); +test('character animation image-to-video flow sends first and last frame data urls to Ark seedance with fixed params', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-video-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); - let uploadCalled = false; let videoSynthesisPayloadText = ''; await withHttpServer( - (dashScopeBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', dashScopeBaseUrl); + (arkBaseUrl) => async (req, res) => { + const url = new URL(req.url || '/', arkBaseUrl); - if (req.method === 'GET' && url.pathname === '/api/v1/uploads') { + if (req.method === 'POST' && url.pathname === '/api/v3/contents/generations/tasks') { + videoSynthesisPayloadText = (await readRequestBody(req)).toString( + 'utf8', + ); sendJson(res, { - data: { - upload_host: `${dashScopeBaseUrl}/upload`, - upload_dir: 'uploads/test-dir', - policy: 'policy', - signature: 'signature', - oss_access_key_id: 'oss-key', - }, + id: 'ark-video-task-1', + status: 'queued', }); return; } - if (req.method === 'POST' && url.pathname === '/upload') { - uploadCalled = true; - await readRequestBody(req); - res.statusCode = 200; - res.end('ok'); - return; - } - if ( - req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis' + req.method === 'GET' && + url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-1' ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8'); sendJson(res, { - output: { - task_id: 'video-task-1', - }, - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-1') { - sendJson(res, { - output: { - task_status: 'SUCCEEDED', - video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`, + id: 'ark-video-task-1', + status: 'succeeded', + content: { + video_url: `${arkBaseUrl}/downloads/preview.mp4`, }, }); return; @@ -704,8 +861,8 @@ test('character animation image-to-video flow uploads a public visual source and res.statusCode = 404; res.end('not found'); }, - async (dashScopeBaseUrl) => { - const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); + async (arkBaseUrl) => { + const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); await withAssetRouteServer(config, async (assetBaseUrl) => { const response = await fetch( `${assetBaseUrl}/api/assets/character-animation/generate`, @@ -725,12 +882,13 @@ test('character animation image-to-video flow uploads a public visual source and referenceVideoDataUrls: [], frameCount: 8, fps: 8, - durationSeconds: 4, + durationSeconds: 7, loop: true, useChromaKey: true, resolution: '720P', + ratio: '16:9', imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.7-i2v', + videoModel: 'doubao-seedance-2-0-fast-260128', referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', }), @@ -741,25 +899,48 @@ test('character animation image-to-video flow uploads a public visual source and const payload = (await response.json()) as { previewVideoPath: string; }; - assert.equal(uploadCalled, true); const videoPayload = JSON.parse(videoSynthesisPayloadText) as { - input: { - media: Array<{ type: string; url: string }>; - }; + resolution?: string; + ratio?: string; + duration?: number; + content: Array<{ + type: string; + role?: string; + image_url?: { url?: string }; + }>; }; - assert.equal(videoPayload.input.media[0]?.type, 'first_frame'); - assert.match(videoPayload.input.media[0]?.url ?? '', /^oss:\/\/uploads\/test-dir\//u); + assert.equal(videoPayload.resolution, '480p'); + assert.equal(videoPayload.ratio, '1:1'); + assert.equal(videoPayload.duration, 4); + assert.equal(videoPayload.content[1]?.type, 'image_url'); + assert.equal(videoPayload.content[1]?.role, 'first_frame'); + assert.match( + videoPayload.content[1]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.content[2]?.type, 'image_url'); + assert.equal(videoPayload.content[2]?.role, 'last_frame'); + assert.match( + videoPayload.content[2]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); - const savedVideoPath = path.join(tempRoot, 'public', payload.previewVideoPath.slice(1)); + const savedVideoPath = path.join( + tempRoot, + 'public', + payload.previewVideoPath.slice(1), + ); assert.equal(fs.existsSync(savedVideoPath), true); }); }, ); }); -test('character animation non-loop image-to-video uses first and last master frames', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-kf2v-')); +test('character animation non-loop image-to-video keeps first and last reference images in Ark request', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-kf2v-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); @@ -767,27 +948,32 @@ test('character animation non-loop image-to-video uses first and last master fra let videoSynthesisPayloadText = ''; await withHttpServer( - (dashScopeBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', dashScopeBaseUrl); + (arkBaseUrl) => async (req, res) => { + const url = new URL(req.url || '/', arkBaseUrl); if ( req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/image2video/video-synthesis' + url.pathname === '/api/v3/contents/generations/tasks' ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8'); + videoSynthesisPayloadText = (await readRequestBody(req)).toString( + 'utf8', + ); sendJson(res, { - output: { - task_id: 'video-task-kf2v-1', - }, + id: 'ark-video-task-kf2v-1', + status: 'queued', }); return; } - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-kf2v-1') { + if ( + req.method === 'GET' && + url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-kf2v-1' + ) { sendJson(res, { - output: { - task_status: 'SUCCEEDED', - video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`, + id: 'ark-video-task-kf2v-1', + status: 'succeeded', + content: { + video_url: `${arkBaseUrl}/downloads/preview.mp4`, }, }); return; @@ -803,8 +989,8 @@ test('character animation non-loop image-to-video uses first and last master fra res.statusCode = 404; res.end('not found'); }, - async (dashScopeBaseUrl) => { - const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); + async (arkBaseUrl) => { + const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); await withAssetRouteServer(config, async (assetBaseUrl) => { const response = await fetch( `${assetBaseUrl}/api/assets/character-animation/generate`, @@ -827,9 +1013,10 @@ test('character animation non-loop image-to-video uses first and last master fra durationSeconds: 4, loop: false, useChromaKey: true, - resolution: '720P', + resolution: '480p', + ratio: '1:1', imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.7-i2v', + videoModel: 'doubao-seedance-2-0-fast-260128', referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', }), @@ -840,25 +1027,34 @@ test('character animation non-loop image-to-video uses first and last master fra const videoPayload = JSON.parse(videoSynthesisPayloadText) as { model: string; - input: { - first_frame_url?: string; - last_frame_url?: string; - }; - parameters: { - resolution?: string; - }; + content: Array<{ + type: string; + role?: string; + image_url?: { url?: string }; + }>; + resolution?: string; }; - assert.equal(videoPayload.model, 'wan2.2-kf2v-flash'); - assert.match(videoPayload.input.first_frame_url ?? '', /^data:image\/png;base64,/u); - assert.match(videoPayload.input.last_frame_url ?? '', /^data:image\/png;base64,/u); - assert.equal(videoPayload.parameters.resolution, '480P'); + assert.equal(videoPayload.model, 'doubao-seedance-2-0-fast-260128'); + assert.equal(videoPayload.content[1]?.role, 'first_frame'); + assert.match( + videoPayload.content[1]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.content[2]?.role, 'last_frame'); + assert.match( + videoPayload.content[2]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.resolution, '480p'); }); }, ); }); -test('character animation die image-to-video does not send a last frame reference', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-kf2v-die-')); +test('character animation die image-to-video still uses Ark first and last frame references', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-kf2v-die-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); @@ -866,27 +1062,32 @@ test('character animation die image-to-video does not send a last frame referenc let videoSynthesisPayloadText = ''; await withHttpServer( - (dashScopeBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', dashScopeBaseUrl); + (arkBaseUrl) => async (req, res) => { + const url = new URL(req.url || '/', arkBaseUrl); if ( req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/image2video/video-synthesis' + url.pathname === '/api/v3/contents/generations/tasks' ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8'); + videoSynthesisPayloadText = (await readRequestBody(req)).toString( + 'utf8', + ); sendJson(res, { - output: { - task_id: 'video-task-kf2v-die-1', - }, + id: 'ark-video-task-die-1', + status: 'queued', }); return; } - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-kf2v-die-1') { + if ( + req.method === 'GET' && + url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-die-1' + ) { sendJson(res, { - output: { - task_status: 'SUCCEEDED', - video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`, + id: 'ark-video-task-die-1', + status: 'succeeded', + content: { + video_url: `${arkBaseUrl}/downloads/preview.mp4`, }, }); return; @@ -902,8 +1103,8 @@ test('character animation die image-to-video does not send a last frame referenc res.statusCode = 404; res.end('not found'); }, - async (dashScopeBaseUrl) => { - const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); + async (arkBaseUrl) => { + const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); await withAssetRouteServer(config, async (assetBaseUrl) => { const response = await fetch( `${assetBaseUrl}/api/assets/character-animation/generate`, @@ -926,9 +1127,10 @@ test('character animation die image-to-video does not send a last frame referenc durationSeconds: 4, loop: false, useChromaKey: true, - resolution: '720P', + resolution: '480p', + ratio: '1:1', imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.7-i2v', + videoModel: 'doubao-seedance-2-0-fast-260128', referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', }), @@ -939,23 +1141,37 @@ test('character animation die image-to-video does not send a last frame referenc const videoPayload = JSON.parse(videoSynthesisPayloadText) as { model: string; - input: { - first_frame_url?: string; - last_frame_url?: string; - prompt?: string; - }; + content: Array<{ + type: string; + role?: string; + image_url?: { url?: string }; + text?: string; + }>; }; - assert.equal(videoPayload.model, 'wan2.2-kf2v-flash'); - assert.match(videoPayload.input.first_frame_url ?? '', /^data:image\/png;base64,/u); - assert.equal(videoPayload.input.last_frame_url, undefined); - assert.match(videoPayload.input.prompt ?? '', /尾帧停在死亡结束姿态/u); + assert.equal(videoPayload.model, 'doubao-seedance-2-0-fast-260128'); + assert.equal(videoPayload.content[1]?.role, 'first_frame'); + assert.match( + videoPayload.content[1]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.content[2]?.role, 'last_frame'); + assert.match( + videoPayload.content[2]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.match( + videoPayload.content[0]?.text ?? '', + /动作英文名:die|动作英文名是 die/u, + ); }); }, ); }); -test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url only', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-i2v-loop-')); +test('character animation loop image-to-video uses Ark seedance fixed params and keeps two reference images', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-i2v-loop-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); @@ -963,27 +1179,32 @@ test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url let videoSynthesisPayloadText = ''; await withHttpServer( - (dashScopeBaseUrl) => async (req, res) => { - const url = new URL(req.url || '/', dashScopeBaseUrl); + (arkBaseUrl) => async (req, res) => { + const url = new URL(req.url || '/', arkBaseUrl); if ( req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis' + url.pathname === '/api/v3/contents/generations/tasks' ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8'); + videoSynthesisPayloadText = (await readRequestBody(req)).toString( + 'utf8', + ); sendJson(res, { - output: { - task_id: 'video-task-i2v-loop-1', - }, + id: 'ark-video-task-loop-1', + status: 'queued', }); return; } - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-i2v-loop-1') { + if ( + req.method === 'GET' && + url.pathname === '/api/v3/contents/generations/tasks/ark-video-task-loop-1' + ) { sendJson(res, { - output: { - task_status: 'SUCCEEDED', - video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`, + id: 'ark-video-task-loop-1', + status: 'succeeded', + content: { + video_url: `${arkBaseUrl}/downloads/preview.mp4`, }, }); return; @@ -999,8 +1220,8 @@ test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url res.statusCode = 404; res.end('not found'); }, - async (dashScopeBaseUrl) => { - const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); + async (arkBaseUrl) => { + const config = createTestConfig(tempRoot, `${arkBaseUrl}/api/v3`); await withAssetRouteServer(config, async (assetBaseUrl) => { const response = await fetch( `${assetBaseUrl}/api/assets/character-animation/generate`, @@ -1023,9 +1244,10 @@ test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url durationSeconds: 4, loop: true, useChromaKey: true, - resolution: '720P', + resolution: '480p', + ratio: '1:1', imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.6-i2v-flash', + videoModel: 'doubao-seedance-2-0-fast-260128', referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', }), @@ -1036,29 +1258,38 @@ test('character animation loop image-to-video uses wan2.6-i2v-flash with img_url const videoPayload = JSON.parse(videoSynthesisPayloadText) as { model: string; - input: { - img_url?: string; - first_frame_url?: string; - last_frame_url?: string; - }; - parameters: { - audio?: boolean; - resolution?: string; - }; + content: Array<{ + type: string; + role?: string; + image_url?: { url?: string }; + }>; + resolution?: string; + ratio?: string; + duration?: number; }; - assert.equal(videoPayload.model, 'wan2.6-i2v-flash'); - assert.match(videoPayload.input.img_url ?? '', /^data:image\/png;base64,/u); - assert.equal(videoPayload.input.first_frame_url, undefined); - assert.equal(videoPayload.input.last_frame_url, undefined); - assert.equal(videoPayload.parameters.audio, false); - assert.equal(videoPayload.parameters.resolution, '720P'); + assert.equal(videoPayload.model, 'doubao-seedance-2-0-fast-260128'); + assert.equal(videoPayload.content[1]?.role, 'first_frame'); + assert.match( + videoPayload.content[1]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.content[2]?.role, 'last_frame'); + assert.match( + videoPayload.content[2]?.image_url?.url ?? '', + /^data:image\/png;base64,/u, + ); + assert.equal(videoPayload.resolution, '480p'); + assert.equal(videoPayload.ratio, '1:1'); + assert.equal(videoPayload.duration, 4); }); }, ); }); test('character animation reference-to-video can use only reference image media', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-r2v-')); + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'genarrative-character-r2v-'), + ); const publicDir = path.join(tempRoot, 'public'); fs.mkdirSync(publicDir, { recursive: true }); fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER); @@ -1091,9 +1322,12 @@ test('character animation reference-to-video can use only reference image media' if ( req.method === 'POST' && - url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis' + url.pathname === + '/api/v1/services/aigc/video-generation/video-synthesis' ) { - videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8'); + videoSynthesisPayloadText = (await readRequestBody(req)).toString( + 'utf8', + ); sendJson(res, { output: { task_id: 'video-task-r2v-1', @@ -1102,7 +1336,10 @@ test('character animation reference-to-video can use only reference image media' return; } - if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-r2v-1') { + if ( + req.method === 'GET' && + url.pathname === '/api/v1/tasks/video-task-r2v-1' + ) { sendJson(res, { output: { task_status: 'SUCCEEDED', @@ -1163,7 +1400,10 @@ test('character animation reference-to-video can use only reference image media' }; }; assert.equal(videoPayload.input.media[0]?.type, 'reference_image'); - assert.match(videoPayload.input.media[0]?.url ?? '', /^oss:\/\/uploads\/test-dir\//u); + assert.match( + videoPayload.input.media[0]?.url ?? '', + /^oss:\/\/uploads\/test-dir\//u, + ); assert.equal(videoPayload.input.media.length, 1); }); }, diff --git a/server-node/src/modules/assets/characterAssetRoutes.ts b/server-node/src/modules/assets/characterAssetRoutes.ts index 85c86603..91f0387b 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.ts @@ -8,9 +8,15 @@ import http, { import https from 'node:https'; import path from 'node:path'; -import { type NextFunction, type Request, type Response, Router } from 'express'; +import { + type NextFunction, + type Request, + type Response, + Router, +} from 'express'; import { PNG } from 'pngjs'; +import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js'; import { buildMasterPrompt, buildVideoActionPrompt, @@ -21,26 +27,35 @@ import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parse import type { AppConfig } from '../../config.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js'; -const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH = '/api/assets/character-prompts/generate'; +const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH = + '/api/assets/character-prompts/generate'; const CHARACTER_WORKFLOW_CACHE_PATH = '/api/assets/character-workflow-cache'; const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate'; const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish'; const CHARACTER_VISUAL_JOBS_PATH = '/api/assets/character-visual/jobs/'; -const CHARACTER_ANIMATION_GENERATE_PATH = '/api/assets/character-animation/generate'; -const CHARACTER_ANIMATION_PUBLISH_PATH = '/api/assets/character-animation/publish'; +const CHARACTER_ANIMATION_GENERATE_PATH = + '/api/assets/character-animation/generate'; +const CHARACTER_ANIMATION_PUBLISH_PATH = + '/api/assets/character-animation/publish'; const CHARACTER_ANIMATION_JOBS_PATH = '/api/assets/character-animation/jobs/'; -const CHARACTER_ANIMATION_IMPORT_VIDEO_PATH = '/api/assets/character-animation/import-video'; -const CHARACTER_ANIMATION_TEMPLATES_PATH = '/api/assets/character-animation/templates'; +const CHARACTER_ANIMATION_IMPORT_VIDEO_PATH = + '/api/assets/character-animation/import-video'; +const CHARACTER_ANIMATION_TEMPLATES_PATH = + '/api/assets/character-animation/templates'; +const DEFAULT_ARK_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3'; const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; const DEFAULT_CHARACTER_VISUAL_MODEL = 'wan2.7-image-pro'; -const DEFAULT_CHARACTER_VIDEO_MODEL = 'wan2.2-kf2v-flash'; -const DEFAULT_CHARACTER_LOOP_VIDEO_MODEL = 'wan2.6-i2v-flash'; +const DEFAULT_CHARACTER_VIDEO_MODEL = 'doubao-seedance-2-0-fast-260128'; const DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL = 'wan2.7-r2v'; const DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move'; +const FIXED_ARK_CHARACTER_VIDEO_RESOLUTION = '480p'; +const FIXED_ARK_CHARACTER_VIDEO_RATIO = '1:1'; +const FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS = 4; const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500; const DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS = 15000; const DASHSCOPE_IMAGE_TASK_TIMEOUT_MS = 180000; const DASHSCOPE_VIDEO_TASK_TIMEOUT_MS = 420000; +const ARK_VIDEO_TASK_POLL_INTERVAL_MS = 5000; const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。 你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。 你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。 @@ -82,13 +97,6 @@ const BUILT_IN_MOTION_TEMPLATES = [ promptSuffix: '短促前踏后横斩,收招干净。', notes: '适合近战角色的基础攻击模板。', }, - { - id: 'hurt_back', - label: '受击后仰', - animation: 'hurt', - promptSuffix: '身体后仰,重心短暂失衡后稳住。', - notes: '适合方案三的受击模板。', - }, { id: 'die_fall', label: '倒地死亡', @@ -113,44 +121,7 @@ type DecodedMediaPayload = { function applyGreenScreenAlphaToPngBuffer(buffer: Buffer) { try { const png = PNG.sync.read(buffer); - const pixels = png.data; - let changed = false; - - 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 greenRatio = green / Math.max(1, red + blue); - - if (alpha === 0) { - continue; - } - - const greenLead = green - Math.max(red, blue); - if (green <= 72 || greenLead <= 20 || greenRatio <= 0.72) { - continue; - } - - let nextAlpha = Math.min(alpha, Math.max(0, 255 - greenLead * 6)); - - if (green > 120 && greenLead > 48 && greenRatio > 1.12) { - nextAlpha = 0; - } - - if (nextAlpha === alpha) { - continue; - } - - 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)), - ); - } - changed = true; - } + const changed = removeBackgroundFromRgba(png.data, png.width, png.height); return changed ? PNG.sync.write(png) : buffer; } catch { @@ -234,7 +205,9 @@ function serializeWorkflowCacheComparableValue( ? value.selectedVisualDraftId : '', selectedAnimation: - typeof value.selectedAnimation === 'string' ? value.selectedAnimation : '', + typeof value.selectedAnimation === 'string' + ? value.selectedAnimation + : '', imageSrc: typeof value.imageSrc === 'string' ? value.imageSrc : '', generatedVisualAssetId: typeof value.generatedVisualAssetId === 'string' @@ -250,7 +223,11 @@ function serializeWorkflowCacheComparableValue( function readJsonBody(req: IncomingMessage & { body?: unknown }) { const parsedBody = req.body; - if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) { + if ( + parsedBody && + typeof parsedBody === 'object' && + !Array.isArray(parsedBody) + ) { return Promise.resolve(parsedBody as Record); } @@ -298,6 +275,10 @@ function normalizeDashScopeBaseUrl(value: string) { return value.replace(/\/$/u, ''); } +function normalizeArkBaseUrl(value: string) { + return value.replace(/\/$/u, ''); +} + function resolveRuntimeEnv(config: AppConfig) { return config.rawEnv; } @@ -343,7 +324,8 @@ function buildFallbackCharacterPromptBundle(params: { const characterAnchor = params.characterName || '该角色'; const descriptionAnchor = params.description || params.backstory || params.personality || '气质鲜明'; - const combatAnchor = params.combatStyle || params.motivation || '动作发力清晰'; + const combatAnchor = + params.combatStyle || params.motivation || '动作发力清晰'; const tagAnchor = params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : ''; @@ -372,7 +354,9 @@ function buildFallbackCharacterPromptBundle(params: { '16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。', `场景叙事气质围绕:${descriptionAnchor}。`, params.backstory ? `背景线索可参考:${params.backstory}。` : '', - params.motivation ? `环境中可埋入与当前目标相关的暗示:${params.motivation}。` : '', + params.motivation + ? `环境中可埋入与当前目标相关的暗示:${params.motivation}。` + : '', '整体风格克制统一,适合剧情探索与战斗底图。', ] .filter(Boolean) @@ -437,7 +421,7 @@ function buildCompactAnimationCharacterBrief(value: string) { } return normalized - .split(/[\/|\n,,。;;]+/u) + .split(/[/|\n,,。;;]+/u) .map((item) => item.trim()) .filter(Boolean) .slice(0, 4) @@ -631,7 +615,9 @@ function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload { .map((item) => item.trim()) .filter(Boolean); const mimeType = metadataParts[0] ?? 'application/octet-stream'; - const isBase64 = metadataParts.some((item) => item.toLowerCase() === 'base64'); + const isBase64 = metadataParts.some( + (item) => item.toLowerCase() === 'base64', + ); if (!isBase64) { throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。'); @@ -730,10 +716,7 @@ async function resolveCharacterVisualPayload( ); } -async function resolveMediaSourceAsDataUrl( - rootDir: string, - source: string, -) { +async function resolveMediaSourceAsDataUrl(rootDir: string, source: string) { if (/^data:/u.test(source)) { return source; } @@ -742,6 +725,11 @@ async function resolveMediaSourceAsDataUrl( return `data:${payload.mimeType};base64,${payload.buffer.toString('base64')}`; } +async function resolveCharacterVisualAsDataUrl(rootDir: string, source: string) { + const payload = await resolveCharacterVisualPayload(rootDir, source); + return `data:${payload.mimeType};base64,${payload.buffer.toString('base64')}`; +} + function requestResponse( urlString: string, options: { @@ -792,10 +780,9 @@ function requestResponse( }); } -function getRequestPathname( - req: IncomingMessage & { originalUrl?: string }, -) { - return new URL(req.originalUrl || req.url || '/', 'http://localhost').pathname; +function getRequestPathname(req: IncomingMessage & { originalUrl?: string }) { + return new URL(req.originalUrl || req.url || '/', 'http://localhost') + .pathname; } function requestTextResponse( @@ -1050,6 +1037,101 @@ async function waitForDashScopeTask( throw new Error('任务执行超时,请稍后重试。'); } +function normalizeGenerationTaskStatus(value: string) { + return value.trim().toLowerCase().replace(/\s+/gu, '_'); +} + +function extractGenerationTaskStatus(payload: Record) { + const topLevelStatus = + typeof payload.status === 'string' ? payload.status.trim() : ''; + const output = isRecordValue(payload.output) ? payload.output : null; + const outputStatus = + output && typeof output.task_status === 'string' + ? output.task_status.trim() + : ''; + const nestedTaskStatus = findFirstStringByKey(payload, 'task_status') ?? ''; + const nestedStatus = findFirstStringByKey(payload, 'status') ?? ''; + + return normalizeGenerationTaskStatus( + topLevelStatus || outputStatus || nestedTaskStatus || nestedStatus, + ); +} + +function isCompletedGenerationTaskStatus(status: string) { + return [ + 'completed', + 'complete', + 'done', + 'finished', + 'success', + 'succeeded', + 'succeed', + ].includes(status); +} + +function isFailedGenerationTaskStatus(status: string) { + return [ + 'failed', + 'canceled', + 'cancelled', + 'error', + 'aborted', + 'rejected', + 'expired', + 'unknown', + ].includes(status); +} + +async function waitForArkContentGenerationTask( + baseUrl: string, + apiKey: string, + taskId: string, + options: { + timeoutMs: number; + intervalMs: number; + }, +) { + const deadline = Date.now() + options.timeoutMs; + + while (Date.now() < deadline) { + const response = await requestTextResponse( + `${baseUrl}/contents/generations/tasks/${encodeURIComponent(taskId)}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error( + extractApiErrorMessage( + response.bodyText, + `查询视频生成任务失败(${response.statusCode})。`, + ), + ); + } + + const parsed = JSON.parse(response.bodyText) as Record; + const taskStatus = extractGenerationTaskStatus(parsed); + + if (extractVideoUrl(parsed) || isCompletedGenerationTaskStatus(taskStatus)) { + return parsed; + } + + if (isFailedGenerationTaskStatus(taskStatus)) { + throw new Error( + extractApiErrorMessage(response.bodyText, '视频生成任务执行失败。'), + ); + } + + await sleep(options.intervalMs); + } + + throw new Error('视频生成任务执行超时,请稍后重试。'); +} + function findFirstStringByKey( value: unknown, targetKey: string, @@ -1108,7 +1190,10 @@ function collectStringsByKey( } function extractTaskId(payload: Record) { - return findFirstStringByKey(payload, 'task_id') ?? ''; + const topLevelId = + typeof payload.id === 'string' && payload.id.trim() ? payload.id.trim() : ''; + + return topLevelId || (findFirstStringByKey(payload, 'task_id') ?? ''); } function extractVideoUrl(payload: Record) { @@ -1126,15 +1211,46 @@ function extractImageUrls(payload: Record) { return [...new Set(urls)]; } -function buildNpcVisualPrompt( - promptText: string, - characterBriefText = '', -) { +function buildNpcVisualPrompt(promptText: string, characterBriefText = '') { const mergedBrief = [characterBriefText.trim(), promptText.trim()] .filter(Boolean) .join('\n'); - return buildMasterPrompt(mergedBrief || '自定义世界角色,服装完整,姿态自然。'); + return buildMasterPrompt( + mergedBrief || '自定义世界角色,服装完整,姿态自然。', + ); +} + +function buildNpcVisualNegativePrompt() { + return [ + '正面视角', + '左朝向', + '完全 90 度纯右视图', + '镜头透视', + '半身像', + '脚被裁切', + '头顶被裁切', + '多角色', + '复杂背景', + '建筑场景', + '漂浮物', + '烟雾环境', + '武器消失', + '武器换手', + '额外手臂', + '额外腿', + '服装变化', + '脸部变化', + '模糊', + '运动模糊', + '文字', + '水印', + 'UI 元素', + '软萌 Q版大头贴', + '儿童绘本风', + '厚涂插画感', + '低对比柔边', + ].join(','); } function buildImageSequencePrompt( @@ -1178,7 +1294,9 @@ function buildNpcAnimationPrompt(options: { return [ buildVideoActionPrompt({ actionTemplate: getActionTemplateById( - options.actionTemplateId as Parameters[0], + options.actionTemplateId as Parameters< + typeof getActionTemplateById + >[0], ), actionDetailText, useChromaKey: options.useChromaKey, @@ -1197,9 +1315,7 @@ function buildNpcAnimationPrompt(options: { options.useChromaKey ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' : '背景简洁纯净,无复杂场景。', - characterBrief - ? `角色设定:${characterBrief}` - : '', + characterBrief ? `角色设定:${characterBrief}` : '', actionDetailText, loopRule, ] @@ -1207,6 +1323,56 @@ function buildNpcAnimationPrompt(options: { .join(' '); } +function buildArkCharacterAnimationPrompt(options: { + animation: string; + promptText: string; + useChromaKey: boolean; + loop: boolean; + characterBriefText?: string; + actionTemplateId?: string; +}) { + const normalizedAnimationName = + options.animation.trim().replace(/\s+/gu, '_') || 'idle'; + const characterBrief = buildCompactAnimationCharacterBrief( + options.characterBriefText ?? '', + ); + const actionDetailText = sanitizeAnimationPromptText(options.promptText, 140); + const frameRule = options.loop + ? '首帧严格使用图片1,尾帧严格使用图片2,循环动作必须自然闭环,不要静止开场。' + : '首帧严格使用图片1,尾帧严格使用图片2,中段完成完整动作变化,收束干净。'; + + if (options.actionTemplateId) { + return [ + buildVideoActionPrompt({ + actionTemplate: getActionTemplateById( + options.actionTemplateId as Parameters[0], + ), + actionDetailText, + useChromaKey: options.useChromaKey, + characterBrief: characterBrief || `${normalizedAnimationName} action role`, + }), + `动作英文名:${normalizedAnimationName}。`, + frameRule, + ] + .filter(Boolean) + .join(' '); + } + + return [ + `单人 NPC 全身动作视频,动作英文名是 ${normalizedAnimationName}。`, + '角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。', + '动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。', + options.useChromaKey + ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' + : '背景简洁纯净,无复杂场景。', + characterBrief ? `角色设定:${characterBrief}` : '', + actionDetailText ? `动作细节:${actionDetailText}` : '', + frameRule, + ] + .filter(Boolean) + .join(' '); +} + function buildFallbackModerationSafeAnimationPrompt(options: { animation: string; loop: boolean; @@ -1267,7 +1433,10 @@ async function handleGenerateCharacterPromptBundle( const motivation = clampPromptSeedText(body.motivation, 180); const combatStyle = clampPromptSeedText(body.combatStyle, 180); const tags = isStringArray(body.tags) - ? body.tags.map((item) => clampPromptSeedText(item, 24)).filter(Boolean).slice(0, 8) + ? body.tags + .map((item) => clampPromptSeedText(item, 24)) + .filter(Boolean) + .slice(0, 8) : []; if (!characterBriefText) { @@ -1393,17 +1562,17 @@ async function handleGenerateCharacterVisuals( typeof body.characterId === 'string' ? body.characterId.trim() : 'character'; - const sourceMode = - typeof body.sourceMode === 'string' ? body.sourceMode.trim() : ''; - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const characterBriefText = - typeof body.characterBriefText === 'string' - ? body.characterBriefText.trim() - : ''; - const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls) - ? body.referenceImageDataUrls.slice(0, 4) - : []; + const sourceMode = + typeof body.sourceMode === 'string' ? body.sourceMode.trim() : ''; + const promptText = + typeof body.promptText === 'string' ? body.promptText.trim() : ''; + const characterBriefText = + typeof body.characterBriefText === 'string' + ? body.characterBriefText.trim() + : ''; + const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls) + ? body.referenceImageDataUrls.slice(0, 4) + : []; const candidateCountRaw = typeof body.candidateCount === 'number' ? body.candidateCount : 3; const candidateCount = Math.max( @@ -1428,51 +1597,52 @@ async function handleGenerateCharacterVisuals( return; } - if (!promptText && !characterBriefText && sourceMode === 'text-to-image') { - sendJson(res, 400, { - error: { message: '文生主形象需要填写角色设定。' }, - }); + if (!promptText && !characterBriefText && sourceMode === 'text-to-image') { + sendJson(res, 400, { + error: { message: '文生主形象需要填写角色设定。' }, + }); return; } - let activeTaskId = ''; - let activePrompt = ''; - try { - const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText); - const normalizedReferenceImages = await Promise.all( - referenceImageDataUrls.map((image) => - resolveMediaSourceAsDataUrl(rootDir, image), - ), - ); - activePrompt = finalPrompt; - const content = [ - { text: finalPrompt }, - ...normalizedReferenceImages.map((image) => ({ image })), - ]; - const createTaskResponse = await proxyJsonRequest( - `${baseUrl}/services/aigc/image-generation/generation`, - apiKey, - { - model, - input: { - messages: [ - { - role: 'user', - content, - }, - ], - }, - parameters: { - n: candidateCount, - size, - prompt_extend: true, - watermark: false, - }, + let activeTaskId = ''; + let activePrompt = ''; + try { + const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText); + const normalizedReferenceImages = await Promise.all( + referenceImageDataUrls.map((image) => + resolveMediaSourceAsDataUrl(rootDir, image), + ), + ); + activePrompt = finalPrompt; + const content = [ + { text: finalPrompt }, + ...normalizedReferenceImages.map((image) => ({ image })), + ]; + const createTaskResponse = await proxyJsonRequest( + `${baseUrl}/services/aigc/image-generation/generation`, + apiKey, + { + model, + input: { + messages: [ + { + role: 'user', + content, + }, + ], }, - { - 'X-DashScope-Async': 'enable', + parameters: { + n: candidateCount, + size, + negative_prompt: buildNpcVisualNegativePrompt(), + prompt_extend: true, + watermark: false, }, - ); + }, + { + 'X-DashScope-Async': 'enable', + }, + ); if ( createTaskResponse.statusCode < 200 || @@ -1614,7 +1784,8 @@ async function handleGenerateCharacterVisuals( prompt: activePrompt, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - errorMessage: error instanceof Error ? error.message : '生成角色主形象失败。', + errorMessage: + error instanceof Error ? error.message : '生成角色主形象失败。', }); } sendJson(res, 500, { @@ -1642,19 +1813,12 @@ async function handleGenerateCharacterAnimation( runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, ); const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; - const timeoutMs = Number( + const dashScopeTimeoutMs = Number( runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS || runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, ); - if (!apiKey) { - sendJson(res, 500, { - error: { message: '缺少 DASHSCOPE_API_KEY,无法生成角色动作。' }, - }); - return; - } - let body: Record; try { body = await readJsonBody(req); @@ -1708,7 +1872,7 @@ async function handleGenerateCharacterAnimation( const resolution = typeof body.resolution === 'string' && body.resolution.trim() ? body.resolution.trim() - : '720P'; + : '480p'; const imageSequenceModel = typeof body.imageSequenceModel === 'string' && body.imageSequenceModel.trim() @@ -1719,25 +1883,11 @@ async function handleGenerateCharacterAnimation( const requestedVideoModel = typeof body.videoModel === 'string' && body.videoModel.trim() ? body.videoModel.trim() - : runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL || + : runtimeEnv.ARK_CHARACTER_VIDEO_MODEL || + runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL || DEFAULT_CHARACTER_VIDEO_MODEL; - const loopVideoModel = - runtimeEnv.DASHSCOPE_CHARACTER_LOOP_VIDEO_MODEL || - (requestedVideoModel === 'wan2.2-kf2v-flash' - ? DEFAULT_CHARACTER_LOOP_VIDEO_MODEL - : requestedVideoModel) || - DEFAULT_CHARACTER_LOOP_VIDEO_MODEL; - const keyframeVideoModel = - runtimeEnv.DASHSCOPE_CHARACTER_KEYFRAME_VIDEO_MODEL || - DEFAULT_CHARACTER_VIDEO_MODEL; - const videoModel = - strategy === 'image-to-video' ? (loop ? loopVideoModel : keyframeVideoModel) : requestedVideoModel; - const durationSeconds = - videoModel === 'wan2.2-kf2v-flash' ? 5 : requestedDurationSeconds; - const normalizedResolution = getLowestSupportedVideoResolution( - videoModel, - videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution, - ); + const videoModel = requestedVideoModel; + const durationSeconds = requestedDurationSeconds; const referenceVideoModel = typeof body.referenceVideoModel === 'string' && body.referenceVideoModel.trim() @@ -1750,6 +1900,24 @@ async function handleGenerateCharacterAnimation( ? body.motionTransferModel.trim() : runtimeEnv.DASHSCOPE_CHARACTER_MOTION_TRANSFER_MODEL || DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL; + const arkBaseUrl = normalizeArkBaseUrl( + runtimeEnv.ARK_CHARACTER_VIDEO_BASE_URL || + runtimeEnv.ARK_BASE_URL || + runtimeEnv.LLM_BASE_URL || + DEFAULT_ARK_BASE_URL, + ); + const arkApiKey = + runtimeEnv.ARK_CHARACTER_VIDEO_API_KEY || + runtimeEnv.ARK_API_KEY || + runtimeEnv.LLM_API_KEY || + ''; + const arkTimeoutMs = Number( + runtimeEnv.ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS || + runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS || + DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, + ); + const normalizedArkResolution = FIXED_ARK_CHARACTER_VIDEO_RESOLUTION; + const normalizedArkRatio = FIXED_ARK_CHARACTER_VIDEO_RATIO; if (!visualSource) { sendJson(res, 400, { @@ -1758,6 +1926,20 @@ async function handleGenerateCharacterAnimation( return; } + if (strategy === 'image-to-video' && !arkApiKey) { + sendJson(res, 500, { + error: { message: '缺少 ARK_API_KEY,无法生成角色动作。' }, + }); + return; + } + + if (strategy !== 'image-to-video' && !apiKey) { + sendJson(res, 500, { + error: { message: '缺少 DASHSCOPE_API_KEY,无法生成角色动作。' }, + }); + return; + } + let activeTaskId = ''; let activePrompt = ''; let activeModel = ''; @@ -1852,8 +2034,8 @@ async function handleGenerateCharacterAnimation( const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs + Number.isFinite(dashScopeTimeoutMs) && dashScopeTimeoutMs > 0 + ? dashScopeTimeoutMs : DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, intervalMs: DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS, }); @@ -1945,7 +2127,7 @@ async function handleGenerateCharacterAnimation( } if (strategy === 'image-to-video') { - const finalPrompt = buildNpcAnimationPrompt({ + const finalPrompt = buildArkCharacterAnimationPrompt({ animation, promptText, useChromaKey, @@ -1960,82 +2142,50 @@ async function handleGenerateCharacterAnimation( }); activePrompt = finalPrompt; activeModel = videoModel; - const isKf2vFlash = videoModel === 'wan2.2-kf2v-flash'; - const isWan26I2vFlash = videoModel === 'wan2.6-i2v-flash'; - const visualInputRef = - isKf2vFlash || isWan26I2vFlash - ? await resolveMediaSourceAsDataUrl(rootDir, visualSource) - : await uploadFileToDashScope( - baseUrl, - apiKey, - videoModel, - `${characterId}-${animation}-visual`, - await resolveMediaSourcePayload(rootDir, visualSource), - ); - const resolvedLastFrameSource = - !loop && animation !== 'die' - ? lastFrameImageDataUrl || visualSource - : ''; - const lastFrameRef = resolvedLastFrameSource - ? isKf2vFlash - ? await resolveMediaSourceAsDataUrl(rootDir, resolvedLastFrameSource) - : await uploadFileToDashScope( - baseUrl, - apiKey, - videoModel, - `${characterId}-${animation}-last-frame`, - await resolveMediaSourcePayload( - rootDir, - resolvedLastFrameSource, - ), - ) - : ''; + const visualInputRef = await resolveCharacterVisualAsDataUrl( + rootDir, + visualSource, + ); + const resolvedLastFrameSource = lastFrameImageDataUrl || visualSource; + const lastFrameRef = await resolveCharacterVisualAsDataUrl( + rootDir, + resolvedLastFrameSource, + ); const createVideoRequestBody = (prompt: string) => ({ model: videoModel, - input: isKf2vFlash - ? { - prompt, - first_frame_url: visualInputRef, - ...(lastFrameRef ? { last_frame_url: lastFrameRef } : {}), - } - : isWan26I2vFlash - ? { - prompt, - img_url: visualInputRef, - } - : { - prompt, - media: [ - { type: 'first_frame', url: visualInputRef }, - ...(lastFrameRef - ? [{ type: 'last_frame', url: lastFrameRef }] - : []), - ], - }, - parameters: { - duration: durationSeconds, - resolution: normalizedResolution, - ...(isKf2vFlash - ? { prompt_extend: true, watermark: false } - : {}), - ...(isWan26I2vFlash ? { audio: false } : {}), - }, + content: [ + { + type: 'text', + text: prompt, + }, + { + type: 'image_url', + image_url: { + url: visualInputRef, + }, + role: 'first_frame', + }, + { + type: 'image_url', + image_url: { + url: lastFrameRef, + }, + role: 'last_frame', + }, + ], + resolution: normalizedArkResolution, + ratio: normalizedArkRatio, + duration: FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS, + watermark: false, }); - const videoSynthesisEndpoint = isKf2vFlash - ? `${baseUrl}/services/aigc/image2video/video-synthesis` - : `${baseUrl}/services/aigc/video-generation/video-synthesis`; const { response: createTaskResponse, prompt: submittedPrompt } = await proxyJsonRequestWithPromptFallback({ - urlString: videoSynthesisEndpoint, - apiKey, + urlString: `${arkBaseUrl}/contents/generations/tasks`, + apiKey: arkApiKey, buildBody: createVideoRequestBody, primaryPrompt: finalPrompt, fallbackPrompt, - extraHeaders: { - 'X-DashScope-Async': 'enable', - 'X-DashScope-OssResourceResolve': 'enable', - }, }); activePrompt = submittedPrompt; @@ -2063,7 +2213,7 @@ async function handleGenerateCharacterAnimation( activeTaskId = taskId; if (!taskId) { - throw new Error('图生视频任务未返回 task_id。'); + throw new Error('角色动作视频任务未返回 id。'); } const createdAt = new Date().toISOString(); @@ -2080,13 +2230,18 @@ async function handleGenerateCharacterAnimation( updatedAt: createdAt, }); - const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { - timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs - : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, - intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, - }); + const taskResult = await waitForArkContentGenerationTask( + arkBaseUrl, + arkApiKey, + taskId, + { + timeoutMs: + Number.isFinite(arkTimeoutMs) && arkTimeoutMs > 0 + ? arkTimeoutMs + : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, + intervalMs: ARK_VIDEO_TASK_POLL_INTERVAL_MS, + }, + ); const videoUrl = extractVideoUrl(taskResult); if (!videoUrl) { @@ -2264,8 +2419,8 @@ async function handleGenerateCharacterAnimation( const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs + Number.isFinite(dashScopeTimeoutMs) && dashScopeTimeoutMs > 0 + ? dashScopeTimeoutMs : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, }); @@ -2467,8 +2622,8 @@ async function handleGenerateCharacterAnimation( const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { timeoutMs: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs + Number.isFinite(dashScopeTimeoutMs) && dashScopeTimeoutMs > 0 + ? dashScopeTimeoutMs : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, }); @@ -2564,7 +2719,8 @@ async function handleGenerateCharacterAnimation( prompt: activePrompt, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - errorMessage: error instanceof Error ? error.message : '生成角色动作失败。', + errorMessage: + error instanceof Error ? error.message : '生成角色动作失败。', }); } sendJson(res, 500, { @@ -2588,7 +2744,9 @@ async function handleReadCharacterJobStatus( const pathname = getRequestPathname(req); const prefix = - kind === 'visual' ? CHARACTER_VISUAL_JOBS_PATH : CHARACTER_ANIMATION_JOBS_PATH; + kind === 'visual' + ? CHARACTER_VISUAL_JOBS_PATH + : CHARACTER_ANIMATION_JOBS_PATH; const taskId = decodeURIComponent(pathname.slice(prefix.length)).trim(); if (!taskId) { @@ -2603,9 +2761,7 @@ async function handleReadCharacterJobStatus( sendJson(res, 404, { error: { message: - error instanceof Error - ? error.message - : '未找到对应的任务记录。', + error instanceof Error ? error.message : '未找到对应的任务记录。', }, }); } @@ -2689,8 +2845,7 @@ async function handleImportCharacterAnimationVideo( } catch (error) { sendJson(res, 500, { error: { - message: - error instanceof Error ? error.message : '导入动作视频失败。', + message: error instanceof Error ? error.message : '导入动作视频失败。', }, }); } @@ -2863,10 +3018,10 @@ async function handleSaveCharacterWorkflowCache( const existingCache = (await readJsonObjectFile(cacheFilePath)) as | CharacterAssetWorkflowCacheRecord | Record; - const comparablePayload = serializeWorkflowCacheComparableValue(payloadBase); - const comparableExisting = serializeWorkflowCacheComparableValue( - existingCache, - ); + const comparablePayload = + serializeWorkflowCacheComparableValue(payloadBase); + const comparableExisting = + serializeWorkflowCacheComparableValue(existingCache); if ( isRecordValue(existingCache) && @@ -3046,9 +3201,7 @@ async function handlePublishCharacterVisual( sendJson(res, 500, { error: { message: - error instanceof Error - ? error.message - : '发布角色主形象失败。', + error instanceof Error ? error.message : '发布角色主形象失败。', }, }); } @@ -3125,7 +3278,8 @@ async function handlePublishCharacterAnimation( } const fps = - typeof rawAnimation.fps === 'number' && Number.isFinite(rawAnimation.fps) + typeof rawAnimation.fps === 'number' && + Number.isFinite(rawAnimation.fps) ? rawAnimation.fps : 8; const loop = rawAnimation.loop === true; @@ -3152,7 +3306,10 @@ async function handlePublishCharacterAnimation( ); frameExtension = framePayload.extension; const frameFileName = `frame${String(index + 1).padStart(2, '0')}.${framePayload.extension}`; - await writeFile(path.join(actionDir, frameFileName), framePayload.buffer); + await writeFile( + path.join(actionDir, frameFileName), + framePayload.buffer, + ); framePaths.push( `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}/${frameFileName}`, ); @@ -3249,9 +3406,7 @@ async function handlePublishCharacterAnimation( sendJson(res, 500, { error: { message: - error instanceof Error - ? error.message - : '发布角色基础动作失败。', + error instanceof Error ? error.message : '发布角色基础动作失败。', }, }); } @@ -3266,7 +3421,8 @@ function toExpressHandler( return (request: Request, response: Response, next: NextFunction) => { Promise.resolve( handler( - request as Request & IncomingMessage & { body?: unknown; originalUrl?: string }, + request as Request & + IncomingMessage & { body?: unknown; originalUrl?: string }, response as Response & ServerResponse, ), ).catch(next); @@ -3329,7 +3485,12 @@ export function createCharacterAssetRoutes( router.use( CHARACTER_VISUAL_JOBS_PATH, toExpressHandler((request, response) => - handleReadCharacterJobStatus(config.projectRoot, request, response, 'visual'), + handleReadCharacterJobStatus( + config.projectRoot, + request, + response, + 'visual', + ), ), ); router.use( @@ -3347,13 +3508,22 @@ export function createCharacterAssetRoutes( router.use( CHARACTER_ANIMATION_JOBS_PATH, toExpressHandler((request, response) => - handleReadCharacterJobStatus(config.projectRoot, request, response, 'animation'), + handleReadCharacterJobStatus( + config.projectRoot, + request, + response, + 'animation', + ), ), ); router.use( CHARACTER_ANIMATION_IMPORT_VIDEO_PATH, toExpressHandler((request, response) => - handleImportCharacterAnimationVideo(config.projectRoot, request, response), + handleImportCharacterAnimationVideo( + config.projectRoot, + request, + response, + ), ), ); router.use( diff --git a/server-node/src/modules/combat/combatResolutionService.ts b/server-node/src/modules/combat/combatResolutionService.ts index e31a5d00..f75b2c8c 100644 --- a/server-node/src/modules/combat/combatResolutionService.ts +++ b/server-node/src/modules/combat/combatResolutionService.ts @@ -1,10 +1,17 @@ import type { RuntimeBattlePresentation, + RuntimeStoryChoicePayload, RuntimeStoryPatch, } from '../../../../packages/shared/src/contracts/story.js'; import { conflict } from '../../errors.js'; +import { + appendBuildBuffs, + resolvePlayerOutgoingDamageResult, +} from '../runtime/runtimeBuildModule.js'; import { getEncounterNpcState, + getPlayerCharacter, + getPlayerSkillCooldowns, setEncounterNpcState, type RuntimeSession, } from '../story/runtimeSession.js'; @@ -16,6 +23,15 @@ type CombatActionConfig = { counterMultiplier: number; heal?: number; manaRestore?: number; + cooldownBonus?: number; + selectedSkillId?: string | null; + appliedCooldownTurns?: number; + buildBuffs?: Array<{ + id: string; + name: string; + tags: string[]; + durationTurns: number; + }>; }; export type CombatResolution = { @@ -26,46 +42,21 @@ export type CombatResolution = { storyText?: string; }; -const COMBAT_ACTIONS: Record = { - battle_all_in_crush: { - actionText: '正面强压', - manaCost: 14, - baseDamage: 22, - counterMultiplier: 1.25, - }, - battle_feint_step: { - actionText: '虚晃切步', - manaCost: 8, - baseDamage: 16, - counterMultiplier: 0.7, - }, - battle_finisher_window: { - 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, - }, -}; +const LEGACY_ATTACK_FUNCTION_IDS = new Set([ + 'battle_all_in_crush', + 'battle_guard_break', + 'battle_probe_pressure', + 'battle_feint_step', + 'battle_finisher_window', +]); + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} function getAliveTarget(session: RuntimeSession) { 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, + 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( session: RuntimeSession, - functionId: string, + params: { + functionId: string; + payload?: RuntimeStoryChoicePayload; + }, ): CombatResolution { const target = getAliveTarget(session); if (!session.inBattle || !target) { throw conflict('当前不在可结算战斗态,不能执行该战斗动作'); } - if (functionId === 'battle_escape_breakout') { + if (params.functionId === 'battle_escape_breakout') { finishBattle(session, 'escaped'); return { - actionText: '强行脱离战斗', + actionText: '逃跑', resultText: `你抓住空当摆脱了${target.name}的压制,先把这轮正面冲突拉开了。`, battle: { targetId: target.id, @@ -146,7 +238,7 @@ export function resolveCombatAction( patches: [ { type: 'battle_resolved', - functionId, + functionId: params.functionId, targetId: target.id, outcome: 'escaped', }, @@ -165,27 +257,66 @@ export function resolveCombatAction( }; } - const action = COMBAT_ACTIONS[functionId]; - if (!action) { - throw conflict(`暂不支持的战斗动作:${functionId}`); - } - + const action = resolveCombatActionConfig({ + session, + functionId: params.functionId, + payload: params.payload, + }); if (action.manaCost > session.playerMana) { throw conflict('当前灵力不足,无法执行这个战斗动作'); } + const character = getPlayerCharacter(session); + if (!character) { + throw conflict('缺少玩家角色,无法结算战斗动作'); + } + const isSpar = session.currentNpcBattleMode === 'spar'; - const targetHpRatio = target.hp / Math.max(target.maxHp, 1); - const damageBonus = - functionId === 'battle_finisher_window' && targetHpRatio <= 0.4 ? 8 : 0; - const damageDealt = isSpar ? 1 : action.baseDamage + damageBonus; + const damageResult = + action.baseDamage > 0 + ? resolvePlayerOutgoingDamageResult( + session.rawGameState as Parameters[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.playerHp += action.heal ?? 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[0]) ?? + [], + action.buildBuffs as Parameters[1], + ); + } + 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[] = []; let resultText = ''; @@ -204,12 +335,15 @@ export function resolveCombatAction( } else { finishBattle(session, 'victory'); outcome = 'victory'; - resultText = `你彻底压垮了${target.name}的节奏,这场战斗已经收口。`; + resultText = `你这一手彻底压垮了${target.name},眼前战斗已经正式结束。`; } } else { const baseCounter = isSpar ? 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; session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken); @@ -220,7 +354,7 @@ export function resolveCombatAction( patches.push(affinityPatch); } outcome = 'spar_complete'; - resultText = `${target.name}也逼到了你的极限,这场切磋点到为止,双方都默认收手。`; + resultText = `${target.name}也把你逼到了极限,这场切磋点到为止,双方都默认收手。`; } else if (!isSpar && session.playerHp <= 0) { session.playerHp = 0; session.inBattle = false; @@ -230,15 +364,19 @@ export function resolveCombatAction( session.currentEncounter = null; outcome = 'escaped'; 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 { - resultText = `${action.actionText}命中了${target.name},但对方仍然顶住并回敬了一轮压力。`; + resultText = `${action.actionText}命中了${target.name},本次攻击已经完成结算。`; } } patches.push( { type: 'battle_resolved', - functionId, + functionId: params.functionId, targetId: target.id, damageDealt, damageTaken, diff --git a/server-node/src/modules/quest/runtimeQuestModule.ts b/server-node/src/modules/quest/runtimeQuestModule.ts index 6c5c6b72..6e327891 100644 --- a/server-node/src/modules/quest/runtimeQuestModule.ts +++ b/server-node/src/modules/quest/runtimeQuestModule.ts @@ -5,8 +5,14 @@ import { QUEST_REWARD_THEMES, QUEST_URGENCY_LEVELS, } from '../../../../packages/shared/src/contracts/story.js'; +import { + buildQuestIntentPrompt, + QUEST_INTENT_SYSTEM_PROMPT, +} from '../../prompts/questPrompts.js'; import { formatCurrency } from '../runtime/runtimeEconomyPrimitives.js'; +export { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT }; + export type QuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number]; export type QuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[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; } -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: { state: RuntimeStateLike; encounter: RuntimeEncounterLike; diff --git a/server-node/src/modules/runtime-item/runtimeItemModule.ts b/server-node/src/modules/runtime-item/runtimeItemModule.ts index 9c24e2d0..b03938c5 100644 --- a/server-node/src/modules/runtime-item/runtimeItemModule.ts +++ b/server-node/src/modules/runtime-item/runtimeItemModule.ts @@ -2,6 +2,12 @@ import { RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES, RUNTIME_ITEM_TONE_VALUES, } 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 = (typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number]; @@ -573,48 +579,16 @@ function describePlan( ].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: { context: RuntimeItemGenerationContext; plans: RuntimeItemPlan[]; }) { - return [ - `生成渠道:${params.context.generationChannel}`, - `以下每个物品都需要给出一条可编译的运行时物品意图。`, - ...params.plans.map((plan, index) => describePlan(params.context, plan, index)), - '请严格返回 JSON。', - ].join('\n\n'); + return buildRuntimeItemIntentPromptText({ + generationChannel: params.context.generationChannel, + planBlocks: params.plans.map((plan, index) => + describePlan(params.context, plan, index), + ), + }); } function buildBaseRuntimeContext(params: { diff --git a/server-node/src/modules/story/runtimeSession.ts b/server-node/src/modules/story/runtimeSession.ts index 02bfb3d7..816086cc 100644 --- a/server-node/src/modules/story/runtimeSession.ts +++ b/server-node/src/modules/story/runtimeSession.ts @@ -1,11 +1,17 @@ import type { RuntimeStoryEncounterViewModel, + RuntimeStoryChoicePayload, RuntimeStoryOptionView, RuntimeStoryViewModel, Task5RuntimeOptionScope, } 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 { resolvePlayerOutgoingDamageResult } from '../runtime/runtimeBuildModule.js'; +import { + isInventoryItemUsable, + resolveInventoryItemUseEffect, +} from '../runtime/runtimeInventoryEffectsModule.js'; type JsonRecord = Record; type StoryHistoryRole = 'action' | 'result'; @@ -62,6 +68,58 @@ export type RuntimeCompanion = { 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 = { sessionId: string; runtimeVersion: number; @@ -97,6 +155,8 @@ const STORY_FUNCTION_IDS = new Set([ ]); const COMBAT_FUNCTION_IDS = new Set([ + 'battle_attack_basic', + 'battle_use_skill', 'battle_all_in_crush', 'battle_escape_breakout', 'battle_feint_step', @@ -164,6 +224,16 @@ const FUNCTION_DEFINITIONS: Record = { detailText: '收束当前遭遇并切往下一段场景流程。', scope: 'story', }, + battle_attack_basic: { + actionText: '普通攻击', + detailText: '本回合执行一次不耗蓝的基础攻击。', + scope: 'combat', + }, + battle_use_skill: { + actionText: '释放技能', + detailText: '直接执行一个具体技能,不再包装成抽象战术动作。', + scope: 'combat', + }, battle_all_in_crush: { actionText: '正面强压', detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。', @@ -195,8 +265,13 @@ const FUNCTION_DEFINITIONS: Record = { scope: 'combat', }, battle_recover_breath: { - actionText: '边守边调息', - detailText: '优先回稳资源,但仍可能吃到轻量反击。', + actionText: '恢复', + detailText: '直接恢复资源,并推进本回合冷却。', + scope: 'combat', + }, + inventory_use: { + actionText: '使用物品', + detailText: '战斗中优先执行一个可立即结算的消耗品。', scope: 'combat', }, npc_chat: { @@ -430,6 +505,344 @@ function normalizeHostileNpcs(value: unknown) { .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[number]; + }) + .filter( + ( + entry, + ): entry is NonNullable[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; +} + +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>, +) { + 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>; + 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[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[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) { return encounter.id || encounter.npcName; } @@ -613,15 +1026,7 @@ function hasGiftablePlayerInventory(session: RuntimeSession) { export function buildAvailableOptions(session: RuntimeSession) { if (session.inBattle) { - return [ - '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)); + return buildBattleActionOptions(session); } if (session.currentEncounter?.kind === 'npc') { @@ -784,6 +1189,9 @@ export function buildLegacyCurrentStory( text: option.actionText, detailText: option.detailText, priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1, + runtimePayload: option.payload, + disabled: option.disabled, + disabledReason: option.reason, visuals: { playerAnimation: 'idle', playerMoveMeters: 0, diff --git a/server-node/src/modules/story/storyActionRoutes.test.ts b/server-node/src/modules/story/storyActionRoutes.test.ts index 97409ce8..0d0c84e2 100644 --- a/server-node/src/modules/story/storyActionRoutes.test.ts +++ b/server-node/src/modules/story/storyActionRoutes.test.ts @@ -378,46 +378,48 @@ test('runtime story actions resolve combat finishers on the server and collapse await withTestServer('combat-finisher', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_combat_finisher', 'secret123'); - await putSnapshot(baseUrl, entry.token, { - worldType: 'WUXIA', - storyHistory: [], - currentEncounter: { - kind: 'npc', - id: 'npc_bandit_01', - npcName: '断桥匪首', - npcDescription: '手提短刀的拦路匪徒', - context: '桥口劫匪', - hostile: true, - }, - npcInteractionActive: false, - sceneHostileNpcs: [ - { + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', id: 'npc_bandit_01', - name: '断桥匪首', - hp: 12, - maxHp: 28, - description: '桥口劫匪', + npcName: '断桥匪首', + npcDescription: '手提短刀的拦路匪徒', + context: '桥口劫匪', + hostile: true, }, - ], - inBattle: true, - playerHp: 42, - playerMaxHp: 50, - playerMana: 20, - playerMaxMana: 20, - npcStates: { - npc_bandit_01: { - affinity: -12, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, + npcInteractionActive: false, + sceneHostileNpcs: [ + { + id: 'npc_bandit_01', + name: '断桥匪首', + hp: 12, + maxHp: 28, + description: '桥口劫匪', + }, + ], + inBattle: true, + playerHp: 42, + playerMaxHp: 50, + playerMana: 20, + playerMaxMana: 20, + playerSkillCooldowns: {}, + npcStates: { + npc_bandit_01: { + affinity: -12, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, }, - }, - companions: [], - currentNpcBattleMode: 'fight', - currentNpcBattleOutcome: null, - }); + currentNpcBattleMode: 'fight', + currentNpcBattleOutcome: null, + }), + ); const response = await httpRequest( `${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; + 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 () => { await withTestServer('task6-inventory-use', async ({ baseUrl }) => { const entry = await authEntry(baseUrl, 'story_task6_inventory', 'secret123'); diff --git a/server-node/src/modules/story/storyActionService.ts b/server-node/src/modules/story/storyActionService.ts index 1dff3ff5..1cb41455 100644 --- a/server-node/src/modules/story/storyActionService.ts +++ b/server-node/src/modules/story/storyActionService.ts @@ -155,17 +155,16 @@ function buildStoryOptionFromRuntimeOption( session: RuntimeSession, option: RuntimeStoryOptionView, ) { - const detailParts = [option.detailText, option.disabled ? option.reason : null] - .filter(Boolean) - .join(' '); - return { functionId: option.functionId, actionText: option.actionText, text: option.actionText, - detailText: detailParts || undefined, + detailText: option.detailText, visuals: DEFAULT_STORY_OPTION_VISUALS, interaction: buildStoryOptionInteraction(session, option), + runtimePayload: option.payload, + disabled: option.disabled, + disabledReason: option.reason, } satisfies JsonRecord; } @@ -173,9 +172,7 @@ function buildStoryOptionsFromRuntimeOptions( session: RuntimeSession, options: RuntimeStoryOptionView[], ) { - return options - .filter((option) => !option.disabled) - .map((option) => buildStoryOptionFromRuntimeOption(session, option)); + return options.map((option) => buildStoryOptionFromRuntimeOption(session, option)); } function escapeRegExp(value: string) { @@ -460,6 +457,22 @@ function normalizeStatusPatch(session: RuntimeSession) { } 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) { session.currentEncounter = null; session.npcInteractionActive = false; @@ -778,7 +791,12 @@ export async function resolveRuntimeStoryAction(params: { ? { ...session.currentEncounter } : null; 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)) { resolution = resolveNpcInteraction(session, functionId); } else if (isSupportedInventoryStoryFunctionId(functionId)) { @@ -840,7 +858,10 @@ export async function resolveRuntimeStoryAction(params: { } catch { savedCurrentStory = buildLegacyCurrentStory(storyText, options); } - } else if (params.llmClient && isCombatFunctionId(functionId)) { + } else if ( + params.llmClient && + shouldGenerateReasonedCombatStory(functionId, resolution) + ) { try { const generatedPayload = await generateReasonedStoryPayload({ llmClient: params.llmClient, diff --git a/server-node/src/prompts/chatPromptBuilders.ts b/server-node/src/prompts/chatPromptBuilders.ts new file mode 100644 index 00000000..65009b11 --- /dev/null +++ b/server-node/src/prompts/chatPromptBuilders.ts @@ -0,0 +1,471 @@ +import type { + CharacterChatReplyRequest, + CharacterChatSuggestionsRequest, + CharacterChatSummaryRequest, + NpcChatDialogueRequest, + NpcChatTurnRequest, + NpcRecruitDialogueRequest, +} from '../../../packages/shared/src/contracts/story.js'; + +type JsonRecord = Record; + +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'); +} diff --git a/server-node/src/prompts/customWorldAgentPrompts.ts b/server-node/src/prompts/customWorldAgentPrompts.ts new file mode 100644 index 00000000..ce20fcd7 --- /dev/null +++ b/server-node/src/prompts/customWorldAgentPrompts.ts @@ -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'); +} diff --git a/server-node/src/prompts/customWorldEntityPrompts.ts b/server-node/src/prompts/customWorldEntityPrompts.ts new file mode 100644 index 00000000..0d910e5c --- /dev/null +++ b/server-node/src/prompts/customWorldEntityPrompts.ts @@ -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'); +} diff --git a/server-node/src/prompts/customWorldOrchestratorPrompts.ts b/server-node/src/prompts/customWorldOrchestratorPrompts.ts new file mode 100644 index 00000000..e2f99a32 --- /dev/null +++ b/server-node/src/prompts/customWorldOrchestratorPrompts.ts @@ -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, summary,targetLandmarkName 必须指向本次输出的其他场景名', + '', + '约束:', + '- 所有世界观、角色、场景必须贴合创作者输入,不要套用通用武侠模板。', + '- 角色名字、势力名、场景名必须互相区分,避免重复。', + '- 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'); +} diff --git a/server-node/src/prompts/customWorldSceneNpcPrompts.ts b/server-node/src/prompts/customWorldSceneNpcPrompts.ts new file mode 100644 index 00000000..cf433c88 --- /dev/null +++ b/server-node/src/prompts/customWorldSceneNpcPrompts.ts @@ -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'); +} diff --git a/server-node/src/prompts/questPrompts.ts b/server-node/src/prompts/questPrompts.ts new file mode 100644 index 00000000..c3233597 --- /dev/null +++ b/server-node/src/prompts/questPrompts.ts @@ -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'); +} diff --git a/server-node/src/prompts/runtimeItemPrompts.ts b/server-node/src/prompts/runtimeItemPrompts.ts new file mode 100644 index 00000000..e41521fe --- /dev/null +++ b/server-node/src/prompts/runtimeItemPrompts.ts @@ -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'); +} diff --git a/server-node/src/prompts/storyOrchestratorPrompts.ts b/server-node/src/prompts/storyOrchestratorPrompts.ts new file mode 100644 index 00000000..bb28a158 --- /dev/null +++ b/server-node/src/prompts/storyOrchestratorPrompts.ts @@ -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'); +} diff --git a/server-node/src/prompts/storyPromptBuilders.ts b/server-node/src/prompts/storyPromptBuilders.ts new file mode 100644 index 00000000..346a8fac --- /dev/null +++ b/server-node/src/prompts/storyPromptBuilders.ts @@ -0,0 +1,197 @@ +type JsonRecord = Record; + +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>; + optionCatalog?: Array>; +}) { + 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>; + optionCatalog?: Array>; +}) { + return (options.optionCatalog ?? []).some((option) => + (readString(option.functionId) ?? '').startsWith('npc_'), + ); +} + +function isPostNpcChatReevaluation(params: { + choice?: string; + context: JsonRecord; + requestOptions?: { + availableOptions?: Array>; + optionCatalog?: Array>; + }; +}) { + 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>; + optionCatalog?: Array>; + }; +}) { + 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'); +} diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts index 666b456a..ade6f6c8 100644 --- a/server-node/src/repositories/runtimeRepository.ts +++ b/server-node/src/repositories/runtimeRepository.ts @@ -9,6 +9,7 @@ import type { ProfileDashboardSummary, ProfilePlayedWorkSummary, ProfilePlayStatsResponse, + ProfileSaveArchiveSummary, ProfileWalletLedgerEntry, RuntimeSettings, SavedGameSnapshot, @@ -19,6 +20,7 @@ import { type CustomWorldPublicationStatus, type CustomWorldSessionRecord, DEFAULT_MUSIC_VOLUME, + DEFAULT_PLATFORM_THEME, SAVE_SNAPSHOT_VERSION, } from '../../../packages/shared/src/contracts/runtime.js'; import type { AppDatabase } from '../db.js'; @@ -39,6 +41,7 @@ type SnapshotRow = QueryResultRow & { type SettingsRow = QueryResultRow & { musicVolume: number; + platformTheme: RuntimeSettings['platformTheme']; }; type CustomWorldEntryRow = QueryResultRow & { @@ -127,6 +130,23 @@ type ProfileWorldSnapshotMeta = { 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; + export type RuntimeRepositoryPort = { getSnapshot(userId: string): Promise; putSnapshot( @@ -136,6 +156,14 @@ export type RuntimeRepositoryPort = { getProfileDashboard(userId: string): Promise; listProfileWalletLedger(userId: string): Promise; getProfilePlayStats(userId: string): Promise; + listProfileSaveArchives(userId: string): Promise; + resumeProfileSaveArchive( + userId: string, + worldKey: string, + ): Promise<{ + entry: ProfileSaveArchiveSummary; + snapshot: SavedSnapshot; + } | null>; deleteSnapshot(userId: string): Promise; getSettings(userId: string): Promise; putSettings( @@ -313,6 +341,10 @@ function normalizePlatformBrowseHistoryWriteEntry( }; } +function readSavedStoryText(value: unknown) { + return readString(asRecord(value)?.text); +} + function readFiniteNumber(value: unknown) { if (typeof value === 'number' && Number.isFinite(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 { constructor(private readonly db: AppDatabase) {} @@ -663,6 +779,29 @@ export class RuntimeRepository implements RuntimeRepositoryPort { return result.rows[0] ?? null; } + private async findProfileSaveArchive(userId: string, worldKey: string) { + const result = await this.db.query( + `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( userId: string, 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( + `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( userId: string, 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( userId: string, snapshot: SavedSnapshot, @@ -883,45 +1126,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort { bottomTab: payload.bottomTab, currentStory: payload.currentStory, } satisfies SavedSnapshot; - const now = new Date().toISOString(); - - const result = await this.db.query( - `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; + const persistedSnapshot = await this.upsertCurrentSnapshot(userId, snapshot); await this.syncProfileDashboardFromSnapshot(userId, persistedSnapshot); + await this.syncProfileSaveArchiveFromSnapshot(userId, persistedSnapshot); await this.syncCustomWorldProfileFromSnapshot(userId, persistedSnapshot); return persistedSnapshot; @@ -993,6 +1201,50 @@ export class RuntimeRepository implements RuntimeRepositoryPort { } satisfies ProfilePlayStatsResponse; } + async listProfileSaveArchives(userId: string) { + const result = await this.db.query( + `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) { await this.db.query(`DELETE FROM save_snapshots WHERE user_id = $1`, [ userId, @@ -1001,9 +1253,10 @@ export class RuntimeRepository implements RuntimeRepositoryPort { async getSettings(userId: string) { const result = await this.db.query( - `SELECT music_volume AS "musicVolume" - FROM runtime_settings - WHERE user_id = $1`, + `SELECT music_volume AS "musicVolume", + platform_theme AS "platformTheme" + FROM runtime_settings + WHERE user_id = $1`, [userId], ); const row = result.rows[0]; @@ -1013,26 +1266,41 @@ export class RuntimeRepository implements RuntimeRepositoryPort { typeof row?.musicVolume === 'number' ? row.musicVolume : DEFAULT_MUSIC_VOLUME, + platformTheme: + row?.platformTheme === 'dark' + ? 'dark' + : DEFAULT_PLATFORM_THEME, } satisfies RuntimeSettings; } async putSettings(userId: string, settings: RuntimeSettings) { const nextSettings = { musicVolume: Math.max(0, Math.min(1, settings.musicVolume)), + platformTheme: + settings.platformTheme === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME, } satisfies RuntimeSettings; const result = await this.db.query( - `INSERT INTO runtime_settings (user_id, music_volume, updated_at) - VALUES ($1, $2, $3) + `INSERT INTO runtime_settings (user_id, music_volume, platform_theme, updated_at) + VALUES ($1, $2, $3, $4) ON CONFLICT (user_id) DO UPDATE SET music_volume = EXCLUDED.music_volume, + platform_theme = EXCLUDED.platform_theme, updated_at = EXCLUDED.updated_at - RETURNING music_volume AS "musicVolume"`, - [userId, nextSettings.musicVolume, new Date().toISOString()], + RETURNING music_volume AS "musicVolume", + platform_theme AS "platformTheme"`, + [ + userId, + nextSettings.musicVolume, + nextSettings.platformTheme, + new Date().toISOString(), + ], ); return { musicVolume: result.rows[0]?.musicVolume ?? nextSettings.musicVolume, + platformTheme: + result.rows[0]?.platformTheme ?? nextSettings.platformTheme, } satisfies RuntimeSettings; } diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts index 9477a62a..dfa38db7 100644 --- a/server-node/src/routes/runtimeRoutes.ts +++ b/server-node/src/routes/runtimeRoutes.ts @@ -9,11 +9,14 @@ import type { CustomWorldGalleryResponse, CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, + PLATFORM_THEMES, PlatformBrowseHistoryBatchSyncRequest, PlatformBrowseHistoryResponse, PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfilePlayStatsResponse, + ProfileSaveArchiveListResponse, + ProfileSaveArchiveResumeResponse, ProfileWalletLedgerResponse, RuntimeSettings, SavedGameSnapshotInput, @@ -89,6 +92,7 @@ const saveSnapshotSchema = z.object({ const settingsSchema = z.object({ musicVolume: z.number().min(0).max(1), + platformTheme: z.enum(PLATFORM_THEMES), }); 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( '/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(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(response, { + entry: resumedArchive.entry, + snapshot: hydrateSavedSnapshot(resumedArchive.snapshot)!, + }); + }), + ); + }); + router.post( '/llm/chat/completions', 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( '/runtime/custom-world-library/:profileId', routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }), diff --git a/server-node/src/services/customWorldAgentEntityGenerationService.ts b/server-node/src/services/customWorldAgentEntityGenerationService.ts index 999fd3af..358ed137 100644 --- a/server-node/src/services/customWorldAgentEntityGenerationService.ts +++ b/server-node/src/services/customWorldAgentEntityGenerationService.ts @@ -3,6 +3,12 @@ import type { CustomWorldFoundationDraftLandmark, } from '../../../packages/shared/src/contracts/customWorldAgent.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 { getWorldFoundationCardId, normalizeFoundationDraftProfile, @@ -438,22 +444,18 @@ async function requestCharacterSuggestionsFromLlm(params: { params.profile.summary; const content = await params.llmClient.requestMessageContent({ - systemPrompt: - '你负责为当前游戏世界底稿补 1 到 3 个新角色。只能输出 JSON 数组,不要输出任何额外说明。', - userPrompt: [ - `当前世界:${params.profile.name}`, - `世界摘要:${params.profile.summary}`, - `创作意图摘要:${creatorIntentSummary}`, - `参考锚点:${anchorSummary}`, - `已有角色:${getAllCharacters(params.profile) + systemPrompt: CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT, + userPrompt: buildCustomWorldAgentCharacterExpansionPrompt({ + worldName: params.profile.name, + worldSummary: params.profile.summary, + creatorIntentSummary, + anchorSummary, + existingNames: getAllCharacters(params.profile) .slice(0, 10) - .map((entry) => entry.name) - .join('、') || '暂无'}`, - `数量:${params.count}`, - `补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`, - '返回 JSON 数组。每个对象字段只允许包含:name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds。', - 'threadIds 必须优先引用现有线程 id。', - ].join('\n'), + .map((entry) => entry.name), + count: params.count, + promptSeed: params.promptSeed, + }), timeoutMs: 45000, debugLabel: 'custom-world-agent-generate-characters', }); @@ -478,22 +480,18 @@ async function requestLandmarkSuggestionsFromLlm(params: { params.profile.summary; const content = await params.llmClient.requestMessageContent({ - systemPrompt: - '你负责为当前游戏世界底稿补 1 到 3 个新地点。只能输出 JSON 数组,不要输出任何额外说明。', - userPrompt: [ - `当前世界:${params.profile.name}`, - `世界摘要:${params.profile.summary}`, - `创作意图摘要:${creatorIntentSummary}`, - `参考锚点:${anchorSummary}`, - `已有地点:${params.profile.landmarks + systemPrompt: CUSTOM_WORLD_AGENT_LANDMARK_EXPANSION_SYSTEM_PROMPT, + userPrompt: buildCustomWorldAgentLandmarkExpansionPrompt({ + worldName: params.profile.name, + worldSummary: params.profile.summary, + creatorIntentSummary, + anchorSummary, + existingNames: params.profile.landmarks .slice(0, 10) - .map((entry) => entry.name) - .join('、') || '暂无'}`, - `数量:${params.count}`, - `补充要求:${params.promptSeed || '没有额外要求,围绕当前底稿自然扩展。'}`, - '返回 JSON 数组。每个对象字段只允许包含:name, purpose, mood, dangerLevel, secret, summary, threadIds, characterIds。', - 'threadIds / characterIds 必须优先引用现有对象 id。', - ].join('\n'), + .map((entry) => entry.name), + count: params.count, + promptSeed: params.promptSeed, + }), timeoutMs: 45000, debugLabel: 'custom-world-agent-generate-landmarks', }); diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.ts b/server-node/src/services/customWorldAgentFoundationDraftService.ts index 4760763f..867271e4 100644 --- a/server-node/src/services/customWorldAgentFoundationDraftService.ts +++ b/server-node/src/services/customWorldAgentFoundationDraftService.ts @@ -8,6 +8,10 @@ import type { EightAnchorContent, } from '../../../packages/shared/src/contracts/customWorldAgent.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 { buildCustomWorldFrameworkJsonRepairPrompt, 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_STORY_COUNT = 6; const FOUNDATION_DRAFT_LANDMARK_COUNT = 4; diff --git a/server-node/src/services/customWorldAgentPhase2.test.ts b/server-node/src/services/customWorldAgentPhase2.test.ts index 483c51cd..8d8297f9 100644 --- a/server-node/src/services/customWorldAgentPhase2.test.ts +++ b/server-node/src/services/customWorldAgentPhase2.test.ts @@ -47,6 +47,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { async getSettings() { return { musicVolume: 0.42, + platformTheme: 'light', }; }, async putSettings(_userId, settings) { diff --git a/server-node/src/services/customWorldAgentPhase3.test.ts b/server-node/src/services/customWorldAgentPhase3.test.ts index bdcda008..dd7d20e4 100644 --- a/server-node/src/services/customWorldAgentPhase3.test.ts +++ b/server-node/src/services/customWorldAgentPhase3.test.ts @@ -39,6 +39,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { async getSettings() { return { musicVolume: 0.42, + platformTheme: 'light', }; }, async putSettings(_userId, settings) { diff --git a/server-node/src/services/customWorldAgentPhase4.test.ts b/server-node/src/services/customWorldAgentPhase4.test.ts index f4e586d9..b82cd13f 100644 --- a/server-node/src/services/customWorldAgentPhase4.test.ts +++ b/server-node/src/services/customWorldAgentPhase4.test.ts @@ -40,6 +40,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { async getSettings() { return { musicVolume: 0.42, + platformTheme: 'light', }; }, async putSettings(_userId, settings) { diff --git a/server-node/src/services/customWorldAgentPhase5.test.ts b/server-node/src/services/customWorldAgentPhase5.test.ts index 8463122b..74c54bca 100644 --- a/server-node/src/services/customWorldAgentPhase5.test.ts +++ b/server-node/src/services/customWorldAgentPhase5.test.ts @@ -39,6 +39,7 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort { async getSettings() { return { musicVolume: 0.42, + platformTheme: 'light', }; }, async putSettings(_userId, settings) { diff --git a/server-node/src/services/customWorldEntityGenerationService.ts b/server-node/src/services/customWorldEntityGenerationService.ts index f2655ae4..6c614f81 100644 --- a/server-node/src/services/customWorldEntityGenerationService.ts +++ b/server-node/src/services/customWorldEntityGenerationService.ts @@ -1,5 +1,11 @@ import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.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'; 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, startIndex: number) { for (let attempt = 0; attempt < 120; attempt += 1) { 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) { const normalized = name.trim() || fallbackName; if (!existingNames.includes(normalized)) { @@ -1040,8 +841,7 @@ async function requestGeneratedEntity( : buildLandmarkPrompt(profile); const content = await llmClient.requestMessageContent({ - systemPrompt: - '你是游戏世界编辑器的实体生成器。你必须只返回可解析 JSON,不要输出解释、前言或 Markdown。', + systemPrompt: CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT, userPrompt, timeoutMs: 45000, debugLabel: `custom-world-generate-${kind}`, diff --git a/server-node/src/services/customWorldSceneNpcGenerationService.ts b/server-node/src/services/customWorldSceneNpcGenerationService.ts index 8e271131..02ae065b 100644 --- a/server-node/src/services/customWorldSceneNpcGenerationService.ts +++ b/server-node/src/services/customWorldSceneNpcGenerationService.ts @@ -1,5 +1,9 @@ import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { badRequest } from '../errors.js'; +import { + buildCustomWorldSceneNpcPrompt, + CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT, +} from '../prompts/customWorldSceneNpcPrompts.js'; import type { UpstreamLlmClient } from './llmClient.js'; 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( rawValue: unknown, profile: ParsedProfile, @@ -571,9 +495,13 @@ export async function generateSceneNpcForLandmark( try { const content = await llmClient.requestMessageContent({ - systemPrompt: - '你是游戏世界编辑器的角色生成器。你必须只返回可解析 JSON,不要输出解释、前言或 markdown 代码块之外的额外内容。', - userPrompt: buildPrompt(profile, landmark, sceneNpcs, otherNpcs), + systemPrompt: CUSTOM_WORLD_SCENE_NPC_SYSTEM_PROMPT, + userPrompt: buildCustomWorldSceneNpcPrompt( + profile, + landmark, + sceneNpcs, + otherNpcs, + ), debugLabel: 'custom-world-scene-npc', }); const parsed = parseJsonResponseText(content); diff --git a/src/components/AdventurePanel.tsx b/src/components/AdventurePanel.tsx index 3c7baa17..73daf3ca 100644 --- a/src/components/AdventurePanel.tsx +++ b/src/components/AdventurePanel.tsx @@ -1106,6 +1106,10 @@ export function AdventurePanel({ const isDeferredContinueOption = hasDeferredAdventureOptions && isContinueAdventureOption(option); + const optionDisabled = option.disabled === true; + const compactOptionDetailText = option.disabledReason + ? option.disabledReason + : getCompactOptionDetailText(option); if (isDeferredContinueOption) { return ( @@ -1142,12 +1146,13 @@ export function AdventurePanel({ key={`${option.functionId}-${option.actionText}-${index}`} type="button" 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)} >
{option.actionText} @@ -1156,9 +1161,9 @@ export function AdventurePanel({ className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100" />
- {!isNpcChatMode && getCompactOptionDetailText(option) && ( + {!isNpcChatMode && compactOptionDetailText && (
- {getCompactOptionDetailText(option)} + {compactOptionDetailText}
)} {!isNpcChatMode && option.goalAffordance?.label && ( @@ -1166,7 +1171,7 @@ export function AdventurePanel({ {option.goalAffordance.label} )} - {!isNpcChatMode && optionImpactSummary && ( + {!isNpcChatMode && optionImpactSummary && !optionDisabled && (
{optionImpactSummary}
@@ -1175,7 +1180,7 @@ export function AdventurePanel({ ); })} {isNpcChatMode ? ( -
+
+
@@ -113,17 +109,17 @@ function SmallButton({ }) { const toneClassName = tone === 'sky' - ? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white' + ? 'platform-button platform-button--primary' : tone === 'rose' - ? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white' - : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'; + ? 'platform-button platform-button--danger' + : 'platform-button platform-button--ghost'; return ( @@ -140,12 +136,12 @@ function SearchBox({ placeholder: string; }) { return ( -
+
onChange(event.target.value)} 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)]" />
); @@ -164,7 +160,7 @@ function ImageFrame({ }) { return (
{src ? ( {alt} @@ -179,8 +175,8 @@ function ImageFrame({ function EmptyState({ title }: { title: string }) { return ( -
-
{title}
+
+
{title}
); } @@ -195,7 +191,7 @@ function buildFallbackRenderKey( function NewBadge() { return ( - + ); @@ -211,21 +207,23 @@ function PendingEntityCard({ progress: number; }) { return ( -
+
-
{title}
-
+
+ {title} +
+
{phaseLabel}
-
+
{Math.round(progress)}%
-
+
@@ -261,7 +259,7 @@ function CatalogCard({ className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${ isSelected ? '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 ? '已选' : '选择'} @@ -277,14 +275,12 @@ function CatalogCard({ className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${ isSelected ? 'border-rose-300/35 bg-rose-500/10' - : disabled - ? 'border-white/10 bg-black/20' - : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28' + : 'platform-subpanel' }`} >
{media}
@@ -315,14 +311,12 @@ function CatalogCard({ className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${ isSelected ? 'border-rose-300/35 bg-rose-500/10' - : disabled - ? 'border-white/10 bg-black/20' - : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28' + : 'platform-subpanel' }`} >
{media}
@@ -1030,7 +1024,7 @@ export function CustomWorldEntityCatalog({
世界档案
-
+
{profile.name}
@@ -1038,17 +1032,17 @@ export function CustomWorldEntityCatalog({
-
+
{RESULT_TABS.map((tab) => (
@@ -1068,7 +1062,7 @@ export function CustomWorldEntityCatalog({
{isBulkDeleteMode ? ( <> -
+
已选 {selectedBulkIds.length}
取消 @@ -1109,19 +1103,19 @@ export function CustomWorldEntityCatalog({ <>
-
+
{profile.playableNpcs.length}
可扮演角色
-
+
{profile.storyNpcs.length}
场景角色
-
+
{profile.landmarks.length + 1}
@@ -1150,15 +1144,15 @@ export function CustomWorldEntityCatalog({ ) } > -
-

{profile.summary}

-
- 主线目标:{profile.playerGoal} +
+

{profile.summary}

+
+ 主线目标:{profile.playerGoal} +
+
+ 世界基调:{profile.tone} +
-
- 世界基调:{profile.tone} -
-
(
{entry.label} @@ -1261,30 +1255,30 @@ export function CustomWorldEntityCatalog({ className="h-full w-full object-cover object-top" /> ) : ( -
- {role.name.slice(0, 4) || '角色'} -
+
+ {role.name.slice(0, 4) || '角色'} +
) } />
{lockedCharacterNames.has(role.name.trim()) ? ( - + 创作者锁定 ) : null} - + 初始好感 {role.initialAffinity} {role.generatedVisualAssetId ? ( - + 已生成主图 ) : null} {role.tags.slice(0, 2).map((tag) => ( {tag} diff --git a/src/components/CustomWorldEntityEditorModal.tsx b/src/components/CustomWorldEntityEditorModal.tsx index 538e75f2..6276ef83 100644 --- a/src/components/CustomWorldEntityEditorModal.tsx +++ b/src/components/CustomWorldEntityEditorModal.tsx @@ -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 { Children, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -38,13 +39,13 @@ import { CustomWorldSceneConnection, type ItemRarity, } from '../types'; -import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { buildAnimationClipFromVideoSource } from './asset-studio/characterAssetWorkflowModel'; import { type CharacterAnimationGenerationPayload, generateCharacterAnimationDraft, publishCharacterAnimationAssets, } from './asset-studio/characterAssetWorkflowPersistence'; +import { useAuthUi } from './auth/AuthUiContext'; import { CharacterAnimator } from './CharacterAnimator'; import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults'; import { @@ -408,9 +409,15 @@ function ModalShell({ disableClose?: boolean; usePixelFont?: boolean; }) { + const authUi = useAuthUi(); + const platformThemeClass = + authUi?.platformTheme === 'dark' + ? 'platform-theme--dark' + : 'platform-theme--light'; + return (
event.stopPropagation()} >
@@ -442,9 +448,9 @@ function ModalShell({ onClick={onClose} disabled={disableClose} 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' : ''}`} > - +
event.stopPropagation()} >
@@ -517,9 +528,9 @@ function CompactDialogShell({ onClick={onClose} disabled={disableClose} 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' : ''}`} > - +
{children}
@@ -1712,16 +1723,9 @@ function SaveBar({
@@ -2132,12 +2136,13 @@ function RoleSkillEditorModal({ lastFrameImageDataUrl: role.imageSrc, frameCount: 8, fps: 10, - durationSeconds: 3, + durationSeconds: 4, loop: false, useChromaKey: true, - resolution: '480P', + resolution: '480p', + ratio: '1:1', imageSequenceModel: 'wan2.7-image-pro', - videoModel: 'wan2.2-kf2v-flash', + videoModel: 'doubao-seedance-2-0-fast-260128', referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', } satisfies CharacterAnimationGenerationPayload); diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index f676be7d..c42462ba 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -2,7 +2,6 @@ import { motion } from 'motion/react'; import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress'; -import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; interface CustomWorldGenerationViewProps { 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))]" style={{ WebkitOverflowScrolling: 'touch' }} > -
+
-
+
{isGenerating ? activeBadgeLabel : error @@ -114,19 +113,13 @@ export function CustomWorldGenerationView({
-
+
{progressTitle}
-
+
{progress?.phaseLabel ?? '正在启动世界生成'}
@@ -137,22 +130,22 @@ export function CustomWorldGenerationView({
总进度
-
+
{progressValue}%
-
+
-
+
当前批次
@@ -160,7 +153,7 @@ export function CustomWorldGenerationView({ {progress?.batchLabel ?? '准备中'}
-
+
预计等待
@@ -168,7 +161,7 @@ export function CustomWorldGenerationView({ {estimatedWaitText}
-
+
计时
@@ -187,7 +180,7 @@ export function CustomWorldGenerationView({ ? 'border-emerald-400/16 bg-emerald-500/8' : step.status === 'active' ? 'border-sky-300/22 bg-sky-500/10' - : 'border-white/8 bg-black/18' + : 'platform-subpanel' }`} >
@@ -217,25 +210,16 @@ export function CustomWorldGenerationView({ ) : onInterrupt ? ( @@ -250,16 +234,10 @@ export function CustomWorldGenerationView({
-
+
-
+
{settingTitle}
@@ -270,7 +248,7 @@ export function CustomWorldGenerationView({ type="button" onClick={onEditSetting} 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} @@ -283,7 +261,7 @@ export function CustomWorldGenerationView({ entry.id, `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" >
{entry.label} @@ -295,7 +273,7 @@ export function CustomWorldGenerationView({ ))}
) : ( -
+
{settingText || structuredEmptyText}
)} diff --git a/src/components/CustomWorldResultView.tsx b/src/components/CustomWorldResultView.tsx index 519d4177..18bff546 100644 --- a/src/components/CustomWorldResultView.tsx +++ b/src/components/CustomWorldResultView.tsx @@ -13,7 +13,6 @@ import { CustomWorldPlayableNpc, CustomWorldProfile, } from '../types'; -import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { CustomWorldEntityCatalog, type ResultTab, @@ -71,11 +70,11 @@ function SmallButton({ type="button" onClick={onClick} disabled={disabled} - className={`rounded-full border px-3 py-2 text-sm transition-colors ${ + className={`${ tone === 'sky' - ? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white' - : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white' - } ${disabled ? 'cursor-not-allowed opacity-45' : ''}`} + ? 'platform-button platform-button--primary' + : 'platform-button platform-button--ghost' + } min-h-0 rounded-full px-3 py-2 text-sm ${disabled ? 'cursor-not-allowed opacity-45' : ''}`} > {children} @@ -351,15 +350,15 @@ export function CustomWorldResultView({ }; const autoSaveBadge = autoSaveState === 'saved' ? ( -
+
已自动保存
) : autoSaveState === 'saving' ? ( -
+
保存中
) : autoSaveState === 'error' ? ( -
+
保存失败
) : null; @@ -371,7 +370,7 @@ export function CustomWorldResultView({ type="button" onClick={onBack} 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} @@ -418,16 +417,18 @@ export function CustomWorldResultView({
{isGenerating && ( -
+
-
+
{progressLabel}
-
{Math.round(progress)}%
+
+ {Math.round(progress)}% +
-
+
@@ -435,19 +436,19 @@ export function CustomWorldResultView({ )} {error ? ( -
+
{error}
) : null} {!error && localGenerationError ? ( -
+
{localGenerationError}
) : null}
{profile.generationStatus === 'key_only' ? ( -
+
当前世界处于快速预览模式,只生成了关键对象。继续补全后,系统会生成长尾场景角色与完整场景网络。
) : null} @@ -474,18 +475,9 @@ export function CustomWorldResultView({ type="button" onClick={onEnterWorld} disabled={isGenerating} - className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`} - style={getNineSliceStyle(UI_CHROME.choiceButton, { - paddingX: 16, - paddingY: 10, - })} + className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`} > -
- - {enterWorldActionLabel} - - -
+ {enterWorldActionLabel} ) : null}
diff --git a/src/components/CustomWorldRoleAssetStudioModal.tsx b/src/components/CustomWorldRoleAssetStudioModal.tsx index b09bce72..3ea8a0f1 100644 --- a/src/components/CustomWorldRoleAssetStudioModal.tsx +++ b/src/components/CustomWorldRoleAssetStudioModal.tsx @@ -1,7 +1,4 @@ -import { - ImagePlus, - RefreshCcw, -} from 'lucide-react'; +import { ImagePlus, RefreshCcw } from 'lucide-react'; import { type ChangeEvent, type CSSProperties, @@ -34,6 +31,7 @@ import { saveCharacterWorkflowCache, } from './asset-studio/characterAssetWorkflowPersistence'; import { buildDefaultRolePromptBundle } from './asset-studio/customWorldRolePromptDefaults'; +import { buildProjectPixelStyleReferenceBoard } from './asset-studio/projectPixelStyleReference'; import { CharacterAnimator } from './CharacterAnimator'; type EditableCustomWorldRole = { @@ -92,16 +90,7 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [ templateId: 'attack_slash', fps: 12, frameCount: 8, - durationSeconds: 3, - loop: false, - }, - { - animation: AnimationState.HURT, - label: '受击', - templateId: 'hurt', - fps: 10, - frameCount: 6, - durationSeconds: 3, + durationSeconds: 4, loop: false, }, { @@ -329,9 +318,7 @@ function ActionButton({ {label} {subLabel ? ( - - {subLabel} - + {subLabel} ) : null} @@ -351,7 +338,9 @@ function buildRoleCharacterBrief( role.personality ? `角色性格:${role.personality}` : '', role.motivation ? `角色动机:${role.motivation}` : '', role.combatStyle ? `战斗风格:${role.combatStyle}` : '', - role.tags && role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '', + role.tags && role.tags.length > 0 + ? `角色标签:${role.tags.join('、')}` + : '', templateLabel ? `参考模板:${templateLabel}` : '', ] .filter(Boolean) @@ -606,6 +595,10 @@ export function CustomWorldRoleAssetStudioModal({ const [referenceImageDataUrls, setReferenceImageDataUrls] = useState< string[] >([]); + const [ + projectStyleReferenceBoardSource, + setProjectStyleReferenceBoardSource, + ] = useState(''); const [visualDrafts, setVisualDrafts] = useState([]); const [selectedVisualDraftId, setSelectedVisualDraftId] = useState(''); const [visualStatus, setVisualStatus] = useState(null); @@ -632,9 +625,9 @@ export function CustomWorldRoleAssetStudioModal({ const selectedTemplate = roleKind === 'playable' && workingRole.templateCharacterId - ? ROLE_TEMPLATE_CHARACTERS.find( + ? (ROLE_TEMPLATE_CHARACTERS.find( (character) => character.id === workingRole.templateCharacterId, - ) ?? null + ) ?? null) : null; const characterBriefText = useMemo( () => @@ -679,7 +672,7 @@ export function CustomWorldRoleAssetStudioModal({ ); const selectedActionConfig = CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ?? - CORE_ACTIONS[0]; + CORE_ACTIONS[0]!; const previewCharacter = useMemo( () => buildAnimationPreviewCharacter({ @@ -691,7 +684,8 @@ export function CustomWorldRoleAssetStudioModal({ const selectedAnimationConfig = previewCharacter?.animationMap?.[ selectedAnimation ] as CharacterAnimationConfig | undefined; - const selectedAnimationStatus = animationStatusByKey[selectedAnimation] ?? null; + const selectedAnimationStatus = + animationStatusByKey[selectedAnimation] ?? null; const isSelectedAnimationGenerating = generatingAnimationMap[selectedAnimation] === true; const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some( @@ -705,9 +699,39 @@ export function CustomWorldRoleAssetStudioModal({ () => 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 = 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(() => { let cancelled = false; setWorkingRole(baseRole); @@ -759,7 +783,9 @@ export function CustomWorldRoleAssetStudioModal({ cache.selectedVisualDraftId || cache.visualDrafts?.[0]?.id || '', ); setSelectedAnimation( - CORE_ACTIONS.some((item) => item.animation === cache.selectedAnimation) + CORE_ACTIONS.some( + (item) => item.animation === cache.selectedAnimation, + ) ? (cache.selectedAnimation as AnimationState) : (CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE), ); @@ -774,11 +800,7 @@ export function CustomWorldRoleAssetStudioModal({ return () => { cancelled = true; }; - }, [ - baseRole, - initialPromptBundle, - roleSnapshotKey, - ]); + }, [baseRole, initialPromptBundle, roleSnapshotKey]); useEffect(() => { if (isHydratingCache) { @@ -913,7 +935,7 @@ export function CustomWorldRoleAssetStudioModal({ sourceMode: visualSourceMode, promptText: visualPromptText, characterBriefText, - referenceImageDataUrls: referenceImageDataUrls, + referenceImageDataUrls: effectiveVisualReferenceImageDataUrls, candidateCount: 1, imageModel: 'wan2.7-image-pro', size: '1024*1024', @@ -940,10 +962,6 @@ export function CustomWorldRoleAssetStudioModal({ throw new Error('请先生成角色形象,再生成动作。'); } - const isLoopAction = config.loop; - const shouldUseLastFrameReference = - !isLoopAction && config.animation !== AnimationState.DIE; - const result = await generateCharacterAnimationDraft({ characterId: workingRole.id, strategy: 'image-to-video', @@ -954,17 +972,16 @@ export function CustomWorldRoleAssetStudioModal({ visualSource: workingRole.imageSrc, referenceImageDataUrls: [], referenceVideoDataUrls: [], - lastFrameImageDataUrl: shouldUseLastFrameReference - ? workingRole.imageSrc - : undefined, + lastFrameImageDataUrl: workingRole.imageSrc, frameCount: config.frameCount, fps: config.fps, durationSeconds: config.durationSeconds, loop: config.loop, useChromaKey: true, - resolution: isLoopAction ? '720P' : '480P', + resolution: '480p', + ratio: '1:1', 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', motionTransferModel: 'wan2.2-animate-move', } satisfies CharacterAnimationGenerationPayload); @@ -1105,7 +1122,9 @@ export function CustomWorldRoleAssetStudioModal({ onClose(); } } catch (error) { - setSaveStatus(error instanceof Error ? error.message : '保存角色形象失败。'); + setSaveStatus( + error instanceof Error ? error.message : '保存角色形象失败。', + ); } finally { setIsSavingToRole(false); } @@ -1188,7 +1207,9 @@ export function CustomWorldRoleAssetStudioModal({ setReferenceImageDataUrls([])} - disabled={isGeneratingVisuals || isApplyingVisual || syncBusy} + disabled={ + isGeneratingVisuals || isApplyingVisual || syncBusy + } />
@@ -1230,7 +1251,8 @@ export function CustomWorldRoleAssetStudioModal({
- {previewCharacter && hasGeneratedAnimation(workingRole, selectedAnimation) ? ( + {previewCharacter && + hasGeneratedAnimation(workingRole, selectedAnimation) ? (
{CORE_ACTIONS.map((item) => { const isSelected = item.animation === selectedAnimation; - const isReady = hasGeneratedAnimation(workingRole, item.animation); - const isGenerating = generatingAnimationMap[item.animation] === true; + const isReady = hasGeneratedAnimation( + workingRole, + item.animation, + ); + const isGenerating = + generatingAnimationMap[item.animation] === true; return (
- {isGenerating ? '生成中' : isReady ? '已生成' : '待生成'} + {isGenerating + ? '生成中' + : isReady + ? '已生成' + : '待生成'}
diff --git a/src/components/GameShell.tsx b/src/components/GameShell.tsx index a9c325af..bb07eaaf 100644 --- a/src/components/GameShell.tsx +++ b/src/components/GameShell.tsx @@ -59,7 +59,7 @@ interface GameShellStoryProps { interface GameShellEntryProps { hasSavedGame: boolean; savedSnapshot: HydratedSavedGameSnapshot | null; - handleContinueGame: () => void; + handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void; handleStartNewGame: () => void; handleSaveAndExit: () => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; diff --git a/src/components/asset-studio/characterAssetWorkflowModel.ts b/src/components/asset-studio/characterAssetWorkflowModel.ts index d3c613a8..2f151db0 100644 --- a/src/components/asset-studio/characterAssetWorkflowModel.ts +++ b/src/components/asset-studio/characterAssetWorkflowModel.ts @@ -1,3 +1,4 @@ +import { removeBackgroundFromRgba } from '../../../packages/shared/src/assets/chromaKey'; import { AnimationState, type Character, @@ -718,71 +719,7 @@ function applyGreenScreenAlpha( height: number, ) { const imageData = context.getImageData(0, 0, width, height); - const pixels = imageData.data; - - 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), - ); - } - } - } + removeBackgroundFromRgba(imageData.data, width, height); context.putImageData(imageData, 0, 0); } diff --git a/src/components/asset-studio/characterAssetWorkflowPersistence.ts b/src/components/asset-studio/characterAssetWorkflowPersistence.ts index 9ee0ada2..00c96bfd 100644 --- a/src/components/asset-studio/characterAssetWorkflowPersistence.ts +++ b/src/components/asset-studio/characterAssetWorkflowPersistence.ts @@ -123,6 +123,7 @@ export type CharacterAnimationGenerationPayload = { loop: boolean; useChromaKey: boolean; resolution: string; + ratio: string; imageSequenceModel: string; videoModel: string; referenceVideoModel: string; diff --git a/src/components/asset-studio/projectPixelStyleReference.ts b/src/components/asset-studio/projectPixelStyleReference.ts new file mode 100644 index 00000000..a8a3cad7 --- /dev/null +++ b/src/components/asset-studio/projectPixelStyleReference.ts @@ -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((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'); +} diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 080603e0..26d4f6b0 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -1,5 +1,8 @@ -import { useEffect, useState } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; +import type { + PlatformTheme, +} from '../../../packages/shared/src/contracts/runtime'; import type { AuthAuditLogEntry, AuthCaptchaChallenge, @@ -7,18 +10,25 @@ import type { AuthSessionSummary, AuthUser, } from '../../services/authService'; +import type { PlatformSettingsSection } from './AuthUiContext'; import { CaptchaChallengeField } from './CaptchaChallengeField'; type AccountModalProps = { user: AuthUser; isOpen: boolean; + initialSection?: PlatformSettingsSection; + platformTheme: PlatformTheme; riskBlocks: AuthRiskBlockSummary[]; sessions: AuthSessionSummary[]; auditLogs: AuthAuditLogEntry[]; loadingRiskBlocks: boolean; loadingSessions: boolean; loadingAuditLogs: boolean; + isHydratingSettings: boolean; + isPersistingSettings: boolean; + settingsError: string | null; onClose: () => void; + onPlatformThemeChange: (theme: PlatformTheme) => void; onLogout: () => Promise; onRefreshRiskBlocks: () => Promise; onLiftRiskBlock: (scopeType: 'phone' | 'ip') => Promise; @@ -40,6 +50,18 @@ type AccountModalProps = { onChangePhone: (phone: string, code: string) => Promise; }; +const SETTINGS_SECTIONS: Array<{ + id: PlatformSettingsSection; + label: string; + detail: string; +}> = [ + { id: 'appearance', label: '主题外观', detail: '亮暗主题' }, + { id: 'account', label: '账号信息', detail: '身份与换绑' }, + { id: 'security', label: '安全状态', detail: '保护与限制' }, + { id: 'devices', label: '登录设备', detail: '会话管理' }, + { id: 'logs', label: '操作记录', detail: '最近动作' }, +]; + function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) { switch (loginMethod) { case 'wechat': @@ -66,16 +88,112 @@ function formatSessionTime(value: string) { }); } +function SectionHeader({ + eyebrow, + title, + description, + action, +}: { + eyebrow: string; + title: string; + description?: string; + action?: ReactNode; +}) { + return ( +
+
+
+ {eyebrow} +
+
+ {title} +
+ {description ? ( +
+ {description} +
+ ) : null} +
+ {action} +
+ ); +} + +function NavButton({ + active, + label, + detail, + onClick, +}: { + active: boolean; + label: string; + detail: string; + onClick: () => void; +}) { + return ( + + ); +} + +function ThemeOptionCard({ + active, + title, + detail, + previewClassName, + onClick, +}: { + active: boolean; + title: string; + detail: string; + previewClassName: string; + onClick: () => void; +}) { + return ( + + ); +} + export function AccountModal({ user, isOpen, + initialSection = 'appearance', + platformTheme, riskBlocks, sessions, auditLogs, loadingRiskBlocks, loadingSessions, loadingAuditLogs, + isHydratingSettings, + isPersistingSettings, + settingsError, onClose, + onPlatformThemeChange, onLogout, onRefreshRiskBlocks, onLiftRiskBlock, @@ -87,6 +205,8 @@ export function AccountModal({ onSendChangePhoneCode, onChangePhone, }: AccountModalProps) { + const [activeSection, setActiveSection] = + useState(initialSection); const [editingPhone, setEditingPhone] = useState(false); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); @@ -97,6 +217,14 @@ export function AccountModal({ const [changingPhone, setChangingPhone] = useState(false); const [cooldownSeconds, setCooldownSeconds] = useState(0); + useEffect(() => { + if (!isOpen) { + return; + } + + setActiveSection(initialSection); + }, [initialSection, isOpen]); + useEffect(() => { if (cooldownSeconds <= 0) { return; @@ -115,9 +243,27 @@ export function AccountModal({ return null; } + const themeStatusText = settingsError + ? settingsError + : isHydratingSettings + ? '正在读取平台设置...' + : isPersistingSettings + ? '正在同步平台设置...' + : '平台设置已同步'; + + const accountSummaryCards = [ + ['登录方式', resolveLoginMethodLabel(user.loginMethod)], + ['手机号', user.phoneNumberMasked || '未绑定'], + ['微信绑定', user.wechatBound ? '已绑定' : '未绑定'], + [ + '账号状态', + user.bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '已激活', + ], + ] as const; + return (
event.stopPropagation()} >
-
- 账号信息 +
+ 设置
-
+
{user.displayName}
+
+ 外观、账号与安全管理 +
-
-
-
- 登录方式:{resolveLoginMethodLabel(user.loginMethod)} -
-
- 手机号:{user.phoneNumberMasked || '未绑定'} -
-
- 微信绑定:{user.wechatBound ? '已绑定' : '未绑定'} -
-
- 账号状态: - {user.bindingStatus === 'pending_bind_phone' - ? ' 待绑定手机号' - : ' 已激活'} -
-
- -
-
-
- 当前安全状态 +
+
+
-
- {loadingRiskBlocks ? ( -
- 正在读取安全状态... -
- ) : riskBlocks.length > 0 ? ( - riskBlocks.map((block) => ( -
-
- {block.title} - - 剩余约 {Math.max(1, Math.ceil(block.remainingSeconds / 60))} 分钟 - -
-
- {block.detail} -
- + + +
+ {activeSection === 'appearance' ? ( +
+ + +
+ onPlatformThemeChange('light')} + /> + onPlatformThemeChange('dark')} + />
- )) - ) : ( -
- 当前没有生效中的安全限制。 -
- )} -
-
-
-
-
- 登录设备 -
- -
-
- {loadingSessions ? ( -
- 正在读取当前登录设备... -
- ) : sessions.length > 0 ? ( - sessions.map((session) => ( -
-
- {session.clientLabel} - - {session.isCurrent ? '当前设备' : '已登录'} +
+
+
+
+ 当前主题 +
+
+ {platformTheme === 'dark' ? '暗色主题' : '亮色主题'} +
+
+ + {themeStatusText}
-
- 最近活跃:{formatSessionTime(session.lastSeenAt)} -
-
- 到期时间:{formatSessionTime(session.expiresAt)} -
- {session.ipMasked ? ( -
- IP:{session.ipMasked} +
+
+ ) : null} + + {activeSection === 'account' ? ( +
+ + +
+ {accountSummaryCards.map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+ +
+
+
+
+ 更换手机号 +
+
+ {editingPhone ? '输入新的手机号与验证码。' : '展开后即可进行换绑。'} +
- ) : null} - {!session.isCurrent ? ( - ) : null} -
- )) - ) : ( -
- 暂无可展示的登录设备。 -
- )} -
-
- -
-
-
- 更换手机号 -
- -
- - {editingPhone ? ( -
- - - {changePhoneHint ? ( -
- {changePhoneHint} -
- ) : null} - - {changePhoneError ? ( -
- {changePhoneError} -
- ) : null} - -
- ) : null} -
- -
-
-
- 最近账号操作 -
- -
-
- {loadingAuditLogs ? ( -
- 正在读取账号操作记录... -
- ) : auditLogs.length > 0 ? ( - auditLogs.map((log) => ( -
-
- {log.title} - - {formatSessionTime(log.createdAt)} -
-
- {log.detail} -
- {log.ipMasked ? ( -
- IP:{log.ipMasked} + + {editingPhone ? ( +
+ + + + {changePhoneHint ? ( +
+ {changePhoneHint} +
+ ) : null} + + + + {changePhoneError ? ( +
+ {changePhoneError} +
+ ) : null} + +
) : null}
- )) - ) : ( -
- 暂无账号操作记录。 + +
+ + +
- )} + ) : null} + + {activeSection === 'security' ? ( +
+ { + void onRefreshRiskBlocks(); + }} + > + 刷新 + + )} + /> + +
+ {loadingRiskBlocks ? ( +
+ 正在读取安全状态... +
+ ) : riskBlocks.length > 0 ? ( + riskBlocks.map((block) => ( +
+
+ {block.title} + + 剩余约{' '} + {Math.max( + 1, + Math.ceil(block.remainingSeconds / 60), + )}{' '} + 分钟 + +
+
+ {block.detail} +
+ +
+ )) + ) : ( +
+ 当前没有生效中的安全限制。 +
+ )} +
+
+ ) : null} + + {activeSection === 'devices' ? ( +
+ { + void onRefreshSessions(); + }} + > + 刷新 + + )} + /> + +
+ {loadingSessions ? ( +
+ 正在读取当前登录设备... +
+ ) : sessions.length > 0 ? ( + sessions.map((session) => ( +
+
+ {session.clientLabel} + + {session.isCurrent ? '当前设备' : '已登录'} + +
+
+ 最近活跃:{formatSessionTime(session.lastSeenAt)} +
+
+ 到期时间:{formatSessionTime(session.expiresAt)} +
+ {session.ipMasked ? ( +
+ IP:{session.ipMasked} +
+ ) : null} + {!session.isCurrent ? ( + + ) : null} +
+ )) + ) : ( +
+ 暂无可展示的登录设备。 +
+ )} +
+ + +
+ ) : null} + + {activeSection === 'logs' ? ( +
+ { + void onRefreshAuditLogs(); + }} + > + 刷新 + + )} + /> + +
+ {loadingAuditLogs ? ( +
+ 正在读取账号操作记录... +
+ ) : auditLogs.length > 0 ? ( + auditLogs.map((log) => ( +
+
+ {log.title} + + {formatSessionTime(log.createdAt)} + +
+
+ {log.detail} +
+ {log.ipMasked ? ( +
+ IP:{log.ipMasked} +
+ ) : null} +
+ )) + ) : ( +
+ 暂无账号操作记录。 +
+ )} +
+
+ ) : null}
- - - -
diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index b9feb8d3..e5919476 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -1,15 +1,20 @@ /* @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 type { AuthUser } from '../../services/authService'; import { AuthGate } from './AuthGate'; +import { useAuthUi } from './AuthUiContext'; const authMocks = vi.hoisted(() => ({ getStoredAccessToken: vi.fn(), ensureAutoAuthUser: vi.fn(), getAuthLoginOptions: vi.fn(), + loginWithPhoneCode: vi.fn(), + sendPhoneLoginCode: vi.fn(), + startWechatLogin: vi.fn(), consumeAuthCallbackResult: vi.fn(), })); @@ -30,12 +35,25 @@ vi.mock('../../services/authService', () => ({ getCaptchaChallengeFromError: vi.fn(() => null), getCurrentAuthUser: vi.fn(), liftAuthRiskBlock: vi.fn(), - loginWithPhoneCode: vi.fn(), + loginWithPhoneCode: authMocks.loginWithPhoneCode, logoutAllAuthSessions: vi.fn(), logoutAuthUser: vi.fn(), revokeAuthSession: vi.fn(), - sendPhoneLoginCode: vi.fn(), - startWechatLogin: vi.fn(), + sendPhoneLoginCode: authMocks.sendPhoneLoginCode, + 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', () => ({ @@ -60,6 +78,12 @@ beforeEach(() => { vi.clearAllMocks(); authMocks.getStoredAccessToken.mockReturnValue(null); authMocks.consumeAuthCallbackResult.mockReturnValue(null); + authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); + authMocks.sendPhoneLoginCode.mockResolvedValue({ + cooldownSeconds: 60, + expiresInSeconds: 300, + }); + authMocks.startWechatLogin.mockResolvedValue(undefined); authMocks.ensureAutoAuthUser.mockResolvedValue({ user: mockUser, 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 ( + + ); +} + +test('auth gate keeps platform content visible when phone login is available', async () => { authMocks.getAuthLoginOptions.mockResolvedValue({ availableLoginMethods: ['phone'], }); @@ -80,7 +119,43 @@ test('auth gate prefers login screen when phone login is available', async () => , ); - expect(await screen.findByText('账号登录')).toBeTruthy(); - expect(screen.getByText('手机号')).toBeTruthy(); + expect(await screen.findByText('应用内容')).toBeTruthy(); + expect(screen.getByRole('button', { name: '登录' })).toBeTruthy(); + expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull(); 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( + + + , + ); + + 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(); +}); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index f0d76fd6..415ef960 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -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 { AUTH_STATE_EVENT, getStoredAccessToken, @@ -30,7 +38,10 @@ import { startWechatLogin, } from '../../services/authService'; import { AccountModal } from './AccountModal'; -import { AuthUiContext } from './AuthUiContext'; +import { + AuthUiContext, + type PlatformSettingsSection, +} from './AuthUiContext'; import { BindPhoneScreen } from './BindPhoneScreen'; import { LoginScreen } from './LoginScreen'; @@ -61,7 +72,10 @@ export function AuthGate({ children }: AuthGateProps) { const [loggingIn, setLoggingIn] = useState(false); const [bindingPhone, setBindingPhone] = 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('appearance'); const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true); const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); @@ -75,6 +89,55 @@ export function AuthGate({ children }: AuthGateProps) { useState(null); const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] = useState(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(() => { let isActive = true; @@ -163,6 +226,7 @@ export function AuthGate({ children }: AuthGateProps) { const callbackResult = consumeAuthCallbackResult(); if (callbackResult?.error && isActive) { setError(callbackResult.error); + setShowLoginModal(true); } const token = getStoredAccessToken(); @@ -217,7 +281,20 @@ export function AuthGate({ children }: AuthGateProps) { }, []); 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; } @@ -299,24 +376,47 @@ export function AuthGate({ children }: AuthGateProps) { return () => { isActive = false; }; - }, [showAccountModal, status]); + }, [showSettingsModal, status]); const authUiValue = useMemo( () => ({ - user, - openAccountModal: () => setShowAccountModal(true), + user: readyUser, + openLoginModal, + requireAuth, + openSettingsModal, + openAccountModal, logout: async () => { await logoutAuthUser(); - setShowAccountModal(false); + setShowSettingsModal(false); }, 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') { return ( -
+
正在校验登录状态...
); @@ -324,84 +424,17 @@ export function AuthGate({ children }: AuthGateProps) { if (status === 'recovering') { return ( -
+
正在自动创建或恢复账号...
); } - if (status === 'unauthenticated') { - return ( - { - 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) { return ( -
-
登录状态异常
-
+
+
+
+ 登录状态异常 +
+
{error || '账号恢复失败,请刷新页面后重试。'}
- +
+ {showGlobalAccountActions ? ( +
+ {readyUser ? ( +
+ + +
+ ) : ( + + )}
-
- ) : null} - setShowAccountModal(false)} - onLogout={async () => { - await logoutAuthUser(); - setShowAccountModal(false); - }} - onRefreshRiskBlocks={async () => { - setLoadingRiskBlocks(true); - try { - setRiskBlocks(await getAuthRiskBlocks()); - } catch (blockError) { - setError( - blockError instanceof Error - ? blockError.message - : '读取安全状态失败,请稍后再试。', - ); - } finally { - setLoadingRiskBlocks(false); - } - }} - onLiftRiskBlock={async (scopeType) => { - try { - await liftAuthRiskBlock(scopeType); - setRiskBlocks(await getAuthRiskBlocks()); - setAuditLogs(await getAuthAuditLogs()); - } catch (liftError) { - setError( - liftError instanceof Error - ? liftError.message - : '解除保护失败,请稍后再试。', - ); - } - }} - onRefreshSessions={async () => { - setLoadingSessions(true); - try { - setSessions(await getAuthSessions()); - } catch (sessionError) { - setError( - sessionError instanceof Error - ? sessionError.message - : '读取登录设备失败,请稍后再试。', - ); - } finally { - setLoadingSessions(false); - } - }} - onRefreshAuditLogs={async () => { - setLoadingAuditLogs(true); - try { - setAuditLogs(await getAuthAuditLogs()); - } catch (auditError) { - setError( - auditError instanceof Error - ? auditError.message - : '读取账号操作记录失败,请稍后再试。', - ); - } finally { - setLoadingAuditLogs(false); - } - }} - onRevokeSession={async (sessionId) => { - try { - await revokeAuthSession(sessionId); - setSessions((current) => - current.filter((session) => session.sessionId !== sessionId), - ); - setAuditLogs(await getAuthAuditLogs()); - } catch (revokeError) { - setError( - revokeError instanceof Error - ? revokeError.message - : '移除登录设备失败,请稍后再试。', - ); - } - }} - onLogoutAll={async () => { - await logoutAllAuthSessions(); - setShowAccountModal(false); - }} - changePhoneCaptchaChallenge={changePhoneCaptchaChallenge} - onSendChangePhoneCode={async (phone, captcha) => { - try { - const result = await sendPhoneLoginCode( - phone, - 'change_phone', - captcha, - ); - setChangePhoneCaptchaChallenge(null); - return result; - } catch (sendError) { - const captchaChallenge = getCaptchaChallengeFromError(sendError); - if (captchaChallenge) { - setChangePhoneCaptchaChallenge(captchaChallenge); + ) : null} + {readyUser ? ( + setShowSettingsModal(false)} + onPlatformThemeChange={settings.setPlatformTheme} + onLogout={async () => { + await logoutAuthUser(); + setShowSettingsModal(false); + }} + onRefreshRiskBlocks={async () => { + setLoadingRiskBlocks(true); + try { + setRiskBlocks(await getAuthRiskBlocks()); + } catch (blockError) { + setError( + blockError instanceof Error + ? blockError.message + : '读取安全状态失败,请稍后再试。', + ); + } finally { + setLoadingRiskBlocks(false); + } + }} + onLiftRiskBlock={async (scopeType) => { + try { + await liftAuthRiskBlock(scopeType); + setRiskBlocks(await getAuthRiskBlocks()); + setAuditLogs(await getAuthAuditLogs()); + } catch (liftError) { + setError( + liftError instanceof Error + ? liftError.message + : '解除保护失败,请稍后再试。', + ); + } + }} + onRefreshSessions={async () => { + setLoadingSessions(true); + try { + setSessions(await getAuthSessions()); + } catch (sessionError) { + setError( + sessionError instanceof Error + ? sessionError.message + : '读取登录设备失败,请稍后再试。', + ); + } finally { + setLoadingSessions(false); + } + }} + onRefreshAuditLogs={async () => { + setLoadingAuditLogs(true); + try { + setAuditLogs(await getAuthAuditLogs()); + } catch (auditError) { + setError( + auditError instanceof Error + ? auditError.message + : '读取账号操作记录失败,请稍后再试。', + ); + } finally { + setLoadingAuditLogs(false); + } + }} + onRevokeSession={async (sessionId) => { + try { + await revokeAuthSession(sessionId); + setSessions((current) => + current.filter((session) => session.sessionId !== sessionId), + ); + setAuditLogs(await getAuthAuditLogs()); + } catch (revokeError) { + setError( + revokeError instanceof Error + ? revokeError.message + : '移除登录设备失败,请稍后再试。', + ); + } + }} + onLogoutAll={async () => { + await logoutAllAuthSessions(); + setShowSettingsModal(false); + }} + changePhoneCaptchaChallenge={changePhoneCaptchaChallenge} + onSendChangePhoneCode={async (phone, captcha) => { + try { + const result = await sendPhoneLoginCode( + phone, + 'change_phone', + captcha, + ); + 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} + { + 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; - } - }} - onChangePhone={async (phone, code) => { - const nextUser = await changePhoneNumber(phone, code); - setChangePhoneCaptchaChallenge(null); - setUser(nextUser); - }} - /> + }} + 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); + } + }} + /> +
{children}
diff --git a/src/components/auth/AuthUiContext.ts b/src/components/auth/AuthUiContext.ts index 9b4a4220..74cd6cc4 100644 --- a/src/components/auth/AuthUiContext.ts +++ b/src/components/auth/AuthUiContext.ts @@ -1,12 +1,30 @@ import { createContext, useContext } from 'react'; +import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime'; import type { AuthUser } from '../../services/authService'; +export type PlatformSettingsSection = + | 'appearance' + | 'account' + | 'security' + | 'devices' + | 'logs'; + type AuthUiContextValue = { user: AuthUser | null; + openLoginModal: (postLoginAction?: (() => void) | null) => void; + requireAuth: (action: () => void) => void; + openSettingsModal: (section?: PlatformSettingsSection) => void; openAccountModal: () => void; logout: () => Promise; 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(null); diff --git a/src/components/auth/BindPhoneScreen.tsx b/src/components/auth/BindPhoneScreen.tsx index 374c4582..4f44c1e8 100644 --- a/src/components/auth/BindPhoneScreen.tsx +++ b/src/components/auth/BindPhoneScreen.tsx @@ -1,10 +1,12 @@ import { useEffect, useState } from 'react'; +import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime'; import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService'; import { CaptchaChallengeField } from './CaptchaChallengeField'; type BindPhoneScreenProps = { user: AuthUser; + platformTheme: PlatformTheme; sendingCode: boolean; binding: boolean; error: string; @@ -25,6 +27,7 @@ type BindPhoneScreenProps = { export function BindPhoneScreen({ user, + platformTheme, sendingCode, binding, error, @@ -54,24 +57,24 @@ export function BindPhoneScreen({ }, [cooldownSeconds]); return ( -
+
-
-
+
+
叙世
视觉叙事 RPG
-

+

账号激活

-

+

绑定手机号

-

+

微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步到后端存档体系。

-
+
当前登录身份:{user.displayName}
@@ -83,10 +86,10 @@ export function BindPhoneScreen({ void onSubmit(phone, code); }} > -
+
+ +
{ + event.preventDefault(); + if (!phoneLoginEnabled) { + return; + } + void onSubmit(phone, code); + }} + > + {phoneLoginEnabled ? ( + <> + + + + + + + ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {phoneLoginEnabled ? ( + + ) : null} + + {wechatLoginEnabled ? ( + + ) : null} + + {!phoneLoginEnabled && !wechatLoginEnabled ? ( +
+ 当前登录入口暂不可用。 +
+ ) : null} +
); diff --git a/src/components/game-shell/CharacterSelectionFlow.test.tsx b/src/components/game-shell/CharacterSelectionFlow.test.tsx new file mode 100644 index 00000000..ee710beb --- /dev/null +++ b/src/components/game-shell/CharacterSelectionFlow.test.tsx @@ -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 }) => ( +
{character.name}
+ ), +})); + +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( + {}} + 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); +}); diff --git a/src/components/game-shell/CharacterSelectionFlow.tsx b/src/components/game-shell/CharacterSelectionFlow.tsx index 669c8412..f8ef080e 100644 --- a/src/components/game-shell/CharacterSelectionFlow.tsx +++ b/src/components/game-shell/CharacterSelectionFlow.tsx @@ -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( character: Character | null, draft?: CharacterSelectionDraft | null, @@ -163,7 +178,15 @@ export function CharacterSelectionFlow({ () => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS), [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(null); const characterCarouselRef = useRef(null); const [characterCarouselProgress, setCharacterCarouselProgress] = useState(0); @@ -173,11 +196,14 @@ export function CharacterSelectionFlow({ const [characterDraftError, setCharacterDraftError] = useState(null); const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState>({}); - const selectedCharacter = useMemo( - () => selectionCharacters.find(character => character.id === selectedCharacterId) ?? selectionCharacters[0] ?? null, - [selectedCharacterId, selectionCharacters], + const selectedCharacterEntry = useMemo( + () => selectionEntries.find(entry => entry.selectionKey === selectedCharacterKey) ?? selectionEntries[0] ?? null, + [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( () => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft), [selectedCharacter, selectedCharacterDraft], @@ -203,21 +229,21 @@ export function CharacterSelectionFlow({ }, [syncCharacterCarousel]); useEffect(() => { - const focusedCharacter = selectionCharacters[focusedCharacterIndex]; - if (focusedCharacter && focusedCharacter.id !== selectedCharacterId) { - setSelectedCharacterId(focusedCharacter.id); + const focusedEntry = selectionEntries[focusedCharacterIndex]; + if (focusedEntry && focusedEntry.selectionKey !== selectedCharacterKey) { + setSelectedCharacterKey(focusedEntry.selectionKey); } - }, [focusedCharacterIndex, selectedCharacterId, selectionCharacters]); + }, [focusedCharacterIndex, selectedCharacterKey, selectionEntries]); useEffect(() => { - if (selectionCharacters.length === 0) return; - if (!selectionCharacters.some(character => character.id === selectedCharacterId)) { - const firstCharacter = selectionCharacters[0]; - if (firstCharacter) { - setSelectedCharacterId(firstCharacter.id); + if (selectionEntries.length === 0) return; + if (!selectionEntries.some(entry => entry.selectionKey === selectedCharacterKey)) { + const firstEntry = selectionEntries[0]; + if (firstEntry) { + setSelectedCharacterKey(firstEntry.selectionKey); } } - }, [selectedCharacterId, selectionCharacters]); + }, [selectedCharacterKey, selectionEntries]); const openCharacterDraftEditor = () => { if (!selectedCharacterPreview) return; @@ -228,7 +254,7 @@ export function CharacterSelectionFlow({ }; const saveCharacterDraft = () => { - if (!selectedCharacter) return; + if (!selectedCharacter || !selectedCharacterEntry) return; const nextName = characterDraftName.trim(); const nextBackstory = characterDraftBackstory.trim(); @@ -243,7 +269,7 @@ export function CharacterSelectionFlow({ setCharacterSelectionDrafts(current => ({ ...current, - [selectedCharacter.id]: { + [selectedCharacterEntry.selectionKey]: { name: nextName, backstory: nextBackstory, }, @@ -278,17 +304,17 @@ export function CharacterSelectionFlow({ onScroll={syncCharacterCarousel} className="character-carousel scrollbar-hide flex-[1_1_auto]" > - {selectionCharacters.map((character, index) => { - const characterDraft = characterSelectionDrafts[character.id]; + {selectionEntries.map(({ character, selectionKey }, index) => { + const characterDraft = characterSelectionDrafts[selectionKey]; const meta = getCharacterMeta(character, {name: characterDraft?.name}); - const selected = character.id === selectedCharacter.id; + const selected = selectionKey === selectedCharacterKey; return ( @@ -146,7 +140,7 @@ export function PlatformCreationTypeModal({
{error ? ( -
+
{error}
) : null} diff --git a/src/components/game-shell/PlatformHomeView.tsx b/src/components/game-shell/PlatformHomeView.tsx index f80b5729..20772389 100644 --- a/src/components/game-shell/PlatformHomeView.tsx +++ b/src/components/game-shell/PlatformHomeView.tsx @@ -1,4 +1,7 @@ import { + Archive, + ArrowRight, + Bell, BookOpen, Camera, ChevronRight, @@ -6,11 +9,15 @@ import { Coins, Copy, Crown, + House, MessageCircle, Pencil, + Search, Settings, + Sparkles, Ticket, UserPlus, + UserRound, } from 'lucide-react'; import { type ComponentType, useMemo } from 'react'; @@ -19,14 +26,13 @@ import type { CustomWorldLibraryEntry, ProfileDashboardCardKey, ProfileDashboardSummary, + ProfileSaveArchiveSummary, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { AuthUser } from '../../services/authService'; import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory'; import type { CustomWorldProfile } from '../../types'; -import { getNineSliceStyle, UI_CHROME } from '../../uiAssets'; import { useAuthUi } from '../auth/AuthUiContext'; -import { PixelIcon } from '../PixelIcon'; import { buildPlatformWorldTags, describePlatformThemeLabel, @@ -36,25 +42,26 @@ import { resolvePlatformWorldLeadPortrait, } from './platformWorldPresentation'; -export type PlatformHomeTab = 'home' | 'create' | 'profile'; +export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile'; + +const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft'; +const HERO_SURFACE_CLASS = + 'platform-surface platform-surface--hero platform-interactive-card'; function SectionHeader({ title, detail }: { title: string; detail: string }) { return (
-
+
{detail}
-
{title}
+
{title}
); } function EmptyShelf({ text }: { text: string }) { return ( -
+
{text}
); @@ -65,11 +72,13 @@ function WorldCard({ badge, metaLabel, onClick, + className, }: { entry: PlatformWorldCardLike; badge: string; metaLabel: string; onClick: () => void; + className?: string; }) { const coverImage = resolvePlatformWorldCoverImage(entry); const leadPortrait = resolvePlatformWorldLeadPortrait(entry); @@ -85,15 +94,13 @@ function WorldCard({ + ); +} + function PlatformTabButton({ active, label, - iconSrc, + icon: Icon, onClick, }: { active: boolean; label: string; - iconSrc: string; + icon: ComponentType<{ className?: string }>; onClick: () => void; }) { return ( + ); +} + +function DesktopTrendingItem({ + entry, + rank, + onClick, +}: { + entry: CustomWorldGalleryCard; + rank: number; + onClick: () => void; +}) { + const coverImage = resolvePlatformWorldCoverImage(entry); + const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2); + + return ( + + ); +} + function formatSnapshotTime(value: string | null | undefined) { if (!value) { return '刚刚保存'; @@ -321,7 +469,7 @@ function ProfileStatCard({ @@ -547,15 +711,11 @@ export function PlatformHomeView({ @@ -590,13 +754,101 @@ export function PlatformHomeView({ )}
) : ( - + )}
); } + if (activeTab === 'saves') { + content = ( +
+ {authUi?.user ? ( + <> +
+ {latestSaveEntry?.coverImageSrc ? ( + {latestSaveEntry.worldName} + ) : null} +
+
+
+ + SAVE ARCHIVE + +
+ {saveEntries.length > 0 ? `${saveEntries.length} 个存档` : '暂无存档'} +
+
+
+
+ {latestSaveEntry ? latestSaveEntry.worldName : '存档'} +
+
+ {latestSaveEntry + ? `最近更新于 ${formatSnapshotTime(latestSaveEntry.lastPlayedAt)},点开后可直接继续游玩。` + : '你在平台里留下的最近可恢复存档会显示在这里。'} +
+
+
+
+ + {saveError ? ( +
+ {saveError} +
+ ) : null} + +
+ + {isLoadingPlatform ? ( + + ) : saveEntries.length > 0 ? ( +
+ {saveEntries.map((entry) => ( + onResumeSave(entry)} + /> + ))} +
+ ) : ( + + )} +
+ + ) : ( +
+
+
尚未登录
+ +
+
+ )} +
+ ); + } + if (activeTab === 'profile') { content = (
@@ -605,11 +857,11 @@ export function PlatformHomeView({
-
@@ -655,7 +907,7 @@ export function PlatformHomeView({
-
+
{isLoadingDashboard ? ( <> @@ -738,85 +984,262 @@ export function PlatformHomeView({
-
- - {recentPlayItems.length > 0 ? ( -
- {recentPlayItems.map((item) => ( - +
+ + ) : ( +
+
+
尚未登录
+ +
+
+ )} +
+ ); + } + + const desktopContent = + activeTab === 'home' ? ( +
+ {platformError ? ( +
+ {platformError} +
+ ) : null} + +
+ + +
+
+ + + LIVE + +
+ {isLoadingPlatform ? ( + + ) : desktopTrendingEntries.length > 0 ? ( +
+ {desktopTrendingEntries.map((entry, index) => ( + onOpenGalleryDetail(entry)} + /> + ))} +
+ ) : ( + + )} +
+
+ +
+
+ + {isLoadingPlatform ? ( + + ) : desktopFeaturedGrid.length > 0 ? ( +
+ {desktopFeaturedGrid.map((entry) => ( + onOpenGalleryDetail(entry)} + className="h-[16rem] w-full min-w-0" + /> + ))} +
+ ) : ( + + )} +
+ +
+ + + +
+
+ {desktopLibraryPreview.length > 0 + ? '最近作品' + : historyEntries.length > 0 + ? '最近浏览' + : isAuthenticated + ? '创作状态' + : '账户状态'} +
+ + {desktopLibraryPreview.length > 0 ? ( +
+ {desktopLibraryPreview.map((entry) => ( + ))}
- ) : ( - - )} -
- -
-
- - {historyEntries.length > 0 ? ( - - ) : null} -
- {historyError ? ( -
- {historyError} -
- ) : null} - {isLoadingPlatform && historyEntries.length === 0 ? ( - ) : historyEntries.length > 0 ? ( -
- {historyEntries.map((entry) => ( +
+ {historyEntries.slice(0, 2).map((entry) => ( ))}
) : ( - - )} -
- -
- -
- - - -
-
- -
- -
- - ) : ( - - )} + )} +
+
+
+ +
+ + {isLoadingPlatform ? ( + + ) : desktopReleaseGrid.length > 0 ? ( +
+ {desktopReleaseGrid.map((entry) => ( + onOpenGalleryDetail(entry)} + className="h-[17rem] w-full min-w-0" + /> + ))} +
+ ) : ( + + )} +
+ ) : ( + content ); - } return (
-
-
叙世
-
- GENARRATIVE +
+
+
+
叙世
+
GENARRATIVE
+
+
+ +
+ {content} +
+ +
+
+ onTabChange('home')} + /> + onTabChange('create')} + /> + onTabChange('saves')} + /> + onTabChange('profile')} + /> +
-
- {content} -
+
+
+
+
+ +
+ + + 搜索世界、角色、主题或灵感 + +
+
-
-
- onTabChange('home')} - /> - onTabChange('create')} - /> - onTabChange('profile')} - /> +
+ + +
+
+ +
+ + +
+ {desktopContent} +
+
diff --git a/src/components/game-shell/PlatformWorldDetailView.tsx b/src/components/game-shell/PlatformWorldDetailView.tsx index 396e1903..3e4784c6 100644 --- a/src/components/game-shell/PlatformWorldDetailView.tsx +++ b/src/components/game-shell/PlatformWorldDetailView.tsx @@ -1,7 +1,8 @@ +import { ArrowLeft } from 'lucide-react'; + import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import type { CustomWorldProfile } from '../../types'; -import { getNineSliceStyle, UI_CHROME } from '../../uiAssets'; import { buildPlatformWorldTags, describePlatformThemeLabel, @@ -23,17 +24,17 @@ function ActionButton({ }) { const toneClass = 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' - ? 'border-rose-400/25 bg-rose-500/10 text-rose-100 hover:border-rose-400/45 hover:text-white' - : 'border-white/10 bg-black/20 text-zinc-200 hover:border-white/20 hover:text-white'; + ? 'platform-button platform-button--danger' + : 'platform-button platform-button--secondary'; return ( @@ -81,30 +82,24 @@ export function PlatformWorldDetailView({ -
+
{entry.visibility === 'published' ? '已发布' : '草稿'}
-
+
{coverImage ? ( {entry.worldName} ) : null} {leadPortrait ? ( @@ -113,19 +108,18 @@ export function PlatformWorldDetailView({ alt="" aria-hidden="true" className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25" - style={{ imageRendering: 'pixelated' }} /> ) : null} -
+
- + {describePlatformThemeLabel(entry.themeMode)} - + {entry.authorDisplayName} - + {entry.visibility === 'published' ? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}` : '仅自己可见'} @@ -146,7 +140,7 @@ export function PlatformWorldDetailView({ {tags.map((tag, index) => ( {tag} @@ -156,18 +150,12 @@ export function PlatformWorldDetailView({
-
+
世界信息
-
+
可玩角色
@@ -175,7 +163,7 @@ export function PlatformWorldDetailView({ {entry.playableNpcCount}
-
+
地标
@@ -183,7 +171,7 @@ export function PlatformWorldDetailView({ {entry.landmarkCount}
-
+
阵营
@@ -191,7 +179,7 @@ export function PlatformWorldDetailView({ {entry.profile.majorFactions.length}
-
+
冲突
@@ -209,7 +197,7 @@ export function PlatformWorldDetailView({ {previewCharacters.map((character, index) => (
{character.title} @@ -230,7 +218,7 @@ export function PlatformWorldDetailView({ {previewLandmarks.map((landmark, index) => (
{landmark.name} @@ -244,13 +232,7 @@ export function PlatformWorldDetailView({
-
+
操作
diff --git a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx index 15aa3034..c5f5f887 100644 --- a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx @@ -17,15 +17,21 @@ import type { AuthUser } from '../../services/authService'; import { clearProfileBrowseHistory, deleteCustomWorldProfile, + getCustomWorldGalleryDetail, getProfileDashboard, listCustomWorldGallery, listCustomWorldLibrary, listProfileBrowseHistory, + listProfileSaveArchives, + resumeProfileSaveArchive, upsertCustomWorldProfile, upsertProfileBrowseHistory, } from '../../services/storageService'; import type { GameState } from '../../types'; -import { AuthUiContext } from '../auth/AuthUiContext'; +import { + type PlatformSettingsSection, + AuthUiContext, +} from '../auth/AuthUiContext'; import { PreGameSelectionFlow, type SelectionStage, @@ -48,7 +54,9 @@ vi.mock('../../services/storageService', () => ({ listCustomWorldGallery: vi.fn(), listCustomWorldLibrary: vi.fn(), listProfileBrowseHistory: vi.fn(), + listProfileSaveArchives: vi.fn(), publishCustomWorldProfile: vi.fn(), + resumeProfileSaveArchive: vi.fn(), syncProfileBrowseHistory: vi.fn(), unpublishCustomWorldProfile: vi.fn(), upsertProfileBrowseHistory: vi.fn(), @@ -179,7 +187,32 @@ const mockAuthUser: AuthUser = { 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; + 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] = useState('platform'); @@ -190,24 +223,36 @@ function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) { gameState={{} as GameState} hasSavedGame={false} savedSnapshot={null} - handleContinueGame={() => {}} + handleContinueGame={onContinueGame ?? (() => {})} handleStartNewGame={() => {}} handleCustomWorldSelect={() => {}} /> ); - if (!withAuth) { + if (!withAuth && !authValue) { return content; } return ( {}, - logout: async () => {}, - setGlobalAccountActionsVisible: () => {}, - }} + value={ + authValue ?? { + user: mockAuthUser, + openLoginModal: () => {}, + requireAuth: (action) => action(), + openSettingsModal: () => {}, + openAccountModal: () => {}, + logout: async () => {}, + setGlobalAccountActionsVisible: () => {}, + musicVolume: 0.42, + setMusicVolume: () => {}, + platformTheme: 'light', + setPlatformTheme: () => {}, + isHydratingSettings: false, + isPersistingSettings: false, + settingsError: null, + } + } > {content} @@ -228,6 +273,27 @@ beforeEach(() => { vi.mocked(listCustomWorldLibrary).mockResolvedValue([]); vi.mocked(listCustomWorldGallery).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(clearProfileBrowseHistory).mockResolvedValue([]); vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]); @@ -309,6 +375,75 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and ).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( + {}, + 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( + {}, + 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 () => { const user = userEvent.setup(); @@ -472,40 +607,78 @@ test('existing draft sessions enter the legacy result layout directly', async () 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(); - vi.mocked(listProfileBrowseHistory).mockResolvedValue([ + vi.mocked(listProfileSaveArchives).mockResolvedValue([ { - ownerUserId: 'author-1', + worldKey: 'custom:world-1', + ownerUserId: null, profileId: 'world-1', + worldType: 'CUSTOM', worldName: '潮雾列岛', subtitle: '旧灯塔与失控航路', - summaryText: '最近浏览过的公开作品。', + summaryText: '回到旧灯塔继续推进调查。', coverImageSrc: null, - themeMode: 'tide', - authorDisplayName: '潮汐作者', - visitedAt: '2026-04-16T12:00:00.000Z', + lastPlayedAt: '2026-04-19T12:00:00.000Z', }, ]); - vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]); - vi.spyOn(window, 'confirm').mockReturnValue(true); render(); - await user.click(screen.getByRole('button', { name: '我的' })); - + 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(() => { - expect(clearProfileBrowseHistory).toHaveBeenCalledTimes(1); + vi.mocked(listProfileSaveArchives).mockResolvedValue([ + { + 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( - screen.getByText('你最近还没有浏览过作品详情,去首页逛一逛吧。'), - ).toBeTruthy(); + render(); + + 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 () => { @@ -544,10 +717,10 @@ test('owned world detail can delete a work and return to the create tab list', a ]); vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]); - render(); + render(); 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 waitFor(() => { diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx index 7aea2359..95e5bbc3 100644 --- a/src/components/game-shell/PreGameSelectionFlow.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.tsx @@ -10,8 +10,8 @@ import { } from 'react'; import type { - CustomWorldAgentMessage, CustomWorldAgentActionRequest, + CustomWorldAgentMessage, CustomWorldAgentOperationRecord, CustomWorldAgentSessionSnapshot, SendCustomWorldAgentMessageRequest, @@ -20,6 +20,7 @@ import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, ProfileDashboardSummary, + ProfileSaveArchiveSummary, } from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; @@ -62,7 +63,9 @@ import { listCustomWorldGallery, listCustomWorldLibrary, listProfileBrowseHistory, + listProfileSaveArchives, publishCustomWorldProfile, + resumeProfileSaveArchive, syncProfileBrowseHistory, unpublishCustomWorldProfile, upsertCustomWorldProfile, @@ -115,7 +118,7 @@ type PreGameSelectionFlowProps = { gameState: GameState; hasSavedGame: boolean; savedSnapshot: HydratedSavedGameSnapshot | null; - handleContinueGame: () => void; + handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void; handleStartNewGame: () => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; }; @@ -198,6 +201,9 @@ export function PreGameSelectionFlow({ const [historyEntries, setHistoryEntries] = useState< PlatformBrowseHistoryEntry[] >([]); + const [saveEntries, setSaveEntries] = useState( + [], + ); const [platformTab, setPlatformTab] = useState('home'); const [selectedDetailEntry, setSelectedDetailEntry] = useState | null>(null); @@ -225,10 +231,14 @@ export function PreGameSelectionFlow({ useState(null); const [dashboardError, setDashboardError] = useState(null); const [historyError, setHistoryError] = useState(null); + const [saveError, setSaveError] = useState(null); const [detailError, setDetailError] = useState(null); const [isLoadingPlatform, setIsLoadingPlatform] = useState(false); const [isLoadingDashboard, setIsLoadingDashboard] = useState(false); const [isClearingHistory, setIsClearingHistory] = useState(false); + const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState< + string | null + >(null); const [isDetailLoading, setIsDetailLoading] = useState(false); const [isMutatingDetail, setIsMutatingDetail] = useState(false); const [customWorldAutoSaveState, setCustomWorldAutoSaveState] = @@ -245,6 +255,9 @@ export function PreGameSelectionFlow({ const customWorldAutoSaveTimeoutRef = useRef(null); const lastAutoSavedProfileSignatureRef = useRef(null); const latestAutoSaveRequestIdRef = useRef(0); + const platformTabBootstrapUserIdRef = useRef( + undefined, + ); const previewCustomWorldCharacters = useMemo( () => @@ -258,6 +271,19 @@ export function PreGameSelectionFlow({ () => publishedGalleryEntries.slice(0, 6), [publishedGalleryEntries], ); + const isAuthenticated = Boolean(authUi?.user); + + const runProtectedAction = useCallback( + (action: () => void) => { + if (!authUi?.requireAuth) { + action(); + return; + } + + authUi.requireAuth(action); + }, + [authUi], + ); const persistAgentUiState = useCallback( (nextSessionId: string | null, nextOperationId: string | null) => { @@ -278,6 +304,13 @@ export function PreGameSelectionFlow({ }, []); const refreshProfileDashboard = useCallback(async () => { + if (!authUi?.user) { + setProfileDashboard(null); + setDashboardError(null); + setIsLoadingDashboard(false); + return; + } + setIsLoadingDashboard(true); setDashboardError(null); @@ -288,7 +321,7 @@ export function PreGameSelectionFlow({ } finally { setIsLoadingDashboard(false); } - }, []); + }, [authUi?.user]); const appendBrowseHistoryEntry = useCallback( async (entry: PlatformBrowseHistoryWriteEntry) => { @@ -296,6 +329,10 @@ export function PreGameSelectionFlow({ setHistoryEntries(nextEntries); setHistoryError(null); + if (!authUi?.user) { + return; + } + try { const syncedEntries = await upsertProfileBrowseHistory(entry); setHistoryEntries(syncedEntries); @@ -341,10 +378,16 @@ export function PreGameSelectionFlow({ const localHistoryEntries = readPlatformBrowseHistory(authUi?.user); setHistoryEntries(localHistoryEntries); setHistoryError(null); + setSaveError(null); setIsLoadingPlatform(true); setPlatformError(null); - setIsLoadingDashboard(true); + setIsLoadingDashboard(isAuthenticated); setDashboardError(null); + if (!isAuthenticated) { + setSavedCustomWorldEntries([]); + setSaveEntries([]); + setProfileDashboard(null); + } try { const [ @@ -352,23 +395,29 @@ export function PreGameSelectionFlow({ galleryEntriesResult, dashboardResult, historyResult, + saveArchivesResult, ] = await Promise.allSettled([ - listCustomWorldLibrary(), + isAuthenticated ? listCustomWorldLibrary() : Promise.resolve([]), listCustomWorldGallery(), - getProfileDashboard(), - (async () => { - let nextEntries = await listProfileBrowseHistory(); + isAuthenticated ? getProfileDashboard() : Promise.resolve(null), + isAuthenticated + ? (async () => { + let nextEntries = await listProfileBrowseHistory(); - if ( - hasPendingPlatformBrowseHistoryMigration(authUi?.user) && - localHistoryEntries.length > 0 - ) { - nextEntries = await syncProfileBrowseHistory(localHistoryEntries); - markPlatformBrowseHistoryMigrated(authUi?.user); - } + if ( + hasPendingPlatformBrowseHistoryMigration(authUi?.user) && + localHistoryEntries.length > 0 + ) { + nextEntries = await syncProfileBrowseHistory( + localHistoryEntries, + ); + markPlatformBrowseHistoryMigrated(authUi?.user); + } - return nextEntries; - })(), + return nextEntries; + })() + : Promise.resolve(localHistoryEntries), + isAuthenticated ? listProfileSaveArchives() : Promise.resolve([]), ]); if (!isActive) { return; @@ -387,7 +436,7 @@ export function PreGameSelectionFlow({ } if ( - libraryEntriesResult.status === 'rejected' || + (isAuthenticated && libraryEntriesResult.status === 'rejected') || galleryEntriesResult.status === 'rejected' ) { const platformFailure = @@ -403,7 +452,7 @@ export function PreGameSelectionFlow({ if (dashboardResult.status === 'fulfilled') { setProfileDashboard(dashboardResult.value); - } else { + } else if (isAuthenticated) { setProfileDashboard(null); setDashboardError( resolveErrorMessage( @@ -415,11 +464,34 @@ export function PreGameSelectionFlow({ if (historyResult.status === 'fulfilled') { setHistoryEntries(historyResult.value); - } else { + } else if (isAuthenticated) { setHistoryError( 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 { if (isActive) { setIsLoadingPlatform(false); @@ -431,7 +503,7 @@ export function PreGameSelectionFlow({ return () => { isActive = false; }; - }, [authUi?.user]); + }, [authUi?.user, isAuthenticated]); useEffect(() => { if ( @@ -990,8 +1062,10 @@ export function PreGameSelectionFlow({ setIsClearingHistory(true); setHistoryError(null); try { - await clearProfileBrowseHistory(); clearPlatformBrowseHistory(authUi?.user); + if (authUi?.user) { + await clearProfileBrowseHistory(); + } setHistoryEntries([]); } catch (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( async (profile = generatedCustomWorldProfile) => { if (!profile) { @@ -1107,7 +1209,9 @@ export function PreGameSelectionFlow({ return; } - handleCustomWorldSelect(selectedDetailEntry.profile); + runProtectedAction(() => { + handleCustomWorldSelect(selectedDetailEntry.profile); + }); }; const handlePublishSelectedWorld = async () => { @@ -1208,6 +1312,8 @@ export function PreGameSelectionFlow({ onTabChange={setPlatformTab} hasSavedGame={hasSavedGame} savedSnapshot={savedSnapshot} + saveEntries={saveEntries} + saveError={saveError} featuredEntries={featuredGalleryEntries} latestEntries={publishedGalleryEntries} myEntries={savedCustomWorldEntries} @@ -1217,20 +1323,30 @@ export function PreGameSelectionFlow({ isLoadingPlatform={isLoadingPlatform} isLoadingDashboard={isLoadingDashboard} isClearingHistory={isClearingHistory} + isResumingSaveWorldKey={isResumingSaveWorldKey} platformError={ isLoadingPlatform ? null : (platformError ?? creationTypeError) } dashboardError={isLoadingDashboard ? null : dashboardError} onContinueGame={handleContinueGame} + onResumeSave={(entry) => { + void handleResumeSaveEntry(entry); + }} onClearHistory={() => { void handleClearBrowseHistory(); }} onOpenCreateWorld={openCustomWorldCreator} onOpenCreateTypePicker={openCreationTypePicker} onOpenGalleryDetail={(entry) => { - void openGalleryDetail(entry); + runProtectedAction(() => { + void openGalleryDetail(entry); + }); + }} + onOpenLibraryDetail={(entry) => { + runProtectedAction(() => { + openLibraryDetail(entry); + }); }} - onOpenLibraryDetail={openLibraryDetail} onOpenProfileDashboardCard={() => { if (dashboardError) { void refreshProfileDashboard(); @@ -1266,23 +1382,41 @@ export function PreGameSelectionFlow({ onStartGame={handleStartSelectedWorld} onContinueEdit={ isSelectedWorldOwned - ? () => openSavedCustomWorldEditor(selectedDetailEntry) + ? () => { + runProtectedAction(() => { + openSavedCustomWorldEditor(selectedDetailEntry); + }); + } : null } onPublish={ selectedDetailEntry.visibility === 'draft' && isSelectedWorldOwned - ? handlePublishSelectedWorld + ? () => { + runProtectedAction(() => { + void handlePublishSelectedWorld(); + }); + } : null } onUnpublish={ selectedDetailEntry.visibility === 'published' && isSelectedWorldOwned - ? handleUnpublishSelectedWorld + ? () => { + runProtectedAction(() => { + void handleUnpublishSelectedWorld(); + }); + } : null } onDelete={ - isSelectedWorldOwned ? handleDeleteSelectedWorld : null + isSelectedWorldOwned + ? () => { + runProtectedAction(() => { + void handleDeleteSelectedWorld(); + }); + } + : null } /> )} @@ -1409,7 +1543,9 @@ export function PreGameSelectionFlow({ onRegenerate={undefined} onContinueExpand={undefined} onEnterWorld={() => { - handleCustomWorldSelect(generatedCustomWorldProfile); + runProtectedAction(() => { + handleCustomWorldSelect(generatedCustomWorldProfile); + }); }} readOnly={false} backLabel={isAgentDraftResultView ? '返回创作' : undefined} @@ -1433,7 +1569,9 @@ export function PreGameSelectionFlow({ setShowCreationTypeModal(false); }} onSelectRpg={() => { - void openRpgAgentWorkspace(); + runProtectedAction(() => { + void openRpgAgentWorkspace(); + }); }} /> diff --git a/src/components/game-shell/types.ts b/src/components/game-shell/types.ts index 768072cc..138761f4 100644 --- a/src/components/game-shell/types.ts +++ b/src/components/game-shell/types.ts @@ -47,7 +47,7 @@ export interface GameShellStoryProps { export interface GameShellEntryProps { hasSavedGame: boolean; savedSnapshot: HydratedSavedGameSnapshot | null; - handleContinueGame: () => void; + handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void; handleStartNewGame: () => void; handleSaveAndExit: () => void; handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; diff --git a/src/data/npcInteractions.test.ts b/src/data/npcInteractions.test.ts index e6af33c5..9a98f589 100644 --- a/src/data/npcInteractions.test.ts +++ b/src/data/npcInteractions.test.ts @@ -210,6 +210,45 @@ describe('npcInteractions', () => { 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', () => { const encounter = createEncounter(); diff --git a/src/data/npcInteractions.ts b/src/data/npcInteractions.ts index 359bea34..c03cf22e 100644 --- a/src/data/npcInteractions.ts +++ b/src/data/npcInteractions.ts @@ -84,6 +84,7 @@ import { import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative'; import { getStoryOptionPriority, + resolveFunctionOption, sortStoryOptionsByPriority, } from './stateFunctions'; @@ -1392,6 +1393,77 @@ function buildNpcOption( } 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: { issuerNpcId: string; issuerNpcName: string; @@ -2024,20 +2096,35 @@ export function buildNpcEncounterStoryMoment({ Boolean(encounter.monsterPresetId); if (isHostileEncounter) { + const hostileDialogueText = + overrideText ?? buildHostileNpcDialogueText(encounter, npcState.affinity); + options.push( + buildHostileNpcEscapeOption({ + state, + worldType, + playerCharacter, + }), + ); options.push( buildNpcOption( NPC_FIGHT_FUNCTION.id, - `迎战${encounter.npcName}`, - '对方敌意已明确,靠近后就会直接进入战斗。', + '与他对战', + '', npcId, 'fight', ), ); return { - text: - overrideText ?? - `${scene?.name ?? '当前地界'}里,${encounter.npcName}已将你视为敌人。它一照面就摆出了进攻姿态,当前好感为 ${npcState.affinity}。`, + text: hostileDialogueText, + displayMode: 'dialogue', + dialogue: [ + { + speaker: 'npc', + speakerName: encounter.npcName, + text: hostileDialogueText, + }, + ], options: sortStoryOptionsByPriority(options), }; } diff --git a/src/hooks/combatStoryUtils.ts b/src/hooks/combatStoryUtils.ts index 2962e279..4e5ff8cc 100644 --- a/src/hooks/combatStoryUtils.ts +++ b/src/hooks/combatStoryUtils.ts @@ -1,4 +1,8 @@ import { createFallbackOption } from '../data/hostileNpcs'; +import { + isInventoryItemUsable, + resolveInventoryItemUseEffect, +} from '../data/inventoryEffects'; import { getDefaultFunctionIdsForContext, getFunctionById, @@ -11,9 +15,9 @@ import { AnimationState, Character, GameState, StoryMoment, StoryOption } from ' const FALLBACK_STORY: StoryMoment = { text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。', options: [ - createFallbackOption('battle_all_in_crush', '战斗:全力进攻,压上对手', AnimationState.SKILL1, 0, false), - createFallbackOption('battle_probe_pressure', '战斗:稳扎稳打,连番试探', AnimationState.SKILL2, 0, false), - createFallbackOption('battle_escape_breakout', '逃跑:抽身后撤,先脱离纠缠', AnimationState.IDLE, -0.6, false), + createFallbackOption('battle_attack_basic', '普通攻击', AnimationState.ATTACK, 0, false), + createFallbackOption('battle_recover_breath', '恢复', AnimationState.IDLE, 0, 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 = {}, +) { + 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>; + 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>, +) { + 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) { + if (state.inBattle) { + return buildSingleActionBattleOptions(state, character); + } + if (!state.worldType) { return FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character)); } @@ -191,6 +367,25 @@ export function getOptionImpactSummary( cooldowns: Record, 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); if (!functionMeta) return null; diff --git a/src/hooks/runtimeAuthGuards.test.tsx b/src/hooks/runtimeAuthGuards.test.tsx new file mode 100644 index 00000000..d27e0074 --- /dev/null +++ b/src/hooks/runtimeAuthGuards.test.tsx @@ -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 ( +
+
{settings.musicVolume.toFixed(2)}
+ +
+ ); +} + +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 ( +
+
{persistence.hasSavedGame ? 'yes' : 'no'}
+
+ {persistence.isHydratingSnapshot ? 'yes' : 'no'} +
+
+ ); +} + +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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByTestId('hydrating').textContent).toBe('no'); + }); + + expect(screen.getByTestId('saved-game').textContent).toBe('no'); + expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled(); +}); diff --git a/src/hooks/story/choiceActions.test.ts b/src/hooks/story/choiceActions.test.ts index b0217115..ffa619c6 100644 --- a/src/hooks/story/choiceActions.test.ts +++ b/src/hooks/story/choiceActions.test.ts @@ -224,7 +224,6 @@ describe('createStoryChoiceActions', () => { updateQuestLog: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), getCampCompanionTravelScene: vi.fn(() => null), - startOpeningAdventure: vi.fn(), enterNpcInteraction: vi.fn(() => false), handleNpcInteraction, handleTreasureInteraction: vi.fn(() => false), @@ -235,7 +234,6 @@ describe('createStoryChoiceActions', () => { option.functionId === 'story_continue_adventure', ), isCampTravelHomeOption: vi.fn(() => false), - isInitialCompanionEncounter: neverNpcEncounter, isRegularNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter, npcPreviewTalkFunctionId: 'npc_preview_talk', @@ -255,53 +253,14 @@ describe('createStoryChoiceActions', () => { 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 option = createBattleOption('npc_chat'); const setGameState = vi.fn(); const setCurrentStory = vi.fn(); + const handleNpcInteraction = vi.fn(() => 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({ gameState: { @@ -340,15 +299,13 @@ describe('createStoryChoiceActions', () => { updateQuestLog: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), getCampCompanionTravelScene: vi.fn(() => null), - startOpeningAdventure: vi.fn(), enterNpcInteraction: vi.fn(() => false), - handleNpcInteraction: vi.fn(() => false), + handleNpcInteraction, handleTreasureInteraction: vi.fn(() => false), commitGeneratedStateWithEncounterEntry: vi.fn(), finalizeNpcBattleResult: vi.fn(() => null), isContinueAdventureOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false), - isInitialCompanionEncounter: neverNpcEncounter, isRegularNpcEncounter: (encounter): encounter is Encounter => Boolean(encounter?.kind === 'npc'), isNpcEncounter: (encounter): encounter is Encounter => @@ -360,30 +317,14 @@ describe('createStoryChoiceActions', () => { await handleChoice(option); - expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith({ - gameState: expect.objectContaining({ - currentEncounter: expect.objectContaining({ - id: 'npc-opponent', - }), - }), - currentStory: createFallbackStory('当前故事'), - option, - }); - expect(setGameState).toHaveBeenCalledWith( + expect(handleNpcInteraction).toHaveBeenCalledWith( expect.objectContaining({ - runtimeActionVersion: 1, - }), - ); - expect(setCurrentStory).toHaveBeenCalledWith( - expect.objectContaining({ - text: '后端已结算关系变化', - options: [ - expect.objectContaining({ - functionId: 'npc_help', - }), - ], + functionId: 'npc_chat', }), ); + 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 () => { @@ -447,7 +388,6 @@ describe('createStoryChoiceActions', () => { updateQuestLog: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), getCampCompanionTravelScene: vi.fn(() => null), - startOpeningAdventure: vi.fn(), enterNpcInteraction: vi.fn(() => false), handleNpcInteraction, handleTreasureInteraction: vi.fn(() => false), @@ -455,7 +395,6 @@ describe('createStoryChoiceActions', () => { finalizeNpcBattleResult: vi.fn(() => null), isContinueAdventureOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false), - isInitialCompanionEncounter: neverNpcEncounter, isRegularNpcEncounter: (encounter): encounter is Encounter => Boolean(encounter?.kind === 'npc'), isNpcEncounter: (encounter): encounter is Encounter => @@ -520,7 +459,6 @@ describe('createStoryChoiceActions', () => { updateQuestLog: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats: vi.fn((inputState: GameState) => inputState), getCampCompanionTravelScene: vi.fn(() => null), - startOpeningAdventure: vi.fn(), enterNpcInteraction: vi.fn(() => false), handleNpcInteraction: vi.fn(() => false), handleTreasureInteraction: vi.fn(() => false), @@ -537,7 +475,6 @@ describe('createStoryChoiceActions', () => { })), isContinueAdventureOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false), - isInitialCompanionEncounter: neverNpcEncounter, isRegularNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter, npcPreviewTalkFunctionId: 'npc_preview_talk', @@ -634,7 +571,6 @@ describe('createStoryChoiceActions', () => { updateQuestLog: vi.fn((inputState: GameState) => inputState), incrementRuntimeStats, getCampCompanionTravelScene: vi.fn(() => null), - startOpeningAdventure: vi.fn(), enterNpcInteraction: vi.fn(() => false), handleNpcInteraction: vi.fn(() => false), handleTreasureInteraction: vi.fn(() => false), @@ -642,7 +578,6 @@ describe('createStoryChoiceActions', () => { finalizeNpcBattleResult: vi.fn(() => null), isContinueAdventureOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false), - isInitialCompanionEncounter: neverNpcEncounter, isRegularNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter, npcPreviewTalkFunctionId: 'npc_preview_talk', diff --git a/src/hooks/story/choiceActions.ts b/src/hooks/story/choiceActions.ts index de560d3b..53d6ae98 100644 --- a/src/hooks/story/choiceActions.ts +++ b/src/hooks/story/choiceActions.ts @@ -90,7 +90,6 @@ export function createStoryChoiceActions({ updateQuestLog, incrementRuntimeStats, getCampCompanionTravelScene, - startOpeningAdventure, enterNpcInteraction, handleNpcInteraction, handleTreasureInteraction, @@ -98,7 +97,6 @@ export function createStoryChoiceActions({ finalizeNpcBattleResult, isContinueAdventureOption, isCampTravelHomeOption, - isInitialCompanionEncounter, isRegularNpcEncounter, isNpcEncounter, npcPreviewTalkFunctionId, @@ -132,7 +130,6 @@ export function createStoryChoiceActions({ updateQuestLog: UpdateQuestLog; incrementRuntimeStats: IncrementRuntimeStats; getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null; - startOpeningAdventure: () => Promise; enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean; handleNpcInteraction: (option: StoryOption) => boolean | Promise; handleTreasureInteraction: ( @@ -147,7 +144,6 @@ export function createStoryChoiceActions({ ) => { nextState: GameState; resultText: string } | null; isContinueAdventureOption: (option: StoryOption) => boolean; isCampTravelHomeOption: (option: StoryOption) => boolean; - isInitialCompanionEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter; isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter; isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter; npcPreviewTalkFunctionId: string; @@ -157,6 +153,7 @@ export function createStoryChoiceActions({ const handleChoice = async (option: StoryOption) => { const character = gameState.playerCharacter; if (!gameState.worldType || !character || isLoading) return; + if (option.disabled) return; if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) { setCurrentStory({ @@ -208,16 +205,6 @@ export function createStoryChoiceActions({ return; } - if ( - option.functionId === npcPreviewTalkFunctionId - && isInitialCompanionEncounter(gameState.currentEncounter) - && !gameState.npcInteractionActive - ) { - setAiError(null); - void startOpeningAdventure(); - return; - } - if ( option.functionId === npcPreviewTalkFunctionId && isRegularNpcEncounter(gameState.currentEncounter) diff --git a/src/hooks/story/npcEncounterActions.test.ts b/src/hooks/story/npcEncounterActions.test.ts new file mode 100644 index 00000000..8a9c7fbc --- /dev/null +++ b/src/hooks/story/npcEncounterActions.test.ts @@ -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 { + 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; + getAvailableOptionsForState?: ReturnType; +}) { + 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: '与他对战', + }), + ], + }), + ); + }); +}); diff --git a/src/hooks/story/npcEncounterActions.ts b/src/hooks/story/npcEncounterActions.ts index 6b8a9d0b..637ea21e 100644 --- a/src/hooks/story/npcEncounterActions.ts +++ b/src/hooks/story/npcEncounterActions.ts @@ -2,7 +2,9 @@ import type { Dispatch, SetStateAction } from 'react'; import { buildRelationState } from '../../data/attributeResolver'; import { hasEncounterEntity } from '../../data/encounterTransition'; -import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog'; +import { + NPC_FIGHT_FUNCTION, +} from '../../data/functionCatalog'; import { addInventoryItems, applyStoryChoiceToStanceProfile, @@ -33,6 +35,7 @@ import { markQuestTurnedIn, } from '../../data/questFlow'; import { incrementGameRuntimeStats } from '../../data/runtimeStats'; +import { resolveFunctionOption } from '../../data/stateFunctions'; import { createSceneCallOutEncounter, resolveSceneEncounterPreview, @@ -578,7 +581,11 @@ export function createStoryNpcEncounterActions({ encounter: Encounter, suggestions: string[], ): StoryOption[] => - suggestions.slice(0, 3).map((suggestion) => ({ + suggestions + .map((suggestion) => sanitizeNpcChatSuggestion(suggestion)) + .filter(Boolean) + .slice(0, 3) + .map((suggestion) => ({ functionId: 'npc_chat', actionText: suggestion, text: suggestion, @@ -596,14 +603,57 @@ export function createStoryNpcEncounterActions({ npcId: encounter.id ?? encounter.npcName, 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 topic = playerMessage.trim() || '刚才那句话'; + const topic = clampNpcChatSuggestionLength( + sanitizeNpcChatSuggestion(playerMessage) || '刚才那句', + ); return [ - `顺着“${topic}”继续追问`, - '先表明你的判断,再看对方反应', - '换个更轻松的语气把话接下去', + sanitizeNpcChatSuggestion(`你刚才那句是什么意思`), + sanitizeNpcChatSuggestion(`这件事和${topic}有关吗`), + sanitizeNpcChatSuggestion('你愿意再说清楚点吗'), ]; }; @@ -628,9 +678,11 @@ export function createStoryNpcEncounterActions({ const buildNpcChatEntryOptions = ( encounter: Encounter, selectedOption: StoryOption, + extraOptions: StoryOption[] = [], ) => { const candidateOptions = [ selectedOption, + ...extraOptions, ...(currentStory?.options ?? []).filter((option) => isNpcChatOptionForEncounter(option, encounter), ), @@ -639,12 +691,20 @@ export function createStoryNpcEncounterActions({ const seenActionTexts = new Set(); for (const option of candidateOptions) { - const actionText = option.actionText?.trim(); - if (!actionText || seenActionTexts.has(actionText)) { + const actionText = sanitizeNpcChatSuggestion(option.actionText ?? ''); + if ( + !actionText || + !isDirectNpcChatSuggestion(actionText) || + seenActionTexts.has(actionText) + ) { continue; } seenActionTexts.add(actionText); - dedupedOptions.push(option); + dedupedOptions.push({ + ...option, + actionText, + text: actionText, + }); if (dedupedOptions.length === 3) { 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 = ( encounter: Encounter, selectedOption: StoryOption, + extraOptions: StoryOption[] = [], ) => { - const openingDialogue = - 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`, - }, - ]; + const openingDialogue = buildNpcChatOpeningDialogue(encounter); setAiError(null); setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: openingDialogue, - options: buildNpcChatEntryOptions(encounter, selectedOption), + options: buildNpcChatEntryOptions( + encounter, + selectedOption, + extraOptions, + ), streaming: false, turnCount: 0, }), @@ -890,32 +1089,102 @@ export function createStoryNpcEncounterActions({ const exitNpcChat = () => { const playerCharacter = gameState.playerCharacter; - if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) { + const encounter = gameState.currentEncounter; + if (!playerCharacter || !isNpcEncounter(encounter)) { return false; } 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; }; const enterNpcInteraction = (encounter: Encounter, actionText: string) => { const playerCharacter = gameState.playerCharacter; if (!playerCharacter) return false; + const npcState = getResolvedNpcState(gameState, encounter); const nextState: GameState = { ...gameState, npcInteractionActive: true, }; - void commitGeneratedState( - nextState, - playerCharacter, - actionText, - `${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`, - NPC_PREVIEW_TALK_FUNCTION.id, + setGameState(nextState); + setAiError(null); + + if (npcState.affinity < 0 || encounter.hostile) { + setCurrentStory( + 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: { @@ -958,17 +1227,51 @@ export function createStoryNpcEncounterActions({ } }; + const inferNpcInteractionFromOption = ( + encounter: Encounter, + option: StoryOption, + ): StoryOption['interaction'] => { + const npcId = encounter.id ?? encounter.npcName; + const actionByFunctionId: Record = { + 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 playerCharacter = gameState.playerCharacter; - if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) { + if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) { return false; } 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 interactionDecision = resolveNpcInteractionDecision( gameState, - option, + resolvedOption, ); if (interactionDecision.kind === 'trade_modal') { @@ -994,7 +1297,7 @@ export function createStoryNpcEncounterActions({ return true; } - switch (option.interaction.action) { + switch (resolvedOption.interaction.action) { case 'help': { setAiError(null); setIsLoading(true); @@ -1062,7 +1365,7 @@ export function createStoryNpcEncounterActions({ encounter, buildNpcHelpCommitActionText(encounter, reward), buildNpcHelpResultText(encounter, reward), - option.functionId, + resolvedOption.functionId, { contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null, @@ -1133,7 +1436,7 @@ export function createStoryNpcEncounterActions({ encounter, buildNpcHelpCommitActionText(encounter, reward), buildNpcHelpResultText(encounter, reward), - option.functionId, + resolvedOption.functionId, { contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null, @@ -1154,23 +1457,23 @@ export function createStoryNpcEncounterActions({ if ( currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) ) { - void handleNpcChatTurn(encounter, option.actionText); + void handleNpcChatTurn(encounter, resolvedOption.actionText); return true; } - return enterNpcChat(encounter, option); + return enterNpcChat(encounter, resolvedOption); } case 'quest_accept': { void resolveServerNpcStoryAction({ - option, + option: resolvedOption, encounter, }); return true; } case 'quest_turn_in': { - const questId = option.interaction.questId; + const questId = resolvedOption.interaction.questId; void resolveServerNpcStoryAction({ - option, + option: resolvedOption, encounter, payload: questId ? { @@ -1212,9 +1515,9 @@ export function createStoryNpcEncounterActions({ entryState, resolvedState, playerCharacter, - option.actionText, + resolvedOption.actionText, buildNpcLeaveResultText(encounter), - option.functionId, + resolvedOption.functionId, ); return true; } @@ -1251,9 +1554,9 @@ export function createStoryNpcEncounterActions({ void commitGeneratedState( nextState, playerCharacter, - option.actionText, + resolvedOption.actionText, `You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`, - option.functionId, + resolvedOption.functionId, ); return true; } @@ -1297,9 +1600,9 @@ export function createStoryNpcEncounterActions({ void commitGeneratedState( nextState, playerCharacter, - option.actionText, + resolvedOption.actionText, `${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`, - option.functionId, + resolvedOption.functionId, ); return true; } diff --git a/src/hooks/story/openingAdventure.ts b/src/hooks/story/openingAdventure.ts index e200da2f..565cedbe 100644 --- a/src/hooks/story/openingAdventure.ts +++ b/src/hooks/story/openingAdventure.ts @@ -123,19 +123,12 @@ export function buildPreparedOpeningAdventure({ export async function playOpeningAdventureSequence({ gameState, - character, encounter, preparedStory, setGameState, setCurrentStory, setAiError, setIsLoading, - buildDialogueStoryMoment, - buildStoryContextFromState, - getStoryGenerationHostileNpcs, - hasRenderableDialogueTurns, - inferOpeningCampFollowupOptions, - getTypewriterDelay, }: { gameState: GameState; character: Character; @@ -168,160 +161,69 @@ export async function playOpeningAdventureSequence({ ) => Promise; getTypewriterDelay: (char: string) => number; }) { - const { - fallbackText, - openingOptions, - resultText: openingBackground, - } = preparedStory; - const actionText = `在营地与 ${encounter.npcName} 交换开场判断`; + const { fallbackText, openingOptions } = preparedStory; const campScene = gameState.worldType ? getWorldCampScenePreset(gameState.worldType) : null; - const entryState: GameState = { - ...gameState, - currentScenePreset: campScene ?? gameState.currentScenePreset, - currentEncounter: { - ...encounter, - xMeters: encounter.xMeters ?? CALL_OUT_ENTRY_X_METERS, - }, - }; - const resolvedEncounter: Encounter = { + const storyEncounter: Encounter = { ...encounter, xMeters: RESOLVED_ENTITY_X_METERS, - }; - const storyEncounter: Encounter = { - ...resolvedEncounter, specialBehavior: 'camp_companion', }; const resolvedState: GameState = { ...gameState, currentScenePreset: campScene ?? gameState.currentScenePreset, - currentEncounter: resolvedEncounter, - npcInteractionActive: false, + currentEncounter: storyEncounter, + npcInteractionActive: true, }; - setGameState(entryState); setAiError(null); - setIsLoading(true); + setIsLoading(false); try { - if (hasEncounterEntity(resolvedState)) { - const runTicks = Math.max( - 1, - Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS), - ); - const tickDurationMs = Math.max( - 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, - }), + setGameState(resolvedState); + setCurrentStory({ + text: fallbackText, + options: sortStoryOptionsByPriority(openingOptions), + displayMode: 'dialogue', + dialogue: [ { - availableOptions: openingOptions, + speaker: 'npc', + speakerName: encounter.npcName, + text: fallbackText, }, - ); - - const generatedText = response.storyText.trim(); - if ( - generatedText && - hasRenderableDialogueTurns(generatedText, encounter.npcName) - ) { - 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, - ), - ); + ], + streaming: false, + npcChatState: { + npcId: storyEncounter.id ?? storyEncounter.npcName, + npcName: storyEncounter.npcName, + turnCount: 0, + customInputPlaceholder: '输入你想对 TA 说的话', + }, + }); } catch (error) { console.error('Failed to play opening adventure sequence:', error); setAiError(error instanceof Error ? error.message : '未知智能生成错误'); - setCurrentStory( - buildDialogueStoryMoment( - encounter.npcName, - fallbackText, - openingOptions, - false, - ), - ); + setGameState(resolvedState); + setCurrentStory({ + text: fallbackText, + options: sortStoryOptionsByPriority(openingOptions), + displayMode: 'dialogue', + dialogue: [ + { + speaker: 'npc', + speakerName: encounter.npcName, + text: fallbackText, + }, + ], + streaming: false, + npcChatState: { + npcId: storyEncounter.id ?? storyEncounter.npcName, + npcName: storyEncounter.npcName, + turnCount: 0, + customInputPlaceholder: '输入你想对 TA 说的话', + }, + }); } finally { setIsLoading(false); } diff --git a/src/hooks/story/storyCampCompanion.ts b/src/hooks/story/storyCampCompanion.ts index 3d2be190..22f8341a 100644 --- a/src/hooks/story/storyCampCompanion.ts +++ b/src/hooks/story/storyCampCompanion.ts @@ -65,12 +65,7 @@ export function buildInitialCompanionDialogueText( const guardedMotive = opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。'; - return [ - `你:${surfaceHook}`, - `${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`, - `你:${immediateConcern}`, - `${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`, - ].join('\n'); + return `${encounter.npcName}看着你,先压低声音开口:“${immediateConcern}。${guardedMotive}”`; } export function buildCampCompanionOpeningResultText( @@ -132,28 +127,14 @@ export function createCampCompanionStoryHelpers(params: { character: Character, encounter: Encounter, ) => { - const targetScene = getCampCompanionTravelScene(state, character); const baseOptions = params.buildNpcStory( state, character, encounter, ).options; - const chatOptions = baseOptions + return baseOptions .filter((option) => option.functionId === NPC_CHAT_FUNCTION.id) - .slice(0, 1); - 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)]; + .slice(0, 3); }; const inferOpeningCampFollowupOptions = async ( diff --git a/src/hooks/story/storyChoiceCoordinator.test.ts b/src/hooks/story/storyChoiceCoordinator.test.ts index 5371dcda..4ef6c5b6 100644 --- a/src/hooks/story/storyChoiceCoordinator.test.ts +++ b/src/hooks/story/storyChoiceCoordinator.test.ts @@ -75,7 +75,6 @@ describe('storyChoiceCoordinator', () => { generateStoryForState: vi.fn(), getAvailableOptionsForState: vi.fn(), getCampCompanionTravelScene: vi.fn(), - startOpeningAdventure: vi.fn(), commitGeneratedStateWithEncounterEntry: vi.fn(), }; const runtimeSupport = { @@ -107,7 +106,6 @@ describe('storyChoiceCoordinator', () => { buildContinueAdventureOption: vi.fn(() => createOption('continue')), isContinueAdventureOption: vi.fn(() => false), isCampTravelHomeOption: vi.fn(() => false), - isInitialCompanionEncounter: neverNpcEncounter, isRegularNpcEncounter: neverNpcEncounter, isNpcEncounter: neverNpcEncounter, npcPreviewTalkFunctionId: 'npc_preview_talk', @@ -126,7 +124,6 @@ describe('storyChoiceCoordinator', () => { updateQuestLog: runtimeSupport.updateQuestLog, incrementRuntimeStats: runtimeSupport.updateRuntimeStats, getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene, - startOpeningAdventure: runtimeController.startOpeningAdventure, commitGeneratedStateWithEncounterEntry: runtimeController.commitGeneratedStateWithEncounterEntry, }), diff --git a/src/hooks/story/storyChoiceCoordinator.ts b/src/hooks/story/storyChoiceCoordinator.ts index 79a2911a..97649c19 100644 --- a/src/hooks/story/storyChoiceCoordinator.ts +++ b/src/hooks/story/storyChoiceCoordinator.ts @@ -53,7 +53,6 @@ export type ChoiceRuntimeController = { state: GameState, character: Character, ) => GameState['currentScenePreset'] | null; - startOpeningAdventure: () => Promise; commitGeneratedStateWithEncounterEntry: ( entryState: GameState, resolvedState: GameState, @@ -113,9 +112,6 @@ export type StoryChoiceCoordinatorParams = { buildContinueAdventureOption: () => StoryOption; isContinueAdventureOption: (option: StoryOption) => boolean; isCampTravelHomeOption: (option: StoryOption) => boolean; - isInitialCompanionEncounter: ( - encounter: GameState['currentEncounter'], - ) => encounter is Encounter; isRegularNpcEncounter: ( encounter: GameState['currentEncounter'], ) => encounter is Encounter; @@ -156,7 +152,6 @@ export function createStoryChoiceCoordinatorConfig( incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats, getCampCompanionTravelScene: params.runtimeController.getCampCompanionTravelScene, - startOpeningAdventure: params.runtimeController.startOpeningAdventure, enterNpcInteraction: params.enterNpcInteraction, handleNpcInteraction: params.handleNpcInteraction, handleTreasureInteraction: params.handleTreasureInteraction, @@ -165,7 +160,6 @@ export function createStoryChoiceCoordinatorConfig( finalizeNpcBattleResult: params.finalizeNpcBattleResult, isContinueAdventureOption: params.isContinueAdventureOption, isCampTravelHomeOption: params.isCampTravelHomeOption, - isInitialCompanionEncounter: params.isInitialCompanionEncounter, isRegularNpcEncounter: params.isRegularNpcEncounter, isNpcEncounter: params.isNpcEncounter, npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId, diff --git a/src/hooks/story/storyChoiceRuntime.test.ts b/src/hooks/story/storyChoiceRuntime.test.ts index e004ea1f..01e6009e 100644 --- a/src/hooks/story/storyChoiceRuntime.test.ts +++ b/src/hooks/story/storyChoiceRuntime.test.ts @@ -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( shouldOpenLocalRuntimeNpcModal( createOption('npc_trade', { @@ -177,7 +186,7 @@ describe('storyChoiceRuntime', () => { ), ).toBe(true); expect( - shouldOpenLocalRuntimeNpcModal(createOption('npc_chat')), + shouldOpenLocalRuntimeNpcModal(createOption('idle_explore_forward')), ).toBe(false); }); diff --git a/src/hooks/story/storyChoiceRuntime.ts b/src/hooks/story/storyChoiceRuntime.ts index 65375534..88b09d7f 100644 --- a/src/hooks/story/storyChoiceRuntime.ts +++ b/src/hooks/story/storyChoiceRuntime.ts @@ -104,8 +104,15 @@ export function buildCombatResolutionContextText(params: { export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) { 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, currentStory: params.currentStory, option: params.option, + payload: params.option.runtimePayload, }); params.setGameState(hydratedSnapshot.gameState); diff --git a/src/hooks/story/storyEncounterState.test.ts b/src/hooks/story/storyEncounterState.test.ts index 4ecd9b5e..7d1eb981 100644 --- a/src/hooks/story/storyEncounterState.test.ts +++ b/src/hooks/story/storyEncounterState.test.ts @@ -90,15 +90,29 @@ function createNpcEncounter( } describe('storyEncounterState', () => { - it('delegates camp companion option pools to the dedicated builder', () => { + it('uses preview talk options for regular npc encounters before formal interaction starts', () => { const character = createCharacter(); const state = createGameState({ - currentEncounter: createNpcEncounter({ - specialBehavior: 'camp_companion', - }), + currentEncounter: createNpcEncounter(), }); - const campStory: StoryMoment = { - text: '营地同伴剧情', + const buildNpcStory = vi.fn(); + + const { getAvailableOptionsForState } = createStoryStateResolvers({ + buildNpcStory, + }); + + expect(getAvailableOptionsForState(state, character)).toEqual([ + expect.objectContaining({ + functionId: 'npc_preview_talk', + }), + ]); + expect(buildNpcStory).not.toHaveBeenCalled(); + }); + + it('uses normal npc story options after the npc interaction has started', () => { + const character = createCharacter(); + const npcStory: StoryMoment = { + text: '普通 NPC 正常对话', options: [ { functionId: 'npc_chat', @@ -115,52 +129,30 @@ describe('storyEncounterState', () => { }, ], }; - const buildCampCompanionIdleOptions = vi.fn(() => campStory); - const buildNpcStory = vi.fn(); - + const state = createGameState({ + currentEncounter: createNpcEncounter(), + npcInteractionActive: true, + }); + const buildNpcStory = vi.fn(() => npcStory); const { getAvailableOptionsForState } = createStoryStateResolvers({ - buildCampCompanionIdleOptions, buildNpcStory, }); expect(getAvailableOptionsForState(state, character)).toEqual( - campStory.options, + npcStory.options, ); - expect(buildCampCompanionIdleOptions).toHaveBeenCalledWith( + expect(buildNpcStory).toHaveBeenCalledWith( state, character, state.currentEncounter, undefined, ); - expect(buildNpcStory).not.toHaveBeenCalled(); - }); - - it('uses preview talk options for initial companion encounters before formal interaction starts', () => { - const character = createCharacter(); - const state = createGameState({ - currentEncounter: createNpcEncounter({ - specialBehavior: 'initial_companion', - }), - }); - const { getAvailableOptionsForState } = createStoryStateResolvers({ - buildCampCompanionIdleOptions: vi.fn(), - buildNpcStory: vi.fn(), - }); - - const options = getAvailableOptionsForState(state, character); - - expect(options).toEqual([ - expect.objectContaining({ - functionId: 'npc_preview_talk', - }), - ]); }); it('preserves explicit fallback text when the state falls back to the generic story moment', () => { const state = createGameState(); const character = createCharacter(); const { buildFallbackStoryForState } = createStoryStateResolvers({ - buildCampCompanionIdleOptions: vi.fn(), buildNpcStory: vi.fn(), }); diff --git a/src/hooks/story/storyEncounterState.ts b/src/hooks/story/storyEncounterState.ts index 9f97e85b..996a3710 100644 --- a/src/hooks/story/storyEncounterState.ts +++ b/src/hooks/story/storyEncounterState.ts @@ -13,10 +13,6 @@ import type { } from '../../types'; import { buildFallbackStoryMoment } from '../combatStoryUtils'; -type CampCompanionEncounter = Encounter & { - specialBehavior: 'camp_companion'; -}; - type EncounterStoryBuilder = ( state: GameState, character: Character, @@ -73,21 +69,10 @@ export function getStoryGenerationHostileNpcs(state: GameState) { return state.inBattle ? getResolvedSceneHostileNpcs(state) : []; } -export function isCampCompanionEncounter( - encounter: GameState['currentEncounter'], -): encounter is CampCompanionEncounter { - return Boolean( - encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion', - ); -} - export function isInitialCompanionEncounter( encounter: GameState['currentEncounter'], ): encounter is Encounter { - return Boolean( - encounter?.kind === 'npc' && - encounter.specialBehavior === 'initial_companion', - ); + return false; } export function isNpcEncounter( @@ -124,34 +109,11 @@ export function buildTreasureStory( function resolveEncounterStory(params: { state: GameState; character: Character; - buildCampCompanionIdleOptions: EncounterStoryBuilder; buildNpcStory: EncounterStoryBuilder; fallbackText?: string; }) { const { state, character, fallbackText } = params; - if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) { - return params.buildCampCompanionIdleOptions( - state, - character, - state.currentEncounter, - fallbackText, - ); - } - - if ( - isInitialCompanionEncounter(state.currentEncounter) && - !state.inBattle && - !state.npcInteractionActive - ) { - return buildNpcPreviewStory( - state, - character, - state.currentEncounter, - fallbackText, - ); - } - if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) { if (!state.npcInteractionActive) { return buildNpcPreviewStory( @@ -192,7 +154,6 @@ function resolveEncounterStory(params: { } export function createStoryStateResolvers(params: { - buildCampCompanionIdleOptions: EncounterStoryBuilder; buildNpcStory: EncounterStoryBuilder; }) { const getAvailableOptionsForState = ( @@ -202,7 +163,6 @@ export function createStoryStateResolvers(params: { resolveEncounterStory({ state, character, - buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions, buildNpcStory: params.buildNpcStory, })?.options ?? null; @@ -215,7 +175,6 @@ export function createStoryStateResolvers(params: { state, character, fallbackText, - buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions, buildNpcStory: params.buildNpcStory, }); if (resolvedStory) { diff --git a/src/hooks/story/storyResponseOptions.test.ts b/src/hooks/story/storyResponseOptions.test.ts index 27481d0e..09b5166a 100644 --- a/src/hooks/story/storyResponseOptions.test.ts +++ b/src/hooks/story/storyResponseOptions.test.ts @@ -108,4 +108,77 @@ describe('storyResponseOptions', () => { '前往山门', ]); }); + + it('keeps only AI-selected options when optionCatalog is used for reasoned follow-ups', () => { + const optionCatalog = [ + createOption('npc_chat', '继续交谈', 3, { + kind: 'npc', + npcId: 'npc-camp', + action: 'chat', + }), + createOption('npc_help', '请求援手', 2, { + kind: 'npc', + npcId: 'npc-camp', + action: 'help', + }), + createOption('npc_trade', '看看能交换什么', 1, { + kind: 'npc', + npcId: 'npc-camp', + action: 'trade', + }), + ]; + const responseOptions = [ + createOption('npc_help', '顺着刚才的话请他搭把手', 3), + createOption('npc_chat', '追问他刚才为什么突然沉默', 2), + ]; + + const resolved = resolveStoryResponseOptions({ + responseOptions, + optionCatalog, + getSanitizedOptions: () => { + throw new Error('option catalog branch should not sanitize'); + }, + }); + + expect(resolved).toEqual([ + expect.objectContaining({ + functionId: 'npc_help', + actionText: '顺着刚才的话请他搭把手', + interaction: { + kind: 'npc', + npcId: 'npc-camp', + action: 'help', + }, + }), + expect.objectContaining({ + functionId: 'npc_chat', + actionText: '追问他刚才为什么突然沉默', + interaction: { + kind: 'npc', + npcId: 'npc-camp', + action: 'chat', + }, + }), + ]); + }); + + it('falls back to the raw catalog only when the AI omits optionCatalog results entirely', () => { + const optionCatalog = [ + createOption('npc_chat', '继续交谈', 2), + createOption('npc_trade', '看看能交换什么', 1), + ]; + + const resolved = resolveStoryResponseOptions({ + responseOptions: [], + optionCatalog, + getSanitizedOptions: () => { + throw new Error('option catalog fallback should not sanitize'); + }, + }); + + expect(resolved.map((option) => option.actionText)).toEqual([ + '继续交谈', + '看看能交换什么', + ]); + }); }); diff --git a/src/hooks/story/storyResponseOptions.ts b/src/hooks/story/storyResponseOptions.ts index 7be70b4f..b9ae54c2 100644 --- a/src/hooks/story/storyResponseOptions.ts +++ b/src/hooks/story/storyResponseOptions.ts @@ -67,6 +67,43 @@ function rewriteOptionsFromBaseOptions( return [...resolved, ...remainingOptions.map(cloneStoryOption)]; } +function rewriteOptionsFromCatalog( + responseOptions: StoryOption[], + optionCatalog: StoryOption[], +) { + if (responseOptions.length === 0) { + return optionCatalog.map(cloneStoryOption); + } + + const optionBuckets = new Map(); + + optionCatalog.forEach((option) => { + const bucket = optionBuckets.get(option.functionId) ?? []; + bucket.push(option); + optionBuckets.set(option.functionId, bucket); + }); + + const resolved = responseOptions.reduce((nextResolved, option) => { + const bucket = optionBuckets.get(option.functionId); + const matchedOption = bucket?.shift(); + if (!matchedOption) { + return nextResolved; + } + + const rewrittenText = option.actionText.trim() || matchedOption.actionText; + nextResolved.push({ + ...cloneStoryOption(matchedOption), + actionText: rewrittenText, + text: rewrittenText || matchedOption.text || matchedOption.actionText, + }); + return nextResolved; + }, []); + + return resolved.length > 0 + ? resolved + : optionCatalog.map(cloneStoryOption); +} + export function resolveStoryResponseOptions({ responseOptions, availableOptions = null, @@ -81,7 +118,7 @@ export function resolveStoryResponseOptions({ if (optionCatalog) { return sortStoryOptionsByPriority( - rewriteOptionsFromBaseOptions(responseOptions, optionCatalog), + rewriteOptionsFromCatalog(responseOptions, optionCatalog), ); } diff --git a/src/hooks/story/useStoryChoiceCoordinator.ts b/src/hooks/story/useStoryChoiceCoordinator.ts index b53f38fa..693ed3ce 100644 --- a/src/hooks/story/useStoryChoiceCoordinator.ts +++ b/src/hooks/story/useStoryChoiceCoordinator.ts @@ -60,9 +60,6 @@ type StoryChoiceCoordinatorParams = { buildContinueAdventureOption: () => StoryOption; isContinueAdventureOption: (option: StoryOption) => boolean; isCampTravelHomeOption: (option: StoryOption) => boolean; - isInitialCompanionEncounter: ( - encounter: GameState['currentEncounter'], - ) => encounter is Encounter; isRegularNpcEncounter: ( encounter: GameState['currentEncounter'], ) => encounter is Encounter; @@ -113,7 +110,6 @@ export function useStoryChoiceCoordinator( buildContinueAdventureOption: params.buildContinueAdventureOption, isContinueAdventureOption: params.isContinueAdventureOption, isCampTravelHomeOption: params.isCampTravelHomeOption, - isInitialCompanionEncounter: params.isInitialCompanionEncounter, isRegularNpcEncounter: params.isRegularNpcEncounter, isNpcEncounter: params.isNpcEncounter, npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId, diff --git a/src/hooks/story/useStoryFlowCoordinator.ts b/src/hooks/story/useStoryFlowCoordinator.ts index 1d0b0826..0a0ba1c8 100644 --- a/src/hooks/story/useStoryFlowCoordinator.ts +++ b/src/hooks/story/useStoryFlowCoordinator.ts @@ -43,9 +43,6 @@ type StoryFlowCoordinatorParams = { clearCharacterChatModal: () => void; isContinueAdventureOption: (option: StoryOption) => boolean; isCampTravelHomeOption: (option: StoryOption) => boolean; - isInitialCompanionEncounter: ( - encounter: GameState['currentEncounter'], - ) => encounter is Encounter; isRegularNpcEncounter: ( encounter: GameState['currentEncounter'], ) => encounter is Encounter; @@ -72,7 +69,6 @@ export function useStoryFlowCoordinator({ clearCharacterChatModal, isContinueAdventureOption, isCampTravelHomeOption, - isInitialCompanionEncounter, isRegularNpcEncounter, isNpcEncounter, npcPreviewTalkFunctionId, @@ -149,10 +145,8 @@ export function useStoryFlowCoordinator({ buildStoryFromResponse: runtimeController.buildStoryFromResponse, getResolvedSceneHostileNpcs, getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene, - startOpeningAdventure: runtimeController.startOpeningAdventure, isContinueAdventureOption, isCampTravelHomeOption, - isInitialCompanionEncounter, isRegularNpcEncounter, isNpcEncounter, npcPreviewTalkFunctionId, diff --git a/src/hooks/story/useStoryInteractionCoordinator.ts b/src/hooks/story/useStoryInteractionCoordinator.ts index 9259e3f6..8c154e5b 100644 --- a/src/hooks/story/useStoryInteractionCoordinator.ts +++ b/src/hooks/story/useStoryInteractionCoordinator.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import type { Character, @@ -43,12 +43,8 @@ type StoryInteractionCoordinatorParams = { state: GameState, ) => GameState['sceneHostileNpcs']; getCampCompanionTravelScene: StoryChoiceCoordinatorParams['runtimeController']['getCampCompanionTravelScene']; - startOpeningAdventure: StoryChoiceCoordinatorParams['runtimeController']['startOpeningAdventure']; isContinueAdventureOption: (option: StoryOption) => boolean; isCampTravelHomeOption: (option: StoryOption) => boolean; - isInitialCompanionEncounter: ( - encounter: GameState['currentEncounter'], - ) => encounter is Encounter; isRegularNpcEncounter: ( encounter: GameState['currentEncounter'], ) => encounter is Encounter; @@ -80,10 +76,8 @@ export function useStoryInteractionCoordinator({ buildStoryFromResponse, getResolvedSceneHostileNpcs, getCampCompanionTravelScene, - startOpeningAdventure, isContinueAdventureOption, isCampTravelHomeOption, - isInitialCompanionEncounter, isRegularNpcEncounter, isNpcEncounter, npcPreviewTalkFunctionId, @@ -109,6 +103,27 @@ export function useStoryInteractionCoordinator({ ...interactionConfig.npcEncounterActions, npcInteractionFlow, }); + + useEffect(() => { + if (isLoading || gameState.inBattle || gameState.npcInteractionActive) { + return; + } + + if (isNpcEncounter(gameState.currentEncounter)) { + enterNpcInteraction( + gameState.currentEncounter, + `与${gameState.currentEncounter.npcName}搭话`, + ); + } + }, [ + enterNpcInteraction, + gameState.currentEncounter, + gameState.inBattle, + gameState.npcInteractionActive, + isLoading, + isNpcEncounter, + ]); + const choiceRuntimeController: Parameters< typeof useStoryChoiceCoordinator >[0]['runtimeController'] = { @@ -137,7 +152,6 @@ export function useStoryInteractionCoordinator({ interactionConfig.npcEncounterActions.getAvailableOptionsForState, getCampCompanionTravelScene: (state, character) => getCampCompanionTravelScene(state, character), - startOpeningAdventure: () => startOpeningAdventure(), commitGeneratedStateWithEncounterEntry: async ( entryState, resolvedState, @@ -180,7 +194,6 @@ export function useStoryInteractionCoordinator({ interactionConfig.npcEncounterActions.buildContinueAdventureOption, isContinueAdventureOption, isCampTravelHomeOption, - isInitialCompanionEncounter, isRegularNpcEncounter, isNpcEncounter, npcPreviewTalkFunctionId, diff --git a/src/hooks/story/useStoryRuntimeController.ts b/src/hooks/story/useStoryRuntimeController.ts index 6fb6666e..ed2ddce5 100644 --- a/src/hooks/story/useStoryRuntimeController.ts +++ b/src/hooks/story/useStoryRuntimeController.ts @@ -3,29 +3,18 @@ import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } fr import { generateInitialStory, generateNextStep } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; import type { Character, GameState, StoryMoment, StoryOption } from '../../types'; -import { buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState } from './openingAdventure'; import { appendStoryHistory, createStoryProgressionActions, } from './progressionActions'; -import { - buildCampCompanionOpeningResultText, - buildInitialCompanionDialogueText, - createCampCompanionStoryHelpers, -} from './storyCampCompanion'; -import { useStoryBootstrap } from './storyBootstrap'; import { createStoryStateResolvers, getStoryGenerationHostileNpcs, - isInitialCompanionEncounter, - isNpcEncounter, } from './storyEncounterState'; -import { getNpcEncounterKey } from './storyGenerationState'; import { buildDialogueStoryMoment, buildStoryFromResponse as buildStoryFromResponseFromPresentation, getTypewriterDelay, - hasRenderableDialogueTurns, } from './storyPresentation'; import { buildNpcStory } from './storyRuntimeSupport'; import { createGenerateStoryForState } from './storyRequestRuntime'; @@ -47,31 +36,12 @@ export function useStoryRuntimeController(params: { const [aiError, setAiError] = useState(null); const [isLoading, setIsLoading] = useState(false); - const { - getCampCompanionTravelScene, - buildCampCompanionOpeningOptions, - inferOpeningCampFollowupOptions, - buildOpeningCampChatContext, - buildCampCompanionIdleStory, - } = useMemo( - () => - createCampCompanionStoryHelpers({ - buildNpcStory, - buildStoryContextFromState, - getStoryGenerationHostileNpcs, - getNpcEncounterKey, - generateNextStep, - }), - [buildStoryContextFromState], - ); - const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo( () => createStoryStateResolvers({ - buildCampCompanionIdleOptions: buildCampCompanionIdleStory, buildNpcStory, }), - [buildCampCompanionIdleStory], + [], ); const buildStoryFromResponse = useCallback( @@ -119,20 +89,6 @@ export function useStoryRuntimeController(params: { const appendHistory = useCallback(appendStoryHistory, []); - const prepareOpeningAdventure = useCallback( - (state: GameState, character: Character) => - buildPreparedOpeningAdventureState({ - state, - character, - getNpcEncounterKey, - appendHistory, - buildCampCompanionOpeningOptions, - buildCampCompanionOpeningResultText, - buildInitialCompanionDialogueText, - }), - [appendHistory, buildCampCompanionOpeningOptions], - ); - const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } = createStoryProgressionActions({ gameState, @@ -144,32 +100,6 @@ export function useStoryRuntimeController(params: { buildFallbackStoryForState, }); - const { - preparedOpeningAdventure, - startOpeningAdventure, - resetPreparedOpeningAdventure, - } = useStoryBootstrap({ - gameState, - currentStory, - isLoading, - setGameState, - setCurrentStory, - setAiError, - setIsLoading, - prepareOpeningAdventure, - getNpcEncounterKey, - buildFallbackStoryForState, - generateStoryForState, - buildDialogueStoryMoment, - buildStoryContextFromState, - getStoryGenerationHostileNpcs, - hasRenderableDialogueTurns, - inferOpeningCampFollowupOptions, - getTypewriterDelay, - isNpcEncounter, - isInitialCompanionEncounter, - }); - return { currentStory, setCurrentStory, @@ -177,14 +107,14 @@ export function useStoryRuntimeController(params: { setAiError, isLoading, setIsLoading, - preparedOpeningAdventure, - startOpeningAdventure, - resetPreparedOpeningAdventure, + preparedOpeningAdventure: null, + startOpeningAdventure: async () => undefined, + resetPreparedOpeningAdventure: () => undefined, buildStoryContextFromState, buildDialogueStoryMoment, getTypewriterDelay, - getCampCompanionTravelScene, - buildOpeningCampChatContext, + getCampCompanionTravelScene: () => null, + buildOpeningCampChatContext: () => ({}), getAvailableOptionsForState, buildFallbackStoryForState, buildStoryFromResponse, diff --git a/src/hooks/useGameFlow.ts b/src/hooks/useGameFlow.ts index f72f5f26..b2471d32 100644 --- a/src/hooks/useGameFlow.ts +++ b/src/hooks/useGameFlow.ts @@ -135,7 +135,6 @@ function createInitialCampEncounter( npcAvatar: npc.avatar, context: npc.role, gender: npc.gender, - specialBehavior: 'initial_companion', xMeters: RESOLVED_ENTITY_X_METERS, }; } diff --git a/src/hooks/useGamePersistence.ts b/src/hooks/useGamePersistence.ts index 4ff35092..12266eb4 100644 --- a/src/hooks/useGamePersistence.ts +++ b/src/hooks/useGamePersistence.ts @@ -39,6 +39,7 @@ function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) { } export function useGamePersistence({ + authenticatedUserId, gameState, bottomTab, currentStory, @@ -48,6 +49,7 @@ export function useGamePersistence({ hydrateStoryState, resetStoryState, }: { + authenticatedUserId: string | null; gameState: GameState; bottomTab: BottomTab; currentStory: StoryMoment | null; @@ -82,6 +84,10 @@ export function useGamePersistence({ }; logLabel: string; }) => { + if (!authenticatedUserId) { + return null; + } + abortActiveSave(); const requestId = saveRequestIdRef.current + 1; @@ -127,10 +133,22 @@ export function useGamePersistence({ } } }, - [abortActiveSave], + [abortActiveSave, authenticatedUserId], ); useEffect(() => { + hydrateControllerRef.current?.abort(); + hydrateControllerRef.current = null; + abortActiveSave(); + + if (!authenticatedUserId) { + setSavedSnapshot(null); + setHasSavedGame(false); + setPersistenceError(null); + setIsHydratingSnapshot(false); + return; + } + const controller = new AbortController(); hydrateControllerRef.current = controller; setIsHydratingSnapshot(true); @@ -166,7 +184,7 @@ export function useGamePersistence({ hydrateControllerRef.current = null; } }; - }, []); + }, [abortActiveSave, authenticatedUserId]); useEffect( () => () => { @@ -228,6 +246,13 @@ export function useGamePersistence({ const clearSavedGame = useCallback(async () => { abortActiveSave(); + if (!authenticatedUserId) { + setSavedSnapshot(null); + setHasSavedGame(false); + setPersistenceError(null); + return; + } + try { await deleteSaveSnapshot(); setPersistenceError(null); @@ -240,59 +265,68 @@ export function useGamePersistence({ setSavedSnapshot(null); setHasSavedGame(false); - }, [abortActiveSave]); + }, [abortActiveSave, authenticatedUserId]); - const continueSavedGame = useCallback(async () => { - const snapshot = - savedSnapshot ?? - (await getSaveSnapshot().catch((error) => { - if (!isAbortError(error)) { - console.warn( - '[useGamePersistence] failed to refetch remote snapshot', - error, - ); - } - return null; - })); - if (!snapshot) { - setSavedSnapshot(null); - setHasSavedGame(false); - return false; - } + const continueSavedGame = useCallback( + async (snapshotOverride?: HydratedSavedGameSnapshot | null) => { + if (!authenticatedUserId) { + return false; + } - resetStoryState(); - const fallbackHydration = resolveRemoteSnapshotState(snapshot); + const snapshot = + snapshotOverride ?? + savedSnapshot ?? + (await getSaveSnapshot().catch((error) => { + if (!isAbortError(error)) { + console.warn( + '[useGamePersistence] failed to refetch remote snapshot', + error, + ); + } + return null; + })); + if (!snapshot) { + setSavedSnapshot(null); + setHasSavedGame(false); + return false; + } - const resumedState = await resumeServerRuntimeStory(snapshot).catch( - (error) => { - if (!isAbortError(error)) { - console.warn( - '[useGamePersistence] failed to refresh runtime story state from server', - error, - ); - } + resetStoryState(); + const fallbackHydration = resolveRemoteSnapshotState(snapshot); - return { - hydratedSnapshot: fallbackHydration, - nextStory: fallbackHydration.currentStory, - }; - }, - ); + const resumedState = await resumeServerRuntimeStory(snapshot).catch( + (error) => { + if (!isAbortError(error)) { + console.warn( + '[useGamePersistence] failed to refresh runtime story state from server', + error, + ); + } - setGameState(resumedState.hydratedSnapshot.gameState); - setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab)); - hydrateStoryState(resumedState.nextStory); - setSavedSnapshot(snapshot); - setHasSavedGame(true); - setPersistenceError(null); - return true; - }, [ - hydrateStoryState, - resetStoryState, - savedSnapshot, - setBottomTab, - setGameState, - ]); + return { + hydratedSnapshot: fallbackHydration, + nextStory: fallbackHydration.currentStory, + }; + }, + ); + + setGameState(resumedState.hydratedSnapshot.gameState); + setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab)); + hydrateStoryState(resumedState.nextStory); + setSavedSnapshot(snapshot); + setHasSavedGame(true); + setPersistenceError(null); + return true; + }, + [ + authenticatedUserId, + hydrateStoryState, + resetStoryState, + savedSnapshot, + setBottomTab, + setGameState, + ], + ); return { hasSavedGame, diff --git a/src/hooks/useGameSettings.ts b/src/hooks/useGameSettings.ts index a6ec75c3..ed8bf5f5 100644 --- a/src/hooks/useGameSettings.ts +++ b/src/hooks/useGameSettings.ts @@ -3,22 +3,34 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import { clampVolume, DEFAULT_MUSIC_VOLUME, + normalizePlatformTheme, + readSavedSettings, + writeSavedSettings, } from '../persistence/gameSettingsStorage'; import { isAbortError } from '../services/apiClient'; import { getSettings, putSettings } from '../services/storageService'; const SETTINGS_SYNC_DELAY_MS = 180; -export function useGameSettings() { - const [musicVolume, setMusicVolumeState] = useState(DEFAULT_MUSIC_VOLUME); +export function useGameSettings(authenticatedUserId: string | null = null) { + const [musicVolume, setMusicVolumeState] = useState( + () => readSavedSettings().musicVolume, + ); + const [platformTheme, setPlatformThemeState] = useState( + () => readSavedSettings().platformTheme, + ); const [hasHydratedSettings, setHasHydratedSettings] = useState(false); const [isHydratingSettings, setIsHydratingSettings] = useState(true); const [isPersistingSettings, setIsPersistingSettings] = useState(false); const [settingsError, setSettingsError] = useState(null); const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME); + const currentVolumeRef = useRef(readSavedSettings().musicVolume); + const lastSyncedThemeRef = useRef(readSavedSettings().platformTheme); + const currentThemeRef = useRef(readSavedSettings().platformTheme); const hydrateControllerRef = useRef(null); const persistControllerRef = useRef(null); const persistRequestIdRef = useRef(0); + const [isRemoteSyncReady, setIsRemoteSyncReady] = useState(false); const abortActivePersist = useCallback(() => { persistControllerRef.current?.abort(); @@ -27,21 +39,47 @@ export function useGameSettings() { }, []); useEffect(() => { + currentVolumeRef.current = musicVolume; + currentThemeRef.current = platformTheme; + writeSavedSettings({ musicVolume, platformTheme }); + }, [musicVolume, platformTheme]); + + useEffect(() => { + hydrateControllerRef.current?.abort(); + hydrateControllerRef.current = null; + abortActivePersist(); + + if (!authenticatedUserId) { + lastSyncedVolumeRef.current = currentVolumeRef.current; + lastSyncedThemeRef.current = currentThemeRef.current; + setSettingsError(null); + setIsHydratingSettings(false); + setHasHydratedSettings(true); + setIsRemoteSyncReady(true); + return; + } + const controller = new AbortController(); hydrateControllerRef.current = controller; + setIsRemoteSyncReady(false); + setHasHydratedSettings(false); setIsHydratingSettings(true); void getSettings({ signal: controller.signal }) .then((settings) => { const nextVolume = clampVolume(settings.musicVolume); + const nextPlatformTheme = normalizePlatformTheme(settings.platformTheme); lastSyncedVolumeRef.current = nextVolume; + lastSyncedThemeRef.current = nextPlatformTheme; setMusicVolumeState(nextVolume); + setPlatformThemeState(nextPlatformTheme); setSettingsError(null); }) .catch((error) => { if (isAbortError(error)) { return; } + lastSyncedVolumeRef.current = currentVolumeRef.current; const message = error instanceof Error ? error.message : '读取远端设置失败'; setSettingsError(message); @@ -52,6 +90,7 @@ export function useGameSettings() { hydrateControllerRef.current = null; setIsHydratingSettings(false); setHasHydratedSettings(true); + setIsRemoteSyncReady(true); } }); @@ -61,7 +100,7 @@ export function useGameSettings() { hydrateControllerRef.current = null; } }; - }, []); + }, [abortActivePersist, authenticatedUserId]); useEffect(() => () => { hydrateControllerRef.current?.abort(); @@ -70,11 +109,14 @@ export function useGameSettings() { }, []); useEffect(() => { - if (!hasHydratedSettings) { + if (!authenticatedUserId || !hasHydratedSettings || !isRemoteSyncReady) { return; } - if (lastSyncedVolumeRef.current === musicVolume) { + if ( + lastSyncedVolumeRef.current === musicVolume + && lastSyncedThemeRef.current === platformTheme + ) { return; } @@ -88,17 +130,32 @@ export function useGameSettings() { setIsPersistingSettings(true); setSettingsError(null); - void putSettings({ musicVolume }, { signal: controller.signal }) + void putSettings( + { + musicVolume, + platformTheme, + }, + { signal: controller.signal }, + ) .then((settings) => { if (persistRequestIdRef.current !== requestId) { return; } const nextVolume = clampVolume(settings.musicVolume); + const nextPlatformTheme = normalizePlatformTheme( + settings.platformTheme, + ); lastSyncedVolumeRef.current = nextVolume; + lastSyncedThemeRef.current = nextPlatformTheme; setMusicVolumeState((currentValue) => currentValue === nextVolume ? currentValue : nextVolume, ); + setPlatformThemeState((currentValue) => + currentValue === nextPlatformTheme + ? currentValue + : nextPlatformTheme, + ); }) .catch((error) => { if (isAbortError(error)) { @@ -120,15 +177,28 @@ export function useGameSettings() { }, SETTINGS_SYNC_DELAY_MS); return () => window.clearTimeout(timeoutId); - }, [abortActivePersist, hasHydratedSettings, musicVolume]); + }, [ + abortActivePersist, + authenticatedUserId, + hasHydratedSettings, + isRemoteSyncReady, + musicVolume, + platformTheme, + ]); const setMusicVolume = useCallback((value: number) => { setMusicVolumeState(clampVolume(value)); }, []); + const setPlatformTheme = useCallback((value: 'light' | 'dark') => { + setPlatformThemeState(normalizePlatformTheme(value)); + }, []); + return { musicVolume, setMusicVolume, + platformTheme, + setPlatformTheme, hasHydratedSettings, isHydratingSettings, isPersistingSettings, diff --git a/src/hooks/useGameShellRuntime.ts b/src/hooks/useGameShellRuntime.ts index 1fc4e10d..5aa4efa8 100644 --- a/src/hooks/useGameShellRuntime.ts +++ b/src/hooks/useGameShellRuntime.ts @@ -1,17 +1,20 @@ import { useEffect } from 'react'; +import { DEFAULT_MUSIC_VOLUME } from '../../packages/shared/src/contracts/runtime'; +import { useAuthUi } from '../components/auth/AuthUiContext'; import type { GameShellProps } from '../components/game-shell/types'; import { activateRosterCompanion, benchActiveCompanion } from '../data/companionRoster'; import { syncGameStatePlayTime } from '../data/runtimeStats'; +import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes'; import { useBackgroundMusic } from './useBackgroundMusic'; import { useCombatFlow } from './useCombatFlow'; import { useGameFlow } from './useGameFlow'; import { useGamePersistence } from './useGamePersistence'; -import { useGameSettings } from './useGameSettings'; import { useNpcInteractionFlow } from './useNpcInteractionFlow'; import { useStoryGeneration } from './useStoryGeneration'; export function useGameShellRuntime(): GameShellProps { + const authUi = useAuthUi(); const { gameState, setGameState, @@ -38,9 +41,8 @@ export function useGameShellRuntime(): GameShellProps { const { companionRenderStates, buildCompanionRenderStates } = useNpcInteractionFlow(gameState); - const settings = useGameSettings(); - const persistence = useGamePersistence({ + authenticatedUserId: authUi?.user?.id ?? null, gameState, bottomTab, currentStory: storyFlow.currentStory, @@ -55,7 +57,7 @@ export function useGameShellRuntime(): GameShellProps { active: Boolean( gameState.playerCharacter && gameState.currentScene === 'Story', ), - volume: settings.musicVolume, + volume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME, }); useEffect(() => { @@ -98,8 +100,8 @@ export function useGameShellRuntime(): GameShellProps { backToWorldSelect(); }; - const handleContinueGame = () => { - void persistence.continueSavedGame(); + const handleContinueGame = (snapshot?: HydratedSavedGameSnapshot | null) => { + void persistence.continueSavedGame(snapshot); }; const handleStartNewGame = () => { @@ -175,8 +177,8 @@ export function useGameShellRuntime(): GameShellProps { onActivateRosterCompanion: handleActivateRosterCompanion, }, audio: { - musicVolume: settings.musicVolume, - onMusicVolumeChange: settings.setMusicVolume, + musicVolume: authUi?.musicVolume ?? DEFAULT_MUSIC_VOLUME, + onMusicVolumeChange: authUi?.setMusicVolume ?? (() => {}), }, }; } diff --git a/src/hooks/useStoryGeneration.ts b/src/hooks/useStoryGeneration.ts index b4cfbeb3..b19cecb3 100644 --- a/src/hooks/useStoryGeneration.ts +++ b/src/hooks/useStoryGeneration.ts @@ -15,7 +15,6 @@ import { buildStoryContextFromState } from './story/storyContextBuilder'; import { getResolvedSceneHostileNpcs, getStoryGenerationHostileNpcs, - isInitialCompanionEncounter, isNpcEncounter, isRegularNpcEncounter, } from './story/storyEncounterState'; @@ -114,7 +113,6 @@ export function useStoryGeneration({ clearCharacterChatModal, isContinueAdventureOption, isCampTravelHomeOption, - isInitialCompanionEncounter, isRegularNpcEncounter, isNpcEncounter, npcPreviewTalkFunctionId: NPC_PREVIEW_TALK_FUNCTION_ID, @@ -130,10 +128,6 @@ export function useStoryGeneration({ canRefreshOptions, handleRefreshOptions, handleChoice, - startOpeningAdventure: runtimeController.startOpeningAdventure, - isOpeningAdventureReady: Boolean( - runtimeController.preparedOpeningAdventure, - ), resetStoryState, hydrateStoryState, travelToSceneFromMap, diff --git a/src/index.css b/src/index.css index 9bf518ae..7f33518f 100644 --- a/src/index.css +++ b/src/index.css @@ -53,7 +53,7 @@ body { text-shadow: 0 2px 0 rgba(0, 0, 0, 0.68), 0 10px 28px rgba(0, 0, 0, 0.48), - 0 0 18px rgba(251, 191, 36, 0.12); + 0 0 22px rgba(129, 140, 248, 0.2); } .selection-hero-brand__subtitle { @@ -79,7 +79,7 @@ body { content: ''; width: clamp(1.75rem, 8vw, 3.2rem); height: 1px; - background: linear-gradient(90deg, transparent, rgba(245, 158, 11, 0.72), transparent); + background: linear-gradient(90deg, transparent, rgba(96, 165, 250, 0.72), transparent); opacity: 0.82; } @@ -87,6 +87,968 @@ body { display: none !important; } +.platform-ui-shell, +.platform-ui-shell * { + font-family: "Inter", ui-sans-serif, system-ui, sans-serif !important; +} + +.platform-theme, +.platform-theme * { + font-family: "Inter", ui-sans-serif, system-ui, sans-serif !important; +} + +.platform-theme { + color: var(--platform-text-strong); +} + +.platform-theme--light { + color-scheme: light; + --platform-body-fill: + radial-gradient(circle at top, rgba(255, 255, 255, 0.24), transparent 22%), + radial-gradient(circle at 82% 18%, rgba(255, 209, 223, 0.26), transparent 18%), + radial-gradient(circle at bottom right, rgba(255, 191, 173, 0.18), transparent 24%), + linear-gradient(180deg, #f54d76 0%, #ed4168 100%); + --platform-panel-shadow: + 0 28px 88px rgba(201, 46, 100, 0.18), + 0 12px 32px rgba(255, 255, 255, 0.2); + --platform-panel-fill: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 247, 249, 0.94)); + --platform-panel-fill-soft: + linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 245, 248, 0.78)); + --platform-hero-fill: + linear-gradient(135deg, rgba(255, 31, 111, 0.96), rgba(255, 135, 103, 0.92)); + --platform-hero-glow-a: rgba(255, 255, 255, 0.18); + --platform-hero-glow-b: rgba(255, 197, 219, 0.18); + --platform-surface-border: rgba(255, 255, 255, 0.5); + --platform-surface-hover-border: rgba(255, 154, 188, 0.58); + --platform-shell-glow-1: rgba(255, 255, 255, 0.22); + --platform-shell-glow-2: rgba(255, 186, 205, 0.22); + --platform-shell-glow-3: rgba(255, 197, 158, 0.16); + --platform-surface-glow-a: rgba(255, 165, 195, 0.16); + --platform-surface-glow-b: rgba(255, 196, 160, 0.14); + --platform-text-strong: #28151d; + --platform-text-base: #5c4650; + --platform-text-soft: #886f79; + --platform-line-soft: rgba(233, 183, 202, 0.42); + --platform-subpanel-fill: + linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(255, 244, 248, 0.72)); + --platform-subpanel-border: rgba(234, 193, 208, 0.5); + --platform-warm-border: rgba(255, 140, 116, 0.28); + --platform-warm-bg: rgba(255, 140, 116, 0.14); + --platform-warm-text: #cf4f4e; + --platform-cool-border: rgba(255, 83, 142, 0.24); + --platform-cool-bg: rgba(255, 83, 142, 0.14); + --platform-cool-text: #d93570; + --platform-neutral-border: rgba(232, 191, 205, 0.44); + --platform-neutral-bg: rgba(255, 255, 255, 0.68); + --platform-neutral-text: #715662; + --platform-button-primary-border: rgba(255, 101, 147, 0.3); + --platform-button-primary-fill: linear-gradient(135deg, #ff4f8b, #ff8a73); + --platform-button-primary-text: #fff7fb; + --platform-button-secondary-fill: rgba(255, 255, 255, 0.72); + --platform-button-secondary-text: #4b3340; + --platform-button-ghost-fill: rgba(255, 255, 255, 0.52); + --platform-button-ghost-text: #6e5460; + --platform-button-danger-border: rgba(251, 113, 133, 0.22); + --platform-button-danger-fill: rgba(255, 228, 233, 0.94); + --platform-button-danger-text: #c2415d; + --platform-success-border: rgba(52, 211, 153, 0.24); + --platform-success-bg: rgba(236, 253, 245, 0.92); + --platform-success-text: #0f8a61; + --platform-icon-fill: rgba(255, 255, 255, 0.62); + --platform-icon-border: rgba(232, 191, 205, 0.46); + --platform-icon-text: #7a5d67; + --platform-nav-fill: + linear-gradient(180deg, rgba(255, 255, 255, 0.66), rgba(255, 245, 248, 0.56)); + --platform-nav-active-fill: + linear-gradient(180deg, rgba(255, 91, 132, 0.18), rgba(255, 151, 116, 0.18)); + --platform-nav-active-border: rgba(255, 126, 154, 0.3); + --platform-modal-fill: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 245, 248, 0.95)); + --platform-modal-border: rgba(255, 255, 255, 0.52); + --platform-desktop-shell-fill: + linear-gradient(180deg, rgba(255, 252, 253, 0.98), rgba(255, 242, 246, 0.98)); + --platform-desktop-shell-border: rgba(255, 255, 255, 0.48); + --platform-desktop-shell-inner-border: rgba(236, 204, 215, 0.64); + --platform-desktop-topbar-fill: + linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 246, 249, 0.74)); + --platform-desktop-panel-fill: + linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 246, 249, 0.72)); + --platform-desktop-panel-border: rgba(235, 195, 209, 0.46); + --platform-desktop-hover-shadow: 0 16px 28px rgba(222, 82, 124, 0.12); + --platform-overlay-fill: + linear-gradient(180deg, rgba(239, 78, 122, 0.22), rgba(255, 255, 255, 0.42)); + --platform-track-border: rgba(234, 193, 208, 0.52); + --platform-track-fill: rgba(255, 255, 255, 0.7); +} + +.platform-theme--dark { + color-scheme: dark; + --platform-body-fill: + radial-gradient(circle at top, rgba(129, 140, 248, 0.2), transparent 30%), + radial-gradient(circle at top right, rgba(59, 130, 246, 0.12), transparent 24%), + radial-gradient(circle at bottom left, rgba(34, 211, 238, 0.08), transparent 26%), + linear-gradient(180deg, #13151c 0%, #090b11 100%); + --platform-panel-shadow: 0 28px 88px rgba(5, 8, 28, 0.5); + --platform-panel-fill: + linear-gradient(180deg, rgba(22, 20, 52, 0.95), rgba(7, 9, 21, 0.98)); + --platform-panel-fill-soft: + linear-gradient(180deg, rgba(122, 92, 255, 0.12), rgba(255, 255, 255, 0.03)); + --platform-hero-fill: + radial-gradient(circle at top left, rgba(129, 140, 248, 0.24), transparent 34%), + radial-gradient(circle at right, rgba(34, 211, 238, 0.12), transparent 32%), + linear-gradient(180deg, rgba(17, 22, 66, 0.95), rgba(8, 10, 24, 0.98)); + --platform-hero-glow-a: rgba(129, 140, 248, 0.18); + --platform-hero-glow-b: rgba(34, 211, 238, 0.1); + --platform-surface-border: rgba(160, 169, 255, 0.12); + --platform-surface-hover-border: rgba(255, 255, 255, 0.16); + --platform-shell-glow-1: rgba(129, 140, 248, 0.2); + --platform-shell-glow-2: rgba(59, 130, 246, 0.12); + --platform-shell-glow-3: rgba(34, 211, 238, 0.08); + --platform-surface-glow-a: rgba(129, 140, 248, 0.18); + --platform-surface-glow-b: rgba(34, 211, 238, 0.1); + --platform-text-strong: #ffffff; + --platform-text-base: rgb(228 228 231); + --platform-text-soft: rgb(161 161 170); + --platform-line-soft: rgba(255, 255, 255, 0.1); + --platform-subpanel-fill: rgba(255, 255, 255, 0.05); + --platform-subpanel-border: rgba(255, 255, 255, 0.1); + --platform-warm-border: rgba(167, 139, 250, 0.24); + --platform-warm-bg: rgba(109, 40, 217, 0.18); + --platform-warm-text: rgb(237 233 254); + --platform-cool-border: rgba(103, 232, 249, 0.22); + --platform-cool-bg: rgba(8, 145, 178, 0.14); + --platform-cool-text: rgb(236 254 255); + --platform-neutral-border: rgba(255, 255, 255, 0.08); + --platform-neutral-bg: rgba(255, 255, 255, 0.05); + --platform-neutral-text: rgb(228 228 231); + --platform-button-primary-border: rgba(129, 140, 248, 0.3); + --platform-button-primary-fill: linear-gradient(135deg, #5b6cff, #3dd9ff); + --platform-button-primary-text: rgb(238 248 255); + --platform-button-secondary-fill: rgba(255, 255, 255, 0.05); + --platform-button-secondary-text: rgb(244 244 245); + --platform-button-ghost-fill: rgba(255, 255, 255, 0.03); + --platform-button-ghost-text: rgb(212 212 216); + --platform-button-danger-border: rgba(251, 113, 133, 0.2); + --platform-button-danger-fill: rgba(244, 63, 94, 0.1); + --platform-button-danger-text: rgb(255 228 230); + --platform-success-border: rgba(52, 211, 153, 0.24); + --platform-success-bg: rgba(16, 185, 129, 0.1); + --platform-success-text: rgb(220 252 231); + --platform-icon-fill: rgba(255, 255, 255, 0.05); + --platform-icon-border: rgba(255, 255, 255, 0.1); + --platform-icon-text: rgb(212 212 216); + --platform-nav-fill: + linear-gradient(180deg, rgba(109, 40, 217, 0.12), rgba(255, 255, 255, 0.03)); + --platform-nav-active-fill: + linear-gradient(180deg, rgba(91, 108, 255, 0.28), rgba(61, 217, 255, 0.1)); + --platform-nav-active-border: rgba(160, 169, 255, 0.18); + --platform-modal-fill: + linear-gradient(180deg, rgba(16, 18, 46, 0.98), rgba(7, 8, 19, 0.98)); + --platform-modal-border: rgba(160, 169, 255, 0.12); + --platform-desktop-shell-fill: + linear-gradient(180deg, rgba(8, 8, 30, 0.98), rgba(5, 6, 18, 0.99)); + --platform-desktop-shell-border: rgba(160, 169, 255, 0.14); + --platform-desktop-shell-inner-border: rgba(255, 255, 255, 0.03); + --platform-desktop-topbar-fill: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.03)); + --platform-desktop-panel-fill: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)); + --platform-desktop-panel-border: rgba(160, 169, 255, 0.12); + --platform-desktop-hover-shadow: 0 16px 28px rgba(8, 11, 38, 0.2); + --platform-overlay-fill: rgba(5, 8, 28, 0.72); + --platform-track-border: rgba(255, 255, 255, 0.12); + --platform-track-fill: rgba(255, 255, 255, 0.08); +} + +.platform-brand-logo { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; +} + +.platform-brand-logo__title, +.platform-brand-logo__subtitle { + font-family: "Noto Serif SC", "Inter", ui-sans-serif, system-ui, sans-serif !important; +} + +.platform-brand-logo__title { + font-size: clamp(1.95rem, 5vw, 2.7rem); + font-weight: 700; + line-height: 0.96; + letter-spacing: 0.18em; + color: #fffdf7; + text-shadow: + 0 2px 0 rgba(3, 7, 18, 0.88), + 0 10px 28px rgba(0, 0, 0, 0.4), + 0 0 18px rgba(129, 140, 248, 0.16); +} + +.platform-brand-logo__subtitle { + padding-left: 0.14rem; + font-size: clamp(0.58rem, 1.8vw, 0.72rem); + font-family: "Inter", ui-sans-serif, system-ui, sans-serif !important; + font-weight: 600; + line-height: 1; + letter-spacing: 0.34em; + text-transform: uppercase; + color: rgba(228, 228, 231, 0.82); + text-shadow: + 0 1px 0 rgba(3, 7, 18, 0.88), + 0 8px 20px rgba(0, 0, 0, 0.34), + 0 0 12px rgba(34, 211, 238, 0.14); +} + +.platform-main-shell { + position: relative; +} + +.platform-main-shell::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at top, var(--platform-shell-glow-1), transparent 30%), + radial-gradient(circle at top right, var(--platform-shell-glow-2), transparent 24%), + radial-gradient(circle at bottom left, var(--platform-shell-glow-3), transparent 26%); + opacity: 0.9; +} + +.platform-surface { + position: relative; + overflow: hidden; + border: 1px solid var(--platform-surface-border); + border-radius: 1.75rem; + background: var(--platform-panel-fill); + box-shadow: var(--platform-panel-shadow); +} + +.platform-surface::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at top left, var(--platform-surface-glow-a), transparent 34%), + radial-gradient(circle at bottom right, var(--platform-surface-glow-b), transparent 26%); +} + +.platform-surface > * { + position: relative; + z-index: 1; +} + +.platform-surface--soft { + background: var(--platform-panel-fill-soft); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 18px 44px rgba(0, 0, 0, 0.12); +} + +.platform-surface--hero { + background: var(--platform-hero-fill); +} + +.platform-surface--light { + border-color: var(--platform-line-soft); + background: var(--platform-subpanel-fill); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.12); +} + +.platform-surface--light::before { + background: + radial-gradient(circle at top right, var(--platform-hero-glow-a), transparent 32%), + radial-gradient(circle at bottom left, var(--platform-hero-glow-b), transparent 26%); +} + +.platform-interactive-card { + transition: + transform 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease, + background-color 180ms ease, + filter 180ms ease; +} + +.platform-interactive-card:hover { + transform: translateY(-2px); + border-color: var(--platform-surface-hover-border); + box-shadow: 0 28px 60px rgba(0, 0, 0, 0.14); +} + +.platform-interactive-card:active { + transform: translateY(0); + filter: brightness(0.98); +} + +.platform-pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + border-radius: 9999px; + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 0.35rem 0.85rem; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.18em; +} + +.platform-pill--warm { + border-color: var(--platform-warm-border); + background: var(--platform-warm-bg); + color: var(--platform-warm-text); +} + +.platform-pill--cool { + border-color: var(--platform-cool-border); + background: var(--platform-cool-bg); + color: var(--platform-cool-text); +} + +.platform-pill--neutral { + border-color: var(--platform-neutral-border); + background: var(--platform-neutral-bg); + color: var(--platform-neutral-text); +} + +.platform-pill--rose { + border-color: rgba(251, 113, 133, 0.2); + background: rgba(244, 63, 94, 0.1); + color: rgb(255 228 230); +} + +.platform-pill--success { + border-color: var(--platform-success-border); + background: var(--platform-success-bg); + color: var(--platform-success-text); +} + +.platform-tab { + border: 1px solid var(--platform-subpanel-border); + border-radius: 9999px; + background: var(--platform-subpanel-fill); + color: var(--platform-text-base); + transition: + transform 180ms ease, + border-color 180ms ease, + background-color 180ms ease, + color 180ms ease, + box-shadow 180ms ease; +} + +.platform-tab:hover { + transform: translateY(-1px); + border-color: var(--platform-surface-hover-border); + color: var(--platform-text-strong); +} + +.platform-tab--active { + border-color: var(--platform-nav-active-border); + background: var(--platform-nav-active-fill); + color: var(--platform-text-strong); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 12px 28px rgba(255, 91, 132, 0.12); +} + +.platform-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.55rem; + min-height: 2.9rem; + border-radius: 1rem; + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 0.7rem 1rem; + font-size: 0.9375rem; + font-weight: 600; + transition: + transform 180ms ease, + border-color 180ms ease, + background-color 180ms ease, + color 180ms ease, + opacity 180ms ease, + box-shadow 180ms ease; +} + +.platform-button:hover { + transform: translateY(-1px); +} + +.platform-button:active { + transform: translateY(0); +} + +.platform-button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.platform-button--primary { + border-color: var(--platform-button-primary-border); + background: var(--platform-button-primary-fill); + color: var(--platform-button-primary-text); + box-shadow: 0 16px 34px rgba(255, 91, 132, 0.18); +} + +.platform-button--secondary { + background: var(--platform-button-secondary-fill); + color: var(--platform-button-secondary-text); +} + +.platform-button--ghost { + background: var(--platform-button-ghost-fill); + color: var(--platform-button-ghost-text); +} + +.platform-button--danger { + border-color: var(--platform-button-danger-border); + background: var(--platform-button-danger-fill); + color: var(--platform-button-danger-text); +} + +.platform-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 9999px; + border: 1px solid var(--platform-icon-border); + background: var(--platform-icon-fill); + color: var(--platform-icon-text); + transition: + background-color 180ms ease, + border-color 180ms ease, + color 180ms ease, + transform 180ms ease; +} + +.platform-icon-button:hover { + transform: translateY(-1px); + border-color: var(--platform-nav-active-border); + background: var(--platform-nav-active-fill); + color: var(--platform-text-strong); +} + +.platform-bottom-nav { + border: 1px solid var(--platform-desktop-panel-border); + background: var(--platform-nav-fill); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + 0 16px 40px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(20px); +} + +.platform-bottom-nav__button { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + border-radius: 1rem; + padding: 0.35rem 0.5rem; + color: rgb(161 161 170); + transition: + background-color 180ms ease, + color 180ms ease, + border-color 180ms ease, + transform 180ms ease; +} + +.platform-bottom-nav__button:hover { + color: white; + background: rgba(255, 255, 255, 0.04); +} + +.platform-bottom-nav__button--active { + border: 1px solid var(--platform-nav-active-border); + background: var(--platform-nav-active-fill); + color: var(--platform-text-strong); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 12px 28px rgba(255, 91, 132, 0.12); +} + +.platform-modal-shell { + border: 1px solid var(--platform-modal-border); + background: var(--platform-modal-fill); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.16); +} + +.platform-overlay { + background: var(--platform-overlay-fill); +} + +.platform-desktop-shell { + position: relative; + overflow: hidden; + border: 1px solid var(--platform-desktop-shell-border); + border-radius: 2.6rem; + background: var(--platform-desktop-shell-fill); + box-shadow: + 0 40px 120px rgba(0, 0, 0, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.platform-desktop-shell::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at top left, var(--platform-shell-glow-1), transparent 24%), + radial-gradient(circle at top right, var(--platform-shell-glow-2), transparent 18%), + radial-gradient(circle at 50% 0%, var(--platform-shell-glow-3), transparent 30%); +} + +.platform-desktop-shell::after { + content: ''; + position: absolute; + inset: 1rem; + border: 1px solid var(--platform-desktop-shell-inner-border); + border-radius: 2rem; + pointer-events: none; +} + +.platform-desktop-topbar { + position: relative; + overflow: hidden; + border: 1px solid var(--platform-desktop-panel-border); + border-radius: 9999px; + background: var(--platform-desktop-topbar-fill); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 16px 32px rgba(0, 0, 0, 0.08); + backdrop-filter: blur(22px); +} + +.platform-desktop-topbar::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at left, var(--platform-shell-glow-1), transparent 22%), + radial-gradient(circle at right, var(--platform-shell-glow-2), transparent 24%); +} + +.platform-desktop-search { + border: 1px solid var(--platform-desktop-panel-border); + border-radius: 9999px; + background: var(--platform-subpanel-fill); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.platform-desktop-rail { + position: relative; + overflow: hidden; + border: 1px solid var(--platform-desktop-panel-border); + border-radius: 1.9rem; + background: var(--platform-desktop-panel-fill); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 18px 44px rgba(0, 0, 0, 0.08); +} + +.platform-desktop-rail__button { + display: flex; + min-height: 5.25rem; + width: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.6rem; + border: 1px solid transparent; + border-radius: 1.5rem; + color: rgb(161 161 170); + transition: + transform 180ms ease, + border-color 180ms ease, + background-color 180ms ease, + box-shadow 180ms ease, + color 180ms ease; +} + +.platform-desktop-rail__button:hover { + transform: translateY(-1px); + border-color: var(--platform-line-soft); + background: var(--platform-subpanel-fill); + color: var(--platform-text-strong); +} + +.platform-desktop-rail__button--active { + border-color: var(--platform-nav-active-border); + background: var(--platform-nav-active-fill); + color: var(--platform-text-strong); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 14px 28px rgba(255, 91, 132, 0.12); +} + +.platform-desktop-panel { + position: relative; + overflow: hidden; + border: 1px solid var(--platform-desktop-panel-border); + border-radius: 1.9rem; + background: var(--platform-desktop-panel-fill); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 18px 44px rgba(0, 0, 0, 0.08); + backdrop-filter: blur(22px); +} + +.platform-desktop-panel::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at top right, var(--platform-surface-glow-a), transparent 24%), + radial-gradient(circle at bottom left, var(--platform-surface-glow-b), transparent 26%); +} + +.platform-desktop-panel > * { + position: relative; + z-index: 1; +} + +.platform-desktop-trending-item { + position: relative; + overflow: hidden; + border: 1px solid var(--platform-subpanel-border); + border-radius: 1.4rem; + background: var(--platform-subpanel-fill); + transition: + transform 180ms ease, + border-color 180ms ease, + background-color 180ms ease, + box-shadow 180ms ease; +} + +.platform-desktop-trending-item:hover { + transform: translateY(-2px); + border-color: var(--platform-surface-hover-border); + background: rgba(255, 255, 255, 0.9); + box-shadow: var(--platform-desktop-hover-shadow); +} + +.platform-desktop-scroll { + min-height: 0; + overflow-y: auto; + padding-right: 0.25rem; +} + +.platform-auth-card { + border: 1px solid var(--platform-modal-border); + background: var(--platform-modal-fill); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.16); + backdrop-filter: blur(18px); +} + +.platform-subpanel { + border: 1px solid var(--platform-subpanel-border); + background: var(--platform-subpanel-fill); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.platform-input { + min-height: 2.75rem; + width: 100%; + border: 1px solid var(--platform-subpanel-border); + border-radius: 1rem; + background: rgba(255, 255, 255, 0.82); + padding: 0.75rem 1rem; + color: var(--platform-text-strong); + outline: none; + transition: + border-color 180ms ease, + background-color 180ms ease, + box-shadow 180ms ease; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.platform-input::placeholder { + color: var(--platform-text-soft); +} + +.platform-input:focus { + border-color: var(--platform-nav-active-border); + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 0 0 3px rgba(255, 91, 132, 0.12); +} + +.platform-banner { + border-radius: 1rem; + border: 1px solid var(--platform-subpanel-border); + padding: 0.875rem 1rem; +} + +.platform-banner--info { + border-color: var(--platform-cool-border); + background: var(--platform-cool-bg); + color: var(--platform-cool-text); +} + +.platform-banner--warning { + border-color: var(--platform-warm-border); + background: var(--platform-warm-bg); + color: var(--platform-warm-text); +} + +.platform-banner--success { + border-color: var(--platform-success-border); + background: var(--platform-success-bg); + color: var(--platform-success-text); +} + +.platform-banner--danger { + border-color: rgba(251, 113, 133, 0.2); + background: rgba(244, 63, 94, 0.1); + color: #c2415d; +} + +.platform-progress-track { + border: 1px solid var(--platform-track-border); + background: var(--platform-track-fill); +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-desktop-topbar, + .platform-desktop-search, + .platform-desktop-rail, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='text-white'], + [class*='text-zinc-50'], + [class*='text-zinc-100'] +) { + color: var(--platform-text-strong) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-desktop-topbar, + .platform-desktop-search, + .platform-desktop-rail, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='text-zinc-200'], + [class*='text-zinc-300'] +) { + color: var(--platform-text-base) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-desktop-topbar, + .platform-desktop-search, + .platform-desktop-rail, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='text-zinc-400'], + [class*='text-zinc-500'] +) { + color: var(--platform-text-soft) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='bg-black/10'], + [class*='bg-black/16'], + [class*='bg-black/18'], + [class*='bg-black/20'], + [class*='bg-black/22'], + [class*='bg-black/25'], + [class*='bg-black/26'], + [class*='bg-black/30'], + [class*='bg-black/35'], + [class*='bg-black/46'], + [class*='bg-black/55'], + [class*='bg-white/5'], + [class*='bg-white/6'], + [class*='bg-white/8'] +) { + background: var(--platform-subpanel-fill) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='border-white/8'], + [class*='border-white/10'], + [class*='border-white/12'], + [class*='border-white/16'], + [class*='border-white/18'], + [class*='border-white/20'], + [class*='border-white/22'], + [class*='border-white/25'] +) { + border-color: var(--platform-subpanel-border) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='text-sky-50'], + [class*='text-sky-100'], + [class*='text-sky-200'], + [class*='text-sky-300'] +) { + color: var(--platform-cool-text) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='bg-sky-500/8'], + [class*='bg-sky-500/10'], + [class*='bg-sky-500/12'], + [class*='bg-sky-500/15'] +) { + background-color: var(--platform-cool-bg) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='border-sky-200/'], + [class*='border-sky-300/'], + [class*='border-sky-400/'] +) { + border-color: var(--platform-cool-border) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='text-amber-50'], + [class*='text-amber-100'], + [class*='text-amber-200'] +) { + color: var(--platform-warm-text) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='bg-amber-500/8'], + [class*='bg-amber-500/10'], + [class*='bg-amber-500/12'], + [class*='bg-amber-500/15'] +) { + background-color: var(--platform-warm-bg) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='border-amber-300/'], + [class*='border-amber-400/'] +) { + border-color: var(--platform-warm-border) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='text-emerald-50'], + [class*='text-emerald-100'], + [class*='text-emerald-600'] +) { + color: var(--platform-success-text) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='bg-emerald-500/8'], + [class*='bg-emerald-500/10'], + [class*='bg-emerald-500/12'], + [class*='bg-emerald-500/15'] +) { + background-color: var(--platform-success-bg) !important; +} + +.platform-theme--light :where( + .platform-surface:not(.platform-surface--hero), + .platform-modal-shell, + .platform-desktop-panel, + .platform-desktop-trending-item, + .platform-auth-card, + .platform-subpanel +) :where( + [class*='border-emerald-300/'], + [class*='border-emerald-400/'] +) { + border-color: var(--platform-success-border) !important; +} + button { cursor: pointer; } @@ -559,6 +1521,20 @@ button { letter-spacing: 0.28em; } + .platform-brand-logo { + gap: 0.28rem; + } + + .platform-brand-logo__title { + font-size: 1.72rem; + letter-spacing: 0.14em; + } + + .platform-brand-logo__subtitle { + font-size: 0.54rem; + letter-spacing: 0.22em; + } + .world-carousel { --world-card-height: 8.75rem; max-width: 100%; diff --git a/src/persistence/gameSettingsStorage.test.ts b/src/persistence/gameSettingsStorage.test.ts index 823f0c3e..63d61de3 100644 --- a/src/persistence/gameSettingsStorage.test.ts +++ b/src/persistence/gameSettingsStorage.test.ts @@ -3,6 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import { clampVolume, DEFAULT_MUSIC_VOLUME, + normalizePlatformTheme, readSavedSettings, writeSavedSettings, } from './gameSettingsStorage'; @@ -34,6 +35,7 @@ describe('gameSettingsStorage', () => { expect(readSavedSettings()).toEqual({ musicVolume: DEFAULT_MUSIC_VOLUME, + platformTheme: 'light', }); }); @@ -44,18 +46,38 @@ describe('gameSettingsStorage', () => { expect(readSavedSettings()).toEqual({ musicVolume: 1, + platformTheme: 'light', }); }); + it('reads stored platform theme when available', () => { + const storage = createMemoryStorage(); + storage.setItem( + 'tavernrealms.settings.v1', + JSON.stringify({musicVolume: 0.5, platformTheme: 'dark'}), + ); + vi.stubGlobal('window', {localStorage: storage}); + + expect(readSavedSettings()).toEqual({ + musicVolume: 0.5, + platformTheme: 'dark', + }); + expect(normalizePlatformTheme('unknown')).toBe('light'); + }); + it('writes versioned settings payloads', () => { const storage = createMemoryStorage(); vi.stubGlobal('window', {localStorage: storage}); - writeSavedSettings({musicVolume: clampVolume(0.6)}); + writeSavedSettings({ + musicVolume: clampVolume(0.6), + platformTheme: 'dark', + }); expect(JSON.parse(storage.getItem('tavernrealms.settings.v1') ?? '{}')).toEqual({ version: 1, musicVolume: 0.6, + platformTheme: 'dark', }); }); }); diff --git a/src/persistence/gameSettingsStorage.ts b/src/persistence/gameSettingsStorage.ts index 74a011ea..9d45678b 100644 --- a/src/persistence/gameSettingsStorage.ts +++ b/src/persistence/gameSettingsStorage.ts @@ -1,5 +1,7 @@ import { DEFAULT_MUSIC_VOLUME, + DEFAULT_PLATFORM_THEME, + type PlatformTheme, type RuntimeSettings, } from '../../packages/shared/src/contracts/runtime'; import {isRecord, readStoredJson, writeStoredJson} from './storage'; @@ -22,6 +24,10 @@ export function clampVolume(value: number) { return Math.max(0, Math.min(1, value)); } +export function normalizePlatformTheme(value: unknown): PlatformTheme { + return value === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME; +} + function parseSavedSettings(value: unknown): SavedGameSettings | null { if (!isRecord(value)) { return null; @@ -30,12 +36,14 @@ function parseSavedSettings(value: unknown): SavedGameSettings | null { if (value.version === SETTINGS_STORAGE_VERSION && typeof value.musicVolume === 'number') { return { musicVolume: clampVolume(value.musicVolume), + platformTheme: normalizePlatformTheme(value.platformTheme), }; } if (typeof value.musicVolume === 'number') { return { musicVolume: clampVolume(value.musicVolume), + platformTheme: normalizePlatformTheme(value.platformTheme), }; } @@ -49,6 +57,7 @@ export function readSavedSettings() { parse: parseSavedSettings, }) ?? { musicVolume: DEFAULT_MUSIC_VOLUME, + platformTheme: DEFAULT_PLATFORM_THEME, } ); } @@ -57,6 +66,7 @@ export function writeSavedSettings(settings: SavedGameSettings) { const payload: StoredGameSettings = { version: SETTINGS_STORAGE_VERSION, musicVolume: clampVolume(settings.musicVolume), + platformTheme: normalizePlatformTheme(settings.platformTheme), }; return writeStoredJson({ diff --git a/src/services/runtimeStoryService.test.ts b/src/services/runtimeStoryService.test.ts index c01f92f9..5e75d185 100644 --- a/src/services/runtimeStoryService.test.ts +++ b/src/services/runtimeStoryService.test.ts @@ -135,7 +135,7 @@ describe('runtimeStoryService', () => { ); }); - it('filters disabled runtime options when rebuilding a story moment', () => { + it('keeps disabled runtime options when rebuilding a story moment', () => { const story = buildStoryMomentFromRuntimeOptions({ storyText: '服务端返回的新故事', options: [ @@ -155,12 +155,16 @@ describe('runtimeStoryService', () => { }); expect(story.text).toBe('服务端返回的新故事'); - expect(story.options).toHaveLength(1); + expect(story.options).toHaveLength(2); expect(story.options[0]?.functionId).toBe('npc_chat'); + expect(story.options[1]?.functionId).toBe('npc_recruit'); + expect(story.options[1]?.disabled).toBe(true); + expect(story.options[1]?.disabledReason).toBe('队伍已满'); }); it('recognizes server-runtime option pools for server-side legality checks', () => { expect(isTask5RuntimeFunctionId('npc_chat')).toBe(true); + expect(isTask5RuntimeFunctionId('battle_attack_basic')).toBe(true); expect(isTask5RuntimeFunctionId('npc_trade')).toBe(false); expect(isServerRuntimeFunctionId('npc_trade')).toBe(true); expect(isServerRuntimeFunctionId('unknown_action')).toBe(false); diff --git a/src/services/runtimeStoryService.ts b/src/services/runtimeStoryService.ts index 5745caf7..0dfb72ad 100644 --- a/src/services/runtimeStoryService.ts +++ b/src/services/runtimeStoryService.ts @@ -103,15 +103,11 @@ function createRuntimeStoryOption( option: RuntimeStoryOptionView, gameState?: Pick, ): StoryOption { - const detailParts = [option.detailText, option.disabled ? option.reason : null] - .filter(Boolean) - .join(' '); - return { functionId: option.functionId, actionText: option.actionText, text: option.actionText, - detailText: detailParts || undefined, + detailText: option.detailText, visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, @@ -121,6 +117,9 @@ function createRuntimeStoryOption( monsterChanges: [], }, interaction: buildRuntimeOptionInteraction(option, gameState), + runtimePayload: option.payload, + disabled: option.disabled, + disabledReason: option.reason, }; } @@ -162,9 +161,9 @@ export function buildStoryMomentFromRuntimeOptions(params: { }) { return { text: params.storyText, - options: params.options - .filter((option) => !option.disabled) - .map((option) => createRuntimeStoryOption(option, params.gameState)), + options: params.options.map((option) => + createRuntimeStoryOption(option, params.gameState), + ), } satisfies StoryMoment; } diff --git a/src/services/storageService.test.ts b/src/services/storageService.test.ts index 905ee1a5..cc56ca58 100644 --- a/src/services/storageService.test.ts +++ b/src/services/storageService.test.ts @@ -7,6 +7,8 @@ const { requestJsonMock } = vi.hoisted(() => ({ import { clearProfileBrowseHistory, listProfileBrowseHistory, + listProfileSaveArchives, + resumeProfileSaveArchive, syncProfileBrowseHistory, upsertProfileBrowseHistory, } from './storageService'; @@ -103,3 +105,54 @@ describe('storageService browse history routes', () => { ); }); }); + +describe('storageService save archive routes', () => { + beforeEach(() => { + requestJsonMock.mockReset(); + requestJsonMock.mockResolvedValue({ entries: [] }); + }); + + it('reads save archives from the runtime profile route', async () => { + await listProfileSaveArchives(); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/profile/save-archives', + expect.objectContaining({ method: 'GET' }), + '读取存档列表失败', + expect.objectContaining({ + retry: expect.objectContaining({ maxRetries: 1 }), + }), + ); + }); + + it('resumes a save archive through the runtime profile route', async () => { + requestJsonMock.mockResolvedValueOnce({ + entry: { + worldKey: 'custom:world-1', + }, + snapshot: { + version: 2, + savedAt: '2026-04-19T10:15:00.000Z', + bottomTab: 'adventure', + currentStory: null, + gameState: { + worldType: 'CUSTOM', + }, + }, + }); + + await resumeProfileSaveArchive('custom:world-1'); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/runtime/profile/save-archives/custom%3Aworld-1', + expect.objectContaining({ method: 'POST' }), + '恢复存档失败', + expect.objectContaining({ + retry: expect.objectContaining({ + maxRetries: 1, + retryUnsafeMethods: true, + }), + }), + ); + }); +}); diff --git a/src/services/storageService.ts b/src/services/storageService.ts index a5d077e4..ddac231e 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -11,6 +11,9 @@ import type { PlatformBrowseHistoryResponse, PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, + ProfileSaveArchiveListResponse, + ProfileSaveArchiveResumeResponse, + ProfileSaveArchiveSummary, ProfilePlayStatsResponse, ProfileWalletLedgerResponse, RuntimeSettings, @@ -137,6 +140,40 @@ export async function getProfilePlayStats(options: RuntimeRequestOptions = {}) { ); } +export async function listProfileSaveArchives( + options: RuntimeRequestOptions = {}, +) { + const response = await requestRuntimeJson( + '/profile/save-archives', + { method: 'GET' }, + '读取存档列表失败', + options, + ); + + return Array.isArray(response?.entries) ? response.entries : []; +} + +export async function resumeProfileSaveArchive( + worldKey: string, + options: RuntimeRequestOptions = {}, +) { + const response = await requestRuntimeJson< + ProfileSaveArchiveResumeResponse + >( + `/profile/save-archives/${encodeURIComponent(worldKey)}`, + { method: 'POST' }, + '恢复存档失败', + options, + ); + + return { + entry: response.entry, + snapshot: rehydrateSavedSnapshot( + response.snapshot as HydratedSavedGameSnapshot, + ), + }; +} + export async function putSettings( settings: RuntimeSettings, options: RuntimeRequestOptions = {}, @@ -363,6 +400,8 @@ export const runtimeStorageClient = { getProfileDashboard, getProfileWalletLedger, getProfilePlayStats, + listProfileSaveArchives, + resumeProfileSaveArchive, listCustomWorldLibrary, listCustomWorldWorks, upsertCustomWorldProfile, @@ -379,3 +418,4 @@ export const runtimeStorageClient = { export type { CustomWorldLibraryEntry }; export type { PlatformBrowseHistoryEntry }; +export type { ProfileSaveArchiveSummary }; diff --git a/src/tools/QwenSpriteSheetTool.tsx b/src/tools/QwenSpriteSheetTool.tsx index 01d18cb9..264742a7 100644 --- a/src/tools/QwenSpriteSheetTool.tsx +++ b/src/tools/QwenSpriteSheetTool.tsx @@ -66,9 +66,9 @@ const ACTION_GENERATION_MODE_OPTIONS = [ { label: '方案一:直接生成精灵表', value: 'direct-sheet' }, { label: '方案二:图生视频后抽帧', value: 'image-to-video' }, ]; -const FIXED_IMAGE_TO_VIDEO_MODEL = 'wan2.2-kf2v-flash'; -const FIXED_IMAGE_TO_VIDEO_RESOLUTION = '480P'; -const FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS = 5; +const FIXED_IMAGE_TO_VIDEO_MODEL = 'doubao-seedance-2-0-fast-260128'; +const FIXED_IMAGE_TO_VIDEO_RESOLUTION = '480p'; +const FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS = 4; function mapActionTemplateIdToAnimationState( actionTemplateId: QwenSpriteActionTemplateId, @@ -420,7 +420,6 @@ export default function QwenSpriteSheetTool() { setSheetStatus(null); try { if (actionGenerationMode === 'image-to-video') { - const isLoopAction = actionTemplate.loop; const result = await generateCharacterAnimationDraft({ characterId: assetKey || 'qwen-sprite-tool', strategy: 'image-to-video', @@ -429,15 +428,16 @@ export default function QwenSpriteSheetTool() { visualSource: selectedMasterSource, referenceImageDataUrls: [], referenceVideoDataUrls: [], - lastFrameImageDataUrl: isLoopAction ? undefined : selectedMasterSource, + lastFrameImageDataUrl: selectedMasterSource, frameCount: 16, fps: actionTemplate.defaultFps, durationSeconds: FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS, loop: actionTemplate.loop, useChromaKey, - resolution: isLoopAction ? '720P' : FIXED_IMAGE_TO_VIDEO_RESOLUTION, + resolution: FIXED_IMAGE_TO_VIDEO_RESOLUTION, + ratio: '1:1', imageSequenceModel: 'wan2.7-image-pro', - videoModel: isLoopAction ? 'wan2.6-i2v-flash' : FIXED_IMAGE_TO_VIDEO_MODEL, + videoModel: FIXED_IMAGE_TO_VIDEO_MODEL, referenceVideoModel: 'wan2.7-r2v', motionTransferModel: 'wan2.2-animate-move', }); @@ -775,7 +775,7 @@ export default function QwenSpriteSheetTool() { {actionGenerationMode === 'image-to-video' ? ( <>
- 当前走图生视频方案:工具会自动把主图送进官方图生视频模型 `wan2.2-kf2v-flash`,固定按 `480P` 生成,再自动抽成 16 帧精灵表。 + 当前走图生视频方案:工具会自动把主图送进官方首尾帧视频模型 `doubao-seedance-2-0-fast-260128`,固定按 `1:1 / 480p / 4 秒` 生成,再自动抽成 16 帧精灵表。
diff --git a/src/types/story.ts b/src/types/story.ts index 1d2d9ee3..f2c2c652 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -23,6 +23,9 @@ export interface StoryOption { skillProbabilities?: Record; interaction?: StoryOptionInteraction; goalAffordance?: StoryOptionGoalAffordance | null; + runtimePayload?: Record; + disabled?: boolean; + disabledReason?: string; } export interface QuestReward { diff --git a/tmp_volc_seedance_doc.html b/tmp_volc_seedance_doc.html new file mode 100644 index 00000000..6532ecb1 --- /dev/null +++ b/tmp_volc_seedance_doc.html @@ -0,0 +1,13 @@ +Seedance 2.0 API 参考官方文档--方舟大模型服务平台-火山引擎 + + +
文档中心
火山方舟

火山方舟

复制全文
下载 pdf
视频生成 API
创建视频生成任务 API
复制全文
下载 pdf
创建视频生成任务 API
最近更新时间:2026.04.17 16:42:08
这个页面对您有帮助吗?
有用
有用
无用
无用
+