import { afterEach, expect, test } from 'vitest'; import type { CustomWorldAgentSessionSnapshot } from '../../packages/shared/src/contracts/customWorldAgent'; import { buildCustomWorldRuntimeCharacters } from '../data/characterPresets'; import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime'; import { getScenePresetsByWorld } from '../data/scenePresets'; import { WorldType } from '../types'; import { buildCustomWorldProfileFromAgentDraft } from './customWorldAgentDraftResult'; afterEach(() => { setRuntimeCustomWorldProfile(null); }); const session: CustomWorldAgentSessionSnapshot = { sessionId: 'session-1', stage: 'object_refining', focusCardId: null, creatorIntent: { sourceMode: 'card', worldHook: '被海雾吞没的旧航路群岛', playerPremise: '玩家回到群岛调查沉船真相。', themeKeywords: ['海雾', '旧航路'], toneDirectives: ['压抑', '悬疑'], openingSituation: '首夜就有陌生船只闯入禁航区。', coreConflicts: ['航运公会与守灯会争夺航路控制权'], keyFactions: [], keyCharacters: [], keyLandmarks: [], iconicElements: ['会移动的海雾'], forbiddenDirectives: [], rawSettingText: '', }, creatorIntentReadiness: { isReady: true, completedKeys: [], missingKeys: [], }, anchorPack: {}, lockState: {}, draftProfile: { name: '潮雾列岛', subtitle: '旧灯塔与失控航路', summary: '第一版世界底稿已经整理完成。', tone: '压抑、潮湿、悬疑', playerGoal: '查清沉船与禁航区异动的真相。', majorFactions: ['守灯会', '航运公会'], coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], playableNpcs: [ { id: 'playable-1', name: '沈砺', title: '旧航路引路人', role: '关键同行者', publicIdentity: '最熟悉旧航路的人。', publicMask: '看上去像可靠旧友。', currentPressure: '他必须在两股势力间站队。', hiddenHook: '暗中替沉船商盟引路。', relationToPlayer: '旧友兼潜在背叛者', threadIds: ['thread-1'], summary: '他像旧友,但也像一把始终没收回鞘的刀。', }, ], storyNpcs: [ { id: 'story-1', name: '顾潮音', title: '守灯会值夜人', role: '场景关键角色', publicIdentity: '负责夜间巡灯与封锁。', publicMask: '对外一直冷静克制。', currentPressure: '她知道更多禁航区真相。', hiddenHook: '曾亲眼见过失控海雾吞船。', relationToPlayer: '最早愿意交换线索的人', threadIds: ['thread-1'], summary: '她总像比所有人更早知道海雾会往哪一侧压下来。', }, ], landmarks: [ { id: 'landmark-1', name: '回潮旧灯塔', purpose: '观察雾潮与往来船只', mood: '潮湿、压抑、风声不止', importance: '开局核心场景', characterIds: ['story-1'], threadIds: ['thread-1'], summary: '旧灯塔是整片群岛最先看见异动的地方。', }, ], factions: [], threads: [], chapters: [], worldHook: '被海雾吞没的旧航路群岛', playerPremise: '玩家回到群岛调查沉船真相。', openingSituation: '首夜就有陌生船只闯入禁航区。', iconicElements: ['会移动的海雾'], sourceAnchorSummary: '海雾、旧灯塔、失控航路。', }, messages: [], draftCards: [], pendingClarifications: [], suggestedActions: [], recommendedReplies: [], qualityFindings: [], assetCoverage: { roleAssets: [], sceneAssets: [], allRoleAssetsReady: false, allSceneAssetsReady: false, }, updatedAt: '2026-04-15T10:00:00.000Z', }; function buildBackstoryReveal(label: string) { return { publicSummary: `${label}的公开背景`, privateChatUnlockAffinity: 40, chapters: [ { id: `${label}-surface`, title: '表层来意', affinityRequired: 15, teaser: `${label}先只肯说表面的来意。`, content: `${label}表面上只愿意谈当前局势。`, contextSnippet: `${label}表面上还在收着话。`, }, { id: `${label}-scar`, title: '旧事裂痕', affinityRequired: 30, teaser: `${label}背后还有一段旧伤。`, content: `${label}曾在旧案里留下无法轻易揭开的伤口。`, contextSnippet: `${label}和旧案之间有一段没说开的裂痕。`, }, { id: `${label}-hidden`, title: '隐藏执念', affinityRequired: 60, teaser: `${label}真正想追的不是表面那件事。`, content: `${label}真正挂着的是旧案里还没结的那条线。`, contextSnippet: `${label}真正执念指向旧案深处。`, }, { id: `${label}-final`, title: '最终底牌', affinityRequired: 90, teaser: `${label}手里还压着最后一张牌。`, content: `${label}手里还握着能直接证明真相的关键证据。`, contextSnippet: `${label}最后的底牌足以改写局势。`, }, ], }; } function buildLegacyResultProfile() { return { id: 'legacy-profile-1', settingText: '被海雾吞没的旧航路群岛', name: '旧版完整结果', subtitle: '直接展示', summary: '优先使用服务端编译好的旧版 profile。', tone: '压抑', playerGoal: '查明真相', templateWorldType: WorldType.WUXIA, compatibilityTemplateWorldType: WorldType.WUXIA, majorFactions: ['守灯会', '航运公会'], coreConflicts: ['争夺航路控制权', '沉船真相'], attributeSchema: { id: 'schema:test', worldId: 'CUSTOM', schemaVersion: 1, schemaName: '测试', generatedFrom: { worldType: WorldType.CUSTOM, worldName: '旧版完整结果', settingSummary: '测试', tone: '测试', conflictCore: '测试', }, slots: [], }, playableNpcs: [ { id: 'playable-1', name: '沈砺', title: '旧航路引路人', role: '关键同行者', description: '最熟悉旧航路的人。', backstory: '曾在沉船夜里带着半支船队逃出海雾。', personality: '表面沉稳,心里一直在算退路。', motivation: '想赶在守灯会封航前查清真相。', combatStyle: '借地形和潮路换位,先拉扯再压近。', initialAffinity: 18, relationshipHooks: ['旧友', '沉船旧案'], tags: ['潮路', '引路'], backstoryReveal: buildBackstoryReveal('沈砺'), skills: [ { id: 'skill-playable-1', name: '潮行引路', summary: '踩着旧潮阶切线前压,替队伍打开角度。', style: '机动周旋', }, { id: 'skill-playable-2', name: '回雾折返', summary: '借海雾遮住身位,再从侧线拉开。', style: '起手压制', }, { id: 'skill-playable-3', name: '旧图定标', summary: '用旧潮图锁定退路和突入口。', style: '爆发终结', }, ], initialItems: [ { id: 'item-playable-1', name: '旧潮短刃', category: '武器', quantity: 1, rarity: 'rare', description: '专门在湿滑甲板上近身换位用的短刃。', tags: ['潮路', '近战'], }, { id: 'item-playable-2', name: '雾盐药包', category: '消耗品', quantity: 2, rarity: 'uncommon', description: '压住寒潮后遗症的随身药包。', tags: ['补给'], }, { id: 'item-playable-3', name: '旧潮图残页', category: '专属物品', quantity: 1, rarity: 'rare', description: '只剩半页,但足够指向沉船夜的另一条线。', tags: ['线索', '真相'], }, ], attributeProfile: { schemaId: 'schema:test', values: { axis_a: 48, axis_b: 72, axis_c: 78 }, topTraits: ['浪步', '舟识'], evidence: [ { slotId: 'axis_b', reason: '长期依赖潮路换位与切线。', }, ], }, narrativeProfile: { publicMask: '像个只想把旧路再走通一次的熟路人。', firstContactMask: '先别问太深,至少今晚这条路我还认得。', visibleLine: '他明面上只想护着队伍别再走错一次潮线。', hiddenLine: '真正让他回来的是沉船夜里被人卖掉的那条航线。', contradiction: '嘴上说只想带路,实际每一步都在找能对上旧案的证据。', debtOrBurden: '背着半支船队没能活着回来的旧债。', taboo: '最忌讳别人轻描淡写地提起那晚的失踪名单。', immediatePressure: '守灯会封航在即,他必须赶在封口前找到证据。', relatedThreadIds: ['thread-visible-1'], relatedScarIds: ['scar-1'], reactionHooks: ['沉船夜', '封航记录'], }, templateCharacterId: 'archer-hero', }, ], storyNpcs: [ { id: 'story-1', name: '顾潮音', title: '守灯会值夜人', role: '场景关键角色', description: '夜里巡灯与封锁禁航区的人。', backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。', personality: '冷静克制,但提到旧灯册时会显得过分警觉。', motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。', combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。', initialAffinity: 8, relationshipHooks: ['禁航记录', '灯塔值夜'], tags: ['守灯会', '灯塔'], backstoryReveal: buildBackstoryReveal('顾潮音'), skills: [ { id: 'skill-story-1', name: '夜潮灯语', summary: '借灯语与潮声干扰对方判断。', style: '起手压制', }, { id: 'skill-story-2', name: '禁航暗潮', summary: '封住错误航线,把人逼回她熟悉的区域。', style: '机动周旋', }, { id: 'skill-story-3', name: '回声巡线', summary: '借塔顶回声迅速锁定异动方向。', style: '爆发终结', }, ], initialItems: [ { id: 'item-story-1', name: '值夜灯尺', category: '武器', quantity: 1, rarity: 'rare', description: '兼作警械和测灯尺的长柄器具。', tags: ['守灯会'], }, { id: 'item-story-2', name: '防潮火折', category: '消耗品', quantity: 2, rarity: 'uncommon', description: '在潮雾里也能点亮的值夜火折。', tags: ['值夜'], }, { id: 'item-story-3', name: '封灯令副本', category: '专属物品', quantity: 1, rarity: 'rare', description: '一份被她私下截留下来的封灯令副本。', tags: ['证据', '灯册'], }, ], imageSrc: '/custom/npcs/gu-chaoyin.png', attributeProfile: { schemaId: 'schema:test', values: { axis_a: 54, axis_c: 82, axis_f: 61 }, topTraits: ['舟识', '回澜'], evidence: [ { slotId: 'axis_c', reason: '长期依赖值夜观察和读灯判断局势。', }, ], }, narrativeProfile: { publicMask: '守灯会值夜人,对外总像比别人更冷静一步。', firstContactMask: '想进塔就按规矩来,今晚谁都别想乱闯禁航区。', visibleLine: '她表面上只是在守灯和封线。', hiddenLine: '她真正盯着的是那本被改过的原始灯册。', contradiction: '越强调规矩,越像在掩住自己手里那份没上报的记录。', debtOrBurden: '她扛着第一次海雾吞船夜里没能及时点亮备用灯的旧责。', taboo: '最忌讳别人把那夜的失踪当成单纯天灾。', immediatePressure: '新的封航令马上要落下来,她必须先把旧灯册转出去。', relatedThreadIds: ['thread-visible-1'], relatedScarIds: ['scar-1'], reactionHooks: ['原始灯册', '封灯令'], }, visual: { race: 'human', bodyColor: 'blue', headIndex: 2, hairColorIndex: 3, hairStyleFrame: 4, facialHairEnabled: false, facialHairColorIndex: 0, facialHairStyleFrame: 0, offHand: { type: 'magic', file: 'lantern.png', frameIndex: 1, }, }, }, ], items: [ { id: 'item-world-1', name: '潮雾罗盘', category: '饰品', rarity: 'rare', description: '会在假航灯附近偏转的旧罗盘。', tags: ['线索', '潮雾'], attributeResonance: { resonanceVector: { axis_c: 0.88, axis_e: 0.31 }, explanation: '它会把持有者的判断力牵到潮雾最异常的地方。', }, }, ], camp: { name: '回潮暂栖所', description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。', dangerLevel: 'low', imageSrc: '/custom/camp/huichao.png', }, landmarks: [ { id: 'landmark-1', name: '回潮旧灯塔', description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', dangerLevel: 'high', imageSrc: '/custom/scenes/lighthouse.png', sceneNpcIds: ['story-1'], connections: [ { targetLandmarkId: 'landmark-2', relativePosition: 'forward', summary: '沿着旧潮阶继续前压到雾栈尽头。', }, ], narrativeResidues: [ { id: 'residue-1', title: '潮痕', visibleClue: '塔壁上有一圈不该出现在高处的潮痕。', linkedFactIds: ['fact-1'], linkedThreadIds: ['thread-visible-1'], }, ], }, { id: 'landmark-2', name: '雾栈尽头', description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。', dangerLevel: 'high', imageSrc: '/custom/scenes/pier.png', sceneNpcIds: [], connections: [ { targetLandmarkId: 'landmark-1', relativePosition: 'back', summary: '退回灯塔还能重新整理路线。', }, ], }, ], themePack: { id: 'theme-pack:tide', displayName: '潮雾悬疑', toneRange: ['压抑', '潮湿', '悬疑'], institutionLexicon: ['守灯会', '航运公会'], tabooLexicon: ['假航灯', '封灯令'], artifactClasses: ['旧潮图', '灯册', '罗盘'], actorArchetypes: ['引路人', '值夜人'], conflictForms: ['封航争夺', '旧案追查'], clueForms: ['灯册残页', '潮痕'], namingPatterns: ['潮', '雾', '灯'], revealStyles: ['试探式回应'], }, storyGraph: { visibleThreads: [ { id: 'thread-visible-1', title: '封航争夺', visibility: 'visible', summary: '守灯会与航运公会正在争夺旧航路的解释权。', conflictType: '控制权争夺', stakes: '谁能定义禁航区,就能决定谁能活着穿过去。', involvedFactionIds: ['faction-guard', 'faction-guild'], involvedActorIds: ['playable-1', 'story-1'], relatedLocationIds: ['landmark-1', 'landmark-2'], }, ], hiddenThreads: [ { id: 'thread-hidden-1', title: '沉船旧案', visibility: 'hidden', summary: '沉船夜的航灯与灯册被人动过手脚。', conflictType: '真相遮蔽', stakes: '真相一旦坐实,守灯会内部会先崩。', involvedFactionIds: ['faction-guard'], involvedActorIds: ['playable-1', 'story-1'], relatedLocationIds: ['landmark-1'], }, ], scars: [ { id: 'scar-1', title: '沉船夜', pastEvent: '假航灯把整支船队引进了死潮区。', publicResidue: '每逢潮夜,灯塔下总有人提起那晚的失踪名单。', hiddenTruth: '禁航记录和灯册都在事后被篡改过。', relatedActorIds: ['playable-1', 'story-1'], relatedLocationIds: ['landmark-1'], }, ], motifs: [ { id: 'motif-1', label: '假航灯', semanticRole: 'technology', lexicalHints: ['假灯', '偏航', '禁航记录'], }, ], }, knowledgeFacts: [ { id: 'fact-1', title: '高处潮痕', content: '回潮旧灯塔的高处潮痕说明那晚海面高度异常。', ownerActorIds: ['story-1'], relatedThreadIds: ['thread-visible-1'], relatedScarIds: ['scar-1'], sourceType: 'scene', visibility: 'discoverable', sayability: 'indirect', }, ], threadContracts: [ { id: 'contract-1', threadId: 'thread-visible-1', issuerActorId: 'story-1', narrativeType: 'investigation', currentStepId: 'contract-step-1', visibleStage: 1, steps: [ { id: 'contract-step-1', title: '查灯塔', revealText: '先查清灯塔顶上的高处潮痕。', completionSignalIds: ['inspect_scene:landmark-1'], optionalFactIds: ['fact-1'], }, ], followupThreadIds: ['thread-hidden-1'], }, ], scenarioPackId: 'scenario-pack:tide', campaignPackId: 'campaign-pack:tide', generationMode: 'fast', generationStatus: 'key_only', }; } function buildProfileFromEmbeddedLegacyResult() { return buildCustomWorldProfileFromAgentDraft({ ...session, draftProfile: { ...session.draftProfile, legacyResultProfile: buildLegacyResultProfile(), }, }); } test('adapts agent draft profile into legacy custom world result profile', () => { const profile = buildCustomWorldProfileFromAgentDraft(session); expect(profile?.name).toBe('潮雾列岛'); expect(profile?.generationStatus).toBe('key_only'); expect(profile?.playableNpcs[0]?.name).toBe('沈砺'); expect(profile?.storyNpcs[0]?.name).toBe('顾潮音'); expect(profile?.landmarks[0]?.name).toBe('回潮旧灯塔'); }); test('prefers embedded legacy result profile without dropping compiled runtime fields', () => { const profile = buildProfileFromEmbeddedLegacyResult(); expect(profile?.name).toBe('旧版完整结果'); expect(profile?.majorFactions).toEqual(['守灯会', '航运公会']); expect(profile?.coreConflicts).toEqual(['争夺航路控制权', '沉船真相']); expect(profile?.themePack?.id).toBe('theme-pack:tide'); expect(profile?.storyGraph?.visibleThreads[0]?.id).toBe('thread-visible-1'); expect(profile?.knowledgeFacts?.[0]?.id).toBe('fact-1'); expect(profile?.threadContracts?.[0]?.id).toBe('contract-1'); expect(profile?.scenarioPackId).toBe('scenario-pack:tide'); expect(profile?.campaignPackId).toBe('campaign-pack:tide'); expect(profile?.playableNpcs[0]?.attributeProfile?.schemaId).toBe( 'schema:test', ); expect(profile?.storyNpcs[0]?.narrativeProfile?.publicMask).toContain( '守灯会值夜人', ); expect(profile?.items[0]?.attributeResonance?.explanation).toContain('潮雾'); expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.title).toBe('潮痕'); }); test('embedded legacy result profile keeps result-page settings in runtime characters and scenes', () => { const profile = buildProfileFromEmbeddedLegacyResult(); expect(profile).toBeTruthy(); setRuntimeCustomWorldProfile(profile); const runtimeCharacters = buildCustomWorldRuntimeCharacters(profile); const leadCharacter = runtimeCharacters.find( (character) => character.id === 'playable-1', ); const lighthouseScene = getScenePresetsByWorld(WorldType.CUSTOM).find( (scene) => scene.name === '回潮旧灯塔', ); const guardNpc = lighthouseScene?.npcs.find((npc) => npc.id === 'story-1'); expect(leadCharacter?.skills[0]?.name).toBe('潮行引路'); expect(leadCharacter?.backstoryReveal?.publicSummary).toBe('沈砺的公开背景'); expect(lighthouseScene?.connections[0]?.summary).toBe( '沿着旧潮阶继续前压到雾栈尽头。', ); expect(lighthouseScene?.narrativeResidues?.[0]?.title).toBe('潮痕'); expect(guardNpc?.narrativeProfile?.publicMask).toBe( '守灯会值夜人,对外总像比别人更冷静一步。', ); });