1
This commit is contained in:
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal file
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
212
docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md
Normal file
212
docs/audits/FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md
Normal file
@@ -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<T extends { category: string; name: string }>` 会优先从显式 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 系统的自动化测试层状况”,但不等于“所有视觉演出与在线模型联动都已人工验证完毕”。
|
||||
@@ -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):自定义世界创作工具当前问题、体验断层和优化优先级审计。
|
||||
|
||||
|
||||
340
server-node/src/modules/assets/characterAssetRoutes.test.ts
Normal file
340
server-node/src/modules/assets/characterAssetRoutes.test.ts
Normal file
@@ -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<Buffer>((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<T>(
|
||||
buildHandler: (baseUrl: string) => (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void>,
|
||||
run: (baseUrl: string) => Promise<T>,
|
||||
) {
|
||||
let handler: (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void> = () => 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<void>((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<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function withAssetRouteServer<T>(
|
||||
config: AppConfig,
|
||||
run: (baseUrl: string) => Promise<T>,
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '25mb' }));
|
||||
app.use(createCharacterAssetRoutes(config));
|
||||
|
||||
const server = await new Promise<import('node:http').Server>((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<void>((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);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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 })),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<CustomWorldAgentActionRequest, { action: 'update_draft_card' }>;
|
||||
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<string, unknown>,
|
||||
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<CustomWorldAgentActionRequest, { action: 'generate_characters' }>;
|
||||
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<string, unknown>,
|
||||
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<CustomWorldAgentActionRequest, { action: 'generate_landmarks' }>;
|
||||
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<string, unknown>,
|
||||
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<CustomWorldAgentActionRequest, { action: 'generate_role_assets' }>;
|
||||
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<CustomWorldAgentActionRequest, { action: 'sync_role_assets' }>;
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
199
server-node/src/services/sceneImageService.test.ts
Normal file
199
server-node/src/services/sceneImageService.test.ts
Normal file
@@ -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<Buffer>((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<T>(
|
||||
buildHandler: (baseUrl: string) => (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void>,
|
||||
run: (baseUrl: string) => Promise<T>,
|
||||
) {
|
||||
let handler: (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void> = () => 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<void>((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<void>((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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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<string, unknown>) {
|
||||
return findFirstStringByKey(payload, 'task_id');
|
||||
}
|
||||
|
||||
function extractImageUrls(payload: Record<string, unknown>) {
|
||||
const urls: string[] = [];
|
||||
collectStringsByKey(payload, 'image', urls);
|
||||
collectStringsByKey(payload, 'url', urls);
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
function ensurePayload(
|
||||
payload: z.infer<typeof sceneImageSchema>,
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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') {
|
||||
|
||||
@@ -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 (
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}>
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold tracking-[0.16em] text-white">{title}</div>
|
||||
{subtitle ? <div className="mt-1 text-xs leading-6 text-zinc-500">{subtitle}</div> : null}
|
||||
<div className="text-xs font-bold tracking-[0.16em] text-white">
|
||||
{title}
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-500">
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<button
|
||||
@@ -102,7 +119,7 @@ function SearchBox({
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2">
|
||||
<input
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-500"
|
||||
/>
|
||||
@@ -122,7 +139,9 @@ function ImageFrame({
|
||||
tone?: 'square' | 'landscape';
|
||||
}) {
|
||||
return (
|
||||
<div className={`overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}>
|
||||
<div
|
||||
className={`overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={alt} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
@@ -149,6 +168,7 @@ function CatalogCard({
|
||||
isSelectionMode,
|
||||
isSelected,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -156,15 +176,19 @@ function CatalogCard({
|
||||
isSelectionMode: boolean;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-rose-300/35 bg-rose-500/10'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||||
: disabled
|
||||
? 'border-white/10 bg-black/20'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -172,7 +196,9 @@ function CatalogCard({
|
||||
{media}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-base font-semibold text-white">{title}</div>
|
||||
<div className="min-w-0 text-base font-semibold text-white">
|
||||
{title}
|
||||
</div>
|
||||
{isSelectionMode ? (
|
||||
<div
|
||||
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
|
||||
@@ -204,6 +230,96 @@ function getSearchPlaceholder(tab: ResultTab) {
|
||||
return '搜索';
|
||||
}
|
||||
|
||||
function compactTextList(values: Array<string | null | undefined>) {
|
||||
return values.map((value) => value?.trim() ?? '').filter(Boolean);
|
||||
}
|
||||
|
||||
function buildOpeningSceneSearchText(
|
||||
profile: CustomWorldProfile,
|
||||
campScene: ReturnType<typeof resolveCustomWorldCampScene>,
|
||||
) {
|
||||
return [
|
||||
campScene.name,
|
||||
campScene.description,
|
||||
campScene.dangerLevel,
|
||||
profile.playerGoal,
|
||||
profile.summary,
|
||||
'开局场景',
|
||||
'开局归处',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
|
||||
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
|
||||
const relationshipSeed = creatorIntent?.keyCharacters[0];
|
||||
const relationshipText = relationshipSeed
|
||||
? compactTextList([
|
||||
relationshipSeed.name,
|
||||
relationshipSeed.role,
|
||||
relationshipSeed.relationToPlayer
|
||||
? `与玩家:${relationshipSeed.relationToPlayer}`
|
||||
: '',
|
||||
relationshipSeed.hiddenHook
|
||||
? `暗线:${relationshipSeed.hiddenHook}`
|
||||
: '',
|
||||
]).join(' · ')
|
||||
: '';
|
||||
const themeToneText = compactTextList([
|
||||
creatorIntent?.themeKeywords.join('、') || '',
|
||||
creatorIntent?.toneDirectives.join('、') || '',
|
||||
]).join(' / ');
|
||||
const playerOpeningText = compactTextList([
|
||||
creatorIntent?.playerPremise || '',
|
||||
creatorIntent?.openingSituation || '',
|
||||
]).join(';');
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'world-hook',
|
||||
label: '世界核心',
|
||||
value:
|
||||
creatorIntent?.worldHook ||
|
||||
profile.anchorPack?.worldSummary ||
|
||||
profile.summary,
|
||||
},
|
||||
{
|
||||
id: 'player-opening',
|
||||
label: '玩家开局',
|
||||
value: playerOpeningText || profile.playerGoal,
|
||||
},
|
||||
{
|
||||
id: 'theme-tone',
|
||||
label: '主题气质',
|
||||
value: themeToneText || profile.tone,
|
||||
},
|
||||
{
|
||||
id: 'core-conflict',
|
||||
label: '核心冲突',
|
||||
value:
|
||||
creatorIntent?.coreConflicts.join(';') ||
|
||||
profile.coreConflicts.join(';') ||
|
||||
profile.summary,
|
||||
},
|
||||
{
|
||||
id: 'relationship-seed',
|
||||
label: '关键关系',
|
||||
value:
|
||||
relationshipText ||
|
||||
profile.playableNpcs[0]?.relationshipHooks.join(';') ||
|
||||
profile.storyNpcs[0]?.relationshipHooks.join(';') ||
|
||||
'待补充',
|
||||
},
|
||||
{
|
||||
id: 'iconic-elements',
|
||||
label: '标志元素',
|
||||
value:
|
||||
creatorIntent?.iconicElements.join('、') ||
|
||||
profile.anchorPack?.motifDirectives.join('、') ||
|
||||
'待补充',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type CatalogRole =
|
||||
| CustomWorldProfile['playableNpcs'][number]
|
||||
| CustomWorldProfile['storyNpcs'][number];
|
||||
@@ -268,9 +384,12 @@ export function CustomWorldEntityCatalog({
|
||||
onDeleteLandmarks,
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
readOnly = false,
|
||||
}: CustomWorldEntityCatalogProps) {
|
||||
const [searchDraft, setSearchDraft] = useState('');
|
||||
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(null);
|
||||
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedBulkIds, setSelectedBulkIds] = useState<string[]>([]);
|
||||
const deferredSearch = useDeferredValue(searchDraft.trim());
|
||||
|
||||
@@ -286,45 +405,101 @@ export function CustomWorldEntityCatalog({
|
||||
() => resolveCustomWorldLandmarkImageMap(profile),
|
||||
[profile],
|
||||
);
|
||||
const resolvedCampScene = useMemo(() => resolveCustomWorldCampScene(profile), [profile]);
|
||||
const resolvedCampScene = useMemo(
|
||||
() => resolveCustomWorldCampScene(profile),
|
||||
[profile],
|
||||
);
|
||||
const resolvedCampImageSrc = useMemo(
|
||||
() => resolveCustomWorldCampSceneImage(profile),
|
||||
[profile],
|
||||
);
|
||||
const previewCharacterById = useMemo(
|
||||
() => new Map(profile.playableNpcs.map((role, index) => [role.id, previewCharacters[index] ?? null])),
|
||||
() =>
|
||||
new Map(
|
||||
profile.playableNpcs.map((role, index) => [
|
||||
role.id,
|
||||
previewCharacters[index] ?? null,
|
||||
]),
|
||||
),
|
||||
[previewCharacters, profile.playableNpcs],
|
||||
);
|
||||
|
||||
const filteredPlayable = useMemo(
|
||||
() => profile.playableNpcs.filter(role =>
|
||||
!deferredSearch
|
||||
|| matchText(buildRoleSearchText(role), deferredSearch),
|
||||
),
|
||||
() =>
|
||||
profile.playableNpcs.filter(
|
||||
(role) =>
|
||||
!deferredSearch ||
|
||||
matchText(buildRoleSearchText(role), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.playableNpcs],
|
||||
);
|
||||
|
||||
const filteredStory = useMemo(
|
||||
() => profile.storyNpcs.filter(npc =>
|
||||
!deferredSearch
|
||||
|| matchText(buildRoleSearchText(npc), deferredSearch),
|
||||
),
|
||||
() =>
|
||||
profile.storyNpcs.filter(
|
||||
(npc) =>
|
||||
!deferredSearch ||
|
||||
matchText(buildRoleSearchText(npc), deferredSearch),
|
||||
),
|
||||
[deferredSearch, profile.storyNpcs],
|
||||
);
|
||||
|
||||
const filteredLandmarks = useMemo(
|
||||
() => profile.landmarks.filter(landmark =>
|
||||
!deferredSearch
|
||||
|| matchText(
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
deferredSearch,
|
||||
() =>
|
||||
profile.landmarks.filter(
|
||||
(landmark) =>
|
||||
!deferredSearch ||
|
||||
matchText(
|
||||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
deferredSearch,
|
||||
),
|
||||
),
|
||||
),
|
||||
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
|
||||
);
|
||||
const structuredFoundationEntries = useMemo(
|
||||
() => buildStructuredFoundationEntries(profile),
|
||||
[profile],
|
||||
);
|
||||
const filteredSceneEntries = useMemo(() => {
|
||||
const openingSceneEntry = {
|
||||
id: 'custom-world-opening-scene',
|
||||
kind: 'camp' as const,
|
||||
name: resolvedCampScene.name,
|
||||
description: resolvedCampScene.description,
|
||||
imageSrc: resolvedCampImageSrc,
|
||||
searchText: buildOpeningSceneSearchText(profile, resolvedCampScene),
|
||||
};
|
||||
const landmarkEntries = filteredLandmarks.map((landmark) => ({
|
||||
id: landmark.id,
|
||||
kind: 'landmark' as const,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||||
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||||
}));
|
||||
const allEntries = [openingSceneEntry, ...landmarkEntries];
|
||||
|
||||
if (!deferredSearch) {
|
||||
return allEntries;
|
||||
}
|
||||
|
||||
return allEntries.filter((entry) =>
|
||||
matchText(entry.searchText, deferredSearch),
|
||||
);
|
||||
}, [
|
||||
deferredSearch,
|
||||
filteredLandmarks,
|
||||
landmarkById,
|
||||
landmarkImageById,
|
||||
profile,
|
||||
resolvedCampImageSrc,
|
||||
resolvedCampScene,
|
||||
storyNpcById,
|
||||
]);
|
||||
|
||||
const creatorIntentSummary = useMemo(
|
||||
() => buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
|
||||
() =>
|
||||
buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const lockedCharacterNames = useMemo(
|
||||
@@ -340,15 +515,15 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
const counts = {
|
||||
world: 1,
|
||||
anchors: 1,
|
||||
playable: profile.playableNpcs.length,
|
||||
story: profile.storyNpcs.length,
|
||||
landmarks: profile.landmarks.length,
|
||||
landmarks: profile.landmarks.length + 1,
|
||||
} satisfies Record<ResultTab, number>;
|
||||
|
||||
const bulkDeleteTab: BulkDeleteTab | null =
|
||||
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
|
||||
const isBulkDeleteMode = bulkDeleteMode === bulkDeleteTab;
|
||||
const isBulkDeleteMode =
|
||||
bulkDeleteTab !== null && bulkDeleteMode === bulkDeleteTab;
|
||||
|
||||
useEffect(() => {
|
||||
if (bulkDeleteMode && bulkDeleteMode !== activeTab) {
|
||||
@@ -365,7 +540,7 @@ export function CustomWorldEntityCatalog({
|
||||
if (!window.confirm(`确认删除可扮演角色「${name}」吗?`)) return;
|
||||
onProfileChange({
|
||||
...profile,
|
||||
playableNpcs: profile.playableNpcs.filter(role => role.id !== id),
|
||||
playableNpcs: profile.playableNpcs.filter((role) => role.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -411,14 +586,20 @@ export function CustomWorldEntityCatalog({
|
||||
return (
|
||||
<div className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide">
|
||||
<div className="px-1 pb-1 text-center">
|
||||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">世界档案</div>
|
||||
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">{profile.name}</div>
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">{profile.subtitle}</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
|
||||
世界档案
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">
|
||||
{profile.name}
|
||||
</div>
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">
|
||||
{profile.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(8,10,17,0.98)_0%,rgba(8,10,17,0.95)_75%,rgba(8,10,17,0)_100%)] px-1 pb-3 pt-1">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{RESULT_TABS.map(tab => (
|
||||
{RESULT_TABS.map((tab) => (
|
||||
<div key={tab.id}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -426,16 +607,22 @@ export function CustomWorldEntityCatalog({
|
||||
className={`rounded-full border px-3 py-2 text-left text-sm transition-colors ${activeTab === tab.id ? 'border-sky-300/25 bg-sky-500/12 text-white' : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'}`}
|
||||
>
|
||||
<div className="font-semibold">{tab.label}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">{counts[tab.id]}</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">
|
||||
{counts[tab.id]}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab !== 'world' && activeTab !== 'anchors' ? (
|
||||
{activeTab !== 'world' ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
|
||||
<SearchBox
|
||||
value={searchDraft}
|
||||
onChange={setSearchDraft}
|
||||
placeholder={getSearchPlaceholder(activeTab)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{isBulkDeleteMode ? (
|
||||
@@ -444,20 +631,25 @@ export function CustomWorldEntityCatalog({
|
||||
已选 {selectedBulkIds.length}
|
||||
</div>
|
||||
<SmallButton onClick={cancelBulkDelete}>取消</SmallButton>
|
||||
<SmallButton
|
||||
onClick={confirmBulkDelete}
|
||||
tone="rose"
|
||||
>
|
||||
<SmallButton onClick={confirmBulkDelete} tone="rose">
|
||||
删除选中
|
||||
</SmallButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{createActionLabel && onCreateAction ? (
|
||||
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
|
||||
{!readOnly && createActionLabel && onCreateAction ? (
|
||||
<SmallButton onClick={onCreateAction} tone="sky">
|
||||
{createActionLabel}
|
||||
</SmallButton>
|
||||
) : null}
|
||||
{bulkDeleteTab && ((bulkDeleteTab === 'story' && onDeleteStoryNpcs) || (bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
|
||||
<SmallButton onClick={() => startBulkDelete(bulkDeleteTab)} tone="rose">
|
||||
{!readOnly &&
|
||||
bulkDeleteTab &&
|
||||
((bulkDeleteTab === 'story' && onDeleteStoryNpcs) ||
|
||||
(bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
|
||||
<SmallButton
|
||||
onClick={() => startBulkDelete(bulkDeleteTab)}
|
||||
tone="rose"
|
||||
>
|
||||
批量删除
|
||||
</SmallButton>
|
||||
) : null}
|
||||
@@ -470,52 +662,91 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'world' ? (
|
||||
<>
|
||||
<Section title="世界概述" actions={<SmallButton onClick={() => onEditTarget({ kind: 'world' })} tone="sky">编辑</SmallButton>}>
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
tone="sky"
|
||||
>
|
||||
查看详情
|
||||
</SmallButton>
|
||||
) : (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-sm leading-7 text-zinc-300">
|
||||
<p>{profile.summary}</p>
|
||||
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">主线目标:{profile.playerGoal}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">世界基调:{profile.tone}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-zinc-400">原始设定:{profile.settingText}</div>
|
||||
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">
|
||||
主线目标:{profile.playerGoal}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
世界基调:{profile.tone}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{creatorIntentSummary ? (
|
||||
<Section title="创作锚点" subtitle="这部分来自创作者输入,AI 会围绕它继续展开世界。">
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section title="开局归处" subtitle="玩家进入自定义世界后的第一处落脚点,也会直接作为开场场景背景。">
|
||||
<Section
|
||||
title="原始设定"
|
||||
subtitle="把开局最关键的 6 个原始锚点拆开看,后续精修会更顺。"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ImageFrame
|
||||
src={resolvedCampImageSrc}
|
||||
alt={resolvedCampScene.name}
|
||||
fallbackLabel={resolvedCampScene.name.slice(0, 4) || '归处'}
|
||||
tone="landscape"
|
||||
/>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-200">
|
||||
{resolvedCampScene.name}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-7 text-zinc-300">
|
||||
{resolvedCampScene.description}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{structuredFoundationEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4"
|
||||
>
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
{entry.label}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-7 text-zinc-100">
|
||||
{entry.value || '待补充'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{profile.settingText ? (
|
||||
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-sm leading-7 text-zinc-200">
|
||||
{profile.settingText}
|
||||
</div>
|
||||
) : null}
|
||||
{creatorIntentSummary && creatorIntentSummary !== profile.settingText ? (
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="档案规模" subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。">
|
||||
<Section
|
||||
title="档案规模"
|
||||
subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">{profile.playableNpcs.length}</div>
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.playableNpcs.length}
|
||||
</div>
|
||||
<div>可扮演角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">{profile.storyNpcs.length}</div>
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.storyNpcs.length}
|
||||
</div>
|
||||
<div>场景角色</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
<div className="text-xl font-black text-white">{profile.landmarks.length}</div>
|
||||
<div className="text-xl font-black text-white">
|
||||
{profile.landmarks.length + 1}
|
||||
</div>
|
||||
<div>场景</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -526,128 +757,72 @@ export function CustomWorldEntityCatalog({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'anchors' ? (
|
||||
<div className="space-y-3">
|
||||
<Section
|
||||
title="创作者输入"
|
||||
subtitle="这些内容来自创作者工作台,会作为 AI 继续展开世界的锚点。"
|
||||
>
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary || '当前还没有记录创作锚点。'}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键势力">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyFactions.length ? (
|
||||
profile.creatorIntent.keyFactions.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名势力'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.publicGoal || '暂无目标说明'}</div>
|
||||
{entry.tension ? <div className="mt-1 text-zinc-400">冲突:{entry.tension}</div> : null}
|
||||
{entry.notes ? <div className="mt-1 text-zinc-500">补充:{entry.notes}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键势力锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键角色">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyCharacters.length ? (
|
||||
profile.creatorIntent.keyCharacters.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名角色'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.role || '未填写身份'}</div>
|
||||
{entry.publicMask ? <div className="mt-1 text-zinc-400">表面:{entry.publicMask}</div> : null}
|
||||
{entry.hiddenHook ? <div className="mt-1 text-zinc-400">暗线:{entry.hiddenHook}</div> : null}
|
||||
{entry.relationToPlayer ? <div className="mt-1 text-zinc-500">与玩家:{entry.relationToPlayer}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键角色锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键地点">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyLandmarks.length ? (
|
||||
profile.creatorIntent.keyLandmarks.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名地点'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.purpose || '未填写作用'}</div>
|
||||
{entry.mood ? <div className="mt-1 text-zinc-400">氛围:{entry.mood}</div> : null}
|
||||
{entry.secret ? <div className="mt-1 text-zinc-500">秘密:{entry.secret}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键地点锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'playable' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
可扮演角色支持新增、删除与更换外观模板。
|
||||
{readOnly
|
||||
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
|
||||
: '可扮演角色支持新增、删除与更换外观模板。'}
|
||||
</div>
|
||||
{filteredPlayable.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
|
||||
) : (
|
||||
filteredPlayable.map(role => {
|
||||
const previewCharacter = previewCharacterById.get(role.id) ?? null;
|
||||
filteredPlayable.map((role) => {
|
||||
const previewCharacter =
|
||||
previewCharacterById.get(role.id) ?? null;
|
||||
|
||||
return (
|
||||
<div key={role.id}>
|
||||
<Section
|
||||
title={role.name}
|
||||
subtitle={role.title}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<SmallButton onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky">编辑</SmallButton>
|
||||
<SmallButton onClick={() => removePlayable(role.id, role.name)} tone="rose">删除</SmallButton>
|
||||
</div>
|
||||
)}
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() =>
|
||||
onEditTarget({
|
||||
kind: 'playable',
|
||||
mode: 'edit',
|
||||
id: role.id,
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
>
|
||||
查看详情
|
||||
</SmallButton>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<SmallButton
|
||||
onClick={() =>
|
||||
onEditTarget({
|
||||
kind: 'playable',
|
||||
mode: 'edit',
|
||||
id: role.id,
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
<SmallButton
|
||||
onClick={() => removePlayable(role.id, role.name)}
|
||||
tone="rose"
|
||||
>
|
||||
删除
|
||||
</SmallButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex h-28 w-28 shrink-0 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35">
|
||||
{previewCharacter ? (
|
||||
<CharacterAnimator state={AnimationState.RUN} character={previewCharacter} className="h-full w-full" imageClassName="object-bottom" />
|
||||
<CharacterAnimator
|
||||
state={AnimationState.RUN}
|
||||
character={previewCharacter}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -656,51 +831,86 @@ export function CustomWorldEntityCatalog({
|
||||
创作者锁定角色
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-sm leading-6 text-zinc-300">{role.description}</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">{role.backstory}</div>
|
||||
<div className="text-sm leading-6 text-zinc-300">
|
||||
{role.description}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">
|
||||
{role.backstory}
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
|
||||
公开背景:{role.backstoryReveal.publicSummary || '未填写'}
|
||||
公开背景:
|
||||
{role.backstoryReveal.publicSummary || '未填写'}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">身份:{role.role}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">初始好感:{role.initialAffinity}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">性格:{role.personality}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">战斗:{role.combatStyle}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
身份:{role.role}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
初始好感:{role.initialAffinity}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
性格:{role.personality}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
战斗:{role.combatStyle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
动机:{role.motivation}
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">动机:{role.motivation}</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">好感背景章节</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
好感背景章节
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.backstoryReveal.chapters.map(chapter => (
|
||||
<div key={`${role.id}-${chapter.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{chapter.affinityRequired} 好感 · {chapter.title}:{chapter.teaser}
|
||||
{role.backstoryReveal.chapters.map((chapter) => (
|
||||
<div
|
||||
key={`${role.id}-${chapter.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{chapter.affinityRequired} 好感 ·{' '}
|
||||
{chapter.title}:{chapter.teaser}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">技能</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
技能
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.skills.map(skill => (
|
||||
<div key={`${role.id}-${skill.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{role.skills.map((skill) => (
|
||||
<div
|
||||
key={`${role.id}-${skill.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{skill.name} · {skill.style}:{skill.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">初始物品</div>
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
初始物品
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.initialItems.map(item => (
|
||||
<div key={`${role.id}-${item.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
{item.name} x{item.quantity} · {item.category} · {item.rarity}:{item.description}
|
||||
{role.initialItems.map((item) => (
|
||||
<div
|
||||
key={`${role.id}-${item.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{item.name} x{item.quantity} · {item.category} ·{' '}
|
||||
{item.rarity}:{item.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{role.tags.map(tag => (
|
||||
<span key={`${role.id}-${tag}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
{role.tags.map((tag) => (
|
||||
<span
|
||||
key={`${role.id}-${tag}`}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
@@ -720,7 +930,7 @@ export function CustomWorldEntityCatalog({
|
||||
{filteredStory.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||
) : (
|
||||
filteredStory.map(npc => (
|
||||
filteredStory.map((npc) => (
|
||||
<div key={npc.id}>
|
||||
<CatalogCard
|
||||
title={npc.name}
|
||||
@@ -730,9 +940,19 @@ export function CustomWorldEntityCatalog({
|
||||
onClick={() =>
|
||||
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={
|
||||
<CustomWorldNpcPortrait
|
||||
npc={npc}
|
||||
profile={profile}
|
||||
@@ -741,7 +961,7 @@ export function CustomWorldEntityCatalog({
|
||||
scale={2.18}
|
||||
preferImageSrc
|
||||
/>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
@@ -751,29 +971,43 @@ export function CustomWorldEntityCatalog({
|
||||
|
||||
{activeTab === 'landmarks' ? (
|
||||
<div className="space-y-3">
|
||||
{filteredLandmarks.length === 0 ? (
|
||||
{filteredSceneEntries.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
) : (
|
||||
filteredLandmarks.map(landmark => (
|
||||
<div key={landmark.id}>
|
||||
filteredSceneEntries.map((scene) => (
|
||||
<div key={scene.id}>
|
||||
<CatalogCard
|
||||
title={landmark.name}
|
||||
description={landmark.description}
|
||||
isSelectionMode={isBulkDeleteMode}
|
||||
isSelected={selectedBulkIds.includes(landmark.id)}
|
||||
onClick={() =>
|
||||
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={
|
||||
<ImageFrame
|
||||
src={landmarkImageById.get(landmark.id) ?? landmark.imageSrc}
|
||||
alt={landmark.name}
|
||||
fallbackLabel={landmark.name.slice(0, 4) || '场景'}
|
||||
src={scene.imageSrc}
|
||||
alt={scene.name}
|
||||
fallbackLabel={scene.name.slice(0, 4) || '场景'}
|
||||
tone="landscape"
|
||||
/>
|
||||
)}
|
||||
}
|
||||
disabled={scene.kind === 'camp' && isBulkDeleteMode}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { type ReactNode,useMemo, useState } from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { Character, CustomWorldProfile } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CustomWorldEntityCatalog, type ResultTab } from './CustomWorldEntityCatalog';
|
||||
import { type CustomWorldEditorTarget,CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
|
||||
import {
|
||||
CustomWorldEntityCatalog,
|
||||
type ResultTab,
|
||||
} from './CustomWorldEntityCatalog';
|
||||
import {
|
||||
type CustomWorldEditorTarget,
|
||||
CustomWorldEntityEditorModal,
|
||||
} from './CustomWorldEntityEditorModal';
|
||||
|
||||
interface CustomWorldResultViewProps {
|
||||
profile: CustomWorldProfile;
|
||||
@@ -17,8 +23,13 @@ interface CustomWorldResultViewProps {
|
||||
onEditSetting?: () => 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<CustomWorldEditorTarget | null>(null);
|
||||
const [editorTarget, setEditorTarget] =
|
||||
useState<CustomWorldEditorTarget | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<ResultTab>('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}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isGenerating && (
|
||||
<div className="mt-3 rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">{progressLabel}</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{progressLabel}
|
||||
</div>
|
||||
<div className="text-xs text-sky-100">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35">
|
||||
@@ -199,28 +234,41 @@ export function CustomWorldResultView({
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{onEditSetting ? (
|
||||
<SmallButton onClick={onEditSetting}>修改设定</SmallButton>
|
||||
<SmallButton onClick={onEditSetting}>{editActionLabel}</SmallButton>
|
||||
) : null}
|
||||
{triggerRegenerate ? (
|
||||
<SmallButton onClick={onRegenerate} tone="sky">重新生成</SmallButton>
|
||||
<SmallButton onClick={onRegenerate} tone="sky">
|
||||
{regenerateActionLabel}
|
||||
</SmallButton>
|
||||
) : null}
|
||||
{profile.generationStatus === 'key_only' && onContinueExpand ? (
|
||||
<SmallButton onClick={onContinueExpand} tone="sky" disabled={isGenerating}>
|
||||
<SmallButton
|
||||
onClick={onContinueExpand}
|
||||
tone="sky"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
继续补全世界
|
||||
</SmallButton>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isGenerating}
|
||||
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">保存到我的作品</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
{onSave ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isGenerating}
|
||||
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{saveActionLabel}
|
||||
</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,12 +55,12 @@ export function CustomWorldAgentDraftDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{group.items.map((card) => {
|
||||
{group.items.map((card, index) => {
|
||||
const isActive = activeCardId === card.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={card.id}
|
||||
key={card.id || `${group.kind}-card-${index}`}
|
||||
type="button"
|
||||
onClick={() => onSelectCard(card.id)}
|
||||
className={`w-full rounded-[1.2rem] border px-3 py-3 text-left transition ${
|
||||
|
||||
@@ -7,15 +7,19 @@ function readLockedItems(lockState: Record<string, unknown> | null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(lockState)
|
||||
return [...new Set(
|
||||
Object.entries(lockState)
|
||||
.flatMap(([key, value]) =>
|
||||
Array.isArray(value)
|
||||
? value.map((item) => `${key}:${String(item)}`)
|
||||
? value
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => `${key}:${item}`)
|
||||
: typeof value === 'string' && value.trim()
|
||||
? [`${key}:${value.trim()}`]
|
||||
: [],
|
||||
)
|
||||
.slice(0, 8);
|
||||
)].slice(0, 8);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentLockBar({
|
||||
@@ -30,9 +34,9 @@ export function CustomWorldAgentLockBar({
|
||||
</div>
|
||||
{lockedItems.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{lockedItems.map((item) => (
|
||||
{lockedItems.map((item, index) => (
|
||||
<span
|
||||
key={item}
|
||||
key={`locked-item-${index}-${item}`}
|
||||
className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100"
|
||||
>
|
||||
{item}
|
||||
|
||||
@@ -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({
|
||||
快捷动作
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<QuickActionButton
|
||||
label={summaryAction?.label ?? '总结当前设定'}
|
||||
onClick={onRequestSummary}
|
||||
disabled={disabled}
|
||||
tone="sky"
|
||||
/>
|
||||
{showSummaryAction ? (
|
||||
<QuickActionButton
|
||||
label={summaryAction?.label ?? '总结当前设定'}
|
||||
onClick={onRequestSummary}
|
||||
disabled={disabled}
|
||||
tone="sky"
|
||||
/>
|
||||
) : null}
|
||||
{draftAction && canDraftFoundation ? (
|
||||
<QuickActionButton
|
||||
label={draftAction.label}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentThread } from './CustomWorldAgentThread';
|
||||
|
||||
afterEach(() => {
|
||||
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(
|
||||
<CustomWorldAgentThread
|
||||
messages={[
|
||||
{
|
||||
id: '',
|
||||
role: 'assistant',
|
||||
kind: 'summary',
|
||||
text: '先把世界骨架收出来。',
|
||||
createdAt: '2026-04-16T10:00:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '继续。',
|
||||
createdAt: '2026-04-16T10:01:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
]}
|
||||
recommendedReplies={[
|
||||
'',
|
||||
'继续补充冲突',
|
||||
'继续补充冲突',
|
||||
' 先确定玩家身份 ',
|
||||
]}
|
||||
onRecommendedReply={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -26,9 +26,16 @@ export function CustomWorldAgentThread({
|
||||
onRecommendedReply,
|
||||
}: CustomWorldAgentThreadProps) {
|
||||
const bottomRef = useRef<HTMLDivElement | null>(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({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => {
|
||||
{messages.map((message, index) => {
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
key={message.id || `message-${index}`}
|
||||
className={`flex ${
|
||||
isUser ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
@@ -70,12 +77,12 @@ export function CustomWorldAgentThread({
|
||||
{formatMessageTime(message.createdAt)}
|
||||
</div>
|
||||
{!isUser &&
|
||||
message.id === lastAssistantMessageId &&
|
||||
recommendedReplies.length > 0 ? (
|
||||
index === lastAssistantMessageIndex &&
|
||||
visibleRecommendedReplies.length > 0 ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{recommendedReplies.slice(0, 3).map((reply) => (
|
||||
{visibleRecommendedReplies.map((reply, replyIndex) => (
|
||||
<button
|
||||
key={reply}
|
||||
key={`recommended-reply-${replyIndex}-${reply}`}
|
||||
type="button"
|
||||
onClick={() => onRecommendedReply?.(reply)}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-left text-xs leading-5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white"
|
||||
|
||||
@@ -212,7 +212,11 @@ const baseSession: CustomWorldAgentSessionSnapshot = {
|
||||
targetId: null,
|
||||
},
|
||||
],
|
||||
recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定', '我还想再补充一点'],
|
||||
recommendedReplies: [
|
||||
'现在开始生成草稿',
|
||||
'先总结一下当前设定',
|
||||
'我还想再补充一点',
|
||||
],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [
|
||||
@@ -268,6 +272,10 @@ test('workspace loads detail, saves edits, opens generate actions, and reflects
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText('卡片详情')).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText('输入消息')).toBeNull();
|
||||
expect(screen.queryByText('当前底稿已经可以继续精修。')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '编辑设定' }));
|
||||
const summaryInput = screen.getByLabelText('摘要');
|
||||
await user.clear(summaryInput);
|
||||
@@ -297,7 +305,9 @@ test('workspace loads detail, saves edits, opens generate actions, and reflects
|
||||
);
|
||||
});
|
||||
|
||||
const [generateCharacterButton] = screen.getAllByRole('button', { name: '新增角色' });
|
||||
const [generateCharacterButton] = screen.getAllByRole('button', {
|
||||
name: '新增角色',
|
||||
});
|
||||
await user.click(generateCharacterButton!);
|
||||
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '生成角色' }));
|
||||
@@ -309,7 +319,9 @@ test('workspace loads detail, saves edits, opens generate actions, and reflects
|
||||
anchorCardIds: ['character-1'],
|
||||
});
|
||||
|
||||
const [generateLandmarkButton] = screen.getAllByRole('button', { name: '新增场景' });
|
||||
const [generateLandmarkButton] = screen.getAllByRole('button', {
|
||||
name: '新增场景',
|
||||
});
|
||||
await user.click(generateLandmarkButton!);
|
||||
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '生成场景' }));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
|
||||
|
||||
test('custom world agent workspace renders progress labels, action button and recommended replies', () => {
|
||||
test('custom world agent workspace renders draft workspace instead of chat after draft cards appear', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
@@ -87,10 +87,11 @@ test('custom world agent workspace renders progress labels, action button and re
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('首轮草稿会先确认这 6 项信息');
|
||||
expect(html).toContain('世界核心');
|
||||
expect(html).toContain('玩家开局');
|
||||
expect(html).toContain('现在开始生成草稿');
|
||||
expect(html).toContain('开始生成草稿');
|
||||
expect(html).toContain('欢迎。当前底稿已经可以继续精修。');
|
||||
expect(html).toContain('卡片详情');
|
||||
expect(html).toContain('快捷动作');
|
||||
expect(html).toContain('草稿抽屉');
|
||||
expect(html).not.toContain('首轮草稿会先确认这 6 项信息');
|
||||
expect(html).not.toContain('现在开始生成草稿');
|
||||
expect(html).not.toContain('欢迎。当前底稿已经可以继续精修。');
|
||||
expect(html).not.toContain('输入消息');
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import type {
|
||||
CustomWorldDraftCardDetail,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
|
||||
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
|
||||
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
|
||||
import { CustomWorldAgentComposer } from './CustomWorldAgentComposer';
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
import { CustomWorldAgentDraftDrawer } from './CustomWorldAgentDraftDrawer';
|
||||
@@ -149,7 +149,8 @@ function resolveRoleAssetTarget(
|
||||
: [],
|
||||
imageSrc: toText(role.imageSrc) || undefined,
|
||||
generatedVisualAssetId: toText(role.generatedVisualAssetId) || undefined,
|
||||
generatedAnimationSetId: toText(role.generatedAnimationSetId) || undefined,
|
||||
generatedAnimationSetId:
|
||||
toText(role.generatedAnimationSetId) || undefined,
|
||||
animationMap: toRecord(role.animationMap) ?? undefined,
|
||||
} satisfies WorkspaceRoleAssetTarget,
|
||||
roleKind: playableRole ? ('playable' as const) : ('story' as const),
|
||||
@@ -352,7 +353,8 @@ export function CustomWorldAgentWorkspace({
|
||||
}
|
||||
|
||||
const isBusy =
|
||||
activeOperation?.status === 'queued' || activeOperation?.status === 'running';
|
||||
activeOperation?.status === 'queued' ||
|
||||
activeOperation?.status === 'running';
|
||||
const canStartDraft =
|
||||
session.creatorIntentReadiness.isReady &&
|
||||
session.stage === 'foundation_review';
|
||||
@@ -360,9 +362,10 @@ export function CustomWorldAgentWorkspace({
|
||||
!session.creatorIntentReadiness.isReady &&
|
||||
session.creatorIntentReadiness.completedKeys.includes('world_hook');
|
||||
const showDraftWorkspace =
|
||||
(session.stage === 'object_refining' || session.stage === 'visual_refining') &&
|
||||
session.draftCards.length > 0;
|
||||
const selectedCard = session.draftCards.find((card) => card.id === selectedCardId) ?? null;
|
||||
session.stage !== 'foundation_review' && session.draftCards.length > 0;
|
||||
const showAgentConversation = !showDraftWorkspace;
|
||||
const selectedCard =
|
||||
session.draftCards.find((card) => card.id === selectedCardId) ?? null;
|
||||
const recommendedReplies = buildRecommendedReplies(session);
|
||||
const selectedRoleAssetContext = resolveRoleAssetTarget(
|
||||
session,
|
||||
@@ -424,27 +427,30 @@ export function CustomWorldAgentWorkspace({
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-[1500px] flex-col gap-3">
|
||||
<CustomWorldAgentHeader onBack={onBack} />
|
||||
<CustomWorldAgentReadinessBar
|
||||
completedKeys={session.creatorIntentReadiness.completedKeys}
|
||||
isReady={canStartDraft}
|
||||
busy={isBusy}
|
||||
onStartDraft={() => {
|
||||
onExecuteAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!showDraftWorkspace ? <CustomWorldAgentHeader onBack={onBack} /> : null}
|
||||
{!showDraftWorkspace ? (
|
||||
<CustomWorldAgentReadinessBar
|
||||
completedKeys={session.creatorIntentReadiness.completedKeys}
|
||||
isReady={canStartDraft}
|
||||
busy={isBusy}
|
||||
onStartDraft={() => {
|
||||
onExecuteAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<CustomWorldAgentOperationBanner operation={activeOperation} />
|
||||
|
||||
{showDraftWorkspace ? (
|
||||
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[18rem_minmax(0,1fr)_24rem]">
|
||||
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[20rem_minmax(0,1fr)]">
|
||||
<div className="flex min-h-0 flex-col gap-3 xl:overflow-hidden">
|
||||
<CustomWorldAgentQuickActions
|
||||
suggestedActions={session.suggestedActions}
|
||||
disabled={isBusy}
|
||||
canDraftFoundation={canStartDraft}
|
||||
showEntityActions
|
||||
showSummaryAction={false}
|
||||
showRoleAssetAction={selectedCard?.kind === 'character'}
|
||||
onRequestSummary={submitSummaryRequest}
|
||||
onDraftFoundation={() => {
|
||||
@@ -464,13 +470,11 @@ export function CustomWorldAgentWorkspace({
|
||||
onFocusSuggestedAction={(action) => {
|
||||
if (action?.targetId) {
|
||||
setSelectedCardId(action.targetId);
|
||||
setDetailModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.draftCards[0]) {
|
||||
setSelectedCardId(session.draftCards[0].id);
|
||||
setDetailModalOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -480,13 +484,12 @@ export function CustomWorldAgentWorkspace({
|
||||
activeCardId={selectedCardId}
|
||||
onSelectCard={(cardId) => {
|
||||
setSelectedCardId(cardId);
|
||||
setDetailModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-h-0 xl:block xl:overflow-y-auto">
|
||||
<div className="min-h-0 xl:overflow-y-auto">
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={detail}
|
||||
loading={detailLoading}
|
||||
@@ -526,38 +529,25 @@ export function CustomWorldAgentWorkspace({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<div className="h-[18rem] min-h-[18rem] xl:min-h-0 xl:flex-1">
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showAgentConversation ? (
|
||||
<>
|
||||
<CustomWorldAgentThread
|
||||
messages={session.messages}
|
||||
recommendedReplies={recommendedReplies}
|
||||
onRecommendedReply={handleRecommendedReply}
|
||||
/>
|
||||
</div>
|
||||
<CustomWorldAgentComposer
|
||||
disabled={isBusy}
|
||||
onSubmit={onSubmitMessage}
|
||||
onSummaryClick={submitSummaryRequest}
|
||||
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
|
||||
showAutoComplete={showAutoCompleteButton}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CustomWorldAgentThread
|
||||
messages={session.messages}
|
||||
recommendedReplies={recommendedReplies}
|
||||
onRecommendedReply={handleRecommendedReply}
|
||||
/>
|
||||
<CustomWorldAgentComposer
|
||||
disabled={isBusy}
|
||||
onSubmit={onSubmitMessage}
|
||||
onSummaryClick={submitSummaryRequest}
|
||||
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
|
||||
showAutoComplete={showAutoCompleteButton}
|
||||
/>
|
||||
<CustomWorldAgentComposer
|
||||
disabled={isBusy}
|
||||
onSubmit={onSubmitMessage}
|
||||
onSummaryClick={submitSummaryRequest}
|
||||
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
|
||||
showAutoComplete={showAutoCompleteButton}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -671,13 +661,13 @@ export function CustomWorldAgentWorkspace({
|
||||
}
|
||||
visualPointCost={
|
||||
selectedRoleAssetContext.assetSummary?.status === 'missing'
|
||||
? selectedRoleAssetContext.assetSummary?.nextPointCost ?? 20
|
||||
? (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 20)
|
||||
: 20
|
||||
}
|
||||
animationPointCost={
|
||||
selectedRoleAssetContext.assetSummary?.status === 'missing'
|
||||
? 60
|
||||
: selectedRoleAssetContext.assetSummary?.nextPointCost ?? 60
|
||||
: (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 60)
|
||||
}
|
||||
syncBusy={
|
||||
activeOperation?.type === 'sync_role_assets' &&
|
||||
|
||||
@@ -70,7 +70,9 @@ function WorldCard({
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const tags = buildPlatformWorldTags(entry);
|
||||
const tags = [
|
||||
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
|
||||
].slice(0, 3);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -120,9 +122,9 @@ function WorldCard({
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag) => (
|
||||
tags.map((tag, index) => (
|
||||
<span
|
||||
key={tag}
|
||||
key={`world-tag-${index}-${tag || 'empty'}`}
|
||||
className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100"
|
||||
>
|
||||
{tag}
|
||||
|
||||
@@ -66,7 +66,9 @@ export function PlatformWorldDetailView({
|
||||
3,
|
||||
);
|
||||
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
|
||||
const tags = buildPlatformWorldTags(entry);
|
||||
const tags = [
|
||||
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
|
||||
].slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -133,9 +135,9 @@ export function PlatformWorldDetailView({
|
||||
{entry.summaryText || '等待补充世界摘要。'}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={tag}
|
||||
key={`world-detail-tag-${index}-${tag || 'empty'}`}
|
||||
className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100"
|
||||
>
|
||||
{tag}
|
||||
@@ -189,9 +191,9 @@ export function PlatformWorldDetailView({
|
||||
关键角色
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{previewCharacters.map((character) => (
|
||||
{previewCharacters.map((character, index) => (
|
||||
<div
|
||||
key={character.id}
|
||||
key={character.id || `preview-character-${index}`}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
@@ -210,9 +212,9 @@ export function PlatformWorldDetailView({
|
||||
关键场景
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{previewLandmarks.map((landmark) => (
|
||||
{previewLandmarks.map((landmark, index) => (
|
||||
<div
|
||||
key={landmark.id}
|
||||
key={landmark.id || `preview-landmark-${index}`}
|
||||
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
|
||||
@@ -8,11 +8,14 @@ import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
createCustomWorldAgentSession,
|
||||
executeCustomWorldAgentAction,
|
||||
getCustomWorldAgentOperation,
|
||||
getCustomWorldAgentSession,
|
||||
} from '../../services/aiService';
|
||||
import {
|
||||
listCustomWorldGallery,
|
||||
listCustomWorldLibrary,
|
||||
upsertCustomWorldProfile,
|
||||
} from '../../services/storageService';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
@@ -41,11 +44,23 @@ vi.mock('../../services/storageService', () => ({
|
||||
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
|
||||
CustomWorldAgentWorkspace: ({
|
||||
session,
|
||||
onExecuteAction,
|
||||
}: {
|
||||
session: CustomWorldAgentSessionSnapshot | null;
|
||||
onExecuteAction: (payload: { action: string }) => void;
|
||||
}) => (
|
||||
<div className="agent-workspace-mock">
|
||||
Agent工作区:{session?.sessionId ?? 'missing-session'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onExecuteAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
}}
|
||||
>
|
||||
开始生成草稿
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -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(<TestWrapper />);
|
||||
|
||||
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(<TestWrapper />);
|
||||
|
||||
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();
|
||||
|
||||
});
|
||||
|
||||
@@ -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<CustomWorldGenerationProgress | null>(null);
|
||||
const [customWorldAutoSaveState, setCustomWorldAutoSaveState] =
|
||||
useState<CustomWorldAutoSaveState>('idle');
|
||||
const [customWorldAutoSaveError, setCustomWorldAutoSaveError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] =
|
||||
useState<CustomWorldGenerationViewSource>(null);
|
||||
const [customWorldResultViewSource, setCustomWorldResultViewSource] =
|
||||
useState<CustomWorldResultViewSource>(null);
|
||||
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
|
||||
useState<number | null>(null);
|
||||
const customWorldAbortControllerRef = useRef<AbortController | null>(null);
|
||||
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
|
||||
const lastAutoSavedProfileSignatureRef = useRef<string | null>(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<CustomWorldProfile>,
|
||||
@@ -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={<LazyPanelFallback label="正在加载世界生成面板..." />}
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
settingText={customWorldSettingPreview}
|
||||
progress={customWorldProgress}
|
||||
isGenerating={isGeneratingCustomWorld}
|
||||
error={customWorldError}
|
||||
onBack={leaveCustomWorldGeneration}
|
||||
onEditSetting={editCustomWorldSetting}
|
||||
onRetry={() => {
|
||||
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
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -1186,22 +1509,52 @@ export function PreGameSelectionFlow({
|
||||
<CustomWorldResultView
|
||||
profile={generatedCustomWorldProfile}
|
||||
previewCharacters={previewCustomWorldCharacters}
|
||||
isGenerating={isGeneratingCustomWorld}
|
||||
progress={customWorldProgress?.overallProgress ?? 0}
|
||||
progressLabel={customWorldProgress?.phaseLabel ?? ''}
|
||||
error={customWorldError}
|
||||
isGenerating={
|
||||
isAgentDraftResultView ? false : isGeneratingCustomWorld
|
||||
}
|
||||
progress={
|
||||
isAgentDraftResultView
|
||||
? 0
|
||||
: (customWorldProgress?.overallProgress ?? 0)
|
||||
}
|
||||
progressLabel={
|
||||
isAgentDraftResultView
|
||||
? ''
|
||||
: (customWorldProgress?.phaseLabel ?? '')
|
||||
}
|
||||
error={resultViewError}
|
||||
onProfileChange={setGeneratedCustomWorldProfile}
|
||||
onBack={leaveCustomWorldResult}
|
||||
onEditSetting={editCustomWorldSetting}
|
||||
onRegenerate={() => {
|
||||
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}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ItemRarity>(['common', 'uncommon', 'rare', 'epic', 'legendary']);
|
||||
const ITEM_RARITIES = new Set<ItemRarity>([
|
||||
'common',
|
||||
'uncommon',
|
||||
'rare',
|
||||
'epic',
|
||||
'legendary',
|
||||
]);
|
||||
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
|
||||
const ANIMATION_STATES = new Set<AnimationState>(Object.values(AnimationState));
|
||||
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>(['human', 'elf', 'orc', 'goblin']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set<CustomWorldNpcVisualGearType>(['cloth', 'leather', 'metal', 'melee', 'magic', 'ranged']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>([
|
||||
'human',
|
||||
'elf',
|
||||
'orc',
|
||||
'goblin',
|
||||
]);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES =
|
||||
new Set<CustomWorldNpcVisualGearType>([
|
||||
'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<T>(value: unknown): T | null {
|
||||
return isRecord(value) ? (value as T) : null;
|
||||
}
|
||||
|
||||
function preserveStructuredRecordArray<T>(value: unknown): T[] | null {
|
||||
return Array.isArray(value)
|
||||
? (value.filter((entry): entry is Record<string, unknown> => 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<AnimationState, CharacterAnimationConfig>
|
||||
>
|
||||
>)
|
||||
: 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<RoleAttributeProfile>(value.attributeProfile) ??
|
||||
undefined,
|
||||
narrativeProfile:
|
||||
preserveStructuredRecord<CustomWorldPlayableNpc['narrativeProfile']>(
|
||||
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<RoleAttributeProfile>(value.attributeProfile) ??
|
||||
undefined,
|
||||
narrativeProfile:
|
||||
preserveStructuredRecord<CustomWorldNpc['narrativeProfile']>(
|
||||
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<ItemAttributeResonance>(
|
||||
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<SceneNarrativeResidue>(
|
||||
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<ThemePack>(value.themePack),
|
||||
storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph),
|
||||
knowledgeFacts:
|
||||
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
|
||||
threadContracts:
|
||||
preserveStructuredRecordArray<ThreadContract>(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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, StoryOption[]>();
|
||||
const consumedOptions = new Set<StoryOption>();
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
385
src/hooks/useGameFlow.customWorld.test.tsx
Normal file
385
src/hooks/useGameFlow.customWorld.test.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCustomWorldSelect(profile)}
|
||||
>
|
||||
选择世界
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (selectedCharacter) {
|
||||
handleCharacterSelect(selectedCharacter);
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认角色
|
||||
</button>
|
||||
<pre data-testid="state-snapshot">{JSON.stringify(snapshot)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(<GameFlowHarness />);
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -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<T extends { category: string; name: string }>(
|
||||
explicitItems: T[],
|
||||
fallbackItems: T[],
|
||||
) {
|
||||
const merged = new Map<string, T>();
|
||||
|
||||
[...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),
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
607
src/services/customWorldAgentDraftResult.test.ts
Normal file
607
src/services/customWorldAgentDraftResult.test.ts
Normal file
@@ -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(
|
||||
'守灯会值夜人,对外总像比别人更冷静一步。',
|
||||
);
|
||||
});
|
||||
242
src/services/customWorldAgentDraftResult.ts
Normal file
242
src/services/customWorldAgentDraftResult.ts
Normal file
@@ -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<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is Record<string, unknown> => 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<string, unknown>) {
|
||||
return (
|
||||
toText(record.summary) ||
|
||||
toText(record.publicIdentity) ||
|
||||
toText(record.publicMask) ||
|
||||
toText(record.currentPressure) ||
|
||||
toText(record.relationToPlayer)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCharacterBackstoryText(record: Record<string, unknown>) {
|
||||
return [
|
||||
toText(record.publicIdentity),
|
||||
toText(record.currentPressure),
|
||||
toText(record.hiddenHook) ? `暗线 ${toText(record.hiddenHook)}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function buildRelationshipHooks(record: Record<string, unknown>) {
|
||||
return [
|
||||
toText(record.relationToPlayer),
|
||||
toText(record.currentPressure),
|
||||
toText(record.hiddenHook) ? `暗线 ${toText(record.hiddenHook)}` : '',
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function buildCharacterTags(
|
||||
record: Record<string, unknown>,
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string>) {
|
||||
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;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
42
view-llm-logs.ps1
Normal file
42
view-llm-logs.ps1
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user