import assert from 'node:assert/strict'; import test from 'node:test'; import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.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() { return null; }, async putSnapshot(_userId, payload) { return payload; }, async deleteSnapshot() { 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 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)); }, }; } async function waitForOperation( orchestrator: CustomWorldAgentOrchestrator, userId: string, sessionId: string, operationId: string, ) { for (let attempt = 0; attempt < 60; 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 createObjectRefiningSession( orchestrator: CustomWorldAgentOrchestrator, userId: string, ) { const createdSession = await orchestrator.createSession(userId, { seedText: '一个被潮雾切开的列岛世界。', }); const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, { clientMessageId: 'phase5-ready-1', text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', focusCardId: null, selectedCardIds: [], }); await waitForOperation( orchestrator, userId, createdSession.sessionId, message1.operation.operationId, ); const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, { clientMessageId: 'phase5-ready-2', text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', focusCardId: null, selectedCardIds: [], }); await waitForOperation( orchestrator, userId, createdSession.sessionId, message2.operation.operationId, ); const foundationOperation = await orchestrator.executeAction( userId, createdSession.sessionId, { action: 'draft_foundation', }, ); await waitForOperation( orchestrator, userId, createdSession.sessionId, foundationOperation.operation.operationId, ); return (await orchestrator.getSessionSnapshot( userId, createdSession.sessionId, ))!; } test('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => { const runtimeRepository = createRuntimeRepositoryStub(); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); const userId = 'user-phase5-generate-role-assets'; const session = await createObjectRefiningSession(orchestrator, userId); const characterIds = session.draftCards .filter((card) => card.kind === 'character') .map((card) => card.id); await assert.rejects( orchestrator.executeAction(userId, session.sessionId, { action: 'generate_role_assets', roleIds: characterIds.slice(0, 2), }), ); const response = await orchestrator.executeAction(userId, session.sessionId, { action: 'generate_role_assets', roleIds: [characterIds[0]!], }); const operation = await waitForOperation( orchestrator, userId, session.sessionId, response.operation.operationId, ); const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); assert.equal(operation?.status, 'completed'); assert.equal(snapshot?.stage, 'visual_refining'); assert.equal(snapshot?.focusCardId, characterIds[0]); assert.ok( snapshot?.messages.some( (message) => message.kind === 'action_result' && message.text.includes('角色资产工坊'), ), ); }); test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => { const runtimeRepository = createRuntimeRepositoryStub(); const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); const userId = 'user-phase5-sync-role-assets'; const session = await createObjectRefiningSession(orchestrator, userId); const characterCard = session.draftCards.find((card) => card.kind === 'character'); assert.ok(characterCard); const prepareResponse = await orchestrator.executeAction( userId, session.sessionId, { action: 'generate_role_assets', roleIds: [characterCard!.id], }, ); await waitForOperation( orchestrator, userId, session.sessionId, prepareResponse.operation.operationId, ); const response = await orchestrator.executeAction(userId, session.sessionId, { action: 'sync_role_assets', roleId: characterCard!.id, portraitPath: '/generated/characters/shenli-portrait.png', generatedVisualAssetId: 'visual-shenli-1', generatedAnimationSetId: 'animation-set-shenli-1', animationMap: { idle: { basePath: '/generated/characters/shenli/idle' }, run: { basePath: '/generated/characters/shenli/run' }, attack: { basePath: '/generated/characters/shenli/attack' }, hurt: { basePath: '/generated/characters/shenli/hurt' }, die: { basePath: '/generated/characters/shenli/die' }, }, }); const operation = await waitForOperation( orchestrator, userId, session.sessionId, response.operation.operationId, ); const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); const syncedRole = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find( (entry) => entry.id === characterCard!.id, ); const syncedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id); const syncedAssetSummary = snapshot?.assetCoverage.roleAssets.find( (entry) => entry.roleId === characterCard!.id, ); const latestRecord = await sessionStore.get(userId, session.sessionId); assert.equal(operation?.status, 'completed'); assert.equal(syncedRole?.imageSrc, '/generated/characters/shenli-portrait.png'); assert.equal(syncedRole?.generatedVisualAssetId, 'visual-shenli-1'); assert.equal(syncedRole?.generatedAnimationSetId, 'animation-set-shenli-1'); assert.equal( (syncedRole?.animationMap as Record | null)?.idle ?.basePath, '/generated/characters/shenli/idle', ); assert.equal(syncedAssetSummary?.status, 'complete'); assert.equal(syncedCard?.assetStatusLabel, '动作已就绪'); assert.ok(syncedCard?.subtitle.includes('动作已就绪')); assert.ok( snapshot?.messages.some( (message) => message.kind === 'action_result' && message.text.includes('动作已就绪'), ), ); assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2); });