Files
Genarrative/server-node/src/services/customWorldAgentPhase3.test.ts
2026-04-21 19:18:26 +08:00

445 lines
14 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 { AppConfig } from '../config.js';
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
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);
assert.equal(readySession?.resultPreview, null);
assert.equal(
readySession?.supportedActions?.find(
(entry) => entry.action === 'draft_foundation',
)?.enabled,
true,
);
assert.equal(
readySession?.supportedActions?.find(
(entry) => entry.action === 'sync_result_profile',
)?.enabled,
false,
);
return readySession!;
}
test('phase3 ready session can execute draft_foundation and expose card detail', async () => {
const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
const sessionStore = new CustomWorldAgentSessionStore(
rpgAgentSessionRepository,
);
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.equal(snapshot?.resultPreview?.source, 'session_preview');
assert.equal(
snapshot?.resultPreview?.preview.name,
typeof (snapshot?.draftProfile as Record<string, unknown>)?.name === 'string'
? ((snapshot?.draftProfile as Record<string, unknown>).name as string)
: '未命名世界底稿',
);
assert.ok(Array.isArray(snapshot?.resultPreview?.blockers));
assert.ok((snapshot?.resultPreview?.blockers?.length ?? 0) >= 0);
assert.equal(snapshot?.resultPreview?.publishReady, false);
assert.equal(snapshot?.resultPreview?.canEnterWorld, false);
assert.equal(
snapshot?.resultPreview?.qualityFindings?.length,
snapshot?.qualityFindings.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('第一版世界底稿整理出来了'),
),
);
assert.equal(
snapshot?.supportedActions?.find(
(entry) => entry.action === 'update_draft_card',
)?.enabled,
true,
);
assert.equal(
snapshot?.supportedActions?.find(
(entry) => entry.action === 'generate_role_assets',
)?.enabled,
true,
);
assert.equal(
snapshot?.supportedActions?.find(
(entry) => entry.action === 'publish_world',
)?.enabled,
true,
);
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 { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
const sessionStore = new CustomWorldAgentSessionStore(
rpgAgentSessionRepository,
);
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 { rpgAgentSessionRepository, rpgWorldProfileRepository } =
createInMemoryRpgWorldRepositoryPorts();
const sessionStore = new CustomWorldAgentSessionStore(
rpgAgentSessionRepository,
);
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 new RpgWorldWorkSummaryService(
rpgWorldProfileRepository,
sessionStore,
).list(userId);
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 { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts();
const sessionStore = new CustomWorldAgentSessionStore(
rpgAgentSessionRepository,
);
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);
});