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 >(); const profilesByUser = new Map[]>(); const getSessionBucket = (userId: string) => { const existing = sessionsByUser.get(userId); if (existing) { return existing; } const nextBucket = new Map(); 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 | 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)?.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); });