From 91b63675ebcfb6715a975729b415d8baf00fe66a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Thu, 16 Apr 2026 15:45:00 +0800 Subject: [PATCH] 1 --- .claude/settings.local.json | 15 + .env.local | 8 +- ...TION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md | 212 +++ docs/audits/README.md | 1 + .../assets/characterAssetRoutes.test.ts | 340 +++++ .../modules/assets/characterAssetRoutes.ts | 66 +- .../customWorldAgentFoundationDraftService.ts | 1151 ++++++++++++++++- .../services/customWorldAgentOrchestrator.ts | 304 +++-- server-node/src/services/llmClient.ts | 25 + .../src/services/sceneImageService.test.ts | 199 +++ server-node/src/services/sceneImageService.ts | 154 ++- src/components/CustomWorldEntityCatalog.tsx | 694 ++++++---- src/components/CustomWorldGenerationView.tsx | 8 +- src/components/CustomWorldResultView.tsx | 104 +- .../CustomWorldAgentDraftDrawer.tsx | 4 +- .../CustomWorldAgentLockBar.tsx | 14 +- .../CustomWorldAgentQuickActions.tsx | 16 +- .../CustomWorldAgentThread.test.tsx | 65 + .../CustomWorldAgentThread.tsx | 25 +- ...omWorldAgentWorkspace.interaction.test.tsx | 18 +- .../CustomWorldAgentWorkspace.test.tsx | 15 +- .../CustomWorldAgentWorkspace.tsx | 92 +- .../game-shell/PlatformHomeView.tsx | 8 +- .../game-shell/PlatformWorldDetailView.tsx | 16 +- ...meSelectionFlow.agent.interaction.test.tsx | 222 ++++ .../game-shell/PreGameSelectionFlow.tsx | 505 ++++++-- src/data/characterPresets.ts | 17 +- src/data/customWorldLibrary.ts | 537 +++++--- src/data/customWorldSceneGraph.ts | 2 + src/data/equipmentEffects.ts | 9 +- src/data/npcInteractions.ts | 37 +- src/hooks/story/storyCampCompanion.test.ts | 32 +- src/hooks/story/storyCampCompanion.ts | 7 +- src/hooks/story/storyResponseOptions.test.ts | 37 + src/hooks/story/storyResponseOptions.ts | 63 +- src/hooks/useGameFlow.customWorld.test.tsx | 385 ++++++ src/hooks/useGameFlow.ts | 165 ++- src/services/customWorld.ts | 13 +- .../customWorldAgentDraftResult.test.ts | 607 +++++++++ src/services/customWorldAgentDraftResult.ts | 242 ++++ ...customWorldAgentGenerationProgress.test.ts | 6 +- .../customWorldAgentGenerationProgress.ts | 23 +- view-llm-logs.ps1 | 42 + 43 files changed, 5652 insertions(+), 853 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md create mode 100644 server-node/src/modules/assets/characterAssetRoutes.test.ts create mode 100644 server-node/src/services/sceneImageService.test.ts create mode 100644 src/components/custom-world-agent/CustomWorldAgentThread.test.tsx create mode 100644 src/hooks/useGameFlow.customWorld.test.tsx create mode 100644 src/services/customWorldAgentDraftResult.test.ts create mode 100644 src/services/customWorldAgentDraftResult.ts create mode 100644 view-llm-logs.ps1 diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..31b5b2e8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(npm test:*)", + "Bash(netstat -ano)", + "Bash(npm run:*)", + "Bash(findstr :8081)", + "Bash(taskkill:*)", + "Bash(findstr LISTENING)", + "Bash(npx tsc:*)", + "Bash(lsof -ti:8081)", + "Bash(curl -s http://localhost:8081/health)" + ] + } +} diff --git a/.env.local b/.env.local index a7ccaac6..c026869b 100644 --- a/.env.local +++ b/.env.local @@ -13,4 +13,10 @@ SMS_AUTH_ENABLED="true" SMS_AUTH_PROVIDER="mock" SMS_AUTH_MOCK_VERIFY_CODE="123456" -VITE_AUTH_ALLOW_DEV_GUEST="false" +VITE_AUTH_ALLOW_DEV_GUEST="true" + +# 启用服务端大模型调试日志(记录所有输入输出) +LLM_DEBUG_LOG="true" + +# 注意:不要在客户端启用调试日志,避免敏感数据泄露 +# VITE_LLM_DEBUG_LOG="false" diff --git a/docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md b/docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md new file mode 100644 index 00000000..8e011b49 --- /dev/null +++ b/docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md @@ -0,0 +1,212 @@ +# Function 运行时完整测试审计(2026-04-16) + +## 1. 本次目标 + +本次不是泛泛地跑一遍前端页面,而是围绕“游戏运行中的 function 主链路”做系统化核查,重点确认下面 4 件事: + +- 当前运行时 function 集合是否还能正确构建、过滤、排序和解析。 +- function 命中的前端承接链路是否仍然稳定。 +- 已服务端化的 runtime action function 是否还能闭环执行。 +- 工程门禁是否处于可持续回归的状态。 + +## 2. 本次实际覆盖范围 + +本轮重点覆盖了这些实现入口: + +- `src/data/stateFunctions.ts` +- `src/data/functionCatalog/**` +- `src/data/npcInteractions.ts` +- `src/hooks/story/**` +- `src/services/runtimeStoryService.ts` +- `src/hooks/useGameFlow.ts` +- `server-node/src/modules/story/**` +- `server-node/src/modules/inventory/**` +- `server-node/src/modules/runtime-item/**` +- `server-node/src/modules/quest/**` +- `scripts/smoke-server-node.ts` + +## 3. 实际执行的测试与检查 + +### 3.1 前端 Vitest 全量测试 + +执行命令: + +```bash +npm.cmd test +``` + +结果: + +- `110` 个测试文件全部通过 +- `276` 条测试全部通过 + +其中与 function 主链路直接相关、并已确认通过的测试包括: + +- `src/data/stateFunctions.test.ts` +- `src/data/functionCatalog/functionCatalog.test.ts` +- `src/data/npcInteractions.test.ts` +- `src/hooks/story/choiceActions.test.ts` +- `src/hooks/story/storyGenerationState.test.ts` +- `src/hooks/story/runtimeStoryCoordinator.test.ts` +- `src/components/AdventurePanel.test.tsx` +- `src/services/runtimeStoryService.test.ts` + +补充说明: + +- 运行 `hostileNpcPresets.test.ts` 时会看到 “network disabled in test” 的日志。 +- 这不是本轮失败项,测试已验证在 LLM 不可达时会正确走 deterministic fallback。 + +### 3.2 内容数据校验 + +执行命令: + +```bash +npm.cmd run check:content +``` + +结果: + +- `check:data` 通过 +- `check:overrides` 通过 +- `check:smoke` 通过 +- 输出为:`Content validation passed. scenes=24 monsters=16 characters=5 functions=12` + +结论: + +- 当前基础内容数据、override 与 smoke 内容校验没有发现新的 function 数据层问题。 + +### 3.3 服务端测试 + +执行命令: + +```bash +npm.cmd run server-node:test +``` + +结果: + +- `108` 条服务端测试全部通过 + +本轮已确认通过的 function 相关服务端能力包括: + +- `inventory_use` +- `equipment_equip` +- `npc_trade` +- `npc_gift` +- `npc_quest_accept` +- `npc_quest_turn_in` +- `treasure_inspect` +- 战斗结算与 quest signal 推进 + +结论: + +- 已服务端化的 runtime function 承接链路当前单测层面是稳定的。 + +### 3.4 服务端 smoke + +执行命令: + +```bash +npm.cmd run server-node:smoke +``` + +结果: + +- 失败 +- 报错: + +```text +TypeError: Cannot read properties of undefined (reading 'enabled') +``` + +### 3.5 TypeScript 类型检查 + +执行命令: + +```bash +npm.cmd run typecheck +``` + +结果: + +- 失败 +- 失败位置: + - `src/hooks/useGameFlow.ts:339` + +### 3.6 生产构建 + +执行命令: + +```bash +npm.cmd run build +``` + +结果: + +- 通过 + +结论: + +- 当前代码可构建,但并不代表类型门禁与 smoke 门禁同样健康。 + +## 4. 本轮确认的问题 + +## P1:`server-node:smoke` 已失效,无法完成服务端运行时冒烟验证 + +- 现象: + - `npm.cmd run server-node:smoke` 无法启动临时 Express 服务,直接在 `createSmsVerificationService` 处报错。 +- 直接原因: + - [`scripts/smoke-server-node.ts`](../../scripts/smoke-server-node.ts) 里的 `createSmokeConfig()` 只构造了 `llm`、`dashScope` 等字段,没有补齐后续新增的 `smsAuth`、`wechatAuth`、`authSession` 配置块。 + - [`server-node/src/config.ts`](../../server-node/src/config.ts) 中 `AppConfig` 已把这些字段定义为必需项。 + - [`server-node/src/services/smsVerificationService.ts`](../../server-node/src/services/smsVerificationService.ts) 在 `createSmsVerificationService` 中直接读取 `config.smsAuth.enabled`,因此 smoke 配置一进入 `createAppContext` 就会崩。 +- 影响: + - 本地无法再用 smoke 脚本验证“服务端真实启动 + 认证 + runtime save/settings 回路”。 + - 这会让 function 服务端化之后的真实接线路径少掉一层最接近运行时的保护。 +- 判断: + - 这是本轮最明确、最稳定复现的真实 bug。 + +## P1:`typecheck` 门禁已破,`useGameFlow` 的 starter inventory 合并存在类型漂移 + +- 现象: + - `npm.cmd run typecheck` 在 [`src/hooks/useGameFlow.ts`](../../src/hooks/useGameFlow.ts) 第 `339` 行失败。 +- 直接原因: + - 同文件中的 `mergeStarterInventoryItems` 会优先从显式 starter item 推断出一个“字段更严格”的 `T`。 + - 但 fallback 侧传入的是 [`InventoryItem`](../../src/types/items.ts) 数组,而 `InventoryItem.description`、`equipmentSlotId`、`runtimeMetadata` 等字段本身是可选的。 + - 于是 `explicitItems` 与 `fallbackItems` 在泛型推断后不再兼容,类型门禁被打穿。 +- 影响: + - 当前 starter inventory 初始化链路虽然能跑、也能通过构建,但已经失去 TypeScript 对结构一致性的保护。 + - 这类问题后续很容易演变成“自定义世界显式初始物品”和“默认初始物品”在字段形态上继续分叉。 +- 判断: + - 这是一个真实的工程 bug,优先级高于纯测试文案问题。 + +## 5. 本轮已确认通过的结论 + +- state function 运行时构建、过滤、优先级、选项解析当前测试全绿。 +- function catalog 文档映射、helper option、trade/gift/recruit modal helper 当前测试全绿。 +- 前端 function 主链路相关测试在最新工作区状态下已全部通过。 +- 服务端 runtime story action、inventory、runtime-item、quest 承接链路当前测试全绿。 +- 内容数据层校验通过。 +- 生产构建通过。 + +## 6. 结论 + +如果只看“游戏运行中的 function 主链路”,当前结论是: + +- 前端 function 运行时逻辑:通过 +- 服务端 function action 承接:通过 +- 内容数据与 function 基础目录:通过 +- 工程回归门禁:不完整 + +当前最需要处理的不是再扩 function 范围,而是先修复这两个门禁缺口: + +1. 修好 `server-node:smoke` 的配置构造,让服务端冒烟恢复可用。 +2. 修好 `useGameFlow` 的 starter inventory 类型漂移,让 `typecheck` 回到绿色基线。 + +## 7. 备注 + +本轮没有做两类事情: + +- 没有接入真实外部 LLM 做在线回归,本轮依赖的是本地测试里已有的 fallback 断言。 +- 没有人手逐个点击整局游戏所有 function 的视觉回放,本轮重点是自动化测试、服务端测试、内容校验与 smoke 门禁。 + +因此,本审计可以说明“当前 function 系统的自动化测试层状况”,但不等于“所有视觉演出与在线模型联动都已人工验证完毕”。 diff --git a/docs/audits/README.md b/docs/audits/README.md index 3bc09ac9..e7516ac1 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -12,6 +12,7 @@ - [FUNCTION_DESIGN_AUDIT_2026-04-03.md](./FUNCTION_DESIGN_AUDIT_2026-04-03.md):Function 体系分层、职责边界和当前结构问题。 - [FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md](./FUNCTION_REQUIREMENT_COMPLETENESS_AUDIT_2026-04-14.md):Function 相关文档需求与当前实现对齐核查。 - [FUNCTION_TEST_AUDIT_2026-04-14.md](./FUNCTION_TEST_AUDIT_2026-04-14.md):Function 运行时测试补充、已确认 bug 与当前验证结果。 +- [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):自定义世界创作工具当前问题、体验断层和优化优先级审计。 diff --git a/server-node/src/modules/assets/characterAssetRoutes.test.ts b/server-node/src/modules/assets/characterAssetRoutes.test.ts new file mode 100644 index 00000000..b059735e --- /dev/null +++ b/server-node/src/modules/assets/characterAssetRoutes.test.ts @@ -0,0 +1,340 @@ +import assert from 'node:assert/strict'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import express from 'express'; + +import type { AppConfig } from '../../config.js'; +import { createCharacterAssetRoutes } from './characterAssetRoutes.js'; + +const PNG_BUFFER = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=', + 'base64', +); +const MP4_BUFFER = Buffer.from('mock-video'); + +function createTestConfig( + projectRoot: string, + dashScopeBaseUrl: string, +): AppConfig { + return { + projectRoot, + assetsApiEnabled: true, + rawEnv: { + DASHSCOPE_BASE_URL: dashScopeBaseUrl, + DASHSCOPE_API_KEY: 'test-dashscope-key', + }, + } as AppConfig; +} + +function readRequestBody(req: IncomingMessage) { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function sendJson(res: ServerResponse, payload: unknown) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(payload)); +} + +async function withHttpServer( + buildHandler: (baseUrl: string) => ( + req: IncomingMessage, + res: ServerResponse, + ) => void | Promise, + run: (baseUrl: string) => Promise, +) { + let handler: ( + req: IncomingMessage, + res: ServerResponse, + ) => void | Promise = () => undefined; + const server = createServer((req, res) => { + Promise.resolve(handler(req, res)).catch((error) => { + res.statusCode = 500; + res.end(error instanceof Error ? error.stack : String(error)); + }); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('failed to resolve test server address'); + } + + const baseUrl = `http://127.0.0.1:${address.port}`; + handler = buildHandler(baseUrl); + + try { + return await run(baseUrl); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +async function withAssetRouteServer( + config: AppConfig, + run: (baseUrl: string) => Promise, +) { + const app = express(); + app.use(express.json({ limit: '25mb' })); + app.use(createCharacterAssetRoutes(config)); + + const server = await new Promise((resolve) => { + const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); + }); + + try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('failed to resolve asset route server address'); + } + + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +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 publicDir = path.join(tempRoot, 'public'); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'reference.png'), PNG_BUFFER); + + let createPayloadText = ''; + + await withHttpServer( + (dashScopeBaseUrl) => async (req, res) => { + const url = new URL(req.url || '/', dashScopeBaseUrl); + if ( + req.method === 'POST' && + url.pathname === '/api/v1/services/aigc/image-generation/generation' + ) { + createPayloadText = (await readRequestBody(req)).toString('utf8'); + sendJson(res, { + output: { + task_id: 'visual-task-1', + }, + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/api/v1/tasks/visual-task-1') { + sendJson(res, { + output: { + task_status: 'SUCCEEDED', + results: [ + { + url: `${dashScopeBaseUrl}/downloads/visual.png`, + }, + ], + }, + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/downloads/visual.png') { + res.statusCode = 200; + res.setHeader('Content-Type', 'image/png'); + res.end(PNG_BUFFER); + return; + } + + res.statusCode = 404; + res.end('not found'); + }, + async (dashScopeBaseUrl) => { + const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); + await withAssetRouteServer(config, async (assetBaseUrl) => { + const response = await fetch( + `${assetBaseUrl}/api/assets/character-visual/generate`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + characterId: 'harbor-guide', + sourceMode: 'image-to-image', + promptText: '潮雾港向导', + characterBriefText: '旧港守望者', + referenceImageDataUrls: ['/reference.png'], + candidateCount: 1, + imageModel: 'wan2.7-image-pro', + size: '1024*1536', + }), + }, + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as { + drafts: Array<{ imageSrc: string }>; + }; + assert.equal(payload.drafts.length, 1); + + const createPayload = JSON.parse(createPayloadText) as { + input: { + messages: Array<{ + content: Array<{ text?: string; image?: string }>; + }>; + }; + }; + const content = createPayload.input.messages[0]?.content ?? []; + assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u); + + const savedDraftPath = path.join(tempRoot, 'public', payload.drafts[0]!.imageSrc.slice(1)); + assert.equal(fs.existsSync(savedDraftPath), true); + }); + }, + ); +}); + +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-')); + 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); + + if (req.method === 'GET' && url.pathname === '/api/v1/uploads') { + sendJson(res, { + data: { + upload_host: `${dashScopeBaseUrl}/upload`, + upload_dir: 'uploads/test-dir', + policy: 'policy', + signature: 'signature', + oss_access_key_id: 'oss-key', + }, + }); + 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' + ) { + 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`, + }, + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') { + res.statusCode = 200; + res.setHeader('Content-Type', 'video/mp4'); + res.end(MP4_BUFFER); + return; + } + + res.statusCode = 404; + res.end('not found'); + }, + async (dashScopeBaseUrl) => { + const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`); + await withAssetRouteServer(config, async (assetBaseUrl) => { + const response = await fetch( + `${assetBaseUrl}/api/assets/character-animation/generate`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + characterId: 'harbor-guide', + strategy: 'image-to-video', + animation: 'idle', + promptText: '站立观察海面', + characterBriefText: '旧港守望者', + visualSource: '/visual.png', + referenceImageDataUrls: [], + referenceVideoDataUrls: [], + frameCount: 8, + fps: 8, + durationSeconds: 4, + loop: true, + useChromaKey: true, + resolution: '720P', + imageSequenceModel: 'wan2.7-image-pro', + videoModel: 'wan2.7-i2v', + referenceVideoModel: 'wan2.7-r2v', + motionTransferModel: 'wan2.2-animate-move', + }), + }, + ); + + assert.equal(response.status, 200); + 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 }>; + }; + }; + assert.equal(videoPayload.input.media[0]?.type, 'first_frame'); + assert.match(videoPayload.input.media[0]?.url ?? '', /^oss:\/\/uploads\/test-dir\//u); + + const savedVideoPath = path.join(tempRoot, 'public', payload.previewVideoPath.slice(1)); + assert.equal(fs.existsSync(savedVideoPath), true); + }); + }, + ); +}); diff --git a/server-node/src/modules/assets/characterAssetRoutes.ts b/server-node/src/modules/assets/characterAssetRoutes.ts index 9fb3634d..d37ac39c 100644 --- a/server-node/src/modules/assets/characterAssetRoutes.ts +++ b/server-node/src/modules/assets/characterAssetRoutes.ts @@ -867,35 +867,40 @@ async function handleGenerateCharacterVisuals( 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 }, - ...referenceImageDataUrls.map((image) => ({ image })), - ]; - const createTaskResponse = await proxyJsonRequest( - `${baseUrl}/services/aigc/image-generation/generation`, - apiKey, - { - model, - input: { - messages: [ - { - role: 'user', - content, - }, - ], + ...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, + }, }, - parameters: { - n: candidateCount, - size, - prompt_extend: true, - watermark: false, + { + 'X-DashScope-Async': 'enable', }, - }, - { - 'X-DashScope-Async': 'enable', - }, - ); + ); if ( createTaskResponse.statusCode < 200 || @@ -1178,6 +1183,15 @@ async function handleGenerateCharacterAnimation( frameCount, useChromaKey, ); + const normalizedVisualSource = await resolveMediaSourceAsDataUrl( + rootDir, + visualSource, + ); + const normalizedReferenceImages = await Promise.all( + referenceImageDataUrls.map((image) => + resolveMediaSourceAsDataUrl(rootDir, image), + ), + ); activePrompt = finalPrompt; activeModel = imageSequenceModel; const createTaskResponse = await proxyJsonRequest( @@ -1191,8 +1205,8 @@ async function handleGenerateCharacterAnimation( role: 'user', content: [ { text: finalPrompt }, - { image: visualSource }, - ...referenceImageDataUrls.map((image) => ({ image })), + { image: normalizedVisualSource }, + ...normalizedReferenceImages.map((image) => ({ image })), ], }, ], diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.ts b/server-node/src/services/customWorldAgentFoundationDraftService.ts index 79662b71..904a03de 100644 --- a/server-node/src/services/customWorldAgentFoundationDraftService.ts +++ b/server-node/src/services/customWorldAgentFoundationDraftService.ts @@ -6,12 +6,37 @@ import type { CustomWorldFoundationDraftProfile, CustomWorldFoundationDraftThread, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; +import { + buildCustomWorldFrameworkJsonRepairPrompt, + buildCustomWorldFrameworkPrompt, + buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt, + buildCustomWorldLandmarkNetworkBatchPrompt, + buildCustomWorldLandmarkSeedBatchJsonRepairPrompt, + buildCustomWorldLandmarkSeedBatchPrompt, + buildCustomWorldRawProfileFromFramework, + buildCustomWorldRoleBatchJsonRepairPrompt, + buildCustomWorldRoleBatchPrompt, + buildCustomWorldRoleOutlineBatchJsonRepairPrompt, + buildCustomWorldRoleOutlineBatchPrompt, + type CustomWorldGenerationFramework, + type CustomWorldGenerationLandmarkOutline, + type CustomWorldGenerationRoleBatchStage, + type CustomWorldGenerationRoleBatchType, + type CustomWorldGenerationRoleOutline, + normalizeCustomWorldGenerationFramework, + normalizeCustomWorldGenerationLandmarkOutlineBatch, + normalizeCustomWorldGenerationRoleOutlineBatch, +} from '../../../src/services/customWorld.js'; +import { buildExpandedCustomWorldProfile } from '../../../src/services/customWorldBuilder.js'; +import type { CustomWorldProfile } from '../../../src/types.js'; import { buildDraftSummaryFromIntent, - normalizeCreatorIntentRecord, type CreatorCharacterSeedRecord, type CustomWorldCreatorIntentRecord, + normalizeCreatorIntentRecord, } from './customWorldAgentIntentExtractionService.js'; +import type { UpstreamLlmClient } from './llmClient.js'; function toText(value: unknown) { return typeof value === 'string' ? value.trim() : ''; @@ -72,13 +97,6 @@ function buildCompactLabel(text: string, fallback: string, maxLength = 14) { return clampText(normalized || fallback, maxLength) || fallback; } -function splitSentences(text: string) { - return text - .split(/[。!?;\n]/u) - .map((entry) => entry.trim()) - .filter(Boolean); -} - function extractConflictSides(conflict: string) { const relationMatch = conflict.match( /([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}?)(?:与|和|及)([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}?)(?:争夺|对抗|角力|围绕|拉扯|较量|冲突)/u, @@ -87,7 +105,11 @@ function extractConflictSides(conflict: string) { return [relationMatch[1].trim(), relationMatch[2].trim()]; } - return [...conflict.matchAll(/([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}(?:会|盟|门|宗|阁|府|庭|院|司|营|局|军|团|殿|邦|教|社|帮|署))/gu)] + return [ + ...conflict.matchAll( + /([A-Za-z0-9\u4e00-\u9fa5·-]{2,12}(?:会|盟|门|宗|阁|府|庭|院|司|营|局|军|团|殿|邦|教|社|帮|署))/gu, + ), + ] .map((entry) => entry[1]?.trim() || '') .filter(Boolean) .slice(0, 3); @@ -147,21 +169,29 @@ function convertElementToLandmarkName(element: string) { } function buildWorldName(intent: CustomWorldCreatorIntentRecord) { - const worldHook = sanitizeEntityName(intent.worldHook || intent.rawSettingText); + const worldHook = sanitizeEntityName( + intent.worldHook || intent.rawSettingText, + ); const namedMatch = worldHook.match( /([A-Za-z0-9\u4e00-\u9fa5·-]{2,16}(?:列岛|群岛|王朝|帝国|海域|边境|疆域|之城|之境|之域|城邦|废都|王庭|海岸|高地))/u, ); return ( - clampText(namedMatch?.[1] || worldHook || intent.iconicElements[0] || '', 18) || - '未命名世界底稿' + clampText( + namedMatch?.[1] || worldHook || intent.iconicElements[0] || '', + 18, + ) || '未命名世界底稿' ); } function buildTone(intent: CustomWorldCreatorIntentRecord) { return ( dedupeStrings( - [...intent.themeKeywords, ...intent.toneDirectives, ...intent.iconicElements], + [ + ...intent.themeKeywords, + ...intent.toneDirectives, + ...intent.iconicElements, + ], 8, ).join('、') || '紧绷、未明、带着继续展开的空间' ); @@ -275,9 +305,11 @@ function buildBaseThreads(params: { openingSituation: string; iconicElements: string[]; }): CustomWorldFoundationDraftThread[] { - const firstConflict = params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向'; + const firstConflict = + params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向'; const hiddenSeed = - params.intent.keyCharacters.find((entry) => entry.hiddenHook.trim())?.hiddenHook || + params.intent.keyCharacters.find((entry) => entry.hiddenHook.trim()) + ?.hiddenHook || params.iconicElements[0] || '表面冲突背后还有更深的一层'; const relationshipSeed = @@ -292,19 +324,28 @@ function buildBaseThreads(params: { title: buildCompactLabel(firstConflict, '主线推进', 16), type: 'main' as const, conflict: firstConflict, - summary: clampText(`明线围绕“${firstConflict}”推进,玩家一入局就会被迫参与。`, 90), + summary: clampText( + `明线围绕“${firstConflict}”推进,玩家一入局就会被迫参与。`, + 90, + ), }, { title: buildCompactLabel(hiddenSeed, '暗线回潮', 16), type: 'hidden' as const, conflict: hiddenSeed, - summary: clampText(`暗线真正牵动的不是表面立场,而是“${buildCompactLabel(hiddenSeed, '暗线真相', 18)}”。`, 90), + summary: clampText( + `暗线真正牵动的不是表面立场,而是“${buildCompactLabel(hiddenSeed, '暗线真相', 18)}”。`, + 90, + ), }, { title: buildCompactLabel(relationshipSeed, '关系裂口', 16), type: 'main' as const, conflict: relationshipSeed, - summary: clampText(`玩家身边的关系与身份会决定这条线最先从哪里裂开。`, 90), + summary: clampText( + `玩家身边的关系与身份会决定这条线最先从哪里裂开。`, + 90, + ), }, ...(extraSeed ? [ @@ -369,30 +410,42 @@ function buildCharacterFromSeed(params: { threads: CustomWorldFoundationDraftThread[]; coreConflict: string; }): CustomWorldFoundationDraftCharacter { - const hiddenThreadId = params.threads.find((entry) => entry.type === 'hidden')?.id; + const hiddenThreadId = params.threads.find( + (entry) => entry.type === 'hidden', + )?.id; const mainThreadId = params.threads[0]?.id ?? null; const relationThreadId = params.threads[2]?.id ?? hiddenThreadId ?? null; return { - id: params.seed.id || createId('character', params.seed.name || params.seed.role, params.index), + id: + params.seed.id || + createId('character', params.seed.name || params.seed.role, params.index), name: sanitizeEntityName(params.seed.name) || - buildCompactLabel(params.seed.role || params.seed.relationToPlayer, '关键角色', 10), + buildCompactLabel( + params.seed.role || params.seed.relationToPlayer, + '关键角色', + 10, + ), title: clampText(params.seed.role || '关键人物', 18) || '关键人物', role: clampText(params.seed.role || '关键人物', 28) || '关键人物', publicIdentity: - clampText(params.seed.publicMask || params.seed.role || '站在当前局势前台的人', 36) || - '站在当前局势前台的人', + clampText( + params.seed.publicMask || params.seed.role || '站在当前局势前台的人', + 36, + ) || '站在当前局势前台的人', currentPressure: clampText(params.seed.hiddenHook || params.coreConflict, 48) || '正在被当前局势不断加压', relationToPlayer: - clampText(params.seed.relationToPlayer || '会直接改变玩家的第一步选择', 36) || - '会直接改变玩家的第一步选择', + clampText( + params.seed.relationToPlayer || '会直接改变玩家的第一步选择', + 36, + ) || '会直接改变玩家的第一步选择', threadIds: dedupeStrings( [ - params.seed.hiddenHook ? hiddenThreadId ?? '' : '', - params.seed.relationToPlayer ? relationThreadId ?? '' : '', + params.seed.hiddenHook ? (hiddenThreadId ?? '') : '', + params.seed.relationToPlayer ? (relationThreadId ?? '') : '', mainThreadId ?? '', ], 3, @@ -414,13 +467,18 @@ function buildGeneratedCharacters(params: { const suffixes = ['联络人', '记录官', '引路人', '修补匠', '代言人']; const generated: CustomWorldFoundationDraftCharacter[] = []; const mainThreadId = params.threads[0]?.id ?? null; - const hiddenThreadId = params.threads.find((entry) => entry.type === 'hidden')?.id; + const hiddenThreadId = params.threads.find( + (entry) => entry.type === 'hidden', + )?.id; const relationThreadId = params.threads[2]?.id ?? mainThreadId; params.factions.forEach((faction, index) => { const prefix = - buildCompactLabel(faction.name.replace(/(会|盟|庭|局|司|府|团|营|帮)$/u, ''), '关键', 6) || - buildCompactLabel(params.iconicElements[index] || '', '关键', 6); + buildCompactLabel( + faction.name.replace(/(会|盟|庭|局|司|府|团|营|帮)$/u, ''), + '关键', + 6, + ) || buildCompactLabel(params.iconicElements[index] || '', '关键', 6); const name = `${prefix}${suffixes[index % suffixes.length]}`; if (params.existingNames.includes(name)) { return; @@ -434,9 +492,14 @@ function buildGeneratedCharacters(params: { publicIdentity: `${faction.name}的前台接口人`, currentPressure: faction.relatedConflict || params.coreConflict, relationToPlayer: - index === 0 ? '会主动把玩家拉进局势中心' : '对玩家既有利用价值也有试探意图', + index === 0 + ? '会主动把玩家拉进局势中心' + : '对玩家既有利用价值也有试探意图', threadIds: dedupeStrings( - [mainThreadId ?? '', index % 2 === 0 ? relationThreadId ?? '' : hiddenThreadId ?? ''], + [ + mainThreadId ?? '', + index % 2 === 0 ? (relationThreadId ?? '') : (hiddenThreadId ?? ''), + ], 3, ), summary: clampText( @@ -456,7 +519,8 @@ function buildCharacters(params: { coreConflicts: string[]; iconicElements: string[]; }) { - const firstConflict = params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向'; + const firstConflict = + params.coreConflicts[0] || '旧秩序与新力量正在拉扯世界走向'; const characters: CustomWorldFoundationDraftCharacter[] = []; const playerProxy = buildPlayerProxyCharacter( params.intent, @@ -495,9 +559,10 @@ function buildCharacters(params: { characters.push(entry); }); - return dedupeStrings(characters.map((entry) => entry.name), 5).map( - (name) => characters.find((entry) => entry.name === name)!, - ); + return dedupeStrings( + characters.map((entry) => entry.name), + 5, + ).map((name) => characters.find((entry) => entry.name === name)!); } function buildCamp(params: { @@ -551,7 +616,9 @@ function buildLandmarks(params: { [ ...explicit.map((entry) => entry.name), openingPlace, - ...params.iconicElements.map((entry) => convertElementToLandmarkName(entry)), + ...params.iconicElements.map((entry) => + convertElementToLandmarkName(entry), + ), conflictTarget ? looksLikePlaceName(conflictTarget) ? conflictTarget @@ -569,14 +636,17 @@ function buildLandmarks(params: { const threadIds = dedupeStrings( [ params.threads[index % Math.max(1, params.threads.length)]?.id ?? '', - params.threads[(index + 1) % Math.max(1, params.threads.length)]?.id ?? '', + params.threads[(index + 1) % Math.max(1, params.threads.length)]?.id ?? + '', ], 3, ); const characterIds = dedupeStrings( [ - params.characters[index % Math.max(1, params.characters.length)]?.id ?? '', - params.characters[(index + 1) % Math.max(1, params.characters.length)]?.id ?? '', + params.characters[index % Math.max(1, params.characters.length)]?.id ?? + '', + params.characters[(index + 1) % Math.max(1, params.characters.length)] + ?.id ?? '', ], 3, ); @@ -640,12 +710,14 @@ function finalizeThreads(params: { characterIds, landmarkIds, summary: clampText( - `${thread.type === 'hidden' ? '暗线' : '明线'}围绕“${buildCompactLabel(thread.conflict, thread.title, 18)}”推进,相关对象包括${[ - characterIds.length > 0 ? `${characterIds.length} 名关键角色` : '', - landmarkIds.length > 0 ? `${landmarkIds.length} 个关键地点` : '', - ] - .filter(Boolean) - .join('、') || '当前第一批底稿对象'}。`, + `${thread.type === 'hidden' ? '暗线' : '明线'}围绕“${buildCompactLabel(thread.conflict, thread.title, 18)}”推进,相关对象包括${ + [ + characterIds.length > 0 ? `${characterIds.length} 名关键角色` : '', + landmarkIds.length > 0 ? `${landmarkIds.length} 个关键地点` : '', + ] + .filter(Boolean) + .join('、') || '当前第一批底稿对象' + }。`, 120, ), }; @@ -669,7 +741,10 @@ function buildChapter(params: { return { id: 'chapter-first-act', - title: clampText(`第一幕:${buildCompactLabel(params.worldName, '世界开幕', 12)}`, 18), + title: clampText( + `第一幕:${buildCompactLabel(params.worldName, '世界开幕', 12)}`, + 18, + ), openingEvent, playerGoal: params.playerGoal, characterIds, @@ -687,8 +762,962 @@ 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; +const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE = 2; +const FOUNDATION_LANDMARK_BATCH_SIZE = 2; +const FOUNDATION_ROLE_DETAIL_BATCH_SIZE = 2; +const FOUNDATION_LLM_TIMEOUT_MS = 90000; + +type DraftProgressPayload = { + phaseLabel: string; + phaseDetail: string; + progress: number; +}; + +type DraftProgressCallback = ( + payload: DraftProgressPayload, +) => void | Promise; + +type MergeableNamedRecord = Record & { + name: string; +}; + +function getNamedRecordKey(value: unknown) { + return toText(value).replace(/\s+/gu, ''); +} + +function chunkArray(items: T[], size: number) { + if (size <= 0 || items.length === 0) { + return items.length === 0 ? [] : [items]; + } + + const chunks: T[][] = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +} + +function mergeRoleBatchDetails( + baseEntries: T[], + detailEntries: Array>, +) { + const nextEntries = baseEntries.map((entry) => ({ ...entry })) as T[]; + const availableIndexes = new Set(nextEntries.map((_, index) => index)); + const indexByName = new Map(); + + nextEntries.forEach((entry, index) => { + const name = getNamedRecordKey(entry.name); + if (name) { + indexByName.set(name, index); + } + }); + + detailEntries.forEach((detail) => { + const detailName = getNamedRecordKey(detail.name); + let targetIndex = + detailName && indexByName.has(detailName) + ? indexByName.get(detailName) + : undefined; + + if (targetIndex === undefined) { + for (const index of availableIndexes) { + targetIndex = index; + break; + } + } + + if (targetIndex === undefined) { + return; + } + + const baseEntry = nextEntries[targetIndex]; + if (!baseEntry) { + return; + } + + nextEntries[targetIndex] = { + ...baseEntry, + ...detail, + name: getNamedRecordKey(baseEntry.name) || detailName || baseEntry.name, + } as T; + availableIndexes.delete(targetIndex); + }); + + return nextEntries; +} + +function appendUniqueNamedEntries( + baseEntries: T[], + nextEntries: T[], + maxCount: number, +) { + const merged = baseEntries.map((entry) => ({ ...entry })) as T[]; + const existingNames = new Set( + merged.map((entry) => getNamedRecordKey(entry.name)).filter(Boolean), + ); + + nextEntries.forEach((entry) => { + if (merged.length >= maxCount) { + return; + } + + const name = getNamedRecordKey(entry.name); + if (!name || existingNames.has(name)) { + return; + } + + merged.push({ ...entry, name } as T); + existingNames.add(name); + }); + + return merged; +} + +function extractJsonPayload(text: string) { + const trimmed = text.trim(); + if (!trimmed) { + return ''; + } + + const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu); + const unfenced = fencedMatch?.[1]?.trim() || trimmed; + const firstBrace = unfenced.indexOf('{'); + const firstBracket = unfenced.indexOf('['); + const starts = [firstBrace, firstBracket].filter((value) => value >= 0); + const start = starts.length > 0 ? Math.min(...starts) : -1; + + if (start < 0) { + return unfenced; + } + + const opener = unfenced[start]; + const closer = opener === '[' ? ']' : '}'; + const end = unfenced.lastIndexOf(closer); + if (end > start) { + return unfenced.slice(start, end + 1); + } + + return unfenced.slice(start); +} + +function sanitizeJsonLikeText(text: string) { + return extractJsonPayload(text) + .replace(/^\uFEFF/u, '') + .replace(/[\u201C\u201D]/gu, '"') + .replace(/[\u2018\u2019]/gu, "'") + .replace(/\u00A0/gu, ' ') + .replace(/,\s*([}\]])/gu, '$1') + .trim(); +} + +function buildFoundationGenerationSeedText(params: { + intent: CustomWorldCreatorIntentRecord; + anchorPack: unknown; +}) { + const anchorRecord = toRecord(params.anchorPack); + const anchorSummary = toText(anchorRecord?.creatorIntentSummary); + if (anchorSummary) { + return anchorSummary; + } + + const sections = [ + params.intent.worldHook ? `世界核心:${params.intent.worldHook}` : '', + params.intent.playerPremise + ? `玩家身份:${params.intent.playerPremise}` + : '', + params.intent.openingSituation + ? `开局处境:${params.intent.openingSituation}` + : '', + params.intent.coreConflicts.length > 0 + ? `核心冲突:${params.intent.coreConflicts.join('、')}` + : '', + params.intent.iconicElements.length > 0 + ? `标志元素:${params.intent.iconicElements.join('、')}` + : '', + ].filter(Boolean); + + return sections.join('\n') || buildDraftSummaryFromIntent(params.intent); +} + +async function emitDraftProgress( + onProgress: DraftProgressCallback | undefined, + payload: DraftProgressPayload, +) { + if (!onProgress) { + return; + } + + await onProgress({ + ...payload, + progress: Math.max(0, Math.min(100, Math.round(payload.progress))), + }); +} + +function toBatchProgress( + start: number, + end: number, + completed: number, + total: number, +) { + if (total <= 0) { + return end; + } + + const ratio = Math.max(0, Math.min(1, completed / total)); + return start + (end - start) * ratio; +} + +async function requestFoundationJsonStage(params: { + llmClient: UpstreamLlmClient; + userPrompt: string; + debugLabel: string; + repairPromptBuilder: (responseText: string) => string; + repairDebugLabel: string; + emptyResponseMessage: string; + signal?: AbortSignal; +}) { + const responseText = await params.llmClient.requestMessageContent({ + systemPrompt: FOUNDATION_JSON_ONLY_SYSTEM_PROMPT, + userPrompt: params.userPrompt, + signal: params.signal, + timeoutMs: FOUNDATION_LLM_TIMEOUT_MS, + debugLabel: params.debugLabel, + }); + + const text = typeof responseText === 'string' ? responseText.trim() : ''; + if (!text) { + throw new Error(params.emptyResponseMessage); + } + + try { + return parseJsonResponseText(text); + } catch { + const sanitized = sanitizeJsonLikeText(text); + if (sanitized && sanitized !== text) { + try { + return parseJsonResponseText(sanitized); + } catch { + // Fall through to model-assisted repair. + } + } + + const repairedText = await params.llmClient.requestMessageContent({ + systemPrompt: FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT, + userPrompt: params.repairPromptBuilder(text), + signal: params.signal, + timeoutMs: Math.min(FOUNDATION_LLM_TIMEOUT_MS, 60000), + debugLabel: params.repairDebugLabel, + }); + + return parseJsonResponseText( + sanitizeJsonLikeText(repairedText) || repairedText, + ); + } +} + +async function generateFoundationRoleOutlineEntries(params: { + llmClient: UpstreamLlmClient; + framework: CustomWorldGenerationFramework; + roleType: CustomWorldGenerationRoleBatchType; + totalCount: number; + batchSize: number; + signal?: AbortSignal; + onProgress?: DraftProgressCallback; + progressRange: [number, number]; +}) { + const plannedBatchCount = Math.max( + 1, + Math.ceil(params.totalCount / params.batchSize), + ); + const roleLabel = params.roleType === 'playable' ? '可扮演角色' : '场景角色'; + let mergedEntries: CustomWorldGenerationRoleOutline[] = []; + + for ( + let batchIndex = 0; + batchIndex < plannedBatchCount && mergedEntries.length < params.totalCount; + batchIndex += 1 + ) { + const batchCount = Math.min( + params.batchSize, + params.totalCount - mergedEntries.length, + ); + await emitDraftProgress(params.onProgress, { + phaseLabel: `生成${roleLabel}`, + phaseDetail: `正在生成${roleLabel}第 ${batchIndex + 1} / ${plannedBatchCount} 批,当前已完成 ${mergedEntries.length}/${params.totalCount}。`, + progress: toBatchProgress( + params.progressRange[0], + params.progressRange[1], + mergedEntries.length, + params.totalCount, + ), + }); + + const batchRaw = await requestFoundationJsonStage({ + llmClient: params.llmClient, + userPrompt: buildCustomWorldRoleOutlineBatchPrompt({ + framework: params.framework, + roleType: params.roleType, + batchCount, + forbiddenNames: mergedEntries.map((entry) => entry.name), + }), + debugLabel: `agent-foundation-${params.roleType}-outline-batch-${batchIndex + 1}`, + repairPromptBuilder: (responseText) => + buildCustomWorldRoleOutlineBatchJsonRepairPrompt({ + responseText, + roleType: params.roleType, + expectedCount: batchCount, + forbiddenNames: mergedEntries.map((entry) => entry.name), + }), + repairDebugLabel: `agent-foundation-${params.roleType}-outline-batch-${batchIndex + 1}-json-repair`, + emptyResponseMessage: `${roleLabel}第 ${batchIndex + 1} 批没有返回有效内容。`, + signal: params.signal, + }); + + mergedEntries = appendUniqueNamedEntries( + mergedEntries, + normalizeCustomWorldGenerationRoleOutlineBatch( + batchRaw, + params.roleType, + ) as MergeableNamedRecord[] as CustomWorldGenerationRoleOutline[], + params.totalCount, + ); + } + + await emitDraftProgress(params.onProgress, { + phaseLabel: `生成${roleLabel}`, + phaseDetail: `${roleLabel}名单已整理完成,共 ${mergedEntries.length} 个。`, + progress: params.progressRange[1], + }); + + return mergedEntries; +} + +async function generateFoundationLandmarkSeedEntries(params: { + llmClient: UpstreamLlmClient; + framework: CustomWorldGenerationFramework; + totalCount: number; + batchSize: number; + signal?: AbortSignal; + onProgress?: DraftProgressCallback; + progressRange: [number, number]; +}) { + const plannedBatchCount = Math.max( + 1, + Math.ceil(params.totalCount / params.batchSize), + ); + let mergedEntries: CustomWorldGenerationLandmarkOutline[] = []; + + for ( + let batchIndex = 0; + batchIndex < plannedBatchCount && mergedEntries.length < params.totalCount; + batchIndex += 1 + ) { + const batchCount = Math.min( + params.batchSize, + params.totalCount - mergedEntries.length, + ); + await emitDraftProgress(params.onProgress, { + phaseLabel: '生成关键场景', + phaseDetail: `正在生成关键场景第 ${batchIndex + 1} / ${plannedBatchCount} 批,当前已完成 ${mergedEntries.length}/${params.totalCount}。`, + progress: toBatchProgress( + params.progressRange[0], + params.progressRange[1], + mergedEntries.length, + params.totalCount, + ), + }); + + const batchRaw = await requestFoundationJsonStage({ + llmClient: params.llmClient, + userPrompt: buildCustomWorldLandmarkSeedBatchPrompt({ + framework: params.framework, + batchCount, + forbiddenNames: mergedEntries.map((entry) => entry.name), + }), + debugLabel: `agent-foundation-landmark-seed-batch-${batchIndex + 1}`, + repairPromptBuilder: (responseText) => + buildCustomWorldLandmarkSeedBatchJsonRepairPrompt({ + responseText, + expectedCount: batchCount, + forbiddenNames: mergedEntries.map((entry) => entry.name), + }), + repairDebugLabel: `agent-foundation-landmark-seed-batch-${batchIndex + 1}-json-repair`, + emptyResponseMessage: `关键场景第 ${batchIndex + 1} 批没有返回有效内容。`, + signal: params.signal, + }); + + mergedEntries = appendUniqueNamedEntries( + mergedEntries, + normalizeCustomWorldGenerationLandmarkOutlineBatch( + batchRaw, + ) as MergeableNamedRecord[] as CustomWorldGenerationLandmarkOutline[], + params.totalCount, + ); + } + + await emitDraftProgress(params.onProgress, { + phaseLabel: '生成关键场景', + phaseDetail: `关键场景骨架已整理完成,共 ${mergedEntries.length} 个。`, + progress: params.progressRange[1], + }); + + return mergedEntries; +} + +async function expandFoundationLandmarkNetworkEntries(params: { + llmClient: UpstreamLlmClient; + framework: CustomWorldGenerationFramework; + storyNpcs: CustomWorldGenerationFramework['storyNpcs']; + baseEntries: CustomWorldGenerationLandmarkOutline[]; + batchSize: number; + signal?: AbortSignal; + onProgress?: DraftProgressCallback; + progressRange: [number, number]; +}) { + let mergedEntries = params.baseEntries.map((entry) => ({ ...entry })); + const batches = chunkArray(params.framework.landmarks, params.batchSize); + let processedCount = 0; + + for (const [batchIndex, landmarkBatch] of batches.entries()) { + await emitDraftProgress(params.onProgress, { + phaseLabel: '建立场景连接', + phaseDetail: `正在补全场景连接第 ${batchIndex + 1} / ${batches.length} 批,当前已完成 ${processedCount}/${params.framework.landmarks.length}。`, + progress: toBatchProgress( + params.progressRange[0], + params.progressRange[1], + processedCount, + params.framework.landmarks.length, + ), + }); + + const batchRaw = await requestFoundationJsonStage({ + llmClient: params.llmClient, + userPrompt: buildCustomWorldLandmarkNetworkBatchPrompt({ + framework: params.framework, + landmarkBatch, + storyNpcs: params.storyNpcs, + }), + debugLabel: `agent-foundation-landmark-network-batch-${batchIndex + 1}`, + repairPromptBuilder: (responseText) => + buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt({ + responseText, + expectedNames: landmarkBatch.map((landmark) => landmark.name), + }), + repairDebugLabel: `agent-foundation-landmark-network-batch-${batchIndex + 1}-json-repair`, + emptyResponseMessage: `场景连接第 ${batchIndex + 1} 批没有返回有效内容。`, + signal: params.signal, + }); + + mergedEntries = mergeRoleBatchDetails( + mergedEntries as MergeableNamedRecord[], + normalizeCustomWorldGenerationLandmarkOutlineBatch(batchRaw), + ) as CustomWorldGenerationLandmarkOutline[]; + processedCount = Math.min( + params.framework.landmarks.length, + processedCount + landmarkBatch.length, + ); + } + + await emitDraftProgress(params.onProgress, { + phaseLabel: '建立场景连接', + phaseDetail: '关键场景的角色分布与路径连接已经整理完成。', + progress: params.progressRange[1], + }); + + return mergedEntries; +} + +async function expandFoundationRoleEntries(params: { + llmClient: UpstreamLlmClient; + framework: CustomWorldGenerationFramework; + roleType: CustomWorldGenerationRoleBatchType; + baseEntries: CustomWorldGenerationRoleOutline[]; + stage: CustomWorldGenerationRoleBatchStage; + batchSize: number; + signal?: AbortSignal; + onProgress?: DraftProgressCallback; + progressRange: [number, number]; +}) { + const roleLabel = params.roleType === 'playable' ? '可扮演角色' : '场景角色'; + const stageLabel = params.stage === 'narrative' ? '叙事基础' : '档案细节'; + const batches = chunkArray(params.baseEntries, params.batchSize); + let mergedEntries = params.baseEntries.map((entry) => ({ ...entry })); + let processedCount = 0; + + for (const [batchIndex, roleBatch] of batches.entries()) { + await emitDraftProgress(params.onProgress, { + phaseLabel: `补全${roleLabel}${stageLabel}`, + phaseDetail: `正在补全${roleLabel}${stageLabel}第 ${batchIndex + 1} / ${batches.length} 批,当前已完成 ${processedCount}/${params.baseEntries.length}。`, + progress: toBatchProgress( + params.progressRange[0], + params.progressRange[1], + processedCount, + params.baseEntries.length, + ), + }); + + const stageRaw = await requestFoundationJsonStage({ + llmClient: params.llmClient, + userPrompt: buildCustomWorldRoleBatchPrompt({ + framework: params.framework, + roleType: params.roleType, + roleBatch, + stage: params.stage, + }), + debugLabel: `agent-foundation-${params.roleType}-${params.stage}-batch-${batchIndex + 1}`, + repairPromptBuilder: (responseText) => + buildCustomWorldRoleBatchJsonRepairPrompt({ + responseText, + roleType: params.roleType, + expectedNames: roleBatch.map((role) => getNamedRecordKey(role.name)), + stage: params.stage, + }), + repairDebugLabel: `agent-foundation-${params.roleType}-${params.stage}-batch-${batchIndex + 1}-json-repair`, + emptyResponseMessage: `${roleLabel}${stageLabel}第 ${batchIndex + 1} 批没有返回有效内容。`, + signal: params.signal, + }); + + const detailEntries = Array.isArray( + stageRaw && typeof stageRaw === 'object' + ? (stageRaw as Record)[ + params.roleType === 'playable' ? 'playableNpcs' : 'storyNpcs' + ] + : [], + ) + ? (((stageRaw as Record)[ + params.roleType === 'playable' ? 'playableNpcs' : 'storyNpcs' + ] as Array>) ?? []) + : []; + + mergedEntries = mergeRoleBatchDetails( + mergedEntries as MergeableNamedRecord[], + detailEntries, + ) as CustomWorldGenerationRoleOutline[]; + processedCount = Math.min( + params.baseEntries.length, + processedCount + roleBatch.length, + ); + } + + await emitDraftProgress(params.onProgress, { + phaseLabel: `补全${roleLabel}${stageLabel}`, + phaseDetail: `${roleLabel}${stageLabel}已经整理完成。`, + progress: params.progressRange[1], + }); + + return mergedEntries; +} + +function buildDraftFactionsFromRuntimeProfile(profile: CustomWorldProfile) { + const factionNames = dedupeStrings(profile.majorFactions, 4); + const firstConflict = profile.coreConflicts[0] || profile.summary; + + return factionNames.slice(0, 4).map((name, index) => { + const relatedConflict = + profile.coreConflicts[ + index % Math.max(1, profile.coreConflicts.length) + ] || firstConflict; + return { + id: createId('faction', name, index), + name, + title: name, + publicGoal: clampText( + extractConflictTarget(relatedConflict) + ? `拿下${extractConflictTarget(relatedConflict)}的主导权` + : '在失衡局势里先抢到主动权', + 28, + ), + relatedConflict, + tension: clampText(relatedConflict, 48), + playerRelation: clampText( + index === 0 + ? '它会主动影响玩家的第一步站位' + : '玩家迟早要和它发生直接交集', + 32, + ), + summary: clampText( + `${name}围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”持续施压,也会直接影响玩家的开局判断。`, + 120, + ), + } satisfies CustomWorldFoundationDraftFaction; + }); +} + +function buildDraftThreadsFromRuntimeProfile(profile: CustomWorldProfile) { + const graphThreads = [ + ...(profile.storyGraph?.visibleThreads ?? []).slice(0, 2), + ...(profile.storyGraph?.hiddenThreads ?? []).slice(0, 2), + ]; + + if (graphThreads.length > 0) { + return graphThreads.map( + (thread, index) => + ({ + id: thread.id || createId('thread', thread.title, index), + title: clampText(thread.title, 18), + type: thread.visibility === 'hidden' ? 'hidden' : 'main', + conflictType: clampText(thread.conflictType, 18), + conflict: clampText(thread.summary || thread.stakes, 72), + stakes: clampText(thread.stakes, 48), + characterIds: thread.involvedActorIds.slice(0, 4), + landmarkIds: thread.relatedLocationIds.slice(0, 4), + summary: clampText(thread.summary, 120), + }) satisfies CustomWorldFoundationDraftThread, + ); + } + + return profile.coreConflicts.slice(0, 3).map((conflict, index) => ({ + id: createId('thread', conflict, index), + title: buildCompactLabel(conflict, `主线${index + 1}`, 16), + type: index === 1 ? 'hidden' : 'main', + conflict, + characterIds: [], + landmarkIds: [], + summary: clampText(`这条线围绕“${conflict}”持续推进。`, 80), + })); +} + +function buildDraftCharactersFromRuntimeProfile( + roles: CustomWorldProfile['playableNpcs'] | CustomWorldProfile['storyNpcs'], + fallbackThreadIds: string[], +) { + return roles.map((role) => ({ + id: role.id, + name: role.name, + title: clampText(role.title || role.role, 18) || '关键角色', + role: clampText(role.role || role.title, 28) || '关键角色', + publicIdentity: + clampText( + role.narrativeProfile?.publicMask || + role.backstoryReveal.publicSummary || + role.description, + 36, + ) || '站在局势前台的人', + publicMask: + clampText( + role.narrativeProfile?.firstContactMask || role.personality, + 36, + ) || undefined, + currentPressure: + clampText( + role.narrativeProfile?.immediatePressure || + role.motivation || + role.backstory, + 48, + ) || '正在被当前局势不断加压', + hiddenHook: + clampText( + role.narrativeProfile?.hiddenLine || + role.backstoryReveal.chapters[2]?.content || + role.backstory, + 48, + ) || undefined, + relationToPlayer: + clampText( + role.relationshipHooks[0] || + role.narrativeProfile?.visibleLine || + role.motivation, + 36, + ) || '会直接改变玩家的下一步选择', + threadIds: + role.narrativeProfile?.relatedThreadIds?.slice(0, 3) ?? + fallbackThreadIds.slice(0, 3), + summary: + clampText(role.description || role.backstoryReveal.publicSummary, 120) || + '这个角色会持续推动当前世界底稿继续展开。', + })) satisfies CustomWorldFoundationDraftCharacter[]; +} + +function buildDraftLandmarksFromRuntimeProfile( + profile: CustomWorldProfile, + threads: CustomWorldFoundationDraftThread[], +) { + return profile.landmarks + .slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT) + .map((landmark) => { + const relatedThreadIds = threads + .filter((thread) => thread.landmarkIds.includes(landmark.id)) + .map((thread) => thread.id) + .slice(0, 3); + + return { + id: landmark.id, + name: landmark.name, + description: clampText(landmark.description, 48) || undefined, + purpose: clampText(landmark.description, 28) || '承接关键剧情推进', + mood: + clampText( + landmark.narrativeResidues?.[0]?.summary || + landmark.dangerLevel || + '带着明显风险的关键地点', + 24, + ) || '带着明显风险的关键地点', + importance: clampText( + landmark.narrativeResidues?.[0]?.changeHint || + landmark.description || + '和当前主线冲突直接勾连的关键地点', + 60, + ), + secret: + clampText( + landmark.narrativeResidues?.[0]?.hiddenTruth || + landmark.connections[0]?.summary || + '', + 36, + ) || undefined, + dangerLevel: landmark.dangerLevel, + characterIds: landmark.sceneNpcIds.slice(0, 4), + threadIds: relatedThreadIds, + summary: clampText( + landmark.description || + landmark.narrativeResidues?.[0]?.summary || + '', + 120, + ), + } satisfies CustomWorldFoundationDraftLandmark; + }); +} + +function convertRuntimeProfileToFoundationDraft(params: { + profile: CustomWorldProfile; + intent: CustomWorldCreatorIntentRecord; + anchorPack: unknown; +}) { + const factions = buildDraftFactionsFromRuntimeProfile(params.profile); + const threads = buildDraftThreadsFromRuntimeProfile(params.profile); + const playableNpcs = buildDraftCharactersFromRuntimeProfile( + params.profile.playableNpcs.slice(0, FOUNDATION_DRAFT_PLAYABLE_COUNT), + threads.slice(0, 2).map((entry) => entry.id), + ); + const storyNpcs = buildDraftCharactersFromRuntimeProfile( + params.profile.storyNpcs.slice(0, FOUNDATION_DRAFT_STORY_COUNT), + threads.slice(1, 3).map((entry) => entry.id), + ); + const landmarks = buildDraftLandmarksFromRuntimeProfile( + params.profile, + threads, + ); + const chapter = buildChapter({ + worldName: params.profile.name, + openingSituation: + clampText(params.intent.openingSituation, 60) || params.profile.summary, + playerGoal: params.profile.playerGoal, + characters: [...playableNpcs, ...storyNpcs], + landmarks, + threads, + }); + const anchorRecord = toRecord(params.anchorPack); + + return { + name: params.profile.name, + subtitle: params.profile.subtitle, + summary: params.profile.summary, + tone: params.profile.tone, + playerGoal: params.profile.playerGoal, + majorFactions: + params.profile.majorFactions.length > 0 + ? params.profile.majorFactions + : factions.map((entry) => entry.name), + coreConflicts: + params.profile.coreConflicts.length > 0 + ? params.profile.coreConflicts + : [params.profile.summary], + playableNpcs, + storyNpcs, + landmarks, + camp: params.profile.camp + ? ({ + id: 'camp-home', + name: params.profile.camp.name, + description: params.profile.camp.description, + mood: clampText(params.profile.tone, 36) || '紧绷但还可暂时收住局势', + dangerLevel: params.profile.camp.dangerLevel, + summary: clampText(params.profile.camp.description, 88), + } satisfies CustomWorldFoundationDraftCamp) + : null, + themePack: params.profile.themePack ?? null, + storyGraph: params.profile.storyGraph ?? null, + factions, + threads, + chapters: [chapter], + worldHook: + clampText(params.intent.worldHook || params.profile.summary, 72) || + params.profile.summary, + playerPremise: + clampText(params.intent.playerPremise, 72) || + '玩家是一名被卷进局势中心的行动者', + openingSituation: + clampText(params.intent.openingSituation, 72) || + '故事开局时,玩家已经站在必须立刻选边的位置上', + iconicElements: dedupeStrings(params.intent.iconicElements, 6), + sourceAnchorSummary: + toText(anchorRecord?.creatorIntentSummary) || + buildDraftSummaryFromIntent(params.intent) || + params.profile.summary, + legacyResultProfile: params.profile as unknown as Record, + } satisfies CustomWorldFoundationDraftProfile & { + legacyResultProfile: Record; + }; +} + +async function buildFoundationDraftProfileWithLlm(params: { + llmClient: UpstreamLlmClient; + creatorIntent: CustomWorldCreatorIntentRecord; + anchorPack: unknown; + signal?: AbortSignal; + onProgress?: DraftProgressCallback; +}) { + const settingText = buildFoundationGenerationSeedText({ + intent: params.creatorIntent, + anchorPack: params.anchorPack, + }); + + await emitDraftProgress(params.onProgress, { + phaseLabel: '整理世界骨架', + phaseDetail: '正在根据创作者锚点生成第一版世界框架。', + progress: 12, + }); + const frameworkRaw = await requestFoundationJsonStage({ + llmClient: params.llmClient, + userPrompt: buildCustomWorldFrameworkPrompt(settingText), + debugLabel: 'agent-foundation-framework', + repairPromptBuilder: (responseText) => + buildCustomWorldFrameworkJsonRepairPrompt(responseText), + repairDebugLabel: 'agent-foundation-framework-json-repair', + emptyResponseMessage: '世界框架阶段没有返回有效内容。', + signal: params.signal, + }); + const framework = normalizeCustomWorldGenerationFramework( + frameworkRaw, + settingText, + ); + + framework.playableNpcs = await generateFoundationRoleOutlineEntries({ + llmClient: params.llmClient, + framework, + roleType: 'playable', + totalCount: FOUNDATION_DRAFT_PLAYABLE_COUNT, + batchSize: FOUNDATION_ROLE_OUTLINE_BATCH_SIZE, + signal: params.signal, + onProgress: params.onProgress, + progressRange: [16, 30], + }); + + framework.storyNpcs = await generateFoundationRoleOutlineEntries({ + llmClient: params.llmClient, + framework, + roleType: 'story', + totalCount: FOUNDATION_DRAFT_STORY_COUNT, + batchSize: FOUNDATION_ROLE_OUTLINE_BATCH_SIZE, + signal: params.signal, + onProgress: params.onProgress, + progressRange: [30, 44], + }); + + framework.landmarks = await generateFoundationLandmarkSeedEntries({ + llmClient: params.llmClient, + framework, + totalCount: FOUNDATION_DRAFT_LANDMARK_COUNT, + batchSize: FOUNDATION_LANDMARK_BATCH_SIZE, + signal: params.signal, + onProgress: params.onProgress, + progressRange: [44, 56], + }); + + framework.landmarks = await expandFoundationLandmarkNetworkEntries({ + llmClient: params.llmClient, + framework, + storyNpcs: framework.storyNpcs, + baseEntries: framework.landmarks, + batchSize: FOUNDATION_LANDMARK_BATCH_SIZE, + signal: params.signal, + onProgress: params.onProgress, + progressRange: [56, 66], + }); + + const playableNarrative = await expandFoundationRoleEntries({ + llmClient: params.llmClient, + framework, + roleType: 'playable', + baseEntries: framework.playableNpcs, + stage: 'narrative', + batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, + signal: params.signal, + onProgress: params.onProgress, + progressRange: [66, 76], + }); + const playableDetailed = await expandFoundationRoleEntries({ + llmClient: params.llmClient, + framework, + roleType: 'playable', + baseEntries: playableNarrative, + stage: 'dossier', + batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, + signal: params.signal, + onProgress: params.onProgress, + progressRange: [76, 84], + }); + const storyNarrative = await expandFoundationRoleEntries({ + llmClient: params.llmClient, + framework, + roleType: 'story', + baseEntries: framework.storyNpcs, + stage: 'narrative', + batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, + signal: params.signal, + onProgress: params.onProgress, + progressRange: [84, 92], + }); + const storyDetailed = await expandFoundationRoleEntries({ + llmClient: params.llmClient, + framework, + roleType: 'story', + baseEntries: storyNarrative, + stage: 'dossier', + batchSize: FOUNDATION_ROLE_DETAIL_BATCH_SIZE, + signal: params.signal, + onProgress: params.onProgress, + progressRange: [92, 96], + }); + + await emitDraftProgress(params.onProgress, { + phaseLabel: '编译世界底稿', + phaseDetail: '正在把分批生成结果整理成旧版世界结果结构,再编成草稿卡底稿。', + progress: 97, + }); + + const rawProfile = buildCustomWorldRawProfileFromFramework( + framework, + ) as Record; + rawProfile.playableNpcs = playableDetailed; + rawProfile.storyNpcs = storyDetailed; + rawProfile.landmarks = framework.landmarks; + + const runtimeProfile = buildExpandedCustomWorldProfile( + rawProfile, + settingText, + ); + return convertRuntimeProfileToFoundationDraft({ + profile: runtimeProfile, + intent: params.creatorIntent, + anchorPack: params.anchorPack, + }); +} + export class CustomWorldAgentFoundationDraftService { - generate(params: { + constructor(private readonly llmClient: UpstreamLlmClient | null = null) {} + + private generateFallbackDraft(params: { creatorIntent: unknown; anchorPack: unknown; }): CustomWorldFoundationDraftProfile { @@ -789,7 +1818,10 @@ export class CustomWorldAgentFoundationDraftService { name: worldName, subtitle: clampText( - [buildCompactLabel(playerPremise, '玩家视角', 12), buildCompactLabel(coreConflicts[0] || '', '核心冲突', 16)] + [ + buildCompactLabel(playerPremise, '玩家视角', 12), + buildCompactLabel(coreConflicts[0] || '', '核心冲突', 16), + ] .filter(Boolean) .join(' · '), 40, @@ -818,4 +1850,25 @@ export class CustomWorldAgentFoundationDraftService { summary, }; } + + async generate(params: { + creatorIntent: unknown; + anchorPack: unknown; + signal?: AbortSignal; + onProgress?: DraftProgressCallback; + }): Promise { + const intent = normalizeCreatorIntentRecord(params.creatorIntent); + + if (!this.llmClient || !intent) { + return this.generateFallbackDraft(params); + } + + return buildFoundationDraftProfileWithLlm({ + llmClient: this.llmClient, + creatorIntent: intent, + anchorPack: params.anchorPack, + signal: params.signal, + onProgress: params.onProgress, + }); + } } diff --git a/server-node/src/services/customWorldAgentOrchestrator.ts b/server-node/src/services/customWorldAgentOrchestrator.ts index 86190c53..a6a32f44 100644 --- a/server-node/src/services/customWorldAgentOrchestrator.ts +++ b/server-node/src/services/customWorldAgentOrchestrator.ts @@ -37,17 +37,18 @@ import { createEmptyCreatorIntentRecord, type CustomWorldCreatorIntentRecord, extractCreatorIntentPatch, + hasMeaningfulCreatorIntentRecord, mergeCreatorIntentRecord, normalizeCreatorIntentRecord, } from './customWorldAgentIntentExtractionService.js'; -import { - type CustomWorldAgentSessionRecord, - CustomWorldAgentSessionStore, -} from './customWorldAgentSessionStore.js'; import { rebuildRoleAssetCoverage, resolveRoleAssetStatusLabel, } from './customWorldAgentRoleAssetStateService.js'; +import { + type CustomWorldAgentSessionRecord, + CustomWorldAgentSessionStore, +} from './customWorldAgentSessionStore.js'; import type { UpstreamLlmClient } from './llmClient.js'; const PHASE2_FORCE_FAIL_TOKEN = '__phase1_force_fail__'; @@ -67,12 +68,14 @@ function sleep(ms: number) { }); } -function buildSuggestedActions(params: { - stage?: CustomWorldAgentSessionRecord['stage']; - isReady?: boolean; - draftProfile?: unknown; - draftCards?: CustomWorldDraftCardSummary[]; -} = {}): CustomWorldSuggestedAction[] { +function buildSuggestedActions( + params: { + stage?: CustomWorldAgentSessionRecord['stage']; + isReady?: boolean; + draftProfile?: unknown; + draftCards?: CustomWorldDraftCardSummary[]; + } = {}, +): CustomWorldSuggestedAction[] { const profile = normalizeFoundationDraftProfile(params.draftProfile); const actions: CustomWorldSuggestedAction[] = [ { @@ -95,7 +98,8 @@ function buildSuggestedActions(params: { } if ( - (params.stage === 'object_refining' || params.stage === 'visual_refining') && + (params.stage === 'object_refining' || + params.stage === 'visual_refining') && profile ) { const worldCardId = @@ -177,13 +181,13 @@ function buildOperation(type: CustomWorldAgentOperationRecord['type']) { ? '正在把这次设定改动写回草稿。' : type === 'generate_characters' ? '正在围绕当前底稿补出新角色。' - : type === 'generate_landmarks' - ? '正在围绕当前底稿补出新地点。' - : type === 'generate_role_assets' - ? '正在准备角色资产工坊入口。' - : type === 'sync_role_assets' - ? '正在把角色资产结果写回世界草稿。' - : '正在整理这一轮新增的世界锚点。'; + : type === 'generate_landmarks' + ? '正在围绕当前底稿补出新地点。' + : type === 'generate_role_assets' + ? '正在准备角色资产工坊入口。' + : type === 'sync_role_assets' + ? '正在把角色资产结果写回世界草稿。' + : '正在整理这一轮新增的世界锚点。'; return { operationId: `operation-${crypto.randomBytes(10).toString('hex')}`, @@ -287,9 +291,17 @@ function buildWelcomeMessage(params: { pendingClarifications: CustomWorldPendingClarification[]; isReady: boolean; }) { - const openingText = params.seedText - ? `收到:${truncateText(params.seedText, 88)}` - : '想做一个什么样的世界?'; + let openingText: string; + + if (params.seedText) { + openingText = `收到:${truncateText(params.seedText, 88)}`; + } else { + // When user enters without saying anything, provide a welcoming introduction + const hasAnyAnchors = hasMeaningfulCreatorIntentRecord(params.intent); + openingText = hasAnyAnchors + ? '继续聊聊你的世界设定吧。' + : '你好!我是你的世界设定助手,可以帮你一起构建游戏世界的核心设定。'; + } return composeAssistantReply({ openingText, @@ -321,7 +333,86 @@ function buildAssistantMessage(params: { } satisfies CustomWorldAgentMessage; } -function buildAgentLlmPrompt(params: { +function buildAgentSystemPrompt(params: { + isReady: boolean; + hasAnyAnchors: boolean; +}) { + const baseInstructions = [ + '你是一个专业的RPG游戏剧情策划,通过对话帮助用户补全结构化世界锚点。', + '', + '# 核心原则', + '- 像创作者搭档,不要写系统说明,不要列规则,不要提到 JSON 或后端', + '- 用中文自然回复,语气专业但友好', + '- 不要重复追问用户已经明确回答过的信息', + '- 每次只聚焦一个关键问题,帮助用户高效推进', + '', + '# 输出格式', + '必须输出严格的 JSON 格式:{“reply”:”...”,”recommendedReplies”:[“...”,”...”,”...”]}', + '', + ]; + + if (params.isReady) { + return [ + ...baseInstructions, + '# 当前阶段:设定已齐备', + '', + '## reply 字段要求', + '- 第一段:明确回应并收住用户刚刚给出的具体设定', + '- 第二段:明确告诉用户关键设定已经足够,可以生成第一版游戏草稿了', + '- 最后:自然询问是否现在开始生成草稿', + '- 整体要短,聚焦推进', + '', + '## recommendedReplies 字段要求', + '- 必须正好 3 条', + '- 每条都是用户下一句可以直接发送的话', + '- 第 1 条:表达开始生成草稿(例如:”现在开始生成草稿”)', + '- 第 2 条:让 Agent 总结当前设定(例如:”先总结一下当前设定”)', + '- 第 3 条:继续补充设定内容(例如:”我还想再补充一点”)', + ].join('\n'); + } + + // When anchors are empty, use inspirational questioning strategy + if (!params.hasAnyAnchors) { + return [ + ...baseInstructions, + '# 当前阶段:初始启发', + '', + '## reply 字段要求', + '- 第一段:如果用户刚进入对话还没说话,用欢迎语气开场(例如:”想创造一个什么样的世界?”)', + '- 第一段:如果用户已经说了话,简短回应用户的输入', + '- 第二段:提出一个开放性、启发性的问题,帮助用户构思世界的核心概念', + '- 问题应该是高层次的,关于世界类型、主题、核心理念,而不是具体细节', + '- 例如:世界的整体风格、故事的核心主题、想传达的感觉', + '- 避免过早询问具体设定细节(如魔法系统、科技水平等)', + '', + '## recommendedReplies 字段要求', + '- 必须正好 3 条', + '- 3 条都是对当前问题的不同方向的回答', + '- 每条回答应该代表一种不同的世界类型或主题方向', + '- 回答要具体但不过于详细,给用户启发和选择空间', + ].join('\n'); + } + + return [ + ...baseInstructions, + '# 当前阶段:收集设定中', + '', + '## reply 字段要求', + '- 第一段:明确回应并收住用户上一次给出的具体落地设定(不能只说”收到”)', + '- 第二段:固定只追问 1 个当前最关键、最能推进游戏设定的问题', + '- 这个问题必须帮助你更快拿到作品最核心的设定信息', + '- 必要时给一个很短的示例,帮助用户高效回答', + '', + '## recommendedReplies 字段要求', + '- 必须正好 3 条', + '- 3 条都必须是对当前这一个问题的直接回答', + '- 不允许继续提问', + '- 不允许写成”你先帮我””继续问我”这种让 Agent 行动的句子', + '- 回答要尽量具体,优先提供能推进作品设定的核心信息', + ].join('\n'); +} + +function buildAgentUserPrompt(params: { session: CustomWorldAgentSessionRecord; latestUserText: string; intent: CustomWorldCreatorIntentRecord; @@ -338,52 +429,18 @@ function buildAgentLlmPrompt(params: { .join('\n'); return [ - '当前结构化世界锚点:', + '# 当前结构化世界锚点', buildCreatorIntentDisplayText(params.intent) || '暂无', '', - '注意:上面这些已确认设定和下面的历史对话都算有效上下文。不要重复追问用户已经明确回答过的信息。', + `# 锚点是否齐备`, + params.isReady ? '是' : '否', '', - `锚点是否齐备:${params.isReady ? '是' : '否'}`, - pendingQuestions ? `待确认问题:\n${pendingQuestions}` : '', - '', - '最近对话:', + pendingQuestions ? `# 待确认问题\n${pendingQuestions}\n` : '', + '# 最近对话', recentMessages || '暂无', '', - `用户最新输入:${params.latestUserText}`, - '', - '请输出严格 JSON,格式如下:{"reply":"...","recommendedReplies":["...","...","..."]}', - '', - params.isReady - ? [ - 'reply 字段要求:', - '- 用中文自然回复。', - '- 像创作者搭档,不要写系统说明,不要列规则,不要提到 JSON 或后端。', - '- 第一段先明确回应并收住用户刚刚给出的具体设定。', - '- 第二段明确告诉用户:关键设定已经足够,可以帮他生成第一版游戏草稿了。', - '- 最后固定补一句自然问题,询问是否现在开始生成草稿。', - '- 整体要短,聚焦推进。', - 'recommendedReplies 字段要求:', - '- 必须正好 3 条。', - '- 每条都是用户下一句可以直接发送的话。', - '- 第 1 条必须表达开始生成草稿。', - '- 第 2 条应是让 Agent 先总结一下当前设定。', - '- 第 3 条应是用户还想再补充一点设定。', - ].join('\n') - : [ - 'reply 字段要求:', - '- 用中文自然回复。', - '- 像创作者搭档,不要写系统说明,不要列规则,不要提到 JSON 或后端。', - '- 第一段必须明确回应并收住用户上一次给出的具体落地设定,不能只说“收到”。', - '- 第二段开始固定只追问 1 个当前最关键、最能推进游戏设定的问题。', - '- 这个问题必须帮助你更快拿到作品最核心的设定信息。', - '- 必要时给一个很短的示例,帮助用户高效回答。', - 'recommendedReplies 字段要求:', - '- 必须正好 3 条。', - '- 3 条都必须是对当前这一个问题的直接回答。', - '- 不允许继续提问。', - '- 不允许写成“你先帮我”“继续问我”这种让 Agent 行动的句子。', - '- 回答要尽量具体,优先提供能推进作品设定的核心信息。', - ].join('\n'), + '# 用户最新输入', + params.latestUserText, ] .filter(Boolean) .join('\n'); @@ -395,8 +452,7 @@ function parseAssistantTurnJson(text: string) { reply?: unknown; recommendedReplies?: unknown; }; - const reply = - typeof parsed.reply === 'string' ? parsed.reply.trim() : ''; + const reply = typeof parsed.reply === 'string' ? parsed.reply.trim() : ''; const recommendedReplies = Array.isArray(parsed.recommendedReplies) ? parsed.recommendedReplies .map((item) => (typeof item === 'string' ? item.trim() : '')) @@ -553,7 +609,9 @@ export class CustomWorldAgentOrchestrator { private readonly sessionStore: CustomWorldAgentSessionStore, private readonly llmClient: UpstreamLlmClient | null = null, ) { - this.foundationDraftService = new CustomWorldAgentFoundationDraftService(); + this.foundationDraftService = new CustomWorldAgentFoundationDraftService( + llmClient, + ); this.draftCompiler = new CustomWorldAgentDraftCompiler(); this.entityGenerationService = new CustomWorldAgentEntityGenerationService( llmClient, @@ -727,7 +785,9 @@ export class CustomWorldAgentOrchestrator { session.draftCards.length > 0, ); if (!hasDraftFoundation) { - throw badRequest(`${payload.action} requires an existing draft foundation`); + throw badRequest( + `${payload.action} requires an existing draft foundation`, + ); } } @@ -793,7 +853,9 @@ export class CustomWorldAgentOrchestrator { if (payload.action === 'generate_role_assets') { if (!Array.isArray(payload.roleIds) || payload.roleIds.length !== 1) { - throw badRequest('generate_role_assets currently requires exactly one roleId'); + throw badRequest( + 'generate_role_assets currently requires exactly one roleId', + ); } const operation = buildOperation('generate_role_assets'); @@ -814,7 +876,10 @@ export class CustomWorldAgentOrchestrator { if (!payload.roleId.trim()) { throw badRequest('sync_role_assets requires roleId'); } - if (!payload.portraitPath.trim() || !payload.generatedVisualAssetId.trim()) { + if ( + !payload.portraitPath.trim() || + !payload.generatedVisualAssetId.trim() + ) { throw badRequest( 'sync_role_assets requires portraitPath and generatedVisualAssetId', ); @@ -876,8 +941,11 @@ export class CustomWorldAgentOrchestrator { try { const content = await this.llmClient.requestMessageContent({ - systemPrompt: '你只输出严格 JSON,不输出 Markdown。', - userPrompt: buildAgentLlmPrompt({ + systemPrompt: buildAgentSystemPrompt({ + isReady: params.isReady, + hasAnyAnchors: hasMeaningfulCreatorIntentRecord(params.intent), + }), + userPrompt: buildAgentUserPrompt({ session: params.session, latestUserText: params.latestUserText, intent: params.intent, @@ -936,15 +1004,28 @@ export class CustomWorldAgentOrchestrator { throw new Error('session is not ready for draft_foundation'); } - const draftProfile = this.foundationDraftService.generate({ + const draftProfile = await this.foundationDraftService.generate({ creatorIntent: latestSession.creatorIntent, anchorPack: latestSession.anchorPack, + onProgress: async (progress) => { + await this.sessionStore.updateOperation( + userId, + sessionId, + operationId, + { + status: 'running', + phaseLabel: progress.phaseLabel, + phaseDetail: progress.phaseDetail, + progress: progress.progress, + }, + ); + }, }); await this.sessionStore.updateOperation(userId, sessionId, operationId, { phaseLabel: '编译草稿卡', phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。', - progress: 72, + progress: 98, }); const draftCards = this.draftCompiler.compileDraftCards(draftProfile); @@ -992,9 +1073,7 @@ export class CustomWorldAgentOrchestrator { phaseDetail: '这一轮没有成功把锚点编成世界底稿。', progress: 100, error: - error instanceof Error - ? error.message - : 'draft foundation failed', + error instanceof Error ? error.message : 'draft foundation failed', }); } } @@ -1003,7 +1082,10 @@ export class CustomWorldAgentOrchestrator { userId: string; sessionId: string; operationId: string; - payload: Extract; + payload: Extract< + CustomWorldAgentActionRequest, + { action: 'update_draft_card' } + >; }) { const { userId, sessionId, operationId, payload } = params; @@ -1024,7 +1106,10 @@ export class CustomWorldAgentOrchestrator { } const nextDraftProfile = updateDraftCardSections({ - draftProfile: (latestSession.draftProfile ?? {}) as Record, + draftProfile: (latestSession.draftProfile ?? {}) as Record< + string, + unknown + >, cardId: payload.cardId, sections: payload.sections, }); @@ -1035,7 +1120,8 @@ export class CustomWorldAgentOrchestrator { progress: 72, }); - const nextDraftCards = this.draftCompiler.compileDraftCards(nextDraftProfile); + const nextDraftCards = + this.draftCompiler.compileDraftCards(nextDraftProfile); const assetCoverage = rebuildRoleAssetCoverage(nextDraftProfile); const nextStage = latestSession.stage === 'visual_refining' @@ -1052,7 +1138,9 @@ export class CustomWorldAgentOrchestrator { payload.cardId, ); const changedSectionIds = new Set( - payload.sections.map((section) => section.sectionId.trim()).filter(Boolean), + payload.sections + .map((section) => section.sectionId.trim()) + .filter(Boolean), ); await this.sessionStore.replaceDerivedState(userId, sessionId, { @@ -1107,7 +1195,10 @@ export class CustomWorldAgentOrchestrator { userId: string; sessionId: string; operationId: string; - payload: Extract; + payload: Extract< + CustomWorldAgentActionRequest, + { action: 'generate_characters' } + >; }) { const { userId, sessionId, operationId, payload } = params; @@ -1131,7 +1222,10 @@ export class CustomWorldAgentOrchestrator { await this.entityGenerationService.generateAdditionalCharacters({ creatorIntent: latestSession.creatorIntent, anchorPack: latestSession.anchorPack, - draftProfile: (latestSession.draftProfile ?? {}) as Record, + draftProfile: (latestSession.draftProfile ?? {}) as Record< + string, + unknown + >, count: payload.count, promptText: payload.promptText, anchorCardIds: @@ -1151,7 +1245,9 @@ export class CustomWorldAgentOrchestrator { const nextDraftCards = this.draftCompiler.compileDraftCards( generationResult.draftProfile, ); - const assetCoverage = rebuildRoleAssetCoverage(generationResult.draftProfile); + const assetCoverage = rebuildRoleAssetCoverage( + generationResult.draftProfile, + ); const nextStage = latestSession.stage === 'visual_refining' ? ('visual_refining' as const) @@ -1183,7 +1279,9 @@ export class CustomWorldAgentOrchestrator { relatedOperationId: operationId, text: this.changeSummaryService.buildSummary({ action: 'generate_characters', - names: generationResult.generatedCharacters.map((entry) => entry.name), + names: generationResult.generatedCharacters.map( + (entry) => entry.name, + ), draftProfile: generationResult.draftProfile, }), }), @@ -1212,7 +1310,10 @@ export class CustomWorldAgentOrchestrator { userId: string; sessionId: string; operationId: string; - payload: Extract; + payload: Extract< + CustomWorldAgentActionRequest, + { action: 'generate_landmarks' } + >; }) { const { userId, sessionId, operationId, payload } = params; @@ -1236,7 +1337,10 @@ export class CustomWorldAgentOrchestrator { await this.entityGenerationService.generateAdditionalLandmarks({ creatorIntent: latestSession.creatorIntent, anchorPack: latestSession.anchorPack, - draftProfile: (latestSession.draftProfile ?? {}) as Record, + draftProfile: (latestSession.draftProfile ?? {}) as Record< + string, + unknown + >, count: payload.count, promptText: payload.promptText, anchorCardIds: @@ -1256,7 +1360,9 @@ export class CustomWorldAgentOrchestrator { const nextDraftCards = this.draftCompiler.compileDraftCards( generationResult.draftProfile, ); - const assetCoverage = rebuildRoleAssetCoverage(generationResult.draftProfile); + const assetCoverage = rebuildRoleAssetCoverage( + generationResult.draftProfile, + ); const nextStage = latestSession.stage === 'visual_refining' ? ('visual_refining' as const) @@ -1288,7 +1394,9 @@ export class CustomWorldAgentOrchestrator { relatedOperationId: operationId, text: this.changeSummaryService.buildSummary({ action: 'generate_landmarks', - names: generationResult.generatedLandmarks.map((entry) => entry.name), + names: generationResult.generatedLandmarks.map( + (entry) => entry.name, + ), draftProfile: generationResult.draftProfile, }), }), @@ -1317,7 +1425,10 @@ export class CustomWorldAgentOrchestrator { userId: string; sessionId: string; operationId: string; - payload: Extract; + payload: Extract< + CustomWorldAgentActionRequest, + { action: 'generate_role_assets' } + >; }) { const { userId, sessionId, operationId, payload } = params; @@ -1379,7 +1490,9 @@ export class CustomWorldAgentOrchestrator { phaseDetail: '这一轮没有成功进入角色资产工坊。', progress: 100, error: - error instanceof Error ? error.message : 'generate role assets failed', + error instanceof Error + ? error.message + : 'generate role assets failed', }); } } @@ -1388,7 +1501,10 @@ export class CustomWorldAgentOrchestrator { userId: string; sessionId: string; operationId: string; - payload: Extract; + payload: Extract< + CustomWorldAgentActionRequest, + { action: 'sync_role_assets' } + >; }) { const { userId, sessionId, operationId, payload } = params; @@ -1542,9 +1658,7 @@ export class CustomWorldAgentOrchestrator { : derivedState.suggestedActions; await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: shouldPreserveDraftStage - ? preservedStage - : derivedState.stage, + stage: shouldPreserveDraftStage ? preservedStage : derivedState.stage, creatorIntent: nextIntent, creatorIntentReadiness: derivedState.readiness, anchorPack: derivedState.anchorPack, @@ -1607,7 +1721,7 @@ export class CustomWorldAgentOrchestrator { ? '这轮补充已挂回当前底稿语境,现有草稿卡保持可继续浏览。' : derivedState.readiness.isReady ? '最小锚点已齐备,可以进入下一阶段。' - : '这一轮的创作锚点和澄清问题已经同步完成。', + : '这一轮的创作锚点和澄清问题已经同步完成。', progress: 100, error: null, }); diff --git a/server-node/src/services/llmClient.ts b/server-node/src/services/llmClient.ts index 159e3fba..e33c99ec 100644 --- a/server-node/src/services/llmClient.ts +++ b/server-node/src/services/llmClient.ts @@ -182,6 +182,19 @@ export class UpstreamLlmClient { ? options.debugLabel.trim() : undefined; + const enableDebugLog = this.config.rawEnv.LLM_DEBUG_LOG === 'true'; + + if (enableDebugLog) { + this.logger.info( + { + llm_model: model, + llm_debug_label: debugLabel, + llm_messages: body.messages, + }, + '[LLM_DEBUG] Request prompt', + ); + } + this.logger.debug( { llm_model: model, @@ -281,6 +294,18 @@ export class UpstreamLlmClient { throw upstreamError('LLM 返回内容为空'); } + const enableDebugLog = this.config.rawEnv.LLM_DEBUG_LOG === 'true'; + if (enableDebugLog) { + this.logger.info( + { + llm_debug_label: params.debugLabel, + llm_response_content: content, + llm_response_length: content.length, + }, + '[LLM_DEBUG] Response content', + ); + } + return content; } diff --git a/server-node/src/services/sceneImageService.test.ts b/server-node/src/services/sceneImageService.test.ts new file mode 100644 index 00000000..29952c80 --- /dev/null +++ b/server-node/src/services/sceneImageService.test.ts @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import type { AppContext } from '../context.js'; +import { type AppConfig } from '../config.js'; +import { generateSceneImage } from './sceneImageService.js'; + +const PNG_BUFFER = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=', + 'base64', +); + +function createTestConfig( + projectRoot: string, + dashScopeBaseUrl: string, +): AppConfig { + return { + projectRoot, + publicDir: path.join(projectRoot, 'public'), + dashScope: { + baseUrl: dashScopeBaseUrl, + apiKey: 'test-dashscope-key', + imageModel: 'wan2.7-image', + requestTimeoutMs: 5_000, + }, + } as AppConfig; +} + +function readRequestBody(req: IncomingMessage) { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function sendJson(res: ServerResponse, payload: unknown) { + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(payload)); +} + +async function withHttpServer( + buildHandler: (baseUrl: string) => ( + req: IncomingMessage, + res: ServerResponse, + ) => void | Promise, + run: (baseUrl: string) => Promise, +) { + let handler: ( + req: IncomingMessage, + res: ServerResponse, + ) => void | Promise = () => undefined; + const server = createServer((req, res) => { + Promise.resolve(handler(req, res)).catch((error) => { + res.statusCode = 500; + res.end(error instanceof Error ? error.stack : String(error)); + }); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('failed to resolve test server address'); + } + + const baseUrl = `http://127.0.0.1:${address.port}`; + handler = buildHandler(baseUrl); + + try { + return await run(baseUrl); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +test('generateSceneImage uploads a public reference image as a data url and saves the generated scene', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-scene-image-')); + const publicDir = path.join(tempRoot, 'public'); + fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'scene_bg', 'reference-layout.png'), PNG_BUFFER); + + const capturedRequests: Array<{ + pathname: string; + bodyText?: string; + }> = []; + + await withHttpServer( + (baseUrl) => async (req, res) => { + const url = new URL(req.url || '/', baseUrl); + const bodyText = + req.method === 'POST' ? (await readRequestBody(req)).toString('utf8') : undefined; + capturedRequests.push({ + pathname: url.pathname, + bodyText, + }); + + if ( + req.method === 'POST' && + url.pathname === '/api/v1/services/aigc/image-generation/generation' + ) { + sendJson(res, { + output: { + task_id: 'scene-task-1', + }, + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/api/v1/tasks/scene-task-1') { + sendJson(res, { + output: { + task_status: 'SUCCEEDED', + results: [ + { + url: `${baseUrl}/downloads/scene.png`, + actual_prompt: '整理后的场景提示词', + }, + ], + }, + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/downloads/scene.png') { + res.statusCode = 200; + res.setHeader('Content-Type', 'image/png'); + res.end(PNG_BUFFER); + return; + } + + res.statusCode = 404; + res.end('not found'); + }, + async (dashScopeBaseUrl) => { + const context = { + config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`), + } as AppContext; + + const result = await generateSceneImage(context, { + prompt: '海雾港口像素风场景', + negativePrompt: '模糊', + size: '1280*720', + model: 'wan2.7-image', + worldName: '潮雾群岛', + profileId: 'world-1', + landmarkName: '旧港灯塔', + landmarkId: 'landmark-1', + referenceImageSrc: '/scene_bg/reference-layout.png', + }); + + assert.equal(result.ok, true); + assert.match(result.imageSrc, /^\/generated-custom-world-scenes\//u); + assert.equal(result.actualPrompt, '整理后的场景提示词'); + + const createRequest = capturedRequests.find( + (entry) => entry.pathname === '/api/v1/services/aigc/image-generation/generation', + ); + assert.ok(createRequest?.bodyText); + + const createPayload = JSON.parse(createRequest.bodyText) as { + input: { + messages: Array<{ + content: Array<{ text?: string; image?: string }>; + }>; + }; + parameters: { + negative_prompt?: string; + }; + }; + + const content = createPayload.input.messages[0]?.content ?? []; + assert.equal(content[0]?.text, '海雾港口像素风场景'); + assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u); + assert.equal(createPayload.parameters.negative_prompt, '模糊'); + + const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1)); + assert.equal(fs.existsSync(savedImagePath), true); + }, + ); +}); diff --git a/server-node/src/services/sceneImageService.ts b/server-node/src/services/sceneImageService.ts index cd4917fa..b21972e3 100644 --- a/server-node/src/services/sceneImageService.ts +++ b/server-node/src/services/sceneImageService.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { z } from 'zod'; @@ -16,8 +17,111 @@ export const sceneImageSchema = z.object({ profileId: z.string().trim().optional().default(''), landmarkName: z.string().trim().optional().default(''), landmarkId: z.string().trim().optional().default(''), + referenceImageSrc: z.string().trim().optional().default(''), }); +function parseImageDataUrl(source: string) { + const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source); + if (!matched) { + return null; + } + + return { + buffer: Buffer.from(matched[2], 'base64'), + mimeType: matched[1], + }; +} + +async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) { + const trimmedSource = source.trim(); + if (!trimmedSource) { + return ''; + } + + const parsedDataUrl = parseImageDataUrl(trimmedSource); + if (parsedDataUrl) { + return trimmedSource; + } + + if (!trimmedSource.startsWith('/')) { + throw badRequest('参考图必须是 Data URL 或 public 目录下的 URL。'); + } + + const normalizedSource = path.posix + .normalize(trimmedSource) + .replace(/^\/+/u, ''); + const absolutePath = path.resolve( + rootDir, + 'public', + ...normalizedSource.split('/'), + ); + const publicRoot = path.resolve(rootDir, 'public'); + if (!absolutePath.startsWith(publicRoot)) { + throw badRequest('参考图路径越界。'); + } + + const buffer = await readFile(absolutePath); + const extension = path.extname(absolutePath).replace(/^\./u, '').toLowerCase(); + const mimeType = (() => { + switch (extension) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'webp': + return 'image/webp'; + default: + return 'image/png'; + } + })(); + + return `data:${mimeType};base64,${buffer.toString('base64')}`; +} + +function collectStringsByKey( + value: unknown, + targetKey: string, + results: string[], +) { + if (typeof value === 'string') { + return; + } + + if (Array.isArray(value)) { + value.forEach((entry) => collectStringsByKey(entry, targetKey, results)); + return; + } + + if (!value || typeof value !== 'object') { + return; + } + + Object.entries(value).forEach(([key, nestedValue]) => { + if (key === targetKey && typeof nestedValue === 'string' && nestedValue.trim()) { + results.push(nestedValue.trim()); + return; + } + + collectStringsByKey(nestedValue, targetKey, results); + }); +} + +function findFirstStringByKey(value: unknown, targetKey: string) { + const results: string[] = []; + collectStringsByKey(value, targetKey, results); + return results[0] ?? ''; +} + +function extractTaskId(payload: Record) { + return findFirstStringByKey(payload, 'task_id'); +} + +function extractImageUrls(payload: Record) { + const urls: string[] = []; + collectStringsByKey(payload, 'image', urls); + collectStringsByKey(payload, 'url', urls); + return [...new Set(urls)]; +} + function ensurePayload( payload: z.infer, defaultModel: string, @@ -38,8 +142,14 @@ export async function generateSceneImage( ) { const payload = ensurePayload(input, context.config.dashScope.imageModel); const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, ''); + const referenceImage = payload.referenceImageSrc + ? await resolveReferenceImageAsDataUrl( + context.config.projectRoot, + payload.referenceImageSrc, + ) + : ''; const createResponse = await fetch( - `${baseUrl}/services/aigc/text2image/image-synthesis`, + `${baseUrl}/services/aigc/image-generation/generation`, { method: 'POST', headers: { @@ -50,16 +160,24 @@ export async function generateSceneImage( body: JSON.stringify({ model: payload.model, input: { - prompt: payload.prompt, - ...(payload.negativePrompt - ? { negative_prompt: payload.negativePrompt } - : {}), + messages: [ + { + role: 'user', + content: [ + { text: payload.prompt }, + ...(referenceImage ? [{ image: referenceImage }] : []), + ], + }, + ], }, parameters: { n: 1, size: payload.size, prompt_extend: true, watermark: false, + ...(payload.negativePrompt + ? { negative_prompt: payload.negativePrompt } + : {}), }, }), }, @@ -71,12 +189,8 @@ export async function generateSceneImage( ); } - const createPayload = JSON.parse(createText) as { - output?: { - task_id?: string; - }; - }; - const taskId = createPayload.output?.task_id?.trim(); + const createPayload = JSON.parse(createText) as Record; + const taskId = extractTaskId(createPayload); if (!taskId) { throw badRequest('场景图片生成任务未返回 task_id'); } @@ -98,21 +212,11 @@ export async function generateSceneImage( ); } - const pollPayload = JSON.parse(pollText) as { - output?: { - task_status?: string; - results?: Array<{ - url?: string; - actual_prompt?: string; - }>; - }; - }; - const status = pollPayload.output?.task_status?.trim(); + const pollPayload = JSON.parse(pollText) as Record; + const status = findFirstStringByKey(pollPayload, 'task_status').trim(); if (status === 'SUCCEEDED') { - imageUrl = - pollPayload.output?.results?.find((item) => item.url?.trim())?.url?.trim() || ''; - actualPrompt = - pollPayload.output?.results?.find((item) => item.url?.trim())?.actual_prompt?.trim() || ''; + imageUrl = extractImageUrls(pollPayload)[0] ?? ''; + actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim(); break; } if (status === 'FAILED' || status === 'UNKNOWN') { diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 1798f892..3d051735 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -1,21 +1,28 @@ -import { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react'; - import { - getCustomWorldSceneRelativePositionLabel, -} from '../data/customWorldSceneGraph'; + type ReactNode, + useDeferredValue, + useEffect, + useMemo, + useState, +} from 'react'; + +import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph'; import { resolveCustomWorldCampSceneImage, resolveCustomWorldLandmarkImageMap, } from '../data/customWorldVisuals'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; -import { buildCustomWorldCreatorIntentDisplayText } from '../services/customWorldCreatorIntent'; +import { + buildCustomWorldCreatorIntentDisplayText, + normalizeCustomWorldCreatorIntent, +} from '../services/customWorldCreatorIntent'; import { AnimationState, Character, CustomWorldProfile } from '../types'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { CharacterAnimator } from './CharacterAnimator'; import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal'; import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor'; -export type ResultTab = 'world' | 'anchors' | 'playable' | 'story' | 'landmarks'; +export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks'; interface CustomWorldEntityCatalogProps { profile: CustomWorldProfile; @@ -28,11 +35,11 @@ interface CustomWorldEntityCatalogProps { onDeleteLandmarks?: (ids: string[]) => void; createActionLabel?: string; onCreateAction?: () => void; + readOnly?: boolean; } const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [ { id: 'world', label: '世界' }, - { id: 'anchors', label: '锚点' }, { id: 'playable', label: '可扮演角色' }, { id: 'story', label: '场景角色' }, { id: 'landmarks', label: '场景' }, @@ -50,11 +57,20 @@ function Section({ children: ReactNode; }) { return ( -
+
-
{title}
- {subtitle ?
{subtitle}
: null} +
+ {title} +
+ {subtitle ? ( +
+ {subtitle} +
+ ) : null}
{actions}
@@ -72,11 +88,12 @@ function SmallButton({ children: ReactNode; tone?: 'default' | 'sky' | 'rose'; }) { - const toneClassName = tone === 'sky' - ? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white' - : 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'; + const toneClassName = + tone === 'sky' + ? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white' + : 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'; return (
))}
- {activeTab !== 'world' && activeTab !== 'anchors' ? ( + {activeTab !== 'world' ? (
- +
{isBulkDeleteMode ? ( @@ -444,20 +631,25 @@ export function CustomWorldEntityCatalog({ 已选 {selectedBulkIds.length}
取消 - + 删除选中 ) : ( <> - {createActionLabel && onCreateAction ? ( - {createActionLabel} + {!readOnly && createActionLabel && onCreateAction ? ( + + {createActionLabel} + ) : null} - {bulkDeleteTab && ((bulkDeleteTab === 'story' && onDeleteStoryNpcs) || (bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? ( - startBulkDelete(bulkDeleteTab)} tone="rose"> + {!readOnly && + bulkDeleteTab && + ((bulkDeleteTab === 'story' && onDeleteStoryNpcs) || + (bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? ( + startBulkDelete(bulkDeleteTab)} + tone="rose" + > 批量删除 ) : null} @@ -470,52 +662,91 @@ export function CustomWorldEntityCatalog({ {activeTab === 'world' ? ( <> -
onEditTarget({ kind: 'world' })} tone="sky">编辑}> +
onEditTarget({ kind: 'world' })} + tone="sky" + > + 查看详情 + + ) : ( + onEditTarget({ kind: 'world' })} + tone="sky" + > + 编辑 + + ) + } + >

{profile.summary}

-
主线目标:{profile.playerGoal}
-
世界基调:{profile.tone}
-
原始设定:{profile.settingText}
+
+ 主线目标:{profile.playerGoal} +
+
+ 世界基调:{profile.tone} +
- {creatorIntentSummary ? ( -
-
- {creatorIntentSummary} -
-
- ) : null} - -
+
- -
- {resolvedCampScene.name} -
-
- {resolvedCampScene.description} +
+ {structuredFoundationEntries.map((entry) => ( +
+
+ {entry.label} +
+
+ {entry.value || '待补充'} +
+
+ ))}
+ {profile.settingText ? ( +
+ {profile.settingText} +
+ ) : null} + {creatorIntentSummary && creatorIntentSummary !== profile.settingText ? ( +
+ {creatorIntentSummary} +
+ ) : null}
-
+
-
{profile.playableNpcs.length}
+
+ {profile.playableNpcs.length} +
可扮演角色
-
{profile.storyNpcs.length}
+
+ {profile.storyNpcs.length} +
场景角色
-
{profile.landmarks.length}
+
+ {profile.landmarks.length + 1} +
场景
@@ -526,128 +757,72 @@ export function CustomWorldEntityCatalog({ ) : null} - {activeTab === 'anchors' ? ( -
-
-
- {creatorIntentSummary || '当前还没有记录创作锚点。'} -
-
- -
-
- {profile.creatorIntent?.keyFactions.length ? ( - profile.creatorIntent.keyFactions.map((entry) => ( -
-
-
{entry.name || '未命名势力'}
- {entry.locked ? ( - - 已锁定 - - ) : null} -
-
{entry.publicGoal || '暂无目标说明'}
- {entry.tension ?
冲突:{entry.tension}
: null} - {entry.notes ?
补充:{entry.notes}
: null} -
- )) - ) : ( - - )} -
-
- -
-
- {profile.creatorIntent?.keyCharacters.length ? ( - profile.creatorIntent.keyCharacters.map((entry) => ( -
-
-
{entry.name || '未命名角色'}
- {entry.locked ? ( - - 已锁定 - - ) : null} -
-
{entry.role || '未填写身份'}
- {entry.publicMask ?
表面:{entry.publicMask}
: null} - {entry.hiddenHook ?
暗线:{entry.hiddenHook}
: null} - {entry.relationToPlayer ?
与玩家:{entry.relationToPlayer}
: null} -
- )) - ) : ( - - )} -
-
- -
-
- {profile.creatorIntent?.keyLandmarks.length ? ( - profile.creatorIntent.keyLandmarks.map((entry) => ( -
-
-
{entry.name || '未命名地点'}
- {entry.locked ? ( - - 已锁定 - - ) : null} -
-
{entry.purpose || '未填写作用'}
- {entry.mood ?
氛围:{entry.mood}
: null} - {entry.secret ?
秘密:{entry.secret}
: null} -
- )) - ) : ( - - )} -
-
-
- ) : null} - {activeTab === 'playable' ? (
- 可扮演角色支持新增、删除与更换外观模板。 + {readOnly + ? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。' + : '可扮演角色支持新增、删除与更换外观模板。'}
{filteredPlayable.length === 0 ? ( ) : ( - filteredPlayable.map(role => { - const previewCharacter = previewCharacterById.get(role.id) ?? null; + filteredPlayable.map((role) => { + const previewCharacter = + previewCharacterById.get(role.id) ?? null; return (
- onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky">编辑 - removePlayable(role.id, role.name)} tone="rose">删除 -
- )} + actions={ + readOnly ? ( + + onEditTarget({ + kind: 'playable', + mode: 'edit', + id: role.id, + }) + } + tone="sky" + > + 查看详情 + + ) : ( +
+ + onEditTarget({ + kind: 'playable', + mode: 'edit', + id: role.id, + }) + } + tone="sky" + > + 编辑 + + removePlayable(role.id, role.name)} + tone="rose" + > + 删除 + +
+ ) + } >
{previewCharacter ? ( - + ) : null}
@@ -656,51 +831,86 @@ export function CustomWorldEntityCatalog({ 创作者锁定角色
) : null} -
{role.description}
-
{role.backstory}
+
+ {role.description} +
+
+ {role.backstory} +
- 公开背景:{role.backstoryReveal.publicSummary || '未填写'} + 公开背景: + {role.backstoryReveal.publicSummary || '未填写'}
-
身份:{role.role}
-
初始好感:{role.initialAffinity}
-
性格:{role.personality}
-
战斗:{role.combatStyle}
+
+ 身份:{role.role} +
+
+ 初始好感:{role.initialAffinity} +
+
+ 性格:{role.personality} +
+
+ 战斗:{role.combatStyle} +
+
+
+ 动机:{role.motivation}
-
动机:{role.motivation}
-
好感背景章节
+
+ 好感背景章节 +
- {role.backstoryReveal.chapters.map(chapter => ( -
- {chapter.affinityRequired} 好感 · {chapter.title}:{chapter.teaser} + {role.backstoryReveal.chapters.map((chapter) => ( +
+ {chapter.affinityRequired} 好感 ·{' '} + {chapter.title}:{chapter.teaser}
))}
-
技能
+
+ 技能 +
- {role.skills.map(skill => ( -
+ {role.skills.map((skill) => ( +
{skill.name} · {skill.style}:{skill.summary}
))}
-
初始物品
+
+ 初始物品 +
- {role.initialItems.map(item => ( -
- {item.name} x{item.quantity} · {item.category} · {item.rarity}:{item.description} + {role.initialItems.map((item) => ( +
+ {item.name} x{item.quantity} · {item.category} ·{' '} + {item.rarity}:{item.description}
))}
- {role.tags.map(tag => ( - + {role.tags.map((tag) => ( + {tag} ))} @@ -720,7 +930,7 @@ export function CustomWorldEntityCatalog({ {filteredStory.length === 0 ? ( ) : ( - filteredStory.map(npc => ( + filteredStory.map((npc) => (
isBulkDeleteMode ? toggleBulkSelected(npc.id) - : onEditTarget({ kind: 'story', mode: 'edit', id: npc.id }) + : readOnly + ? onEditTarget({ + kind: 'story', + mode: 'edit', + id: npc.id, + }) + : onEditTarget({ + kind: 'story', + mode: 'edit', + id: npc.id, + }) } - media={( + media={ - )} + } />
)) @@ -751,29 +971,43 @@ export function CustomWorldEntityCatalog({ {activeTab === 'landmarks' ? (
- {filteredLandmarks.length === 0 ? ( + {filteredSceneEntries.length === 0 ? ( ) : ( - filteredLandmarks.map(landmark => ( -
+ filteredSceneEntries.map((scene) => ( +
- isBulkDeleteMode - ? toggleBulkSelected(landmark.id) - : onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id }) + title={scene.name} + description={ + scene.kind === 'camp' + ? `开局场景 · ${scene.description}` + : scene.description } - media={( + isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode} + isSelected={ + scene.kind === 'landmark' && + selectedBulkIds.includes(scene.id) + } + onClick={() => + scene.kind === 'camp' + ? onEditTarget({ kind: 'world' }) + : isBulkDeleteMode + ? toggleBulkSelected(scene.id) + : onEditTarget({ + kind: 'landmark', + mode: 'edit', + id: scene.id, + }) + } + media={ - )} + } + disabled={scene.kind === 'camp' && isBulkDeleteMode} />
)) diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index c0d8318b..b9f69f81 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -1,8 +1,6 @@ import { motion } from 'motion/react'; -import type { - CustomWorldGenerationProgress, -} from '../../packages/shared/src/contracts/runtime'; +import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; interface CustomWorldGenerationViewProps { @@ -74,7 +72,9 @@ export function CustomWorldGenerationView({ ? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}` : '正在校准预计等待时间'; const elapsedText = - progress != null ? `已耗时 ${formatDuration(progress.elapsedMs)}` : '正在启动世界生成'; + progress != null + ? `已耗时 ${formatDuration(progress.elapsedMs)}` + : '正在启动世界生成'; return (
void; onRegenerate?: () => void; onContinueExpand?: () => void; - onSave: () => void; + onSave?: () => void; onProfileChange: (profile: CustomWorldProfile) => void; + readOnly?: boolean; + backLabel?: string; + editActionLabel?: string; + regenerateActionLabel?: string; + saveActionLabel?: string; } function SmallButton({ @@ -48,7 +59,9 @@ function SmallButton({ ); } -function getCreateTargetByTab(activeTab: ResultTab): CustomWorldEditorTarget | null { +function getCreateTargetByTab( + activeTab: ResultTab, +): CustomWorldEditorTarget | null { if (activeTab === 'playable') return { kind: 'playable', mode: 'create' }; if (activeTab === 'story') return { kind: 'story', mode: 'create' }; if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' }; @@ -82,7 +95,10 @@ function removeStoryNpcsFromProfile( } satisfies CustomWorldProfile; } -function removeLandmarksFromProfile(profile: CustomWorldProfile, ids: string[]) { +function removeLandmarksFromProfile( + profile: CustomWorldProfile, + ids: string[], +) { const idSet = new Set(ids); const nextLandmarks = profile.landmarks.filter( (landmark) => !idSet.has(landmark.id), @@ -115,12 +131,24 @@ export function CustomWorldResultView({ onContinueExpand, onSave, onProfileChange, + readOnly = false, + backLabel = '返回', + editActionLabel = '修改设定', + regenerateActionLabel = '重新生成', + saveActionLabel = '保存到我的作品', }: CustomWorldResultViewProps) { - const [editorTarget, setEditorTarget] = useState(null); + const [editorTarget, setEditorTarget] = + useState(null); const [activeTab, setActiveTab] = useState('world'); - const createTarget = useMemo(() => getCreateTargetByTab(activeTab), [activeTab]); - const createLabel = useMemo(() => getCreateLabelByTab(activeTab), [activeTab]); + const createTarget = useMemo( + () => getCreateTargetByTab(activeTab), + [activeTab], + ); + const createLabel = useMemo( + () => getCreateLabelByTab(activeTab), + [activeTab], + ); const onRegenerate = () => { if (isGenerating || !triggerRegenerate) return; @@ -151,7 +179,7 @@ export function CustomWorldResultView({ 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' : ''}`} > - 返回 + {backLabel}
@@ -165,15 +193,22 @@ export function CustomWorldResultView({ onProfileChange={onProfileChange} onDeleteStoryNpcs={handleDeleteStoryNpcs} onDeleteLandmarks={handleDeleteLandmarks} - createActionLabel={createLabel} - onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined} + createActionLabel={readOnly ? undefined : createLabel} + onCreateAction={ + readOnly || !createTarget + ? undefined + : () => setEditorTarget(createTarget) + } + readOnly={readOnly} />
{isGenerating && (
-
{progressLabel}
+
+ {progressLabel} +
{Math.round(progress)}%
@@ -199,28 +234,41 @@ export function CustomWorldResultView({ ) : null}
{onEditSetting ? ( - 修改设定 + {editActionLabel} ) : null} {triggerRegenerate ? ( - 重新生成 + + {regenerateActionLabel} + ) : null} {profile.generationStatus === 'key_only' && onContinueExpand ? ( - + 继续补全世界 ) : null} - + {onSave ? ( + + ) : null}
diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx index 972fa231..d74519aa 100644 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx @@ -55,12 +55,12 @@ export function CustomWorldAgentDraftDrawer({
- {group.items.map((card) => { + {group.items.map((card, index) => { const isActive = activeCardId === card.id; return (
{lockedItems.length > 0 ? (
- {lockedItems.map((item) => ( + {lockedItems.map((item, index) => ( {item} diff --git a/src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx b/src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx index b67c61a3..408e3b2b 100644 --- a/src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx @@ -5,6 +5,7 @@ type CustomWorldAgentQuickActionsProps = { disabled: boolean; canDraftFoundation: boolean; showEntityActions?: boolean; + showSummaryAction?: boolean; onRequestSummary: () => void; onDraftFoundation: () => void; onGenerateCharacter?: () => void; @@ -45,6 +46,7 @@ export function CustomWorldAgentQuickActions({ disabled, canDraftFoundation, showEntityActions = false, + showSummaryAction = true, onRequestSummary, onDraftFoundation, onGenerateCharacter, @@ -70,12 +72,14 @@ export function CustomWorldAgentQuickActions({ 快捷动作
- + {showSummaryAction ? ( + + ) : null} {draftAction && canDraftFoundation ? ( { + vi.restoreAllMocks(); +}); + +test('filters empty recommended replies and avoids duplicate key warnings', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; + } + + render( + {}} + />, + ); + + expect(screen.getByRole('button', { name: '继续补充冲突' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '先确定玩家身份' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: /^\s*$/u })).toBeNull(); + expect(screen.getAllByRole('button')).toHaveLength(2); + + 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/custom-world-agent/CustomWorldAgentThread.tsx b/src/components/custom-world-agent/CustomWorldAgentThread.tsx index 99d213ce..fda6d286 100644 --- a/src/components/custom-world-agent/CustomWorldAgentThread.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentThread.tsx @@ -26,9 +26,16 @@ export function CustomWorldAgentThread({ onRecommendedReply, }: CustomWorldAgentThreadProps) { const bottomRef = useRef(null); - const lastAssistantMessageId = [...messages] - .reverse() - .find((message) => message.role === 'assistant')?.id; + const visibleRecommendedReplies = [ + ...new Set( + recommendedReplies.map((reply) => reply.trim()).filter(Boolean), + ), + ].slice(0, 3); + const lastAssistantMessageIndex = messages.reduce( + (lastIndex, message, index) => + message.role === 'assistant' ? index : lastIndex, + -1, + ); useEffect(() => { bottomRef.current?.scrollIntoView({ @@ -45,13 +52,13 @@ export function CustomWorldAgentThread({
) : (
- {messages.map((message) => { + {messages.map((message, index) => { const isUser = message.role === 'user'; const isSystem = message.role === 'system'; return (
{!isUser && - message.id === lastAssistantMessageId && - recommendedReplies.length > 0 ? ( + index === lastAssistantMessageIndex && + visibleRecommendedReplies.length > 0 ? (
- {recommendedReplies.slice(0, 3).map((reply) => ( + {visibleRecommendedReplies.map((reply, replyIndex) => (
), })); @@ -117,9 +132,51 @@ beforeEach(() => { window.sessionStorage.clear(); vi.mocked(listCustomWorldLibrary).mockResolvedValue([]); vi.mocked(listCustomWorldGallery).mockResolvedValue([]); + vi.mocked(upsertCustomWorldProfile).mockResolvedValue({ + entry: { + ownerUserId: 'user-1', + profileId: 'agent-draft-custom-world-agent-session-1', + profile: { + id: 'agent-draft-custom-world-agent-session-1', + name: '潮雾列岛', + } as never, + visibility: 'draft', + publishedAt: null, + updatedAt: '2026-04-14T12:00:00.000Z', + authorDisplayName: '玩家', + worldName: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summaryText: '第一版世界底稿已经整理完成。', + coverImageSrc: null, + themeMode: 'tide', + playableNpcCount: 1, + landmarkCount: 1, + }, + entries: [], + }); vi.mocked(createCustomWorldAgentSession).mockResolvedValue({ session: mockSession, }); + vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({ + operation: { + operationId: 'operation-draft-foundation-1', + type: 'draft_foundation', + status: 'queued', + phaseLabel: '已接收请求', + phaseDetail: '正在准备生成世界底稿。', + progress: 10, + error: null, + }, + }); + vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({ + operationId: 'operation-draft-foundation-1', + type: 'draft_foundation', + status: 'running', + phaseLabel: '生成世界底稿', + phaseDetail: '正在根据已确认锚点编译第一版世界结构。', + progress: 38, + error: null, + }); vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession); }); @@ -151,3 +208,168 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and await screen.findByText('Agent工作区:custom-world-agent-session-1'), ).toBeTruthy(); }); + +test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => { + const user = userEvent.setup(); + + render(); + + 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( + await screen.findByText('Agent工作区:custom-world-agent-session-1'), + ).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '开始生成草稿' })); + + await waitFor(() => { + expect(executeCustomWorldAgentAction).toHaveBeenCalledWith( + 'custom-world-agent-session-1', + { + action: 'draft_foundation', + }, + ); + }); + + expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); + expect(screen.queryByText(/Agent工作区/u)).toBeNull(); + expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0); +}); + +test('existing draft sessions enter the legacy result layout directly', async () => { + const user = userEvent.setup(); + + vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({ + operationId: 'operation-draft-foundation-1', + type: 'draft_foundation', + status: 'completed', + phaseLabel: '世界底稿已生成', + phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。', + progress: 100, + error: null, + }); + vi.mocked(getCustomWorldAgentSession).mockResolvedValue({ + ...mockSession, + stage: 'object_refining', + creatorIntent: { + sourceMode: 'card', + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + themeKeywords: ['海雾', '旧航路'], + toneDirectives: ['压抑', '悬疑'], + openingSituation: '首夜就有陌生船只闯入禁航区。', + coreConflicts: ['航运公会与守灯会争夺航路控制权'], + keyFactions: [], + keyCharacters: [], + keyLandmarks: [], + iconicElements: ['会移动的海雾'], + forbiddenDirectives: [], + rawSettingText: '', + }, + draftProfile: { + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船与禁航区异动的真相。', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + title: '旧航路引路人', + role: '关键同行者', + publicIdentity: '最熟悉旧航路的人。', + publicMask: '看上去像可靠旧友。', + currentPressure: '他必须在两股势力间站队。', + hiddenHook: '暗中替沉船商盟引路。', + relationToPlayer: '旧友兼潜在背叛者', + threadIds: ['thread-1'], + summary: '他像旧友,但也像一把始终没收回鞘的刀。', + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + title: '守灯会值夜人', + role: '场景关键角色', + publicIdentity: '负责夜间巡灯与封锁。', + publicMask: '对外一直冷静克制。', + currentPressure: '她知道更多禁航区真相。', + hiddenHook: '曾亲眼见过失控海雾吞船。', + relationToPlayer: '最早愿意交换线索的人', + threadIds: ['thread-1'], + summary: '她总像比所有人更早知道海雾会往哪一侧压下来。', + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + purpose: '观察雾潮与往来船只', + mood: '潮湿、压抑、风声不止', + importance: '开局核心场景', + characterIds: ['story-1'], + threadIds: ['thread-1'], + summary: '旧灯塔是整片群岛最先看见异动的地方。', + }, + ], + factions: [], + threads: [], + chapters: [], + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + openingSituation: '首夜就有陌生船只闯入禁航区。', + iconicElements: ['会移动的海雾'], + sourceAnchorSummary: '海雾、旧灯塔、失控航路。', + }, + draftCards: [ + { + id: 'world-foundation', + kind: 'world', + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + status: 'warning', + linkedIds: ['playable-1', 'story-1', 'landmark-1'], + warningCount: 0, + }, + ], + }); + + render(); + + await user.click(screen.getByRole('button', { name: '创作' })); + await user.click(screen.getByRole('button', { name: /开启新的创作/u })); + await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u })); + + await waitFor( + async () => { + expect(await screen.findByText('世界档案')).toBeTruthy(); + expect( + screen.getByRole('button', { + name: /保存到我的作品|自动保存中|已保存到我的作品/u, + }), + ).toBeTruthy(); + }, + { timeout: 2500 }, + ); + + expect(screen.queryByText(/Agent工作区/u)).toBeNull(); + expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull(); + expect(screen.getByText(/原始设定/u)).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: /场景角色/u })); + await user.click(screen.getByRole('button', { name: /顾潮音/u })); + + expect(await screen.findByText(/编辑场景角色:顾潮音/u)).toBeTruthy(); + expect( + screen.getByRole('button', { name: /AI生成形象与动作/u }), + ).toBeTruthy(); + expect(screen.getByText('技能')).toBeTruthy(); + +}); diff --git a/src/components/game-shell/PreGameSelectionFlow.tsx b/src/components/game-shell/PreGameSelectionFlow.tsx index 2c1676a5..6e740da8 100644 --- a/src/components/game-shell/PreGameSelectionFlow.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.tsx @@ -31,16 +31,17 @@ import { getCustomWorldAgentSession, sendCustomWorldAgentMessage, } from '../../services/aiService'; -import { - readCustomWorldAgentUiState, - writeCustomWorldAgentUiState, -} from '../../services/customWorldAgentUiState'; +import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult'; import { buildAgentDraftFoundationGenerationProgress, buildAgentDraftFoundationSettingText, isDraftFoundationOperation, isDraftFoundationOperationRunning, } from '../../services/customWorldAgentGenerationProgress'; +import { + readCustomWorldAgentUiState, + writeCustomWorldAgentUiState, +} from '../../services/customWorldAgentUiState'; import { buildCustomWorldCreatorIntentDisplayText, buildCustomWorldCreatorIntentGenerationText, @@ -61,7 +62,7 @@ import { type GameState, } from '../../types'; import { PlatformCreationTypeModal } from './PlatformCreationTypeModal'; -import { type PlatformHomeTab,PlatformHomeView } from './PlatformHomeView'; +import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView'; import { PlatformWorldDetailView } from './PlatformWorldDetailView'; const CustomWorldGenerationView = lazy(async () => { @@ -106,6 +107,9 @@ type CustomWorldGenerationViewSource = | 'agent-draft-foundation' | null; +type CustomWorldResultViewSource = 'classic' | 'agent-draft' | null; +type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error'; + type PreGameSelectionFlowProps = { selectionStage: SelectionStage; setSelectionStage: (stage: SelectionStage) => void; @@ -270,11 +274,21 @@ export function PreGameSelectionFlow({ const [isMutatingDetail, setIsMutatingDetail] = useState(false); const [customWorldProgress, setCustomWorldProgress] = useState(null); + const [customWorldAutoSaveState, setCustomWorldAutoSaveState] = + useState('idle'); + const [customWorldAutoSaveError, setCustomWorldAutoSaveError] = useState< + string | null + >(null); const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] = useState(null); + const [customWorldResultViewSource, setCustomWorldResultViewSource] = + useState(null); const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] = useState(null); const customWorldAbortControllerRef = useRef(null); + const customWorldAutoSaveTimeoutRef = useRef(null); + const lastAutoSavedProfileSignatureRef = useRef(null); + const latestAutoSaveRequestIdRef = useRef(0); const previewCustomWorldCharacters = useMemo( () => @@ -307,34 +321,6 @@ export function PreGameSelectionFlow({ return nextSession; }, []); - const refreshPlatformData = useCallback(async () => { - setIsLoadingPlatform(true); - setPlatformError(null); - - try { - const [libraryEntries, galleryEntries] = await Promise.all([ - listCustomWorldLibrary(), - listCustomWorldGallery(), - ]); - setSavedCustomWorldEntries(libraryEntries); - setPublishedGalleryEntries(galleryEntries); - if (selectedDetailEntry) { - const nextOwnedEntry = libraryEntries.find( - (entry) => - entry.ownerUserId === selectedDetailEntry.ownerUserId && - entry.profileId === selectedDetailEntry.profileId, - ); - if (nextOwnedEntry) { - setSelectedDetailEntry(nextOwnedEntry); - } - } - } catch (error) { - setPlatformError(resolveErrorMessage(error, '读取平台数据失败。')); - } finally { - setIsLoadingPlatform(false); - } - }, [selectedDetailEntry]); - useEffect(() => { if (hasAppliedInitialAgentWorkspaceRef.current) { return; @@ -397,6 +383,9 @@ export function PreGameSelectionFlow({ useEffect( () => () => { customWorldAbortControllerRef.current?.abort(); + if (customWorldAutoSaveTimeoutRef.current !== null) { + window.clearTimeout(customWorldAutoSaveTimeoutRef.current); + } }, [], ); @@ -512,6 +501,72 @@ export function PreGameSelectionFlow({ syncAgentSessionSnapshot, ]); + useEffect(() => { + if ( + !isDraftFoundationOperationRunning(agentOperation) || + agentDraftGenerationStartedAt + ) { + return; + } + + setAgentDraftGenerationStartedAt(Date.now()); + }, [agentDraftGenerationStartedAt, agentOperation]); + + useEffect(() => { + if ( + selectionStage !== 'custom-world-generating' || + customWorldGenerationViewSource !== 'agent-draft-foundation' || + !isDraftFoundationOperation(agentOperation) || + agentOperation.status !== 'completed' + ) { + return; + } + + let cancelled = false; + const timeoutId = window.setTimeout(() => { + void (async () => { + const latestSession = activeAgentSessionId + ? await syncAgentSessionSnapshot(activeAgentSessionId).catch( + () => null, + ) + : agentSession; + + if (cancelled) { + return; + } + + const draftResultProfile = buildCustomWorldProfileFromAgentDraft( + latestSession ?? agentSession, + ); + if (!draftResultProfile) { + setAgentDraftGenerationStartedAt(null); + setCustomWorldGenerationViewSource(null); + setSelectionStage('agent-workspace'); + return; + } + + setGeneratedCustomWorldProfile(draftResultProfile); + setAgentDraftGenerationStartedAt(null); + setCustomWorldGenerationViewSource(null); + setCustomWorldResultViewSource('agent-draft'); + setSelectionStage('custom-world-result'); + })(); + }, 900); + + return () => { + cancelled = true; + window.clearTimeout(timeoutId); + }; + }, [ + activeAgentSessionId, + agentOperation, + customWorldGenerationViewSource, + agentSession, + selectionStage, + setSelectionStage, + syncAgentSessionSnapshot, + ]); + const customWorldSettingPreview = useMemo(() => { if (customWorldCreatorIntent.sourceMode === 'freeform') { return customWorldCreatorIntent.rawSettingText.trim(); @@ -531,6 +586,51 @@ export function PreGameSelectionFlow({ () => buildAgentDraftFoundationSettingText(agentSession), [agentSession], ); + const agentDraftResultProfile = useMemo( + () => buildCustomWorldProfileFromAgentDraft(agentSession), + [agentSession], + ); + const shouldAutoOpenAgentDraftResult = useMemo( + () => + Boolean( + agentDraftResultProfile && + agentSession && + (agentSession.stage === 'object_refining' || + agentSession.stage === 'visual_refining' || + agentSession.stage === 'long_tail_review' || + agentSession.stage === 'ready_to_publish' || + agentSession.stage === 'published') && + agentSession.draftCards.length > 0, + ), + [agentDraftResultProfile, agentSession], + ); + + useEffect(() => { + if (!shouldAutoOpenAgentDraftResult || !agentDraftResultProfile) { + return; + } + + if (selectionStage === 'agent-workspace') { + setGeneratedCustomWorldProfile(agentDraftResultProfile); + setCustomWorldResultViewSource('agent-draft'); + setSelectionStage('custom-world-result'); + return; + } + + if ( + selectionStage === 'custom-world-result' && + !generatedCustomWorldProfile + ) { + setGeneratedCustomWorldProfile(agentDraftResultProfile); + setCustomWorldResultViewSource('agent-draft'); + } + }, [ + agentDraftResultProfile, + generatedCustomWorldProfile, + selectionStage, + setSelectionStage, + shouldAutoOpenAgentDraftResult, + ]); const agentDraftGenerationProgress = useMemo( () => @@ -543,25 +643,38 @@ export function PreGameSelectionFlow({ const isAgentDraftGenerationView = customWorldGenerationViewSource === 'agent-draft-foundation'; + const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft'; + const activeGenerationSettingText = isAgentDraftGenerationView + ? agentDraftSettingPreview + : customWorldSettingPreview; const activeGenerationProgress = isAgentDraftGenerationView ? agentDraftGenerationProgress : customWorldProgress; const isActiveGenerationRunning = isAgentDraftGenerationView ? isDraftFoundationOperationRunning(agentOperation) : isGeneratingCustomWorld; - const activeGenerationError = - isAgentDraftGenerationView && - isDraftFoundationOperation(agentOperation) && - agentOperation.status === 'failed' + const activeGenerationError = isAgentDraftGenerationView + ? isDraftFoundationOperation(agentOperation) && + agentOperation.status === 'failed' ? agentOperation.error || agentOperation.phaseDetail - : customWorldError; + : null + : customWorldError; const leaveCustomWorldResult = () => { setGeneratedCustomWorldProfile(null); setCustomWorldError(null); + setCustomWorldAutoSaveError(null); + setCustomWorldAutoSaveState('idle'); setCustomWorldProgress(null); setCustomWorldGenerationViewSource(null); - setSelectionStage(selectedDetailEntry ? 'detail' : 'platform'); + setCustomWorldResultViewSource(null); + setSelectionStage( + isAgentDraftResultView + ? 'agent-workspace' + : selectedDetailEntry + ? 'detail' + : 'platform', + ); }; const leaveCustomWorldGeneration = () => { @@ -570,8 +683,11 @@ export function PreGameSelectionFlow({ } setCustomWorldError(null); + setCustomWorldAutoSaveError(null); + setCustomWorldAutoSaveState('idle'); setCustomWorldProgress(null); setCustomWorldGenerationViewSource(null); + setCustomWorldResultViewSource(null); setSelectionStage('platform'); }; @@ -596,6 +712,12 @@ export function PreGameSelectionFlow({ const { session } = await createCustomWorldAgentSession({}); setAgentSession(session); setAgentOperation(null); + setGeneratedCustomWorldProfile(null); + setCustomWorldAutoSaveError(null); + setCustomWorldAutoSaveState('idle'); + setAgentDraftGenerationStartedAt(null); + setCustomWorldGenerationViewSource(null); + setCustomWorldResultViewSource(null); persistAgentUiState(session.sessionId, null); setShowCreationTypeModal(false); setPlatformTab('create'); @@ -639,6 +761,20 @@ export function PreGameSelectionFlow({ return; } + const isDraftFoundationAction = payload.action === 'draft_foundation'; + + if (isDraftFoundationAction) { + setGeneratedCustomWorldProfile(null); + setCustomWorldError(null); + setCustomWorldAutoSaveError(null); + setCustomWorldAutoSaveState('idle'); + setCustomWorldProgress(null); + setCustomWorldGenerationViewSource('agent-draft-foundation'); + setCustomWorldResultViewSource(null); + setAgentDraftGenerationStartedAt(Date.now()); + setSelectionStage('custom-world-generating'); + } + try { const { operation } = await executeCustomWorldAgentAction( activeAgentSessionId, @@ -665,10 +801,44 @@ export function PreGameSelectionFlow({ const leaveAgentWorkspace = () => { setPlatformTab('create'); setAgentOperation(null); + setGeneratedCustomWorldProfile(null); + setCustomWorldAutoSaveError(null); + setCustomWorldAutoSaveState('idle'); + setAgentDraftGenerationStartedAt(null); + setCustomWorldGenerationViewSource(null); + setCustomWorldResultViewSource(null); persistAgentUiState(activeAgentSessionId, null); setSelectionStage('platform'); }; + const leaveAgentDraftGeneration = () => { + if (isDraftFoundationOperationRunning(agentOperation)) { + return; + } + + setAgentDraftGenerationStartedAt(null); + setCustomWorldGenerationViewSource(null); + setSelectionStage('agent-workspace'); + }; + + const leaveAgentDraftResult = () => { + setGeneratedCustomWorldProfile(null); + setCustomWorldError(null); + setCustomWorldAutoSaveError(null); + setCustomWorldAutoSaveState('idle'); + setCustomWorldProgress(null); + setCustomWorldGenerationViewSource(null); + setCustomWorldResultViewSource(null); + setPlatformTab('create'); + setSelectionStage('platform'); + }; + + const retryAgentDraftGeneration = () => { + void executeAgentAction({ + action: 'draft_foundation', + }); + }; + const openCustomWorldCreator = () => { if (isGeneratingCustomWorld) { return; @@ -683,7 +853,11 @@ export function PreGameSelectionFlow({ setPlatformError(null); setDetailError(null); setCustomWorldError(null); + setCustomWorldAutoSaveError(null); + setCustomWorldAutoSaveState('idle'); setCustomWorldProgress(null); + setCustomWorldGenerationViewSource(null); + setCustomWorldResultViewSource(null); setCustomWorldCreatorIntent( createEmptyCustomWorldCreatorIntent('freeform'), ); @@ -710,7 +884,11 @@ export function PreGameSelectionFlow({ } setCustomWorldError(null); + setCustomWorldAutoSaveError(null); + setCustomWorldAutoSaveState('idle'); setCustomWorldProgress(null); + setCustomWorldGenerationViewSource(null); + setCustomWorldResultViewSource(null); setShowCustomWorldModal(true); }; @@ -740,26 +918,98 @@ export function PreGameSelectionFlow({ } }; - const saveGeneratedCustomWorld = async () => { + const saveGeneratedCustomWorld = useCallback( + async (profile = generatedCustomWorldProfile) => { + if (!profile) { + return null; + } + + const profileSignature = JSON.stringify(profile); + const requestId = latestAutoSaveRequestIdRef.current + 1; + latestAutoSaveRequestIdRef.current = requestId; + setCustomWorldAutoSaveState('saving'); + setCustomWorldAutoSaveError(null); + + try { + const mutation = await upsertCustomWorldProfile(profile); + if (latestAutoSaveRequestIdRef.current !== requestId) { + return mutation; + } + + lastAutoSavedProfileSignatureRef.current = profileSignature; + setSavedCustomWorldEntries(mutation.entries); + setSelectedDetailEntry((current) => { + if (!current || current.profileId === mutation.entry.profileId) { + return mutation.entry; + } + + return current; + }); + setCustomWorldAutoSaveState('saved'); + setCustomWorldAutoSaveError(null); + return mutation; + } catch (error) { + if (latestAutoSaveRequestIdRef.current !== requestId) { + return null; + } + + setCustomWorldAutoSaveState('error'); + setCustomWorldAutoSaveError( + resolveErrorMessage(error, '保存自定义世界失败。'), + ); + return null; + } + }, + [generatedCustomWorldProfile], + ); + + useEffect(() => { if (!generatedCustomWorldProfile) { + setCustomWorldAutoSaveState('idle'); + setCustomWorldAutoSaveError(null); + lastAutoSavedProfileSignatureRef.current = null; + if (customWorldAutoSaveTimeoutRef.current !== null) { + window.clearTimeout(customWorldAutoSaveTimeoutRef.current); + customWorldAutoSaveTimeoutRef.current = null; + } return; } - try { - const mutation = await upsertCustomWorldProfile( - generatedCustomWorldProfile, - ); - setSavedCustomWorldEntries(mutation.entries); - setSelectedDetailEntry(mutation.entry); - await refreshPlatformData(); - setGeneratedCustomWorldProfile(null); - setCustomWorldError(null); - setCustomWorldProgress(null); - setSelectionStage('platform'); - } catch (error) { - setCustomWorldError(resolveErrorMessage(error, '保存自定义世界失败。')); + if ( + selectionStage !== 'custom-world-result' || + isGeneratingCustomWorld + ) { + return; } - }; + + const nextSignature = JSON.stringify(generatedCustomWorldProfile); + if (nextSignature === lastAutoSavedProfileSignatureRef.current) { + return; + } + + setCustomWorldAutoSaveState('saving'); + if (customWorldAutoSaveTimeoutRef.current !== null) { + window.clearTimeout(customWorldAutoSaveTimeoutRef.current); + } + + const profileToSave = generatedCustomWorldProfile; + customWorldAutoSaveTimeoutRef.current = window.setTimeout(() => { + void saveGeneratedCustomWorld(profileToSave); + customWorldAutoSaveTimeoutRef.current = null; + }, 600); + + return () => { + if (customWorldAutoSaveTimeoutRef.current !== null) { + window.clearTimeout(customWorldAutoSaveTimeoutRef.current); + customWorldAutoSaveTimeoutRef.current = null; + } + }; + }, [ + generatedCustomWorldProfile, + isGeneratingCustomWorld, + saveGeneratedCustomWorld, + selectionStage, + ]); const openSavedCustomWorldEditor = ( entry: CustomWorldLibraryEntry, @@ -770,6 +1020,9 @@ export function PreGameSelectionFlow({ setSelectedDetailEntry(entry); setGeneratedCustomWorldProfile(entry.profile); + lastAutoSavedProfileSignatureRef.current = JSON.stringify(entry.profile); + setCustomWorldAutoSaveState('saved'); + setCustomWorldAutoSaveError(null); setCustomWorldCreatorIntent( entry.profile.creatorIntent ?? ({ @@ -780,6 +1033,8 @@ export function PreGameSelectionFlow({ setCustomWorldGenerationMode(entry.profile.generationMode ?? 'full'); setCustomWorldError(null); setCustomWorldProgress(null); + setCustomWorldGenerationViewSource(null); + setCustomWorldResultViewSource('classic'); setSelectionStage('custom-world-result'); }; @@ -844,6 +1099,9 @@ export function PreGameSelectionFlow({ ...mergedProfile, id: generatedCustomWorldProfile.id, }); + lastAutoSavedProfileSignatureRef.current = null; + setCustomWorldAutoSaveState('idle'); + setCustomWorldAutoSaveError(null); setCustomWorldProgress(null); setCustomWorldError(null); } catch (error) { @@ -899,7 +1157,11 @@ export function PreGameSelectionFlow({ customWorldAbortControllerRef.current?.abort(); customWorldAbortControllerRef.current = abortController; setCustomWorldError(null); + setCustomWorldAutoSaveError(null); + setCustomWorldAutoSaveState('idle'); setCustomWorldProgress(null); + setCustomWorldGenerationViewSource('classic'); + setCustomWorldResultViewSource(null); setShowCustomWorldModal(false); setSelectionStage('custom-world-generating'); setIsGeneratingCustomWorld(true); @@ -929,8 +1191,13 @@ export function PreGameSelectionFlow({ } : profile, ); + lastAutoSavedProfileSignatureRef.current = null; + setCustomWorldAutoSaveState('idle'); + setCustomWorldAutoSaveError(null); setCustomWorldProgress(null); setCustomWorldError(null); + setCustomWorldGenerationViewSource(null); + setCustomWorldResultViewSource('classic'); setSelectionStage('custom-world-result'); } catch (error) { if (abortController.signal.aborted) { @@ -1019,6 +1286,17 @@ export function PreGameSelectionFlow({ entry.profileId === selectedDetailEntry.profileId, ), ); + const resultViewSaveActionLabel = + customWorldAutoSaveState === 'saving' + ? '自动保存中' + : customWorldAutoSaveState === 'saved' + ? '已保存到我的作品' + : customWorldAutoSaveState === 'error' + ? '重新保存到我的作品' + : '保存到我的作品'; + const resultViewError = + customWorldAutoSaveError ?? + (isAgentDraftResultView ? null : customWorldError); return ( <> @@ -1156,16 +1434,61 @@ export function PreGameSelectionFlow({ fallback={} > { - void createCustomWorld(); - }} - onInterrupt={interruptCustomWorldGeneration} + settingText={activeGenerationSettingText} + progress={activeGenerationProgress} + isGenerating={isActiveGenerationRunning} + error={activeGenerationError} + onBack={ + isAgentDraftGenerationView + ? leaveAgentDraftGeneration + : leaveCustomWorldGeneration + } + onEditSetting={ + isAgentDraftGenerationView + ? leaveAgentDraftGeneration + : editCustomWorldSetting + } + onRetry={ + isAgentDraftGenerationView + ? retryAgentDraftGeneration + : () => { + void createCustomWorld(); + } + } + onInterrupt={ + isAgentDraftGenerationView + ? undefined + : interruptCustomWorldGeneration + } + backLabel={ + isAgentDraftGenerationView ? '返回工作区' : undefined + } + settingActionLabel={ + isAgentDraftGenerationView ? '回到工作区' : undefined + } + retryLabel={ + isAgentDraftGenerationView ? '重新生成草稿' : undefined + } + settingTitle={ + isAgentDraftGenerationView ? '当前共创设定' : undefined + } + settingDescription={ + isAgentDraftGenerationView + ? '这批锚点会被整理成第一版世界底稿与草稿卡。' + : undefined + } + progressTitle={ + isAgentDraftGenerationView ? '世界草稿生成进度' : undefined + } + activeBadgeLabel={ + isAgentDraftGenerationView ? '草稿编译中' : undefined + } + pausedBadgeLabel={ + isAgentDraftGenerationView ? '草稿生成已暂停' : undefined + } + idleBadgeLabel={ + isAgentDraftGenerationView ? '等待返回工作区' : undefined + } /> @@ -1186,22 +1509,52 @@ export function PreGameSelectionFlow({ { - void createCustomWorld(); - }} - onContinueExpand={() => { - void continueExpandCustomWorld(); - }} + onBack={ + isAgentDraftResultView + ? leaveAgentDraftResult + : leaveCustomWorldResult + } + onEditSetting={ + isAgentDraftResultView ? undefined : editCustomWorldSetting + } + onRegenerate={ + isAgentDraftResultView + ? retryAgentDraftGeneration + : () => { + void createCustomWorld(); + } + } + onContinueExpand={ + isAgentDraftResultView + ? undefined + : () => { + void continueExpandCustomWorld(); + } + } onSave={() => { void saveGeneratedCustomWorld(); }} + readOnly={false} + backLabel={isAgentDraftResultView ? '返回创作' : undefined} + regenerateActionLabel={ + isAgentDraftResultView ? '重新生成草稿' : undefined + } + saveActionLabel={resultViewSaveActionLabel} /> diff --git a/src/data/characterPresets.ts b/src/data/characterPresets.ts index 0af68d8b..af70d41e 100644 --- a/src/data/characterPresets.ts +++ b/src/data/characterPresets.ts @@ -421,8 +421,11 @@ export function resolveEncounterRecruitCharacter( return getCharacterById(resolveFallbackRecruitTemplateCharacterId(source)); } -export function getCharacterEquipment(character: Character) { - const runtimeProfile = getRuntimeCustomWorldProfile(); +export function getCharacterEquipment( + character: Character, + customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(), +) { + const runtimeProfile = customWorldProfile; if (runtimeProfile) { const starterEquipment = buildCustomWorldStarterEquipmentItems(character, runtimeProfile); const toRarityLabel = (rarity: InventoryItem['rarity'] | undefined) => ({ @@ -492,9 +495,13 @@ export function getCharacterEquipment(character: Character) { ]; } -export function getInventoryItems(character: Character, worldType: WorldType | null) { - if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) { - return buildCustomWorldStarterInventoryItems(character).map(item => ({ +export function getInventoryItems( + character: Character, + worldType: WorldType | null, + customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(), +) { + if (worldType === WorldType.CUSTOM && customWorldProfile) { + return buildCustomWorldStarterInventoryItems(character, customWorldProfile).map(item => ({ category: item.category, name: item.name, quantity: item.quantity, diff --git a/src/data/customWorldLibrary.ts b/src/data/customWorldLibrary.ts index 41633a9e..6e688854 100644 --- a/src/data/customWorldLibrary.ts +++ b/src/data/customWorldLibrary.ts @@ -1,5 +1,9 @@ -import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage'; -import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator'; +import { + isRecord, + readStoredJson, + writeStoredJson, +} from '../persistence/storage'; +import { generateWorldAttributeSchema } from '../services/attributeSchemaGenerator'; import { buildFallbackCustomWorldCampScene } from '../services/customWorldCamp'; import { buildCustomWorldAnchorPackFromIntent, @@ -26,16 +30,23 @@ import { CustomWorldRoleInitialItem, CustomWorldRoleSkill, EquipmentSlotId, + ItemAttributeResonance, ItemRarity, ItemStatProfile, ItemUseProfile, + KnowledgeFact, + RoleAttributeProfile, + SceneNarrativeResidue, + ThemePack, + ThreadContract, WorldType, + WorldStoryGraph, } from '../types'; import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS, DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, } from './affinityLevels'; -import {coerceWorldAttributeSchema} from './attributeValidation'; +import { coerceWorldAttributeSchema } from './attributeValidation'; import { type CustomWorldLandmarkDraft, normalizeCustomWorldLandmarks, @@ -48,11 +59,30 @@ const MIN_CUSTOM_WORLD_AFFINITY = -40; const MAX_CUSTOM_WORLD_AFFINITY = 90; const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18; const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6; -const ITEM_RARITIES = new Set(['common', 'uncommon', 'rare', 'epic', 'legendary']); +const ITEM_RARITIES = new Set([ + 'common', + 'uncommon', + 'rare', + 'epic', + 'legendary', +]); const EQUIPMENT_SLOTS = new Set(['weapon', 'armor', 'relic']); const ANIMATION_STATES = new Set(Object.values(AnimationState)); -const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set(['human', 'elf', 'orc', 'goblin']); -const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set(['cloth', 'leather', 'metal', 'melee', 'magic', 'ranged']); +const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set([ + 'human', + 'elf', + 'orc', + 'goblin', +]); +const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = + new Set([ + 'cloth', + 'leather', + 'metal', + 'melee', + 'magic', + 'ranged', + ]); const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([ '武器', '护甲', @@ -65,7 +95,12 @@ const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([ ]); const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS; -const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = ['表层来意', '旧事裂痕', '隐藏执念', '最终底牌'] as const; +const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [ + '表层来意', + '旧事裂痕', + '隐藏执念', + '最终底牌', +] as const; type CustomWorldRoleFallbackSource = { name: string; @@ -92,25 +127,43 @@ function toText(value: unknown, fallback = '') { function toStringArray(value: unknown) { return Array.isArray(value) ? value - .filter((item): item is string => typeof item === 'string') - .map(item => item.trim()) - .filter(Boolean) + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) : []; } function toOptionalNumber(value: unknown) { - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; } function toOptionalInteger(value: unknown) { - return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined; + return typeof value === 'number' && Number.isFinite(value) + ? Math.round(value) + : undefined; +} + +function preserveStructuredRecord(value: unknown): T | null { + return isRecord(value) ? (value as T) : null; +} + +function preserveStructuredRecordArray(value: unknown): T[] | null { + return Array.isArray(value) + ? (value.filter((entry): entry is Record => isRecord(entry)) as T[]) + : null; } function normalizeInitialAffinity(value: unknown, fallback: number) { - const resolved = typeof value === 'number' && Number.isFinite(value) - ? Math.round(value) - : fallback; - return Math.max(MIN_CUSTOM_WORLD_AFFINITY, Math.min(MAX_CUSTOM_WORLD_AFFINITY, resolved)); + const resolved = + typeof value === 'number' && Number.isFinite(value) + ? Math.round(value) + : fallback; + return Math.max( + MIN_CUSTOM_WORLD_AFFINITY, + Math.min(MAX_CUSTOM_WORLD_AFFINITY, resolved), + ); } function truncateText(value: string, maxLength: number) { @@ -125,7 +178,7 @@ function splitNarrativeSentences(text: string) { if (!normalized) return []; const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu); - return (matches ?? [normalized]).map(item => item.trim()).filter(Boolean); + return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean); } function normalizeRoleItemCategory(value: unknown, fallback = '材料') { @@ -143,12 +196,17 @@ function normalizeRoleItemCategory(value: unknown, fallback = '材料') { return fallback; } -function buildFallbackBackstoryReveal(source: CustomWorldRoleFallbackSource): CharacterBackstoryRevealConfig { - const normalizedBackstory = source.backstory.trim() || `${source.name}对自己的过去仍有保留。`; +function buildFallbackBackstoryReveal( + source: CustomWorldRoleFallbackSource, +): CharacterBackstoryRevealConfig { + const normalizedBackstory = + source.backstory.trim() || `${source.name}对自己的过去仍有保留。`; const backstorySentences = splitNarrativeSentences(normalizedBackstory); const backstoryLead = backstorySentences[0] ?? normalizedBackstory; - const backstoryDetail = backstorySentences.slice(0, 2).join('') || normalizedBackstory; - const publicSummary = source.description.trim() || truncateText(normalizedBackstory, 42); + const backstoryDetail = + backstorySentences.slice(0, 2).join('') || normalizedBackstory; + const publicSummary = + source.description.trim() || truncateText(normalizedBackstory, 42); const fallbackContents = [ source.description.trim() || backstoryLead, backstoryDetail, @@ -163,17 +221,28 @@ function buildFallbackBackstoryReveal(source: CustomWorldRoleFallbackSource): Ch return { publicSummary, privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, - chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map((affinityRequired, index) => ({ - id: `saved-backstory-${index + 1}`, - title: CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? `背景片段${index + 1}`, - affinityRequired, - teaser: truncateText(fallbackContents[index] ?? normalizedBackstory, 22), - content: truncateText(fallbackContents[index] ?? normalizedBackstory, 72), - contextSnippet: truncateText( - `${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`, - 48, - ), - }) satisfies CharacterBackstoryChapter), + chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map( + (affinityRequired, index) => + ({ + id: `saved-backstory-${index + 1}`, + title: + CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? + `背景片段${index + 1}`, + affinityRequired, + teaser: truncateText( + fallbackContents[index] ?? normalizedBackstory, + 22, + ), + content: truncateText( + fallbackContents[index] ?? normalizedBackstory, + 72, + ), + contextSnippet: truncateText( + `${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`, + 48, + ), + }) satisfies CharacterBackstoryChapter, + ), }; } @@ -193,21 +262,38 @@ function normalizeBackstoryReveal( return { publicSummary: toText(value.publicSummary, fallback.publicSummary), privateChatUnlockAffinity: - typeof value.privateChatUnlockAffinity === 'number' && Number.isFinite(value.privateChatUnlockAffinity) - ? normalizeInitialAffinity(value.privateChatUnlockAffinity, DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY) + typeof value.privateChatUnlockAffinity === 'number' && + Number.isFinite(value.privateChatUnlockAffinity) + ? normalizeInitialAffinity( + value.privateChatUnlockAffinity, + DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, + ) : fallback.privateChatUnlockAffinity, - chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map((defaultAffinity, index) => { - const rawChapter = rawChapters[index]; - const fallbackChapter = fallback.chapters[index]; - return { - id: rawChapter ? toText(rawChapter.id, fallbackChapter?.id) : fallbackChapter?.id ?? `saved-backstory-${index + 1}`, - title: rawChapter ? toText(rawChapter.title, fallbackChapter?.title) : fallbackChapter?.title ?? `背景片段${index + 1}`, - affinityRequired: fallbackChapter?.affinityRequired ?? defaultAffinity, - teaser: rawChapter ? toText(rawChapter.teaser, fallbackChapter?.teaser) : fallbackChapter?.teaser ?? '', - content: rawChapter ? toText(rawChapter.content, fallbackChapter?.content) : fallbackChapter?.content ?? '', - contextSnippet: rawChapter ? toText(rawChapter.contextSnippet, fallbackChapter?.contextSnippet) : fallbackChapter?.contextSnippet ?? '', - } satisfies CharacterBackstoryChapter; - }), + chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map( + (defaultAffinity, index) => { + const rawChapter = rawChapters[index]; + const fallbackChapter = fallback.chapters[index]; + return { + id: rawChapter + ? toText(rawChapter.id, fallbackChapter?.id) + : (fallbackChapter?.id ?? `saved-backstory-${index + 1}`), + title: rawChapter + ? toText(rawChapter.title, fallbackChapter?.title) + : (fallbackChapter?.title ?? `背景片段${index + 1}`), + affinityRequired: + fallbackChapter?.affinityRequired ?? defaultAffinity, + teaser: rawChapter + ? toText(rawChapter.teaser, fallbackChapter?.teaser) + : (fallbackChapter?.teaser ?? ''), + content: rawChapter + ? toText(rawChapter.content, fallbackChapter?.content) + : (fallbackChapter?.content ?? ''), + contextSnippet: rawChapter + ? toText(rawChapter.contextSnippet, fallbackChapter?.contextSnippet) + : (fallbackChapter?.contextSnippet ?? ''), + } satisfies CharacterBackstoryChapter; + }, + ), } satisfies CharacterBackstoryRevealConfig; } @@ -217,19 +303,28 @@ function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) { { id: 'saved-role-skill-1', name: `${nameSeed}起手`, - summary: truncateText(source.combatStyle || `${source.name}擅长稳住局面。`, 36), + summary: truncateText( + source.combatStyle || `${source.name}擅长稳住局面。`, + 36, + ), style: '起手压制', }, { id: 'saved-role-skill-2', name: `${nameSeed}变招`, - summary: truncateText(source.personality || `${source.name}习惯在周旋中找破绽。`, 36), + summary: truncateText( + source.personality || `${source.name}习惯在周旋中找破绽。`, + 36, + ), style: '机动周旋', }, { id: 'saved-role-skill-3', name: `${nameSeed}底牌`, - summary: truncateText(source.motivation || `${source.name}会在关键时刻亮出压箱手段。`, 36), + summary: truncateText( + source.motivation || `${source.name}会在关键时刻亮出压箱手段。`, + 36, + ), style: '爆发终结', }, ] satisfies CustomWorldRoleSkill[]; @@ -241,18 +336,23 @@ function normalizeRoleSkills( ) { const normalized = Array.isArray(value) ? value - .filter(isRecord) - .map((entry, index) => ({ - id: toText(entry.id, `saved-role-skill-${index + 1}`), - name: toText(entry.name), - summary: toText(entry.summary, toText(entry.description)), - style: toText(entry.style, toText(entry.category, '常用')), - } satisfies CustomWorldRoleSkill)) - .filter(entry => entry.name) - .slice(0, 3) + .filter(isRecord) + .map( + (entry, index) => + ({ + id: toText(entry.id, `saved-role-skill-${index + 1}`), + name: toText(entry.name), + summary: toText(entry.summary, toText(entry.description)), + style: toText(entry.style, toText(entry.category, '常用')), + }) satisfies CustomWorldRoleSkill, + ) + .filter((entry) => entry.name) + .slice(0, 3) : []; - return normalized.length > 0 ? normalized : buildFallbackRoleSkills(fallbackSource); + return normalized.length > 0 + ? normalized + : buildFallbackRoleSkills(fallbackSource); } function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { @@ -264,7 +364,10 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { category: '武器', quantity: 1, rarity: 'rare', - description: truncateText(source.combatStyle || `${source.name}随身携带的主要作战物件。`, 36), + description: truncateText( + source.combatStyle || `${source.name}随身携带的主要作战物件。`, + 36, + ), tags: source.tags.slice(0, 2), }, { @@ -273,7 +376,10 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { category: '消耗品', quantity: 2, rarity: 'uncommon', - description: truncateText(source.personality || `${source.name}为长期行动准备的基础补给。`, 36), + description: truncateText( + source.personality || `${source.name}为长期行动准备的基础补给。`, + 36, + ), tags: source.relationshipHooks.slice(0, 2), }, { @@ -282,7 +388,12 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { category: '专属物品', quantity: 1, rarity: 'rare', - description: truncateText(source.backstory || source.motivation || `${source.name}不愿随意交出的信物。`, 36), + description: truncateText( + source.backstory || + source.motivation || + `${source.name}不愿随意交出的信物。`, + 36, + ), tags: [...source.tags, ...source.relationshipHooks].slice(0, 3), }, ] satisfies CustomWorldRoleInitialItem[]; @@ -294,23 +405,29 @@ function normalizeRoleInitialItems( ) { const normalized = Array.isArray(value) ? value - .filter(isRecord) - .map((entry, index) => ({ - id: toText(entry.id, `saved-role-item-${index + 1}`), - name: toText(entry.name), - category: normalizeRoleItemCategory(entry.category), - quantity: - typeof entry.quantity === 'number' && Number.isFinite(entry.quantity) - ? Math.max(1, Math.min(99, Math.round(entry.quantity))) - : 1, - rarity: typeof entry.rarity === 'string' && ITEM_RARITIES.has(entry.rarity as ItemRarity) - ? entry.rarity as ItemRarity - : 'rare', - description: toText(entry.description), - tags: toStringArray(entry.tags), - } satisfies CustomWorldRoleInitialItem)) - .filter(entry => entry.name) - .slice(0, 3) + .filter(isRecord) + .map( + (entry, index) => + ({ + id: toText(entry.id, `saved-role-item-${index + 1}`), + name: toText(entry.name), + category: normalizeRoleItemCategory(entry.category), + quantity: + typeof entry.quantity === 'number' && + Number.isFinite(entry.quantity) + ? Math.max(1, Math.min(99, Math.round(entry.quantity))) + : 1, + rarity: + typeof entry.rarity === 'string' && + ITEM_RARITIES.has(entry.rarity as ItemRarity) + ? (entry.rarity as ItemRarity) + : 'rare', + description: toText(entry.description), + tags: toStringArray(entry.tags), + }) satisfies CustomWorldRoleInitialItem, + ) + .filter((entry) => entry.name) + .slice(0, 3) : []; return normalized.length > 0 @@ -319,17 +436,24 @@ function normalizeRoleInitialItems( } function normalizeEquipmentSlot(value: unknown) { - return typeof value === 'string' && EQUIPMENT_SLOTS.has(value as EquipmentSlotId) - ? value as EquipmentSlotId + return typeof value === 'string' && + EQUIPMENT_SLOTS.has(value as EquipmentSlotId) + ? (value as EquipmentSlotId) : null; } -function normalizeCustomWorldNpcVisualGear(value: unknown): CustomWorldNpcVisualGear | null { +function normalizeCustomWorldNpcVisualGear( + value: unknown, +): CustomWorldNpcVisualGear | null { if (!isRecord(value)) return null; - const type = typeof value.type === 'string' && CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES.has(value.type as CustomWorldNpcVisualGearType) - ? value.type as CustomWorldNpcVisualGearType - : null; + const type = + typeof value.type === 'string' && + CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES.has( + value.type as CustomWorldNpcVisualGearType, + ) + ? (value.type as CustomWorldNpcVisualGearType) + : null; const file = toText(value.file); if (!type || !file) return null; @@ -341,12 +465,16 @@ function normalizeCustomWorldNpcVisualGear(value: unknown): CustomWorldNpcVisual }; } -function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | undefined { +function normalizeCustomWorldNpcVisual( + value: unknown, +): CustomWorldNpcVisual | undefined { if (!isRecord(value)) return undefined; - const race = typeof value.race === 'string' && CUSTOM_WORLD_NPC_VISUAL_RACES.has(value.race as CustomWorldNpcVisualRace) - ? value.race as CustomWorldNpcVisualRace - : null; + const race = + typeof value.race === 'string' && + CUSTOM_WORLD_NPC_VISUAL_RACES.has(value.race as CustomWorldNpcVisualRace) + ? (value.race as CustomWorldNpcVisualRace) + : null; if (!race) return undefined; @@ -357,8 +485,14 @@ function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | u hairColorIndex: Math.max(1, toOptionalInteger(value.hairColorIndex) ?? 1), hairStyleFrame: Math.max(0, toOptionalInteger(value.hairStyleFrame) ?? 0), facialHairEnabled: Boolean(value.facialHairEnabled), - facialHairColorIndex: Math.max(1, toOptionalInteger(value.facialHairColorIndex) ?? 1), - facialHairStyleFrame: Math.max(0, toOptionalInteger(value.facialHairStyleFrame) ?? 0), + facialHairColorIndex: Math.max( + 1, + toOptionalInteger(value.facialHairColorIndex) ?? 1, + ), + facialHairStyleFrame: Math.max( + 0, + toOptionalInteger(value.facialHairStyleFrame) ?? 0, + ), headgear: normalizeCustomWorldNpcVisualGear(value.headgear), mainHand: normalizeCustomWorldNpcVisualGear(value.mainHand), offHand: normalizeCustomWorldNpcVisualGear(value.offHand), @@ -407,9 +541,9 @@ function normalizeGeneratedAnimationMap(value: unknown) { }); return entries.length > 0 - ? Object.fromEntries(entries) as Partial< + ? (Object.fromEntries(entries) as Partial< Record - > + >) : undefined; } @@ -423,7 +557,9 @@ function normalizeItemStatProfile(value: unknown): ItemStatProfile | null { incomingDamageMultiplier: toOptionalNumber(value.incomingDamageMultiplier), }; - return Object.values(profile).some(entry => entry !== undefined) ? profile : null; + return Object.values(profile).some((entry) => entry !== undefined) + ? profile + : null; } function normalizeItemUseProfile(value: unknown): ItemUseProfile | null { @@ -435,10 +571,15 @@ function normalizeItemUseProfile(value: unknown): ItemUseProfile | null { cooldownReduction: toOptionalNumber(value.cooldownReduction), }; - return Object.values(profile).some(entry => entry !== undefined) ? profile : null; + return Object.values(profile).some((entry) => entry !== undefined) + ? profile + : null; } -function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayableNpc | null { +function normalizePlayableNpc( + value: unknown, + index: number, +): CustomWorldPlayableNpc | null { if (!isRecord(value)) return null; const name = toText(value.name); @@ -456,13 +597,14 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl personality: toText(value.personality), motivation: toText(value.motivation, toText(value.description)), combatStyle: toText(value.combatStyle), - relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3), + relationshipHooks: + relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3), tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5), } satisfies CustomWorldRoleFallbackSource; - return { - id: toText(value.id, `saved-playable-${index + 1}`), - name, + return { + id: toText(value.id, `saved-playable-${index + 1}`), + name, title, role, description: fallbackSource.description, @@ -470,21 +612,37 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl personality: fallbackSource.personality, motivation: fallbackSource.motivation, combatStyle: fallbackSource.combatStyle, - initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY), - relationshipHooks: fallbackSource.relationshipHooks, - tags: fallbackSource.tags, - backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource), - skills: normalizeRoleSkills(value.skills, fallbackSource), - initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource), - imageSrc: toText(value.imageSrc) || undefined, - generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined, - generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined, - animationMap: normalizeGeneratedAnimationMap(value.animationMap), - templateCharacterId: toText(value.templateCharacterId) || undefined, - }; - } + initialAffinity: normalizeInitialAffinity( + value.initialAffinity, + DEFAULT_PLAYABLE_INITIAL_AFFINITY, + ), + relationshipHooks: fallbackSource.relationshipHooks, + tags: fallbackSource.tags, + backstoryReveal: normalizeBackstoryReveal( + value.backstoryReveal, + fallbackSource, + ), + skills: normalizeRoleSkills(value.skills, fallbackSource), + initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource), + imageSrc: toText(value.imageSrc) || undefined, + generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined, + generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined, + animationMap: normalizeGeneratedAnimationMap(value.animationMap), + attributeProfile: + preserveStructuredRecord(value.attributeProfile) ?? + undefined, + narrativeProfile: + preserveStructuredRecord( + value.narrativeProfile, + ) ?? undefined, + templateCharacterId: toText(value.templateCharacterId) || undefined, + }; +} -function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null { +function normalizeStoryNpc( + value: unknown, + index: number, +): CustomWorldNpc | null { if (!isRecord(value)) return null; const name = toText(value.name); @@ -502,13 +660,14 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null personality: toText(value.personality), motivation: toText(value.motivation), combatStyle: toText(value.combatStyle), - relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3), + relationshipHooks: + relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3), tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5), } satisfies CustomWorldRoleFallbackSource; - return { - id: toText(value.id, `saved-story-${index + 1}`), - name, + return { + id: toText(value.id, `saved-story-${index + 1}`), + name, title, role, description: fallbackSource.description, @@ -516,28 +675,43 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null personality: fallbackSource.personality, motivation: fallbackSource.motivation, combatStyle: fallbackSource.combatStyle, - initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY), - relationshipHooks: fallbackSource.relationshipHooks, - tags: fallbackSource.tags, - backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource), - skills: normalizeRoleSkills(value.skills, fallbackSource), - initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource), - imageSrc: toText(value.imageSrc) || undefined, - generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined, - generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined, - animationMap: normalizeGeneratedAnimationMap(value.animationMap), - visual: normalizeCustomWorldNpcVisual(value.visual), - }; - } + initialAffinity: normalizeInitialAffinity( + value.initialAffinity, + DEFAULT_STORY_NPC_INITIAL_AFFINITY, + ), + relationshipHooks: fallbackSource.relationshipHooks, + tags: fallbackSource.tags, + backstoryReveal: normalizeBackstoryReveal( + value.backstoryReveal, + fallbackSource, + ), + skills: normalizeRoleSkills(value.skills, fallbackSource), + initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource), + imageSrc: toText(value.imageSrc) || undefined, + generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined, + generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined, + animationMap: normalizeGeneratedAnimationMap(value.animationMap), + attributeProfile: + preserveStructuredRecord(value.attributeProfile) ?? + undefined, + narrativeProfile: + preserveStructuredRecord( + value.narrativeProfile, + ) ?? undefined, + visual: normalizeCustomWorldNpcVisual(value.visual), + }; +} function normalizeItem(value: unknown, index: number): CustomWorldItem | null { if (!isRecord(value)) return null; const name = toText(value.name); const category = toText(value.category); - const rarity = typeof value.rarity === 'string' && ITEM_RARITIES.has(value.rarity as ItemRarity) - ? value.rarity as ItemRarity - : null; + const rarity = + typeof value.rarity === 'string' && + ITEM_RARITIES.has(value.rarity as ItemRarity) + ? (value.rarity as ItemRarity) + : null; if (!name || !category || !rarity) return null; return { @@ -549,15 +723,25 @@ function normalizeItem(value: unknown, index: number): CustomWorldItem | null { tags: toStringArray(value.tags), iconSrc: toText(value.iconSrc) || undefined, sourcePath: toText(value.sourcePath) || undefined, - origin: value.origin === 'generated' || value.origin === 'catalog' ? value.origin : undefined, + origin: + value.origin === 'generated' || value.origin === 'catalog' + ? value.origin + : undefined, equipmentSlotId: normalizeEquipmentSlot(value.equipmentSlotId), statProfile: normalizeItemStatProfile(value.statProfile), useProfile: normalizeItemUseProfile(value.useProfile), value: toOptionalNumber(value.value), + attributeResonance: + preserveStructuredRecord( + value.attributeResonance, + ) ?? undefined, }; } -function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark | null { +function normalizeLandmark( + value: unknown, + index: number, +): CustomWorldLandmark | null { if (!isRecord(value)) return null; const name = toText(value.name); @@ -569,6 +753,10 @@ function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark | description: toText(value.description), dangerLevel: toText(value.dangerLevel), imageSrc: toText(value.imageSrc) || undefined, + narrativeResidues: + preserveStructuredRecordArray( + value.narrativeResidues, + ) ?? undefined, sceneNpcIds: [], connections: [], }; @@ -578,7 +766,12 @@ function normalizeCampScene( value: unknown, fallbackProfile: Pick< CustomWorldProfile, - 'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType' + | 'name' + | 'summary' + | 'tone' + | 'playerGoal' + | 'settingText' + | 'templateWorldType' >, ) { const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); @@ -655,6 +848,12 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { const summary = toText(value.summary); const tone = toText(value.tone); const playerGoal = toText(value.playerGoal); + const majorFactions = toStringArray(value.majorFactions); + const coreConflicts = toStringArray(value.coreConflicts); + const resolvedCoreConflicts = + coreConflicts.length > 0 + ? coreConflicts + : [summary || playerGoal || settingText || name]; const camp = normalizeCampScene(value.camp, { name, summary, @@ -670,18 +869,18 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { summary, tone, playerGoal, - majorFactions: [], - coreConflicts: [summary || playerGoal || settingText || name], + majorFactions, + coreConflicts: resolvedCoreConflicts, }); const storyNpcs = Array.isArray(value.storyNpcs) ? value.storyNpcs - .map((entry, index) => normalizeStoryNpc(entry, index)) - .filter((entry): entry is CustomWorldNpc => Boolean(entry)) + .map((entry, index) => normalizeStoryNpc(entry, index)) + .filter((entry): entry is CustomWorldNpc => Boolean(entry)) : []; const landmarkDrafts = Array.isArray(value.landmarks) ? value.landmarks - .map((entry, index) => normalizeLandmarkDraft(entry, index)) - .filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry)) + .map((entry, index) => normalizeLandmarkDraft(entry, index)) + .filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry)) : []; const normalizedProfile = { @@ -694,27 +893,34 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { playerGoal, templateWorldType, compatibilityTemplateWorldType, - majorFactions: [], - coreConflicts: [summary || playerGoal || settingText || name], - attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema), + majorFactions, + coreConflicts: resolvedCoreConflicts, + attributeSchema: coerceWorldAttributeSchema( + value.attributeSchema, + generatedAttributeSchema, + ), playableNpcs: Array.isArray(value.playableNpcs) ? value.playableNpcs - .map((entry, index) => normalizePlayableNpc(entry, index)) - .filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry)) + .map((entry, index) => normalizePlayableNpc(entry, index)) + .filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry)) : [], storyNpcs, items: Array.isArray(value.items) ? value.items - .map((entry, index) => normalizeItem(entry, index)) - .filter((entry): entry is CustomWorldItem => Boolean(entry)) + .map((entry, index) => normalizeItem(entry, index)) + .filter((entry): entry is CustomWorldItem => Boolean(entry)) : [], camp, landmarks: normalizeCustomWorldLandmarks({ landmarks: landmarkDrafts, storyNpcs, }), - themePack: null, - storyGraph: null, + themePack: preserveStructuredRecord(value.themePack), + storyGraph: preserveStructuredRecord(value.storyGraph), + knowledgeFacts: + preserveStructuredRecordArray(value.knowledgeFacts), + threadContracts: + preserveStructuredRecordArray(value.threadContracts), creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent), anchorPack: value.anchorPack && typeof value.anchorPack === 'object' @@ -733,9 +939,12 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { ? value.generationMode : 'full', generationStatus: - value.generationStatus === 'key_only' || value.generationStatus === 'complete' + value.generationStatus === 'key_only' || + value.generationStatus === 'complete' ? value.generationStatus : 'complete', + scenarioPackId: toText(value.scenarioPackId) || null, + campaignPackId: toText(value.campaignPackId) || null, } satisfies CustomWorldProfile; return { @@ -747,9 +956,15 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { }; } +export function normalizeCustomWorldProfileRecord( + value: unknown, +): CustomWorldProfile | null { + return normalizeProfile(value); +} + function writeProfiles(profiles: CustomWorldProfile[]) { const normalizedProfiles = profiles - .map(profile => normalizeProfile(profile)) + .map((profile) => normalizeProfile(profile)) .filter((profile): profile is CustomWorldProfile => Boolean(profile)) .slice(0, MAX_SAVED_CUSTOM_WORLDS); @@ -772,13 +987,17 @@ export function readSavedCustomWorldProfiles() { return ( readStoredJson({ key: CUSTOM_WORLD_LIBRARY_STORAGE_KEY, - parse: value => { - if (!isRecord(value) || value.version !== CUSTOM_WORLD_LIBRARY_VERSION || !Array.isArray(value.profiles)) { + parse: (value) => { + if ( + !isRecord(value) || + value.version !== CUSTOM_WORLD_LIBRARY_VERSION || + !Array.isArray(value.profiles) + ) { return null; } return value.profiles - .map(profile => normalizeProfile(profile)) + .map((profile) => normalizeProfile(profile)) .filter((profile): profile is CustomWorldProfile => Boolean(profile)) .slice(0, MAX_SAVED_CUSTOM_WORLDS); }, @@ -789,7 +1008,9 @@ export function readSavedCustomWorldProfiles() { export function upsertSavedCustomWorldProfile(profile: CustomWorldProfile) { const nextProfiles = [ profile, - ...readSavedCustomWorldProfiles().filter(savedProfile => savedProfile.id !== profile.id), + ...readSavedCustomWorldProfiles().filter( + (savedProfile) => savedProfile.id !== profile.id, + ), ]; return writeProfiles(nextProfiles); } diff --git a/src/data/customWorldSceneGraph.ts b/src/data/customWorldSceneGraph.ts index edbb14f2..186beb3d 100644 --- a/src/data/customWorldSceneGraph.ts +++ b/src/data/customWorldSceneGraph.ts @@ -385,6 +385,7 @@ export function normalizeCustomWorldLandmarks(params: { description: landmark.description, dangerLevel: landmark.dangerLevel, imageSrc: landmark.imageSrc, + narrativeResidues: landmark.narrativeResidues, sceneNpcIds: resolveSceneNpcIdsForLandmark( landmark, storyNpcs, @@ -407,6 +408,7 @@ export function syncCustomWorldLandmarkConnections( return normalizeCustomWorldLandmarks({ landmarks: landmarks.map((landmark) => ({ ...landmark, + narrativeResidues: landmark.narrativeResidues, sceneNpcIds: landmark.sceneNpcIds, connections: landmark.connections.map((connection) => ({ targetLandmarkId: connection.targetLandmarkId, diff --git a/src/data/equipmentEffects.ts b/src/data/equipmentEffects.ts index 9b87cedb..3fccd39b 100644 --- a/src/data/equipmentEffects.ts +++ b/src/data/equipmentEffects.ts @@ -1,4 +1,4 @@ -import { Character, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types'; +import { Character, CustomWorldProfile, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types'; import { normalizeBuildRole, normalizeBuildTags } from './buildTags'; import type { CharacterEquipmentItem } from './characterPresets'; import { getCharacterEquipment, getCharacterMaxHp, getCharacterMaxMana } from './characterPresets'; @@ -201,9 +201,12 @@ export function isInventoryItemEquippable(item: InventoryItem) { return getEquipmentSlotFromItem(item) !== null; } -export function buildInitialEquipmentLoadout(character: Character) { +export function buildInitialEquipmentLoadout( + character: Character, + customWorldProfile: CustomWorldProfile | null = null, +) { const loadout = createEmptyEquipmentLoadout(); - const starterEquipment = getCharacterEquipment(character); + const starterEquipment = getCharacterEquipment(character, customWorldProfile); starterEquipment.forEach((equipmentItem, index) => { const inferredSlot = inferSlotFromText(`${equipmentItem.slot} ${equipmentItem.item}`) diff --git a/src/data/npcInteractions.ts b/src/data/npcInteractions.ts index b580710f..359bea34 100644 --- a/src/data/npcInteractions.ts +++ b/src/data/npcInteractions.ts @@ -439,12 +439,19 @@ function mergeInventory(items: InventoryItem[]) { function buildCharacterInventory( character: Character, worldType: WorldType | null, + customWorldProfile = getRuntimeCustomWorldProfile(), ) { - if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) { - return sortInventoryItems(buildCustomWorldStarterInventoryItems(character)); + if (worldType === WorldType.CUSTOM && customWorldProfile) { + return sortInventoryItems( + buildCustomWorldStarterInventoryItems(character, customWorldProfile), + ); } - const packItems = getInventoryItems(character, worldType).map((item) => + const packItems = getInventoryItems( + character, + worldType, + customWorldProfile, + ).map((item) => buildInventoryItem('player', item.category, item.name, item.quantity), ); return sortInventoryItems(mergeInventory(packItems)); @@ -453,10 +460,17 @@ function buildCharacterInventory( function buildCharacterNpcInventory( character: Character, worldType: WorldType | null, + customWorldProfile = getRuntimeCustomWorldProfile(), ) { - if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) { - const starterEquipment = buildCustomWorldStarterEquipmentItems(character); - const starterInventory = buildCustomWorldStarterInventoryItems(character); + if (worldType === WorldType.CUSTOM && customWorldProfile) { + const starterEquipment = buildCustomWorldStarterEquipmentItems( + character, + customWorldProfile, + ); + const starterInventory = buildCustomWorldStarterInventoryItems( + character, + customWorldProfile, + ); return sortInventoryItems( mergeInventory([ ...(Object.values(starterEquipment).filter(Boolean) as InventoryItem[]), @@ -465,7 +479,7 @@ function buildCharacterNpcInventory( ); } - const equipmentItems = getCharacterEquipment(character).map((item) => + const equipmentItems = getCharacterEquipment(character, customWorldProfile).map((item) => buildInventoryItem( `npc-${character.id}`, item.slot, @@ -1503,8 +1517,9 @@ export function removeInventoryItem( export function buildInitialPlayerInventory( character: Character, worldType: WorldType | null, + customWorldProfile = getRuntimeCustomWorldProfile(), ) { - return buildCharacterInventory(character, worldType); + return buildCharacterInventory(character, worldType, customWorldProfile); } function buildMonsterPresetInventory( @@ -1547,7 +1562,11 @@ export function buildInitialNpcState( ? (() => { const character = getCharacterById(encounter.characterId); return character - ? buildCharacterNpcInventory(character, worldType) + ? buildCharacterNpcInventory( + character, + worldType, + state?.customWorldProfile ?? getRuntimeCustomWorldProfile(), + ) : buildRoleInventory(encounter, worldType, state); })() : encounter.monsterPresetId diff --git a/src/hooks/story/storyCampCompanion.test.ts b/src/hooks/story/storyCampCompanion.test.ts index 78f6fdbc..27f33991 100644 --- a/src/hooks/story/storyCampCompanion.test.ts +++ b/src/hooks/story/storyCampCompanion.test.ts @@ -51,11 +51,13 @@ function createCharacter(): Character { function createOption( functionId: string, actionText = functionId, + interaction?: StoryOption['interaction'], ): StoryOption { return { functionId, actionText, text: actionText, + interaction, visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, @@ -196,14 +198,21 @@ describe('storyCampCompanion', () => { }); it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => { - const baseOptions = [createOption('npc_chat', '继续交谈')]; + const baseOptions = [ + createOption('npc_chat', '继续交谈', { + kind: 'npc', + npcId: 'camp-companion', + action: 'chat', + }), + createOption('camp_travel_home_scene', '前往旧地点'), + ]; const generateNextStep = vi .fn() .mockResolvedValueOnce({ storyText: '继续营地交谈', options: [ - createOption('npc_trade', '先看对方带来的东西'), - createOption('npc_chat', '继续交谈'), + createOption('npc_chat', '顺着刚才的话继续问下去'), + createOption('camp_travel_home_scene', '先回云河渡'), ], }) .mockRejectedValueOnce(new Error('llm failed')); @@ -258,9 +267,20 @@ describe('storyCampCompanion', () => { openingCampDialogue: '你们刚交换完第一轮判断。', }), ); - expect(resolvedOptions.map((option) => option.functionId)).toEqual([ - 'npc_trade', - 'npc_chat', + expect(resolvedOptions).toEqual([ + expect.objectContaining({ + functionId: 'npc_chat', + actionText: '顺着刚才的话继续问下去', + interaction: { + kind: 'npc', + npcId: 'camp-companion', + action: 'chat', + }, + }), + expect.objectContaining({ + functionId: 'camp_travel_home_scene', + actionText: '先回云河渡', + }), ]); expect(fallbackOptions).toBe(baseOptions); } finally { diff --git a/src/hooks/story/storyCampCompanion.ts b/src/hooks/story/storyCampCompanion.ts index 6bf9d3dd..3d2be190 100644 --- a/src/hooks/story/storyCampCompanion.ts +++ b/src/hooks/story/storyCampCompanion.ts @@ -26,6 +26,7 @@ import type { StoryOption, WorldType, } from '../../types'; +import { resolveStoryResponseOptions } from './storyResponseOptions'; type BuildNpcStory = ( state: GameState, @@ -182,7 +183,11 @@ export function createCampCompanionStoryHelpers(params: { }, ); - return sortStoryOptionsByPriority(response.options); + return resolveStoryResponseOptions({ + responseOptions: response.options, + availableOptions: baseOptions, + getSanitizedOptions: () => sortStoryOptionsByPriority(baseOptions), + }); } catch (error) { console.error('Failed to infer opening camp follow-up options:', error); return baseOptions; diff --git a/src/hooks/story/storyResponseOptions.test.ts b/src/hooks/story/storyResponseOptions.test.ts index 934b0fcd..27481d0e 100644 --- a/src/hooks/story/storyResponseOptions.test.ts +++ b/src/hooks/story/storyResponseOptions.test.ts @@ -7,12 +7,14 @@ function createOption( functionId: string, actionText: string, priority = 0, + interaction?: StoryOption['interaction'], ): StoryOption { return { functionId, actionText, text: actionText, priority, + interaction, visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, @@ -52,6 +54,41 @@ describe('storyResponseOptions', () => { ]); }); + it('preserves interaction metadata when AI rewrites provided npc options', () => { + const availableOptions = [ + createOption('npc_chat', '继续交谈', 3, { + kind: 'npc', + npcId: 'npc-camp', + action: 'chat', + }), + createOption('camp_travel_home_scene', '前往旧地点', 1), + ]; + const responseOptions = [ + createOption('npc_chat', '顺着你刚才那句提醒继续追问', 3), + createOption('camp_travel_home_scene', '先回云河渡', 1), + ]; + + const resolved = resolveStoryResponseOptions({ + responseOptions, + availableOptions, + getSanitizedOptions: () => { + throw new Error('available options branch should not sanitize'); + }, + }); + + expect(resolved[0]).toEqual( + expect.objectContaining({ + functionId: 'npc_chat', + actionText: '顺着你刚才那句提醒继续追问', + interaction: { + kind: 'npc', + npcId: 'npc-camp', + action: 'chat', + }, + }), + ); + }); + it('falls back to available options when the response omits them entirely', () => { const availableOptions = [ createOption('npc_chat', '继续交谈', 2), diff --git a/src/hooks/story/storyResponseOptions.ts b/src/hooks/story/storyResponseOptions.ts index d5bdf309..7be70b4f 100644 --- a/src/hooks/story/storyResponseOptions.ts +++ b/src/hooks/story/storyResponseOptions.ts @@ -8,6 +8,65 @@ type ResolveStoryResponseOptionsParams = { getSanitizedOptions: () => StoryOption[]; }; +function cloneStoryOption(option: StoryOption): StoryOption { + return { + ...option, + visuals: { + ...option.visuals, + monsterChanges: option.visuals.monsterChanges.map((change) => ({ + ...change, + })), + }, + interaction: option.interaction ? { ...option.interaction } : undefined, + goalAffordance: option.goalAffordance + ? { ...option.goalAffordance } + : option.goalAffordance, + }; +} + +function rewriteOptionsFromBaseOptions( + responseOptions: StoryOption[], + baseOptions: StoryOption[], +) { + if (responseOptions.length === 0) { + return baseOptions.map(cloneStoryOption); + } + + const optionBuckets = new Map(); + const consumedOptions = new Set(); + + baseOptions.forEach((option) => { + const bucket = optionBuckets.get(option.functionId) ?? []; + bucket.push(option); + optionBuckets.set(option.functionId, bucket); + }); + + const resolved: StoryOption[] = []; + + responseOptions.forEach((option) => { + const bucket = optionBuckets.get(option.functionId); + const matchedOption = bucket?.shift(); + if (!matchedOption) return; + + consumedOptions.add(matchedOption); + const rewrittenText = option.actionText.trim() || matchedOption.actionText; + resolved.push({ + ...cloneStoryOption(matchedOption), + actionText: rewrittenText, + text: rewrittenText || matchedOption.text || matchedOption.actionText, + }); + }); + + if (resolved.length === baseOptions.length) { + return resolved; + } + + const remainingOptions = baseOptions.filter( + (option) => !consumedOptions.has(option), + ); + return [...resolved, ...remainingOptions.map(cloneStoryOption)]; +} + export function resolveStoryResponseOptions({ responseOptions, availableOptions = null, @@ -16,13 +75,13 @@ export function resolveStoryResponseOptions({ }: ResolveStoryResponseOptionsParams) { if (availableOptions) { return sortStoryOptionsByPriority( - responseOptions.length > 0 ? responseOptions : availableOptions, + rewriteOptionsFromBaseOptions(responseOptions, availableOptions), ); } if (optionCatalog) { return sortStoryOptionsByPriority( - responseOptions.length > 0 ? responseOptions : optionCatalog, + rewriteOptionsFromBaseOptions(responseOptions, optionCatalog), ); } diff --git a/src/hooks/useGameFlow.customWorld.test.tsx b/src/hooks/useGameFlow.customWorld.test.tsx new file mode 100644 index 00000000..20fc9c28 --- /dev/null +++ b/src/hooks/useGameFlow.customWorld.test.tsx @@ -0,0 +1,385 @@ +/* @vitest-environment jsdom */ + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useMemo } from 'react'; +import { afterEach, expect, test } from 'vitest'; + +import { + buildCustomWorldPlayableCharacters, + setRuntimeCharacterOverrides, +} from '../data/characterPresets'; +import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime'; +import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary'; +import { WorldType } from '../types'; +import { useGameFlow } from './useGameFlow'; + +function buildBackstoryReveal(label: string) { + return { + publicSummary: `${label}的公开背景`, + privateChatUnlockAffinity: 40, + chapters: [ + { + id: `${label}-surface`, + title: '表层来意', + affinityRequired: 15, + teaser: `${label}先只肯说表面的来意。`, + content: `${label}表面上只愿意谈当前局势。`, + contextSnippet: `${label}表面上还在收着话。`, + }, + { + id: `${label}-scar`, + title: '旧事裂痕', + affinityRequired: 30, + teaser: `${label}背后还有一段旧伤。`, + content: `${label}曾在旧案里留下无法轻易揭开的伤口。`, + contextSnippet: `${label}和旧案之间有一段没说开的裂痕。`, + }, + { + id: `${label}-hidden`, + title: '隐藏执念', + affinityRequired: 60, + teaser: `${label}真正想追的不是表面那件事。`, + content: `${label}真正挂着的是旧案里还没结的那条线。`, + contextSnippet: `${label}真正执念指向旧案深处。`, + }, + { + id: `${label}-final`, + title: '最终底牌', + affinityRequired: 90, + teaser: `${label}手里还压着最后一张牌。`, + content: `${label}手里还握着能直接证明真相的关键证据。`, + contextSnippet: `${label}最后的底牌足以改写局势。`, + }, + ], + }; +} + +function buildSavedProfile() { + const profile = normalizeCustomWorldProfileRecord({ + id: 'saved-runtime-profile', + settingText: '被海雾吞没的旧航路群岛', + name: '回潮群岛', + subtitle: '旧灯塔与断续潮路', + summary: '围绕旧灯塔、假航灯和沉船旧案展开的结果页世界。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船夜与封航记录被改动的真相。', + templateWorldType: WorldType.WUXIA, + compatibilityTemplateWorldType: WorldType.WUXIA, + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['封航争夺', '沉船真相'], + attributeSchema: { + id: 'schema:test', + worldId: 'CUSTOM', + schemaVersion: 1, + schemaName: '测试', + generatedFrom: { + worldType: WorldType.CUSTOM, + worldName: '回潮群岛', + settingSummary: '潮雾旧航路', + tone: '压抑', + conflictCore: '沉船真相', + }, + slots: [], + }, + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + title: '旧航路引路人', + role: '关键同行者', + description: '最熟悉旧潮路的人。', + backstory: '他在沉船夜里带着半支船队逃出过假航灯。', + personality: '表面沉稳,心里一直在算退路。', + motivation: '想赶在封航前查清真相。', + combatStyle: '借潮路换位,先拉扯再压近。', + initialAffinity: 18, + relationshipHooks: ['旧友', '沉船旧案'], + tags: ['潮路', '引路'], + backstoryReveal: buildBackstoryReveal('沈砺'), + skills: [ + { + id: 'skill-playable-1', + name: '潮行引路', + summary: '踩着旧潮阶切线前压,替队伍打开角度。', + style: '机动周旋', + }, + { + id: 'skill-playable-2', + name: '回雾折返', + summary: '借海雾遮住身位,再从侧线拉开。', + style: '起手压制', + }, + { + id: 'skill-playable-3', + name: '旧图定标', + summary: '用旧潮图锁定退路和突入口。', + style: '爆发终结', + }, + ], + initialItems: [ + { + id: 'item-playable-1', + name: '旧潮短刃', + category: '武器', + quantity: 1, + rarity: 'rare', + description: '专门在湿滑甲板上近身换位用的短刃。', + tags: ['潮路', '近战'], + }, + { + id: 'item-playable-2', + name: '雾盐药包', + category: '消耗品', + quantity: 2, + rarity: 'uncommon', + description: '压住寒潮后遗症的随身药包。', + tags: ['补给'], + }, + { + id: 'item-playable-3', + name: '旧潮图残页', + category: '专属物品', + quantity: 1, + rarity: 'rare', + description: '足够指向沉船夜另一条线的残页。', + tags: ['线索', '真相'], + }, + ], + templateCharacterId: 'archer-hero', + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + title: '守灯会值夜人', + role: '场景关键角色', + description: '夜里巡灯与封锁禁航区的人。', + backstory: '她在第一次海雾吞船那夜守到了最后一盏灯。', + personality: '冷静克制,但提到旧灯册会明显变调。', + motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。', + combatStyle: '借塔顶视角与风向压制,再用灯火错位扰乱。', + initialAffinity: 8, + relationshipHooks: ['禁航记录', '灯塔值夜'], + tags: ['守灯会', '灯塔'], + backstoryReveal: buildBackstoryReveal('顾潮音'), + skills: [ + { + id: 'skill-story-1', + name: '夜潮灯语', + summary: '借灯语与潮声干扰对方判断。', + style: '起手压制', + }, + { + id: 'skill-story-2', + name: '禁航暗潮', + summary: '封住错误航线,把人逼回她熟悉的区域。', + style: '机动周旋', + }, + { + id: 'skill-story-3', + name: '回声巡线', + summary: '借塔顶回声迅速锁定异动方向。', + style: '爆发终结', + }, + ], + initialItems: [ + { + id: 'item-story-1', + name: '值夜灯尺', + category: '武器', + quantity: 1, + rarity: 'rare', + description: '兼作警械和测灯尺的长柄器具。', + tags: ['守灯会'], + }, + ], + narrativeProfile: { + publicMask: '守灯会值夜人,对外总像比别人更冷静一步。', + firstContactMask: '想进塔就按规矩来,今晚谁都别想乱闯禁航区。', + visibleLine: '她表面上只是在守灯和封线。', + hiddenLine: '她真正盯着的是那本被改过的原始灯册。', + contradiction: '越强调规矩,越像在掩住自己手里那份没上报的记录。', + debtOrBurden: '她扛着第一次海雾吞船夜里没能及时点亮备用灯的旧责。', + taboo: '最忌讳别人把那夜的失踪当成单纯天灾。', + immediatePressure: '新的封航令马上要落下来,她必须先把旧灯册转出去。', + relatedThreadIds: ['thread-visible-1'], + relatedScarIds: ['scar-1'], + reactionHooks: ['原始灯册', '封灯令'], + }, + }, + ], + items: [], + camp: { + name: '回潮暂栖所', + description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。', + dangerLevel: 'low', + }, + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', + dangerLevel: 'high', + sceneNpcIds: ['story-1'], + connections: [ + { + targetLandmarkId: 'landmark-2', + relativePosition: 'forward', + summary: '沿着旧潮阶继续前压到雾栈尽头。', + }, + ], + narrativeResidues: [ + { + id: 'residue-1', + title: '潮痕', + visibleClue: '塔壁上有一圈不该出现在高处的潮痕。', + linkedFactIds: ['fact-1'], + linkedThreadIds: ['thread-visible-1'], + }, + ], + }, + { + id: 'landmark-2', + name: '雾栈尽头', + description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。', + dangerLevel: 'high', + sceneNpcIds: [], + connections: [ + { + targetLandmarkId: 'landmark-1', + relativePosition: 'back', + summary: '退回灯塔还能重新整理路线。', + }, + ], + }, + ], + scenarioPackId: 'scenario-pack:tide', + campaignPackId: 'campaign-pack:tide', + generationMode: 'full', + generationStatus: 'complete', + }); + + if (!profile) { + throw new Error('failed to build saved custom world profile'); + } + + return profile; +} + +function readSnapshot() { + const raw = screen.getByTestId('state-snapshot').textContent ?? '{}'; + return JSON.parse(raw) as { + worldType: string | null; + currentScene: string; + profileName: string | null; + activeScenarioPackId: string | null; + activeCampaignPackId: string | null; + currentScenePresetId: string | null; + currentScenePresetName: string | null; + currentSceneConnectedIds: string[]; + firstLandmarkResidueTitle: string | null; + playerCharacterName: string | null; + playerInventoryNames: string[]; + playerEquipment: { + weapon: string | null; + armor: string | null; + relic: string | null; + }; + }; +} + +function GameFlowHarness() { + const profile = useMemo(() => buildSavedProfile(), []); + const playableCharacters = useMemo( + () => buildCustomWorldPlayableCharacters(profile), + [profile], + ); + const selectedCharacter = playableCharacters[0] ?? null; + const { gameState, handleCustomWorldSelect, handleCharacterSelect } = + useGameFlow(); + + const snapshot = { + worldType: gameState.worldType, + currentScene: gameState.currentScene, + profileName: gameState.customWorldProfile?.name ?? null, + activeScenarioPackId: gameState.activeScenarioPackId ?? null, + activeCampaignPackId: gameState.activeCampaignPackId ?? null, + currentScenePresetId: gameState.currentScenePreset?.id ?? null, + currentScenePresetName: gameState.currentScenePreset?.name ?? null, + currentSceneConnectedIds: gameState.currentScenePreset?.connectedSceneIds ?? [], + firstLandmarkResidueTitle: + gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0] + ?.title ?? null, + playerCharacterName: gameState.playerCharacter?.name ?? null, + playerInventoryNames: gameState.playerInventory.map((item) => item.name), + playerEquipment: { + weapon: gameState.playerEquipment.weapon?.name ?? null, + armor: gameState.playerEquipment.armor?.name ?? null, + relic: gameState.playerEquipment.relic?.name ?? null, + }, + }; + + return ( +
+ + +
{JSON.stringify(snapshot)}
+
+ ); +} + +afterEach(() => { + setRuntimeCustomWorldProfile(null); + setRuntimeCharacterOverrides(null); +}); + +test('saved custom world result settings flow into game state after entering the world', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: '选择世界' })); + + await waitFor(() => { + expect(readSnapshot().worldType).toBe(WorldType.CUSTOM); + }); + + expect(readSnapshot().profileName).toBe('回潮群岛'); + expect(readSnapshot().currentScenePresetId).toBe('custom-scene-camp'); + expect(readSnapshot().currentScenePresetName).toBe('回潮暂栖所'); + expect(readSnapshot().currentSceneConnectedIds).toContain( + 'custom-scene-landmark-1', + ); + expect(readSnapshot().firstLandmarkResidueTitle).toBe('潮痕'); + expect(readSnapshot().activeScenarioPackId).toBe('scenario-pack:tide'); + expect(readSnapshot().activeCampaignPackId).toBe('campaign-pack:tide'); + + await user.click(screen.getByRole('button', { name: '确认角色' })); + + await waitFor(() => { + expect(readSnapshot().currentScene).toBe('Story'); + }); + + expect(readSnapshot().playerCharacterName).toBe('沈砺'); + expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃'); + expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页'); + expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃'); + expect(readSnapshot().playerEquipment.relic).toBe('旧潮图残页'); + expect(readSnapshot().playerEquipment.armor).toBeTruthy(); +}); diff --git a/src/hooks/useGameFlow.ts b/src/hooks/useGameFlow.ts index fd7424d2..f72f5f26 100644 --- a/src/hooks/useGameFlow.ts +++ b/src/hooks/useGameFlow.ts @@ -15,13 +15,102 @@ import { createInitialGameRuntimeStats } from '../data/runtimeStats'; import { ensureSceneEncounterPreview,RESOLVED_ENTITY_X_METERS } from '../data/sceneEncounterPreviews'; import { getScenePreset,getWorldCampScenePreset } from '../data/scenePresets'; import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine'; -import { AnimationState, Character, CustomWorldProfile, Encounter, GameState, SceneNpc, WorldType } from '../types'; +import { AnimationState, Character, CustomWorldProfile, Encounter, EquipmentLoadout, GameState, InventoryItem, SceneNpc, WorldType } from '../types'; import type { BottomTab } from '../types/navigation'; const PLAYER_BASE_MAX_HP = 180; export type {BottomTab} from '../types/navigation'; +function mergeStarterInventoryItems( + explicitItems: T[], + fallbackItems: T[], +) { + const merged = new Map(); + + [...explicitItems, ...fallbackItems].forEach((item) => { + merged.set(`${item.category}:${item.name}`, item); + }); + + return [...merged.values()]; +} + +function normalizeExplicitStarterCategory(category: string) { + const normalized = category.trim(); + return normalized === '专属物' ? '专属物品' : normalized; +} + +function inferExplicitStarterSlot(category: string) { + const normalized = normalizeExplicitStarterCategory(category); + if (normalized === '武器') return 'weapon' as const; + if (normalized === '护甲') return 'armor' as const; + if ( + normalized === '饰品' || + normalized === '稀有品' || + normalized === '专属物品' + ) { + return 'relic' as const; + } + return null; +} + +function buildExplicitCustomWorldRoleStarterState( + profile: CustomWorldProfile, + character: Character, +) { + const role = + profile.playableNpcs.find((entry) => entry.id === character.id) ?? + profile.storyNpcs.find((entry) => entry.id === character.id) ?? + profile.playableNpcs.find( + (entry) => entry.templateCharacterId === character.id, + ) ?? + profile.playableNpcs.find((entry) => entry.name === character.name) ?? + profile.storyNpcs.find((entry) => entry.name === character.name) ?? + null; + + const inventory = role + ? role.initialItems.map((item, index) => { + const category = normalizeExplicitStarterCategory(item.category); + return { + id: `custom-role-item:${role.id}:${index + 1}`, + category, + name: item.name, + quantity: Math.max(1, item.quantity), + rarity: item.rarity, + tags: [...item.tags], + description: item.description, + equipmentSlotId: inferExplicitStarterSlot(category), + runtimeMetadata: { + origin: 'ai_compiled' as const, + generationChannel: 'discovery' as const, + seedKey: `${role.id}:${index + 1}`, + relationAnchor: { + type: 'npc' as const, + npcId: role.id, + npcName: role.name, + roleText: role.role, + }, + sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`, + }, + } satisfies InventoryItem; + }) + : []; + + const equipment: EquipmentLoadout = createEmptyEquipmentLoadout(); + inventory.forEach((item) => { + const slot = item.equipmentSlotId; + if (!slot || equipment[slot]) { + return; + } + equipment[slot] = item; + }); + + return { + inventory, + equipment, + }; +} + function createInitialCampEncounter( worldType: WorldType | null, playerCharacter: Character, @@ -169,23 +258,47 @@ export function useGameFlow() { setBottomTab('adventure'); setIsMapOpen(false); - const initialScenePreset = gameState.worldType - ? getWorldCampScenePreset(gameState.worldType) ?? getScenePreset(gameState.worldType, 0) - : null; - const initialEncounter = createInitialCampEncounter(gameState.worldType, character); - const initialNpcState = initialEncounter - ? buildInitialNpcState(initialEncounter, gameState.worldType, gameState) - : null; - const initialEquipment = buildInitialEquipmentLoadout(character); - const playerMaxHp = getCharacterMaxHp( - character, - gameState.worldType, - gameState.customWorldProfile, - ); - setGameState(prev => - ensureSceneEncounterPreview( - applyEquipmentLoadoutToState({ + { + const resolvedWorldType = prev.worldType; + const resolvedCustomWorldProfile = prev.customWorldProfile; + const initialScenePreset = resolvedWorldType + ? getWorldCampScenePreset(resolvedWorldType) ?? getScenePreset(resolvedWorldType, 0) + : null; + const initialEncounter = createInitialCampEncounter( + resolvedWorldType, + character, + ); + const initialNpcState = initialEncounter + ? buildInitialNpcState(initialEncounter, resolvedWorldType, prev) + : null; + const initialEquipment = buildInitialEquipmentLoadout( + character, + resolvedCustomWorldProfile, + ); + const explicitStarterItems = + resolvedWorldType === WorldType.CUSTOM + ? buildExplicitCustomWorldRoleStarterState( + resolvedCustomWorldProfile!, + character, + ) + : null; + const mergedStarterEquipment = { + weapon: + explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon, + armor: + explicitStarterItems?.equipment.armor ?? initialEquipment.armor, + relic: + explicitStarterItems?.equipment.relic ?? initialEquipment.relic, + }; + const playerMaxHp = getCharacterMaxHp( + character, + resolvedWorldType, + resolvedCustomWorldProfile, + ); + + return ensureSceneEncounterPreview( + applyEquipmentLoadoutToState({ ...prev, playerCharacter: character, runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }), @@ -218,10 +331,17 @@ export function useGameFlow() { activeBuildBuffs: [], activeCombatEffects: [], playerCurrency: getInitialPlayerCurrency( - gameState.worldType, - gameState.customWorldProfile, + resolvedWorldType, + resolvedCustomWorldProfile, + ), + playerInventory: mergeStarterInventoryItems( + explicitStarterItems?.inventory ?? [], + buildInitialPlayerInventory( + character, + resolvedWorldType, + resolvedCustomWorldProfile, + ), ), - playerInventory: buildInitialPlayerInventory(character, gameState.worldType), playerEquipment: createEmptyEquipmentLoadout(), npcStates: initialEncounter && initialNpcState ? { @@ -238,8 +358,9 @@ export function useGameFlow() { sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, - }, initialEquipment), - ), + }, mergedStarterEquipment), + ); + }, ); }; diff --git a/src/services/customWorld.ts b/src/services/customWorld.ts index 8ae3e65e..63e8ab0a 100644 --- a/src/services/customWorld.ts +++ b/src/services/customWorld.ts @@ -1877,11 +1877,15 @@ export function buildCustomWorldRoleBatchPrompt(params: { '- 名称必须与批次名单完全一致,不得增删改名。', '- 只补全 backstory、personality、motivation、combatStyle 这 4 个字段,不要输出 backstoryReveal、skills、initialItems。', '- 必须严格沿用框架中的 title、role、description、initialAffinity、relationshipHooks、tags 所表达的角色定位,不要改名,不要改阵营。', + '- backstory 必须写出角色和当前世界的具体关系,至少落到一个势力、一个地点、一个正在发生的局势变化,不要只写抽象气质或泛泛成长史。', + '- personality 不能只写单个形容词,要体现角色在这个世界里的处事习惯、应对压力的方式和与人相处的锋面。', + '- motivation 必须是“此刻正在推动角色行动”的现实目标,而不是空泛理想;它要和玩家目标、核心冲突或开局处境形成直接拉扯。', + '- combatStyle 要体现角色为什么会这样战斗,它最好能反映其身份、经历、所属势力或长期栖身的场景环境。', roleType === 'story' ? '- 怪物型场景角色要在 backstory 或 combatStyle 中直接写出怪物特征、栖息环境或攻击方式。' : '- 可扮演角色要体现成长空间、协作价值和明确的入队理由。', '- 所有生成文本都必须使用中文。', - '- 每个字符串尽量简洁:backstory/personality/motivation/combatStyle 控制在 10 到 40 个汉字内。', + '- 每个字符串尽量简洁但不能空泛:backstory/personality/motivation/combatStyle 控制在 18 到 56 个汉字内。', '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', ].join('\n'); } @@ -1933,6 +1937,11 @@ export function buildCustomWorldRoleBatchPrompt(params: { '- 名称必须与批次名单完全一致,不得增删改名。', '- 这一阶段只补全 backstoryReveal、skills、initialItems,不要重复输出 title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。', '- 背景章节、技能和初始物品必须严格围绕框架中的角色定位来写,不要改变阵营、身份或出现场景。', + '- backstoryReveal 的 4 章必须形成明显递进:第 1 章写表层来意与第一印象,第 2 章写旧伤或代价,第 3 章写角色真正隐瞒的线索,第 4 章写最终底牌或不可回避的真相。', + '- 每一章都必须紧贴当前世界设定,至少落到具体势力、地点、事件、制度、禁忌或关系链中的一项,不要写成可套用到任何世界的空泛心情。', + '- teaser 必须像“继续相处后能戳到的钩子”,content 必须像“真正解锁后得到的新信息”,contextSnippet 必须可直接被后续剧情复用,三者不要只是同一句话改写。', + '- skills 不只是职业标签,要体现角色的个人经历、所属阵营、地理环境或禁忌系统影响,尽量写出这个世界独有的招式语感。', + '- initialItems 不只是常规装备清单,至少要有一件能反映角色背景、关系或任务压力的私人物件。', `- backstoryReveal.chapters 必须恰好 4 章,affinityRequired 固定使用 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}。`, '- 每个角色必须提供恰好 3 个 skills 和恰好 3 个 initialItems。', '- initialItems.category 只能使用:武器、护甲、饰品、消耗品、材料、稀有品、专属物品。', @@ -1940,7 +1949,7 @@ export function buildCustomWorldRoleBatchPrompt(params: { ? '- 怪物型角色仍然放进 storyNpcs,并在 role、description、backstory、combatStyle、tags、backstoryReveal、skills 或 initialItems 中明确写出怪物特征、栖息环境、攻击方式或异形外观。' : '- 可扮演角色要保持明确的成长空间、协作价值和入队理由。', '- 所有生成文本都必须使用中文。', - '- 每个字符串尽量简洁:backstoryReveal.publicSummary 控制在 10 到 28 个汉字内,backstoryReveal.content 控制在 12 到 36 个汉字内,skills.summary 和 initialItems.description 控制在 8 到 24 个汉字内。', + '- 每个字符串尽量简洁但要有信息量:backstoryReveal.publicSummary 控制在 14 到 36 个汉字内,backstoryReveal.teaser 控制在 12 到 28 个汉字内,backstoryReveal.content 控制在 20 到 64 个汉字内,contextSnippet 控制在 12 到 36 个汉字内,skills.summary 和 initialItems.description 控制在 12 到 32 个汉字内。', '- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。', ].join('\n'); } diff --git a/src/services/customWorldAgentDraftResult.test.ts b/src/services/customWorldAgentDraftResult.test.ts new file mode 100644 index 00000000..ddddf067 --- /dev/null +++ b/src/services/customWorldAgentDraftResult.test.ts @@ -0,0 +1,607 @@ +import { afterEach, expect, test } from 'vitest'; + +import type { CustomWorldAgentSessionSnapshot } from '../../packages/shared/src/contracts/customWorldAgent'; +import { buildCustomWorldRuntimeCharacters } from '../data/characterPresets'; +import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime'; +import { getScenePresetsByWorld } from '../data/scenePresets'; +import { WorldType } from '../types'; +import { buildCustomWorldProfileFromAgentDraft } from './customWorldAgentDraftResult'; + +afterEach(() => { + setRuntimeCustomWorldProfile(null); +}); + +const session: CustomWorldAgentSessionSnapshot = { + sessionId: 'session-1', + stage: 'object_refining', + focusCardId: null, + creatorIntent: { + sourceMode: 'card', + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + themeKeywords: ['海雾', '旧航路'], + toneDirectives: ['压抑', '悬疑'], + openingSituation: '首夜就有陌生船只闯入禁航区。', + coreConflicts: ['航运公会与守灯会争夺航路控制权'], + keyFactions: [], + keyCharacters: [], + keyLandmarks: [], + iconicElements: ['会移动的海雾'], + forbiddenDirectives: [], + rawSettingText: '', + }, + creatorIntentReadiness: { + isReady: true, + completedKeys: [], + missingKeys: [], + }, + anchorPack: {}, + lockState: {}, + draftProfile: { + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船与禁航区异动的真相。', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + title: '旧航路引路人', + role: '关键同行者', + publicIdentity: '最熟悉旧航路的人。', + publicMask: '看上去像可靠旧友。', + currentPressure: '他必须在两股势力间站队。', + hiddenHook: '暗中替沉船商盟引路。', + relationToPlayer: '旧友兼潜在背叛者', + threadIds: ['thread-1'], + summary: '他像旧友,但也像一把始终没收回鞘的刀。', + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + title: '守灯会值夜人', + role: '场景关键角色', + publicIdentity: '负责夜间巡灯与封锁。', + publicMask: '对外一直冷静克制。', + currentPressure: '她知道更多禁航区真相。', + hiddenHook: '曾亲眼见过失控海雾吞船。', + relationToPlayer: '最早愿意交换线索的人', + threadIds: ['thread-1'], + summary: '她总像比所有人更早知道海雾会往哪一侧压下来。', + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + purpose: '观察雾潮与往来船只', + mood: '潮湿、压抑、风声不止', + importance: '开局核心场景', + characterIds: ['story-1'], + threadIds: ['thread-1'], + summary: '旧灯塔是整片群岛最先看见异动的地方。', + }, + ], + factions: [], + threads: [], + chapters: [], + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + openingSituation: '首夜就有陌生船只闯入禁航区。', + iconicElements: ['会移动的海雾'], + sourceAnchorSummary: '海雾、旧灯塔、失控航路。', + }, + messages: [], + draftCards: [], + pendingClarifications: [], + suggestedActions: [], + recommendedReplies: [], + qualityFindings: [], + assetCoverage: { + roleAssets: [], + sceneAssets: [], + allRoleAssetsReady: false, + allSceneAssetsReady: false, + }, + updatedAt: '2026-04-15T10:00:00.000Z', +}; + +function buildBackstoryReveal(label: string) { + return { + publicSummary: `${label}的公开背景`, + privateChatUnlockAffinity: 40, + chapters: [ + { + id: `${label}-surface`, + title: '表层来意', + affinityRequired: 15, + teaser: `${label}先只肯说表面的来意。`, + content: `${label}表面上只愿意谈当前局势。`, + contextSnippet: `${label}表面上还在收着话。`, + }, + { + id: `${label}-scar`, + title: '旧事裂痕', + affinityRequired: 30, + teaser: `${label}背后还有一段旧伤。`, + content: `${label}曾在旧案里留下无法轻易揭开的伤口。`, + contextSnippet: `${label}和旧案之间有一段没说开的裂痕。`, + }, + { + id: `${label}-hidden`, + title: '隐藏执念', + affinityRequired: 60, + teaser: `${label}真正想追的不是表面那件事。`, + content: `${label}真正挂着的是旧案里还没结的那条线。`, + contextSnippet: `${label}真正执念指向旧案深处。`, + }, + { + id: `${label}-final`, + title: '最终底牌', + affinityRequired: 90, + teaser: `${label}手里还压着最后一张牌。`, + content: `${label}手里还握着能直接证明真相的关键证据。`, + contextSnippet: `${label}最后的底牌足以改写局势。`, + }, + ], + }; +} + +function buildLegacyResultProfile() { + return { + id: 'legacy-profile-1', + settingText: '被海雾吞没的旧航路群岛', + name: '旧版完整结果', + subtitle: '直接展示', + summary: '优先使用服务端编译好的旧版 profile。', + tone: '压抑', + playerGoal: '查明真相', + templateWorldType: WorldType.WUXIA, + compatibilityTemplateWorldType: WorldType.WUXIA, + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['争夺航路控制权', '沉船真相'], + attributeSchema: { + id: 'schema:test', + worldId: 'CUSTOM', + schemaVersion: 1, + schemaName: '测试', + generatedFrom: { + worldType: WorldType.CUSTOM, + worldName: '旧版完整结果', + settingSummary: '测试', + tone: '测试', + conflictCore: '测试', + }, + slots: [], + }, + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + title: '旧航路引路人', + role: '关键同行者', + description: '最熟悉旧航路的人。', + backstory: '曾在沉船夜里带着半支船队逃出海雾。', + personality: '表面沉稳,心里一直在算退路。', + motivation: '想赶在守灯会封航前查清真相。', + combatStyle: '借地形和潮路换位,先拉扯再压近。', + initialAffinity: 18, + relationshipHooks: ['旧友', '沉船旧案'], + tags: ['潮路', '引路'], + backstoryReveal: buildBackstoryReveal('沈砺'), + skills: [ + { + id: 'skill-playable-1', + name: '潮行引路', + summary: '踩着旧潮阶切线前压,替队伍打开角度。', + style: '机动周旋', + }, + { + id: 'skill-playable-2', + name: '回雾折返', + summary: '借海雾遮住身位,再从侧线拉开。', + style: '起手压制', + }, + { + id: 'skill-playable-3', + name: '旧图定标', + summary: '用旧潮图锁定退路和突入口。', + style: '爆发终结', + }, + ], + initialItems: [ + { + id: 'item-playable-1', + name: '旧潮短刃', + category: '武器', + quantity: 1, + rarity: 'rare', + description: '专门在湿滑甲板上近身换位用的短刃。', + tags: ['潮路', '近战'], + }, + { + id: 'item-playable-2', + name: '雾盐药包', + category: '消耗品', + quantity: 2, + rarity: 'uncommon', + description: '压住寒潮后遗症的随身药包。', + tags: ['补给'], + }, + { + id: 'item-playable-3', + name: '旧潮图残页', + category: '专属物品', + quantity: 1, + rarity: 'rare', + description: '只剩半页,但足够指向沉船夜的另一条线。', + tags: ['线索', '真相'], + }, + ], + attributeProfile: { + schemaId: 'schema:test', + values: { axis_a: 48, axis_b: 72, axis_c: 78 }, + topTraits: ['浪步', '舟识'], + evidence: [ + { + slotId: 'axis_b', + reason: '长期依赖潮路换位与切线。', + }, + ], + }, + narrativeProfile: { + publicMask: '像个只想把旧路再走通一次的熟路人。', + firstContactMask: '先别问太深,至少今晚这条路我还认得。', + visibleLine: '他明面上只想护着队伍别再走错一次潮线。', + hiddenLine: '真正让他回来的是沉船夜里被人卖掉的那条航线。', + contradiction: '嘴上说只想带路,实际每一步都在找能对上旧案的证据。', + debtOrBurden: '背着半支船队没能活着回来的旧债。', + taboo: '最忌讳别人轻描淡写地提起那晚的失踪名单。', + immediatePressure: '守灯会封航在即,他必须赶在封口前找到证据。', + relatedThreadIds: ['thread-visible-1'], + relatedScarIds: ['scar-1'], + reactionHooks: ['沉船夜', '封航记录'], + }, + templateCharacterId: 'archer-hero', + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + title: '守灯会值夜人', + role: '场景关键角色', + description: '夜里巡灯与封锁禁航区的人。', + backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。', + personality: '冷静克制,但提到旧灯册时会显得过分警觉。', + motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。', + combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。', + initialAffinity: 8, + relationshipHooks: ['禁航记录', '灯塔值夜'], + tags: ['守灯会', '灯塔'], + backstoryReveal: buildBackstoryReveal('顾潮音'), + skills: [ + { + id: 'skill-story-1', + name: '夜潮灯语', + summary: '借灯语与潮声干扰对方判断。', + style: '起手压制', + }, + { + id: 'skill-story-2', + name: '禁航暗潮', + summary: '封住错误航线,把人逼回她熟悉的区域。', + style: '机动周旋', + }, + { + id: 'skill-story-3', + name: '回声巡线', + summary: '借塔顶回声迅速锁定异动方向。', + style: '爆发终结', + }, + ], + initialItems: [ + { + id: 'item-story-1', + name: '值夜灯尺', + category: '武器', + quantity: 1, + rarity: 'rare', + description: '兼作警械和测灯尺的长柄器具。', + tags: ['守灯会'], + }, + { + id: 'item-story-2', + name: '防潮火折', + category: '消耗品', + quantity: 2, + rarity: 'uncommon', + description: '在潮雾里也能点亮的值夜火折。', + tags: ['值夜'], + }, + { + id: 'item-story-3', + name: '封灯令副本', + category: '专属物品', + quantity: 1, + rarity: 'rare', + description: '一份被她私下截留下来的封灯令副本。', + tags: ['证据', '灯册'], + }, + ], + imageSrc: '/custom/npcs/gu-chaoyin.png', + attributeProfile: { + schemaId: 'schema:test', + values: { axis_a: 54, axis_c: 82, axis_f: 61 }, + topTraits: ['舟识', '回澜'], + evidence: [ + { + slotId: 'axis_c', + reason: '长期依赖值夜观察和读灯判断局势。', + }, + ], + }, + narrativeProfile: { + publicMask: '守灯会值夜人,对外总像比别人更冷静一步。', + firstContactMask: '想进塔就按规矩来,今晚谁都别想乱闯禁航区。', + visibleLine: '她表面上只是在守灯和封线。', + hiddenLine: '她真正盯着的是那本被改过的原始灯册。', + contradiction: '越强调规矩,越像在掩住自己手里那份没上报的记录。', + debtOrBurden: '她扛着第一次海雾吞船夜里没能及时点亮备用灯的旧责。', + taboo: '最忌讳别人把那夜的失踪当成单纯天灾。', + immediatePressure: '新的封航令马上要落下来,她必须先把旧灯册转出去。', + relatedThreadIds: ['thread-visible-1'], + relatedScarIds: ['scar-1'], + reactionHooks: ['原始灯册', '封灯令'], + }, + visual: { + race: 'human', + bodyColor: 'blue', + headIndex: 2, + hairColorIndex: 3, + hairStyleFrame: 4, + facialHairEnabled: false, + facialHairColorIndex: 0, + facialHairStyleFrame: 0, + offHand: { + type: 'magic', + file: 'lantern.png', + frameIndex: 1, + }, + }, + }, + ], + items: [ + { + id: 'item-world-1', + name: '潮雾罗盘', + category: '饰品', + rarity: 'rare', + description: '会在假航灯附近偏转的旧罗盘。', + tags: ['线索', '潮雾'], + attributeResonance: { + resonanceVector: { axis_c: 0.88, axis_e: 0.31 }, + explanation: '它会把持有者的判断力牵到潮雾最异常的地方。', + }, + }, + ], + camp: { + name: '回潮暂栖所', + description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。', + dangerLevel: 'low', + imageSrc: '/custom/camp/huichao.png', + }, + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', + dangerLevel: 'high', + imageSrc: '/custom/scenes/lighthouse.png', + sceneNpcIds: ['story-1'], + connections: [ + { + targetLandmarkId: 'landmark-2', + relativePosition: 'forward', + summary: '沿着旧潮阶继续前压到雾栈尽头。', + }, + ], + narrativeResidues: [ + { + id: 'residue-1', + title: '潮痕', + visibleClue: '塔壁上有一圈不该出现在高处的潮痕。', + linkedFactIds: ['fact-1'], + linkedThreadIds: ['thread-visible-1'], + }, + ], + }, + { + id: 'landmark-2', + name: '雾栈尽头', + description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。', + dangerLevel: 'high', + imageSrc: '/custom/scenes/pier.png', + sceneNpcIds: [], + connections: [ + { + targetLandmarkId: 'landmark-1', + relativePosition: 'back', + summary: '退回灯塔还能重新整理路线。', + }, + ], + }, + ], + themePack: { + id: 'theme-pack:tide', + displayName: '潮雾悬疑', + toneRange: ['压抑', '潮湿', '悬疑'], + institutionLexicon: ['守灯会', '航运公会'], + tabooLexicon: ['假航灯', '封灯令'], + artifactClasses: ['旧潮图', '灯册', '罗盘'], + actorArchetypes: ['引路人', '值夜人'], + conflictForms: ['封航争夺', '旧案追查'], + clueForms: ['灯册残页', '潮痕'], + namingPatterns: ['潮', '雾', '灯'], + revealStyles: ['试探式回应'], + }, + storyGraph: { + visibleThreads: [ + { + id: 'thread-visible-1', + title: '封航争夺', + visibility: 'visible', + summary: '守灯会与航运公会正在争夺旧航路的解释权。', + conflictType: '控制权争夺', + stakes: '谁能定义禁航区,就能决定谁能活着穿过去。', + involvedFactionIds: ['faction-guard', 'faction-guild'], + involvedActorIds: ['playable-1', 'story-1'], + relatedLocationIds: ['landmark-1', 'landmark-2'], + }, + ], + hiddenThreads: [ + { + id: 'thread-hidden-1', + title: '沉船旧案', + visibility: 'hidden', + summary: '沉船夜的航灯与灯册被人动过手脚。', + conflictType: '真相遮蔽', + stakes: '真相一旦坐实,守灯会内部会先崩。', + involvedFactionIds: ['faction-guard'], + involvedActorIds: ['playable-1', 'story-1'], + relatedLocationIds: ['landmark-1'], + }, + ], + scars: [ + { + id: 'scar-1', + title: '沉船夜', + pastEvent: '假航灯把整支船队引进了死潮区。', + publicResidue: '每逢潮夜,灯塔下总有人提起那晚的失踪名单。', + hiddenTruth: '禁航记录和灯册都在事后被篡改过。', + relatedActorIds: ['playable-1', 'story-1'], + relatedLocationIds: ['landmark-1'], + }, + ], + motifs: [ + { + id: 'motif-1', + label: '假航灯', + semanticRole: 'technology', + lexicalHints: ['假灯', '偏航', '禁航记录'], + }, + ], + }, + knowledgeFacts: [ + { + id: 'fact-1', + title: '高处潮痕', + content: '回潮旧灯塔的高处潮痕说明那晚海面高度异常。', + ownerActorIds: ['story-1'], + relatedThreadIds: ['thread-visible-1'], + relatedScarIds: ['scar-1'], + sourceType: 'scene', + visibility: 'discoverable', + sayability: 'indirect', + }, + ], + threadContracts: [ + { + id: 'contract-1', + threadId: 'thread-visible-1', + issuerActorId: 'story-1', + narrativeType: 'investigation', + currentStepId: 'contract-step-1', + visibleStage: 1, + steps: [ + { + id: 'contract-step-1', + title: '查灯塔', + revealText: '先查清灯塔顶上的高处潮痕。', + completionSignalIds: ['inspect_scene:landmark-1'], + optionalFactIds: ['fact-1'], + }, + ], + followupThreadIds: ['thread-hidden-1'], + }, + ], + scenarioPackId: 'scenario-pack:tide', + campaignPackId: 'campaign-pack:tide', + generationMode: 'fast', + generationStatus: 'key_only', + }; +} + +function buildProfileFromEmbeddedLegacyResult() { + return buildCustomWorldProfileFromAgentDraft({ + ...session, + draftProfile: { + ...session.draftProfile, + legacyResultProfile: buildLegacyResultProfile(), + }, + }); +} + +test('adapts agent draft profile into legacy custom world result profile', () => { + const profile = buildCustomWorldProfileFromAgentDraft(session); + + expect(profile?.name).toBe('潮雾列岛'); + expect(profile?.generationStatus).toBe('key_only'); + expect(profile?.playableNpcs[0]?.name).toBe('沈砺'); + expect(profile?.storyNpcs[0]?.name).toBe('顾潮音'); + expect(profile?.landmarks[0]?.name).toBe('回潮旧灯塔'); +}); + +test('prefers embedded legacy result profile without dropping compiled runtime fields', () => { + const profile = buildProfileFromEmbeddedLegacyResult(); + + expect(profile?.name).toBe('旧版完整结果'); + expect(profile?.majorFactions).toEqual(['守灯会', '航运公会']); + expect(profile?.coreConflicts).toEqual(['争夺航路控制权', '沉船真相']); + expect(profile?.themePack?.id).toBe('theme-pack:tide'); + expect(profile?.storyGraph?.visibleThreads[0]?.id).toBe('thread-visible-1'); + expect(profile?.knowledgeFacts?.[0]?.id).toBe('fact-1'); + expect(profile?.threadContracts?.[0]?.id).toBe('contract-1'); + expect(profile?.scenarioPackId).toBe('scenario-pack:tide'); + expect(profile?.campaignPackId).toBe('campaign-pack:tide'); + expect(profile?.playableNpcs[0]?.attributeProfile?.schemaId).toBe( + 'schema:test', + ); + expect(profile?.storyNpcs[0]?.narrativeProfile?.publicMask).toContain( + '守灯会值夜人', + ); + expect(profile?.items[0]?.attributeResonance?.explanation).toContain('潮雾'); + expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.title).toBe('潮痕'); +}); + +test('embedded legacy result profile keeps result-page settings in runtime characters and scenes', () => { + const profile = buildProfileFromEmbeddedLegacyResult(); + + expect(profile).toBeTruthy(); + + setRuntimeCustomWorldProfile(profile); + + const runtimeCharacters = buildCustomWorldRuntimeCharacters(profile); + const leadCharacter = runtimeCharacters.find( + (character) => character.id === 'playable-1', + ); + const lighthouseScene = getScenePresetsByWorld(WorldType.CUSTOM).find( + (scene) => scene.name === '回潮旧灯塔', + ); + const guardNpc = lighthouseScene?.npcs.find((npc) => npc.id === 'story-1'); + + expect(leadCharacter?.skills[0]?.name).toBe('潮行引路'); + expect(leadCharacter?.backstoryReveal?.publicSummary).toBe('沈砺的公开背景'); + expect(lighthouseScene?.connections[0]?.summary).toBe( + '沿着旧潮阶继续前压到雾栈尽头。', + ); + expect(lighthouseScene?.narrativeResidues?.[0]?.title).toBe('潮痕'); + expect(guardNpc?.narrativeProfile?.publicMask).toBe( + '守灯会值夜人,对外总像比别人更冷静一步。', + ); +}); diff --git a/src/services/customWorldAgentDraftResult.ts b/src/services/customWorldAgentDraftResult.ts new file mode 100644 index 00000000..d761b622 --- /dev/null +++ b/src/services/customWorldAgentDraftResult.ts @@ -0,0 +1,242 @@ +import type { CustomWorldAgentSessionSnapshot } from '../../packages/shared/src/contracts/customWorldAgent'; +import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary'; +import { type CustomWorldProfile, WorldType } from '../types'; +import { buildAgentDraftFoundationSettingText } from './customWorldAgentGenerationProgress'; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is Record => isRecord(item)) + : []; +} + +function toStringArray(value: unknown, max = 8) { + if (!Array.isArray(value)) { + return []; + } + + return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice( + 0, + max, + ); +} + +function inferTemplateWorldType(settingText: string) { + return /[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText) + ? WorldType.XIANXIA + : WorldType.WUXIA; +} + +function buildCharacterSummaryText(record: Record) { + return ( + toText(record.summary) || + toText(record.publicIdentity) || + toText(record.publicMask) || + toText(record.currentPressure) || + toText(record.relationToPlayer) + ); +} + +function buildCharacterBackstoryText(record: Record) { + return [ + toText(record.publicIdentity), + toText(record.currentPressure), + toText(record.hiddenHook) ? `暗线 ${toText(record.hiddenHook)}` : '', + ] + .filter(Boolean) + .join(';'); +} + +function buildRelationshipHooks(record: Record) { + return [ + toText(record.relationToPlayer), + toText(record.currentPressure), + toText(record.hiddenHook) ? `暗线 ${toText(record.hiddenHook)}` : '', + ].filter(Boolean); +} + +function buildCharacterTags( + record: Record, + roleKind: 'playable' | 'story', +) { + const threadIds = toStringArray(record.threadIds, 4); + return [...threadIds, roleKind === 'playable' ? '草稿主角' : '草稿角色']; +} + +type AdaptedDraftCharacter = { + id: string; + name: string; + title: string; + role: string; + description: string; + backstory: string; + personality: string; + motivation: string; + combatStyle: string; + initialAffinity: number; + relationshipHooks: string[]; + tags: string[]; + imageSrc?: string; + generatedVisualAssetId?: string; + generatedAnimationSetId?: string; + animationMap?: Record; +}; + +function adaptDraftCharacters(value: unknown, roleKind: 'playable' | 'story') { + return toRecordArray(value) + .map((record, index) => { + const name = toText(record.name); + if (!name) { + return null; + } + + const title = + toText(record.title) || + toText(record.role) || + (roleKind === 'playable' ? '关键角色' : '场景角色'); + const role = toText(record.role) || title; + const description = buildCharacterSummaryText(record); + const relationshipHooks = buildRelationshipHooks(record); + + return { + id: toText(record.id) || `${roleKind}-draft-${index + 1}`, + name, + title, + role, + description, + backstory: buildCharacterBackstoryText(record), + personality: + toText(record.publicMask) || + toText(record.publicIdentity) || + description, + motivation: + toText(record.relationToPlayer) || + toText(record.currentPressure) || + toText(record.hiddenHook), + combatStyle: role, + initialAffinity: roleKind === 'playable' ? 18 : 6, + relationshipHooks, + tags: buildCharacterTags(record, roleKind), + imageSrc: toText(record.imageSrc) || undefined, + generatedVisualAssetId: + toText(record.generatedVisualAssetId) || undefined, + generatedAnimationSetId: + toText(record.generatedAnimationSetId) || undefined, + animationMap: isRecord(record.animationMap) + ? record.animationMap + : undefined, + } satisfies AdaptedDraftCharacter; + }) + .filter(Boolean) as AdaptedDraftCharacter[]; +} + +type AdaptedDraftLandmark = { + id: string; + name: string; + description: string; + dangerLevel: string; + imageSrc?: string; + sceneNpcIds: string[]; + connections: never[]; +}; + +function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set) { + return toRecordArray(value) + .map((record, index) => { + const name = toText(record.name); + if (!name) { + return null; + } + + return { + id: toText(record.id) || `landmark-draft-${index + 1}`, + name, + description: + toText(record.description) || + toText(record.summary) || + [toText(record.purpose), toText(record.mood)] + .filter(Boolean) + .join(';'), + dangerLevel: + toText(record.dangerLevel) || + toText(record.importance) || + toText(record.mood), + imageSrc: toText(record.imageSrc) || undefined, + sceneNpcIds: toStringArray(record.characterIds).filter((id) => + storyNpcIdSet.has(id), + ), + connections: [], + } satisfies AdaptedDraftLandmark; + }) + .filter(Boolean) as AdaptedDraftLandmark[]; +} + +export function buildCustomWorldProfileFromAgentDraft( + session: CustomWorldAgentSessionSnapshot | null | undefined, +): CustomWorldProfile | null { + if (!session || !isRecord(session.draftProfile)) { + return null; + } + + const draftProfile = session.draftProfile; + const legacyResultProfile = normalizeCustomWorldProfileRecord( + draftProfile.legacyResultProfile, + ); + if (legacyResultProfile) { + return legacyResultProfile; + } + + const settingText = buildAgentDraftFoundationSettingText(session); + const templateWorldType = inferTemplateWorldType(settingText); + const playableNpcs = adaptDraftCharacters( + draftProfile.playableNpcs, + 'playable', + ); + const storyNpcs = adaptDraftCharacters(draftProfile.storyNpcs, 'story'); + const storyNpcIdSet = new Set( + storyNpcs.map((entry) => toText(entry.id)).filter(Boolean), + ); + const normalized = normalizeCustomWorldProfileRecord({ + id: `agent-draft-${session.sessionId}`, + settingText, + name: toText(draftProfile.name) || '未命名世界底稿', + subtitle: toText(draftProfile.subtitle) || '第一版世界底稿', + summary: + toText(draftProfile.summary) || + settingText || + '第一版世界底稿已经整理完成。', + tone: toText(draftProfile.tone) || '整体气质仍可继续精修', + playerGoal: toText(draftProfile.playerGoal) || '先站稳开局,再判断下一步', + templateWorldType, + compatibilityTemplateWorldType: templateWorldType, + majorFactions: toStringArray(draftProfile.majorFactions, 6), + coreConflicts: toStringArray(draftProfile.coreConflicts, 6), + playableNpcs, + storyNpcs, + landmarks: adaptDraftLandmarks(draftProfile.landmarks, storyNpcIdSet), + camp: isRecord(draftProfile.camp) + ? { + name: toText(draftProfile.camp.name), + description: toText(draftProfile.camp.description), + dangerLevel: + toText(draftProfile.camp.dangerLevel) || + toText(draftProfile.camp.mood), + imageSrc: toText(draftProfile.camp.imageSrc) || undefined, + } + : undefined, + creatorIntent: session.creatorIntent, + anchorPack: session.anchorPack, + lockState: session.lockState, + generationMode: 'fast', + generationStatus: 'key_only', + }); + + return normalized; +} diff --git a/src/services/customWorldAgentGenerationProgress.test.ts b/src/services/customWorldAgentGenerationProgress.test.ts index 6ed1f591..e194b47e 100644 --- a/src/services/customWorldAgentGenerationProgress.test.ts +++ b/src/services/customWorldAgentGenerationProgress.test.ts @@ -116,9 +116,9 @@ test('marks all legacy progress steps complete when draft foundation finishes', test('builds readable draft setting text from creator intent first', () => { const settingText = buildAgentDraftFoundationSettingText(baseSession); - expect(settingText).toContain('世界核心'); - expect(settingText).toContain('玩家开局'); - expect(settingText).toContain('标志元素'); + expect(settingText).toContain('世界核心命题'); + expect(settingText).toContain('玩家身份'); + expect(settingText).toContain('标志性要素'); }); test('falls back to latest user message when creator intent is unavailable', () => { diff --git a/src/services/customWorldAgentGenerationProgress.ts b/src/services/customWorldAgentGenerationProgress.ts index f36c4428..d899ef58 100644 --- a/src/services/customWorldAgentGenerationProgress.ts +++ b/src/services/customWorldAgentGenerationProgress.ts @@ -87,11 +87,7 @@ function buildAgentDraftFoundationSteps( detail: step.detail, completed: isCompleted ? 1 : 0, total: 1, - status: isCompleted - ? 'completed' - : isActive - ? 'active' - : 'pending', + status: isCompleted ? 'completed' : isActive ? 'active' : 'pending', } satisfies CustomWorldGenerationStep; }); } @@ -113,10 +109,7 @@ function resolveEstimatedRemainingMs( const elapsedMs = Math.max(0, nowMs - startedAtMs); const progressFraction = progress / 100; - return Math.max( - 0, - Math.round(elapsedMs / progressFraction - elapsedMs), - ); + return Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs)); } export function isDraftFoundationOperation( @@ -184,19 +177,19 @@ export function buildAgentDraftFoundationSettingText( ); if (creatorIntent) { - const displayText = - buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim(); const generationText = buildCustomWorldCreatorIntentGenerationText(creatorIntent).trim(); - - if (displayText) { - return displayText; - } + const displayText = + buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim(); if (generationText) { return generationText; } + if (displayText) { + return displayText; + } + if (creatorIntent.rawSettingText.trim()) { return creatorIntent.rawSettingText.trim(); } diff --git a/view-llm-logs.ps1 b/view-llm-logs.ps1 new file mode 100644 index 00000000..2decb309 --- /dev/null +++ b/view-llm-logs.ps1 @@ -0,0 +1,42 @@ +# LLM 日志查看脚本 +# 使用方法:右键点击此文件 -> "使用 PowerShell 运行" + +$LogDir = "E:\Repos\Genarrative\server-node\logs" +$TodayLog = Get-ChildItem $LogDir -Filter "server.log.*.1" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + +if ($TodayLog) { + Write-Host "正在监控日志文件: $($TodayLog.FullName)" -ForegroundColor Green + Write-Host "按 Ctrl+C 退出" -ForegroundColor Yellow + Write-Host "" + Write-Host "=== 只显示 LLM 调试日志 ===" -ForegroundColor Cyan + Write-Host "" + + Get-Content $TodayLog.FullName -Wait -Tail 20 | Where-Object { $_ -match "LLM_DEBUG" } | ForEach-Object { + # 尝试解析 JSON 并美化输出 + try { + $json = $_ | ConvertFrom-Json + if ($json.msg -eq "[LLM_DEBUG] Request prompt") { + Write-Host "`n[请求 Prompt]" -ForegroundColor Yellow + Write-Host "时间: $($json.time)" -ForegroundColor Gray + Write-Host "标签: $($json.llm_debug_label)" -ForegroundColor Gray + Write-Host "模型: $($json.llm_model)" -ForegroundColor Gray + Write-Host "消息:" -ForegroundColor Gray + $json.llm_messages | ForEach-Object { + Write-Host " [$($_.role)]" -ForegroundColor Cyan + Write-Host " $($_.content.Substring(0, [Math]::Min(200, $_.content.Length)))..." -ForegroundColor White + } + } elseif ($json.msg -eq "[LLM_DEBUG] Response content") { + Write-Host "`n[响应内容]" -ForegroundColor Green + Write-Host "时间: $($json.time)" -ForegroundColor Gray + Write-Host "标签: $($json.llm_debug_label)" -ForegroundColor Gray + Write-Host "长度: $($json.llm_response_length) 字符" -ForegroundColor Gray + Write-Host "内容:" -ForegroundColor Gray + Write-Host " $($json.llm_response_content.Substring(0, [Math]::Min(500, $json.llm_response_content.Length)))..." -ForegroundColor White + } + } catch { + Write-Host $_ -ForegroundColor White + } + } +} else { + Write-Host "未找到日志文件" -ForegroundColor Red +}