import { afterEach, describe, expect, it } from 'vitest'; import { buildExpandedCustomWorldProfile } from '../services/customWorldBuilder'; import { AnimationState } from '../types'; import { buildCustomWorldPlayableCharacters, buildCustomWorldRuntimeCharacters, getCharacterById, resolveEncounterRecruitCharacter, setRuntimeCharacterOverrides, } from './characterPresets'; import { setRuntimeCustomWorldProfile } from './customWorldRuntime'; function createRole(index: number) { return { name: `角色${index + 1}`, title: `头衔${index + 1}`, role: `身份${index + 1}`, description: `角色描述${index + 1}`, backstory: `角色背景${index + 1}`, personality: `角色性格${index + 1}`, motivation: `角色动机${index + 1}`, combatStyle: `角色战斗风格${index + 1}`, initialAffinity: 18, relationshipHooks: [`关系${index + 1}`], tags: [`标签${index + 1}`], backstoryReveal: { publicSummary: `公开背景${index + 1}`, chapters: [ { id: `surface-${index + 1}`, title: '表层来意', affinityRequired: 10, teaser: `提示${index + 1}-1`, content: `内容${index + 1}-1`, contextSnippet: `摘要${index + 1}-1`, }, { id: `scar-${index + 1}`, title: '旧事裂痕', affinityRequired: 30, teaser: `提示${index + 1}-2`, content: `内容${index + 1}-2`, contextSnippet: `摘要${index + 1}-2`, }, { id: `hidden-${index + 1}`, title: '隐藏执念', affinityRequired: 55, teaser: `提示${index + 1}-3`, content: `内容${index + 1}-3`, contextSnippet: `摘要${index + 1}-3`, }, { id: `final-${index + 1}`, title: '最终底牌', affinityRequired: 80, teaser: `提示${index + 1}-4`, content: `内容${index + 1}-4`, contextSnippet: `摘要${index + 1}-4`, }, ], }, skills: [ { name: `技能${index + 1}-1`, summary: '技能摘要1', style: '起手压制' }, { name: `技能${index + 1}-2`, summary: '技能摘要2', style: '机动周旋' }, { name: `技能${index + 1}-3`, summary: '技能摘要3', style: '爆发终结' }, ], initialItems: [ { name: `武器${index + 1}`, category: '武器', quantity: 1, rarity: 'rare' as const, description: '武器描述', tags: ['武器标签'], }, { name: `补给${index + 1}`, category: '消耗品', quantity: 2, rarity: 'uncommon' as const, description: '补给描述', tags: ['补给标签'], }, { name: `信物${index + 1}`, category: '专属物品', quantity: 1, rarity: 'rare' as const, description: '信物描述', tags: ['信物标签'], }, ], }; } describe('characterPresets custom world runtime characters', () => { afterEach(() => { setRuntimeCharacterOverrides(null); setRuntimeCustomWorldProfile(null); }); it('hydrates story npcs into runtime characters and preserves custom dossiers', () => { const profile = buildExpandedCustomWorldProfile( { name: '裂潮边城', subtitle: '潮痕未褪', summary: '一座围绕潮路、断桥和夜港旧案展开的世界。', tone: '潮湿、压抑、克制', playerGoal: '查清夜港失踪案和潮路背后的势力牵连。', templateWorldType: 'WUXIA', playableNpcs: Array.from({ length: 5 }, (_, index) => createRole(index), ), storyNpcs: [ { ...createRole(10), name: '沈雾', title: '潮路领航人', role: '夜港向导', description: '熟悉潮路暗栈与旧渡的人。', backstory: '曾在断桥坠潮夜里失去整队同伴。', personality: '谨慎冷静,先观察再表态。', motivation: '想把失踪航线重新找出来。', combatStyle: '短刀试探后再借地形逼近。', initialAffinity: 12, relationshipHooks: ['断桥旧案', '夜港潮路'], tags: ['码头', '潮路', '短刀'], imageSrc: '/custom/npcs/shenwu.png', generatedVisualAssetId: 'visual-custom-shenwu', generatedAnimationSetId: 'animation-set-custom-shenwu', animationMap: { [AnimationState.IDLE]: { folder: 'idle', prefix: 'frame', frames: 8, startFrame: 1, extension: 'png', basePath: '/generated-animations/custom-shenwu/animation-set-custom-shenwu/idle', }, [AnimationState.ATTACK]: { folder: 'attack', prefix: 'frame', frames: 8, startFrame: 1, extension: 'png', basePath: '/generated-animations/custom-shenwu/animation-set-custom-shenwu/attack', }, }, visual: { race: 'human', bodyColor: 'blue', headIndex: 2, hairColorIndex: 3, hairStyleFrame: 5, facialHairEnabled: false, facialHairColorIndex: 1, facialHairStyleFrame: 0, mainHand: { type: 'melee', file: 'dagger.png', frameIndex: 4, }, }, }, { ...createRole(11), name: '陆沉', title: '断桥守更', role: '守桥人', description: '夜里守着断桥口旧灯火的人。', }, { ...createRole(12), name: '顾潮', title: '潮册记录员', role: '账房记录员', description: '在潮账房里整理失踪名册的人。', }, ], landmarks: [ { name: '夜港旧栈', description: '潮雾和旧木桥把视线切成断续几段。', sceneNpcNames: ['沈雾', '陆沉', '顾潮'], connections: [ { targetLandmarkName: '断桥外沿', relativePosition: 'forward', summary: '顺着潮路继续前压就是断桥外沿。', }, ], }, { name: '断桥外沿', description: '旧桥断口还挂着潮湿残旗。', sceneNpcNames: ['沈雾', '陆沉', '顾潮'], connections: [ { targetLandmarkName: '夜港旧栈', relativePosition: 'back', summary: '沿旧潮路退回夜港旧栈。', }, ], }, ], }, '玩家想要一个围绕夜港潮路与断桥旧案展开的世界。', ); setRuntimeCustomWorldProfile(profile); const runtimeCharacters = buildCustomWorldRuntimeCharacters(profile); setRuntimeCharacterOverrides(runtimeCharacters); const storyRole = profile.storyNpcs[0]; expect(storyRole).toBeTruthy(); const storyCharacter = getCharacterById(storyRole!.id); const runtimeStoryCharacter = runtimeCharacters.find( (character) => character.id === storyRole!.id, ); expect(storyCharacter).toBeTruthy(); expect(runtimeStoryCharacter).toBeTruthy(); expect(storyCharacter?.name).toBe('沈雾'); expect(storyCharacter?.title).toBe('潮路领航人'); expect(storyCharacter?.backstory).toContain('断桥坠潮夜'); expect(storyCharacter?.skills[0]?.name).toBe('技能11-1'); expect(storyCharacter?.portrait).toBe('/custom/npcs/shenwu.png'); expect(storyCharacter?.generatedVisualAssetId).toBe( 'visual-custom-shenwu', ); expect(storyCharacter?.generatedAnimationSetId).toBe( 'animation-set-custom-shenwu', ); expect(storyCharacter?.animationMap?.[AnimationState.IDLE]?.basePath).toBe( '/generated-animations/custom-shenwu/animation-set-custom-shenwu/idle', ); expect(storyCharacter?.visual).toEqual(storyRole?.visual); expect(storyCharacter?.groundOffsetY).toBe(22); const recruitCharacter = resolveEncounterRecruitCharacter({ characterId: storyRole!.id, context: storyRole!.role, npcName: storyRole!.name, }); expect(recruitCharacter?.id).toBe(storyRole!.id); expect(recruitCharacter?.name).toBe('沈雾'); }); it('uses draft playable role image directly before generated animations exist', () => { const profile = buildExpandedCustomWorldProfile( { name: '潮雾列岛', subtitle: '灯塔未眠', summary: '围绕潮雾、灯塔和失踪航路展开的世界。', tone: '冷峻、潮湿、悬疑', playerGoal: '找到灯塔失踪航路。', templateWorldType: 'WUXIA', playableNpcs: [ { ...createRole(0), id: 'playable-lighthouse-keeper', imageSrc: '/generated-characters/lighthouse-keeper/portrait.png', generatedVisualAssetId: 'assetobj-lighthouse-keeper', generatedAnimationSetId: undefined, animationMap: undefined, }, ], }, '玩家想测试灯塔守望者草稿。', ); const [playableCharacter] = buildCustomWorldPlayableCharacters(profile); expect(playableCharacter?.portrait).toBe( '/generated-characters/lighthouse-keeper/portrait.png', ); expect(playableCharacter?.avatar).toBe( '/generated-characters/lighthouse-keeper/portrait.png', ); expect(playableCharacter?.animationMap).toBeUndefined(); }); });