import assert from 'node:assert/strict'; import test from 'node:test'; 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'; 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: 'phase4-ready-1', text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', focusCardId: null, selectedCardIds: [], }); await waitForOperation( orchestrator, userId, createdSession.sessionId, message1.operation.operationId, ); const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, { clientMessageId: 'phase4-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('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => { const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); const sessionStore = new CustomWorldAgentSessionStore( rpgAgentSessionRepository, ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); const userId = 'user-phase4-edit'; const session = await createObjectRefiningSession(orchestrator, userId); const characterCard = session.draftCards.find((card) => card.kind === 'character'); assert.ok(characterCard); const response = await orchestrator.executeAction(userId, session.sessionId, { action: 'update_draft_card', cardId: characterCard!.id, sections: [ { sectionId: 'publicMask', value: '表面上仍是守灯会里最懂旧航道的人。', }, { sectionId: 'relationToPlayer', value: '和玩家共享一段无法轻易翻篇的旧灯塔往事。', }, { sectionId: 'summary', value: '他像旧友,也像最早知道航道秘密的人。', }, ], }); 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 editedCharacter = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find( (entry) => entry.id === characterCard!.id, ); const editedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id); assert.equal(operation?.status, 'completed'); assert.equal( editedCharacter?.publicMask, '表面上仍是守灯会里最懂旧航道的人。', ); assert.equal( editedCharacter?.relationToPlayer, '和玩家共享一段无法轻易翻篇的旧灯塔往事。', ); assert.equal(editedCard?.summary, '他像旧友,也像最早知道航道秘密的人。'); assert.ok( snapshot?.messages.some( (message) => message.kind === 'action_result' && message.text.includes('已更新'), ), ); }); test('phase4 sync_result_profile writes result-page snapshot back into session draft chain', async () => { const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); const sessionStore = new CustomWorldAgentSessionStore( rpgAgentSessionRepository, ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); const userId = 'user-phase4-sync-result-profile'; const session = await createObjectRefiningSession(orchestrator, userId); const response = await orchestrator.executeAction(userId, session.sessionId, { action: 'sync_result_profile', profile: { id: `agent-draft-${session.sessionId}`, settingText: '被海雾吞没的旧航路群岛', name: '潮雾列岛·结果页精修版', subtitle: '旧灯塔与失控航路', summary: '结果页已经把世界概述继续往沉船夜暗线收紧。', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船夜与假航灯的真正操盘者。', templateWorldType: 'WUXIA', majorFactions: ['守灯会', '航运公会'], coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], attributeSchema: { id: 'schema:test', worldId: 'CUSTOM', schemaVersion: 1, schemaName: '测试', generatedFrom: { worldType: 'CUSTOM', worldName: '潮雾列岛·结果页精修版', settingSummary: '测试', tone: '测试', conflictCore: '测试', }, slots: [], }, playableNpcs: [], storyNpcs: [], items: [], landmarks: [], generationMode: 'full', generationStatus: 'complete', }, }); 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 draftRecord = snapshot?.draftProfile as Record | null; const legacyResultProfile = draftRecord?.legacyResultProfile as | Record | undefined; assert.equal(operation?.status, 'completed'); assert.equal(profile?.name, '潮雾列岛·结果页精修版'); assert.equal( profile?.summary, '结果页已经把世界概述继续往沉船夜暗线收紧。', ); assert.equal(snapshot?.resultPreview?.source, 'session_preview'); assert.equal( snapshot?.resultPreview?.preview.name, '潮雾列岛·结果页精修版', ); assert.equal( snapshot?.resultPreview?.preview.playerGoal, '查清沉船夜与假航灯的真正操盘者。', ); assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版'); assert.equal( legacyResultProfile?.playerGoal, '查清沉船夜与假航灯的真正操盘者。', ); assert.ok( snapshot?.messages.some( (message) => message.kind === 'action_result' && message.text.includes('结果页里的最新世界结构已经同步回当前草稿'), ), ); }); test('phase4 sync_result_profile keeps existing foundation structure while updating summary snapshot', async () => { const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); const sessionStore = new CustomWorldAgentSessionStore( rpgAgentSessionRepository, ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); const userId = 'user-phase4-sync-result-profile-structure'; const session = await createObjectRefiningSession(orchestrator, userId); const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile); const baselinePlayableName = baselineProfile?.playableNpcs[0]?.name; const baselineStoryName = baselineProfile?.storyNpcs[0]?.name; const baselineLandmarkName = baselineProfile?.landmarks[0]?.name; assert.ok(baselinePlayableName); assert.ok(baselineStoryName); assert.ok(baselineLandmarkName); const response = await orchestrator.executeAction(userId, session.sessionId, { action: 'sync_result_profile', profile: { id: `agent-draft-${session.sessionId}`, settingText: '被海雾吞没的旧航路群岛', name: '潮雾列岛·结果页精修版', subtitle: '旧灯塔与失控航路', summary: '结果页已经把世界概述继续往沉船夜暗线收紧。', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船夜与假航灯的真正操盘者。', templateWorldType: 'WUXIA', majorFactions: ['守灯会', '航运公会'], coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], attributeSchema: { id: 'schema:test', worldId: 'CUSTOM', schemaVersion: 1, schemaName: '测试', generatedFrom: { worldType: 'CUSTOM', worldName: '潮雾列岛·结果页精修版', settingSummary: '测试', tone: '测试', conflictCore: '测试', }, slots: [], }, playableNpcs: [ { id: 'playable-runtime-only', name: '结果页临时角色', title: '运行时角色', role: '测试角色', description: '不应该直接覆盖 foundation draft。', backstory: '仅用于验证 sync 边界。', personality: '谨慎', motivation: '验证同步边界', combatStyle: '观察', initialAffinity: 0, relationshipHooks: [], tags: [], }, ], storyNpcs: [ { id: 'story-runtime-only', name: '结果页临时场景角色', title: '运行时场景角色', role: '测试角色', description: '不应该直接覆盖 foundation draft。', backstory: '仅用于验证 sync 边界。', personality: '克制', motivation: '验证同步边界', combatStyle: '观察', initialAffinity: 0, relationshipHooks: [], tags: [], }, ], items: [], landmarks: [ { id: 'landmark-runtime-only', name: '结果页临时地点', description: '不应该直接覆盖 foundation draft。', dangerLevel: '低', sceneNpcIds: [], connections: [], }, ], generationMode: 'full', generationStatus: 'complete', }, }); 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 draftRecord = snapshot?.draftProfile as Record | null; const legacyResultProfile = draftRecord?.legacyResultProfile as | Record | undefined; assert.equal(operation?.status, 'completed'); assert.equal(profile?.name, '潮雾列岛·结果页精修版'); assert.equal(profile?.playableNpcs[0]?.name, baselinePlayableName); assert.equal(profile?.storyNpcs[0]?.name, baselineStoryName); assert.equal(profile?.landmarks[0]?.name, baselineLandmarkName); assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版'); assert.equal( (legacyResultProfile?.playableNpcs as Array<{ name?: string }> | undefined)?.[0] ?.name, '结果页临时角色', ); }); test('phase4 sync_result_profile also writes latest role and scene assets back into draft profile', async () => { const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); const sessionStore = new CustomWorldAgentSessionStore( rpgAgentSessionRepository, ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); const userId = 'user-phase4-sync-result-profile-assets'; const session = await createObjectRefiningSession(orchestrator, userId); const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; const playableRole = baselineProfile.playableNpcs[0]!; const storyRole = baselineProfile.storyNpcs[0]!; const landmark = baselineProfile.landmarks[0]!; const response = await orchestrator.executeAction(userId, session.sessionId, { action: 'sync_result_profile', profile: { id: `agent-draft-${session.sessionId}`, settingText: '被海雾吞没的旧航路群岛', name: '潮雾列岛·结果页精修版', subtitle: '旧灯塔与失控航路', summary: '结果页已经把最新图与动作一起确认。 ', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船夜与假航灯的真正操盘者。', templateWorldType: 'WUXIA', majorFactions: ['守灯会', '航运公会'], coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], attributeSchema: { id: 'schema:test', worldId: 'CUSTOM', schemaVersion: 1, schemaName: '测试', generatedFrom: { worldType: 'CUSTOM', worldName: '潮雾列岛·结果页精修版', settingSummary: '测试', tone: '测试', conflictCore: '测试', }, slots: [], }, playableNpcs: [ { id: playableRole.id, name: playableRole.name, title: '结果页角色', role: '关键同行者', description: '结果页确认的最新角色资产。', backstory: '测试', personality: '冷静', motivation: '验证资产回写', combatStyle: '观察', initialAffinity: 12, relationshipHooks: [], tags: [], imageSrc: '/generated/playable/latest-master.png', generatedVisualAssetId: 'visual-playable-latest', generatedAnimationSetId: 'anim-playable-latest', animationMap: { idle: { spriteSheetPath: '/generated/playable/idle.png', }, }, }, ], storyNpcs: [ { id: storyRole.id, name: storyRole.name, title: '结果页场景角色', role: '场景关键角色', description: '结果页确认的最新场景角色资产。', backstory: '测试', personality: '克制', motivation: '验证资产回写', combatStyle: '观察', initialAffinity: 6, relationshipHooks: [], tags: [], imageSrc: '/generated/story/latest-master.png', generatedVisualAssetId: 'visual-story-latest', }, ], items: [], landmarks: [ { id: landmark.id, name: landmark.name, description: '结果页确认的最新地点图。', dangerLevel: '中', sceneNpcIds: [], connections: [], imageSrc: '/generated/landmark/latest-scene.png', }, ], sceneChapterBlueprints: [ { id: 'scene-chapter-1', sceneId: landmark.id, title: '灯塔初章', summary: '结果页确认最新分幕图。', linkedThreadIds: [], linkedLandmarkIds: [landmark.id], acts: [ { id: `${landmark.id}-act-1`, sceneId: landmark.id, title: '第一幕', summary: '第一幕', stageCoverage: ['opening'], backgroundImageSrc: '/generated/scene/act-1-latest.png', backgroundAssetId: 'scene-asset-latest', encounterNpcIds: [], primaryNpcId: '', linkedThreadIds: [], advanceRule: 'after_primary_contact', actGoal: '验证分幕图回写', transitionHook: '进入下一幕', }, ], }, ], generationMode: 'full', generationStatus: 'complete', }, }); 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 syncedPlayable = profile.playableNpcs.find( (entry) => entry.id === playableRole.id, ); const syncedStory = profile.storyNpcs.find((entry) => entry.id === storyRole.id); const syncedLandmark = profile.landmarks.find((entry) => entry.id === landmark.id); const syncedSceneAct = profile.sceneChapters[0]?.acts[0]; assert.equal(operation?.status, 'completed'); assert.equal(syncedPlayable?.imageSrc, '/generated/playable/latest-master.png'); assert.equal(syncedPlayable?.generatedVisualAssetId, 'visual-playable-latest'); assert.equal(syncedPlayable?.generatedAnimationSetId, 'anim-playable-latest'); assert.deepEqual(syncedPlayable?.animationMap, { idle: { spriteSheetPath: '/generated/playable/idle.png', }, }); assert.equal(syncedStory?.imageSrc, '/generated/story/latest-master.png'); assert.equal(syncedStory?.generatedVisualAssetId, 'visual-story-latest'); assert.equal(syncedLandmark?.imageSrc, '/generated/landmark/latest-scene.png'); assert.equal(syncedSceneAct?.backgroundImageSrc, '/generated/scene/act-1-latest.png'); assert.equal(syncedSceneAct?.backgroundAssetId, 'scene-asset-latest'); }); test('phase4 generate_characters appends story npcs and updates work summary counts', async () => { const { rpgAgentSessionRepository, rpgWorldProfileRepository } = createInMemoryRpgWorldRepositoryPorts(); const sessionStore = new CustomWorldAgentSessionStore( rpgAgentSessionRepository, ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); const userId = 'user-phase4-characters'; const session = await createObjectRefiningSession(orchestrator, userId); const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; const baselineCharacterCount = [ ...new Set( [...baselineProfile.playableNpcs, ...baselineProfile.storyNpcs].map( (entry) => entry.id, ), ), ].length; const response = await orchestrator.executeAction(userId, session.sessionId, { action: 'generate_characters', count: 2, promptText: '补两位更贴近旧航道线的边缘角色。', anchorCardIds: [session.draftCards.find((card) => card.kind === 'thread')!.id], }); 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 nextCharacterCount = [ ...new Set( [...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id), ), ].length; const workItems = await new RpgWorldWorkSummaryService( rpgWorldProfileRepository, sessionStore, ).list(userId); const draftItem = workItems.find((item) => item.sessionId === session.sessionId); assert.equal(operation?.status, 'completed'); assert.ok(profile.storyNpcs.length >= 2); assert.ok(nextCharacterCount >= baselineCharacterCount + 2); assert.ok(snapshot?.draftCards.filter((card) => card.kind === 'character').length); assert.ok(snapshot?.focusCardId); assert.ok( snapshot?.messages.some( (message) => message.kind === 'action_result' && message.text.includes('新角色'), ), ); assert.ok((draftItem?.playableNpcCount ?? 0) >= baselineCharacterCount + 2); }); test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => { const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); const sessionStore = new CustomWorldAgentSessionStore( rpgAgentSessionRepository, ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); const userId = 'user-phase4-landmarks'; const session = await createObjectRefiningSession(orchestrator, userId); const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; const baselineLandmarkCount = baselineProfile.landmarks.length; const response = await orchestrator.executeAction(userId, session.sessionId, { action: 'generate_landmarks', count: 2, promptText: '补两个适合藏旧航道秘密的地点。', anchorCardIds: [session.draftCards.find((card) => card.kind === 'character')!.id], }); 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 latestSessionRecord = await sessionStore.get(userId, session.sessionId); assert.equal(operation?.status, 'completed'); assert.ok(profile.landmarks.length >= baselineLandmarkCount + 2); assert.ok( snapshot?.draftCards.filter((card) => card.kind === 'landmark').length, ); assert.ok( snapshot?.messages.some( (message) => message.kind === 'action_result' && message.text.includes('新地点'), ), ); assert.ok((latestSessionRecord?.checkpoints.length ?? 0) >= 2); }); test('phase4 work summaries exclude library draft entries after phase3 downgrade', async () => { const { rpgAgentSessionRepository, rpgWorldProfileRepository } = createInMemoryRpgWorldRepositoryPorts(); const sessionStore = new CustomWorldAgentSessionStore( rpgAgentSessionRepository, ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); const userId = 'user-phase4-work-summary-phase3'; const session = await createObjectRefiningSession(orchestrator, userId); await rpgWorldProfileRepository.upsertOwnProfile( userId, 'library-draft-1', { id: 'library-draft-1', name: '旧兼容草稿', subtitle: '仍保留在作品库', summary: '不应该继续出现在创作中心 works 聚合里。', playableNpcs: [], landmarks: [], }, '玩家', ); const workItems = await new RpgWorldWorkSummaryService( rpgWorldProfileRepository, sessionStore, ).list(userId); assert.ok(workItems.some((item) => item.sessionId === session.sessionId)); assert.equal( workItems.some((item) => item.profileId === 'library-draft-1'), false, ); }); test('phase4 work summaries hide published agent sessions from draft lane and keep published entry enterable', async () => { const { rpgAgentSessionRepository, rpgWorldProfileRepository } = createInMemoryRpgWorldRepositoryPorts(); const sessionStore = new CustomWorldAgentSessionStore( rpgAgentSessionRepository, ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); const userId = 'user-phase4-work-summary-published'; const session = await createObjectRefiningSession(orchestrator, userId); await sessionStore.replaceDerivedState(userId, session.sessionId, { stage: 'published', qualityFindings: [], }); await rpgWorldProfileRepository.upsertOwnProfile( userId, `agent-draft-${session.sessionId}`, { id: `agent-draft-${session.sessionId}`, name: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '已发布版本。', playableNpcs: [], landmarks: [], }, '玩家', ); await rpgWorldProfileRepository.publishOwnProfile( userId, `agent-draft-${session.sessionId}`, '玩家', ); const workItems = await new RpgWorldWorkSummaryService( rpgWorldProfileRepository, sessionStore, ).list(userId); const draftItem = workItems.find((item) => item.sessionId === session.sessionId); const publishedItem = workItems.find( (item) => item.profileId === `agent-draft-${session.sessionId}`, ); assert.equal(draftItem, undefined); assert.equal(publishedItem?.status, 'published'); assert.equal(publishedItem?.canEnterWorld, true); assert.equal(publishedItem?.publishReady, true); assert.equal(publishedItem?.blockerCount, 0); });