471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
|
import type { AppConfig } from '../config.js';
|
|
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
|
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
|
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
|
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
|
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
|
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
|
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
|
|
|
|
function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
|
const sessionsByUser = new Map<
|
|
string,
|
|
Map<string, CustomWorldSessionRecord>
|
|
>();
|
|
const profilesByUser = new Map<string, Record<string, unknown>[]>();
|
|
|
|
const getSessionBucket = (userId: string) => {
|
|
const existing = sessionsByUser.get(userId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const nextBucket = new Map<string, CustomWorldSessionRecord>();
|
|
sessionsByUser.set(userId, nextBucket);
|
|
return nextBucket;
|
|
};
|
|
|
|
return {
|
|
async getSnapshot(_userId) {
|
|
return null;
|
|
},
|
|
async putSnapshot(_userId, _payload) {
|
|
throw new Error('not implemented');
|
|
},
|
|
async deleteSnapshot(_userId) {
|
|
return undefined;
|
|
},
|
|
async getSettings() {
|
|
return {
|
|
musicVolume: 0.42,
|
|
platformTheme: 'light',
|
|
};
|
|
},
|
|
async putSettings(_userId, settings) {
|
|
return settings;
|
|
},
|
|
async listCustomWorldProfiles(userId) {
|
|
return [...(profilesByUser.get(userId) ?? [])];
|
|
},
|
|
async upsertCustomWorldProfile(userId, profileId, profile) {
|
|
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
|
(item) => String(item.id ?? '') !== profileId,
|
|
);
|
|
current.unshift({
|
|
...profile,
|
|
id: profileId,
|
|
});
|
|
profilesByUser.set(userId, current);
|
|
return current;
|
|
},
|
|
async deleteCustomWorldProfile(userId, profileId) {
|
|
const current = [...(profilesByUser.get(userId) ?? [])].filter(
|
|
(item) => String(item.id ?? '') !== profileId,
|
|
);
|
|
profilesByUser.set(userId, current);
|
|
return current;
|
|
},
|
|
async listProfileSaveArchives() {
|
|
return [];
|
|
},
|
|
async resumeProfileSaveArchive() {
|
|
return null;
|
|
},
|
|
async listCustomWorldSessions(userId) {
|
|
return [...getSessionBucket(userId).values()];
|
|
},
|
|
async getCustomWorldSession(userId, sessionId) {
|
|
return getSessionBucket(userId).get(sessionId) ?? null;
|
|
},
|
|
async upsertCustomWorldSession(userId, sessionId, session) {
|
|
getSessionBucket(userId).set(
|
|
sessionId,
|
|
JSON.parse(JSON.stringify(session)),
|
|
);
|
|
return JSON.parse(JSON.stringify(session));
|
|
},
|
|
};
|
|
}
|
|
|
|
function createAutoAssetTestConfig(testName: string): AppConfig {
|
|
const projectRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), `genarrative-agent-phase3-${testName}-`),
|
|
);
|
|
|
|
return {
|
|
nodeEnv: 'test',
|
|
projectRoot,
|
|
publicDir: path.join(projectRoot, 'public'),
|
|
logsDir: path.join(projectRoot, 'logs'),
|
|
dataDir: path.join(projectRoot, 'data'),
|
|
rawEnv: {},
|
|
databaseUrl: `pg-mem://${testName}`,
|
|
serverAddr: ':0',
|
|
logLevel: 'silent',
|
|
editorApiEnabled: true,
|
|
assetsApiEnabled: true,
|
|
jwtSecret: 'test',
|
|
jwtExpiresIn: '7d',
|
|
jwtIssuer: 'test',
|
|
llm: {
|
|
baseUrl: 'https://example.invalid',
|
|
apiKey: '',
|
|
model: 'test-model',
|
|
},
|
|
dashScope: {
|
|
baseUrl: 'https://example.invalid',
|
|
apiKey: '',
|
|
imageModel: 'test-image-model',
|
|
requestTimeoutMs: 1000,
|
|
},
|
|
smsAuth: {
|
|
enabled: false,
|
|
provider: 'mock',
|
|
endpoint: '',
|
|
accessKeyId: '',
|
|
accessKeySecret: '',
|
|
signName: '',
|
|
templateCode: '',
|
|
templateParamKey: '',
|
|
countryCode: '86',
|
|
schemeName: '',
|
|
codeLength: 6,
|
|
codeType: 1,
|
|
validTimeSeconds: 300,
|
|
intervalSeconds: 60,
|
|
duplicatePolicy: 1,
|
|
caseAuthPolicy: 1,
|
|
returnVerifyCode: false,
|
|
mockVerifyCode: '123456',
|
|
maxSendPerPhonePerDay: 20,
|
|
maxSendPerIpPerHour: 30,
|
|
maxVerifyFailuresPerPhonePerHour: 12,
|
|
maxVerifyFailuresPerIpPerHour: 24,
|
|
captchaTtlSeconds: 180,
|
|
captchaTriggerVerifyFailuresPerPhone: 3,
|
|
captchaTriggerVerifyFailuresPerIp: 5,
|
|
blockPhoneFailureThreshold: 6,
|
|
blockIpFailureThreshold: 10,
|
|
blockPhoneDurationMinutes: 30,
|
|
blockIpDurationMinutes: 30,
|
|
},
|
|
wechatAuth: {
|
|
enabled: false,
|
|
provider: 'mock',
|
|
appId: '',
|
|
appSecret: '',
|
|
authorizeEndpoint: '',
|
|
accessTokenEndpoint: '',
|
|
userInfoEndpoint: '',
|
|
callbackPath: '',
|
|
defaultRedirectPath: '/',
|
|
mockUserId: '',
|
|
mockUnionId: '',
|
|
mockDisplayName: '',
|
|
mockAvatarUrl: '',
|
|
},
|
|
authSession: {
|
|
accessCookieName: 'genarrative_access_session',
|
|
accessCookieTtlSeconds: 7200,
|
|
accessCookieSecure: false,
|
|
accessCookieSameSite: 'Lax',
|
|
accessCookiePath: '/',
|
|
refreshCookieName: 'refresh_token',
|
|
refreshSessionTtlDays: 30,
|
|
refreshCookieSecure: false,
|
|
refreshCookieSameSite: 'Lax',
|
|
refreshCookiePath: '/',
|
|
},
|
|
};
|
|
}
|
|
|
|
function createFallbackAutoAssetService(testName: string) {
|
|
const config = createAutoAssetTestConfig(testName);
|
|
return new CustomWorldAgentAutoAssetService(
|
|
config,
|
|
CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config),
|
|
CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config),
|
|
);
|
|
}
|
|
|
|
async function waitForOperation(
|
|
orchestrator: CustomWorldAgentOrchestrator,
|
|
userId: string,
|
|
sessionId: string,
|
|
operationId: string,
|
|
) {
|
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
const operation = await orchestrator.getOperation(
|
|
userId,
|
|
sessionId,
|
|
operationId,
|
|
);
|
|
|
|
if (operation?.status === 'completed' || operation?.status === 'failed') {
|
|
return operation;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}
|
|
|
|
throw new Error('operation did not finish in time');
|
|
}
|
|
|
|
async function createReadySession(
|
|
orchestrator: CustomWorldAgentOrchestrator,
|
|
userId: string,
|
|
) {
|
|
const createdSession = await orchestrator.createSession(userId, {
|
|
seedText: '一个被潮雾切开的列岛世界。',
|
|
});
|
|
|
|
const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
|
|
clientMessageId: 'phase3-ready-1',
|
|
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
|
|
focusCardId: null,
|
|
selectedCardIds: [],
|
|
});
|
|
await waitForOperation(
|
|
orchestrator,
|
|
userId,
|
|
createdSession.sessionId,
|
|
message1.operation.operationId,
|
|
);
|
|
|
|
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, {
|
|
clientMessageId: 'phase3-ready-2',
|
|
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
|
|
focusCardId: null,
|
|
selectedCardIds: [],
|
|
});
|
|
await waitForOperation(
|
|
orchestrator,
|
|
userId,
|
|
createdSession.sessionId,
|
|
message2.operation.operationId,
|
|
);
|
|
|
|
const readySession = await orchestrator.getSessionSnapshot(
|
|
userId,
|
|
createdSession.sessionId,
|
|
);
|
|
|
|
assert.equal(readySession?.stage, 'foundation_review');
|
|
assert.equal(readySession?.creatorIntentReadiness.isReady, true);
|
|
|
|
return readySession!;
|
|
}
|
|
|
|
test('phase3 ready session can execute draft_foundation and expose card detail', async () => {
|
|
const runtimeRepository = createRuntimeRepositoryStub();
|
|
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
|
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
|
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
|
autoAssetService: createFallbackAutoAssetService('draft'),
|
|
});
|
|
const userId = 'user-phase3-draft';
|
|
const readySession = await createReadySession(orchestrator, userId);
|
|
|
|
const response = await orchestrator.executeAction(
|
|
userId,
|
|
readySession.sessionId,
|
|
{
|
|
action: 'draft_foundation',
|
|
},
|
|
);
|
|
const operation = await waitForOperation(
|
|
orchestrator,
|
|
userId,
|
|
readySession.sessionId,
|
|
response.operation.operationId,
|
|
);
|
|
const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId);
|
|
const draftProfile = snapshot?.draftProfile as Record<string, unknown> | undefined;
|
|
const playableNpcs = Array.isArray(draftProfile?.playableNpcs)
|
|
? draftProfile?.playableNpcs
|
|
: [];
|
|
const storyNpcs = Array.isArray(draftProfile?.storyNpcs)
|
|
? draftProfile?.storyNpcs
|
|
: [];
|
|
const sceneChapters = Array.isArray(draftProfile?.sceneChapters)
|
|
? draftProfile?.sceneChapters
|
|
: [];
|
|
|
|
assert.equal(operation?.status, 'completed');
|
|
assert.equal(snapshot?.stage, 'object_refining');
|
|
assert.ok(snapshot?.draftCards.length);
|
|
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'world'));
|
|
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'faction'));
|
|
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'character'));
|
|
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'landmark'));
|
|
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'thread'));
|
|
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'chapter'));
|
|
assert.equal(playableNpcs.length, 1);
|
|
assert.ok(storyNpcs.length >= 4);
|
|
assert.equal(sceneChapters.length, 2);
|
|
assert.ok(
|
|
sceneChapters.every(
|
|
(entry) => Array.isArray((entry as { acts?: unknown[] }).acts) && ((entry as { acts?: unknown[] }).acts?.length ?? 0) === 3,
|
|
),
|
|
);
|
|
assert.ok(
|
|
playableNpcs.every(
|
|
(entry) =>
|
|
typeof (entry as { imageSrc?: unknown }).imageSrc === 'string' &&
|
|
typeof (entry as { generatedVisualAssetId?: unknown }).generatedVisualAssetId === 'string',
|
|
),
|
|
);
|
|
assert.ok((snapshot?.assetCoverage.sceneAssets.length ?? 0) >= 6);
|
|
assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true);
|
|
assert.equal(
|
|
typeof (snapshot?.draftProfile as Record<string, unknown>)?.name,
|
|
'string',
|
|
);
|
|
assert.ok(
|
|
snapshot?.messages.some(
|
|
(message) =>
|
|
message.role === 'assistant' &&
|
|
message.text.includes('第一版世界底稿整理出来了'),
|
|
),
|
|
);
|
|
|
|
const worldCard = snapshot?.draftCards.find((card) => card.kind === 'world');
|
|
assert.ok(worldCard);
|
|
|
|
const detail = await orchestrator.getCardDetail(
|
|
userId,
|
|
readySession.sessionId,
|
|
worldCard!.id,
|
|
);
|
|
|
|
assert.ok(detail);
|
|
assert.equal(detail?.kind, 'world');
|
|
assert.ok(detail?.sections.length);
|
|
assert.ok(detail?.sections.some((section) => section.label === '世界一句话'));
|
|
});
|
|
|
|
test('phase3 draft_foundation rejects not-ready session', async () => {
|
|
const runtimeRepository = createRuntimeRepositoryStub();
|
|
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
|
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
|
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
|
autoAssetService: createFallbackAutoAssetService('not-ready'),
|
|
});
|
|
const userId = 'user-phase3-not-ready';
|
|
const createdSession = await orchestrator.createSession(userId, {
|
|
seedText: '一个被潮雾切开的列岛世界。',
|
|
});
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
orchestrator.executeAction(userId, createdSession.sessionId, {
|
|
action: 'draft_foundation',
|
|
}),
|
|
/progressPercent >= 100|draft_foundation/u,
|
|
);
|
|
});
|
|
|
|
test('phase3 work summaries prefer compiled foundation draft fields', async () => {
|
|
const runtimeRepository = createRuntimeRepositoryStub();
|
|
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
|
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
|
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
|
autoAssetService: createFallbackAutoAssetService('summary'),
|
|
});
|
|
const userId = 'user-phase3-summary';
|
|
const readySession = await createReadySession(orchestrator, userId);
|
|
|
|
const response = await orchestrator.executeAction(
|
|
userId,
|
|
readySession.sessionId,
|
|
{
|
|
action: 'draft_foundation',
|
|
},
|
|
);
|
|
await waitForOperation(
|
|
orchestrator,
|
|
userId,
|
|
readySession.sessionId,
|
|
response.operation.operationId,
|
|
);
|
|
|
|
const items = await listCustomWorldWorkSummaries(userId, {
|
|
runtimeRepository,
|
|
customWorldAgentSessions: sessionStore,
|
|
});
|
|
const draft = items.find((item) => item.sessionId === readySession.sessionId);
|
|
const compiledProfile = normalizeFoundationDraftProfile(
|
|
(
|
|
await orchestrator.getSessionSnapshot(userId, readySession.sessionId)
|
|
)?.draftProfile,
|
|
);
|
|
const totalRoleCount = [
|
|
...new Set(
|
|
[
|
|
...(compiledProfile?.playableNpcs ?? []),
|
|
...(compiledProfile?.storyNpcs ?? []),
|
|
].map((entry) => entry.id),
|
|
),
|
|
].length;
|
|
|
|
assert.ok(draft);
|
|
assert.equal(draft?.playableNpcCount ?? 0, totalRoleCount);
|
|
assert.equal(draft?.landmarkCount ?? 0, 2);
|
|
assert.match(draft?.summary ?? '', /潮雾|守灯|航道/u);
|
|
assert.match(draft?.subtitle ?? '', /守灯|冲突|列岛/u);
|
|
});
|
|
|
|
test('phase3 draft foundation still completes when auto asset generation fails', async () => {
|
|
const runtimeRepository = createRuntimeRepositoryStub();
|
|
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
|
const autoAssetService = new CustomWorldAgentAutoAssetService(
|
|
createAutoAssetTestConfig('asset-failure'),
|
|
async () => {
|
|
throw new Error('visual service timeout');
|
|
},
|
|
async () => {
|
|
throw new Error('scene service timeout');
|
|
},
|
|
);
|
|
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
|
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
|
autoAssetService,
|
|
});
|
|
const userId = 'user-phase3-asset-failure';
|
|
const readySession = await createReadySession(orchestrator, userId);
|
|
|
|
const response = await orchestrator.executeAction(
|
|
userId,
|
|
readySession.sessionId,
|
|
{
|
|
action: 'draft_foundation',
|
|
},
|
|
);
|
|
const operation = await waitForOperation(
|
|
orchestrator,
|
|
userId,
|
|
readySession.sessionId,
|
|
response.operation.operationId,
|
|
);
|
|
const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId);
|
|
|
|
assert.equal(operation?.status, 'completed');
|
|
assert.doesNotMatch(operation?.phaseDetail ?? '', /资产补齐待后续处理/u);
|
|
assert.ok(snapshot?.draftCards.length);
|
|
assert.ok(
|
|
snapshot?.messages.every(
|
|
(message) =>
|
|
message.role !== 'assistant' || !message.text.includes('资产补齐未完成'),
|
|
),
|
|
);
|
|
assert.equal(snapshot?.assetCoverage.allRoleAssetsReady, true);
|
|
assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true);
|
|
});
|