import { describe, expect, it } from 'vitest'; import { AnimationState, type Character, WorldType } from '../types'; import { buildExpandedCustomWorldProfile } from './customWorldBuilder'; import { buildUserPrompt } from './prompt'; import { buildSceneNarrativeDirective } from './storyEngine/sceneNarrativeDirector'; import { buildEncounterVisibilitySlice } from './storyEngine/visibilityEngine'; function createCharacter(): Character { return { id: 'hero', name: '林澈', title: '行旅客', description: '一名谨慎前行的旅人。', backstory: '从北境一路追着旧案残线而来。', avatar: '/hero.png', portrait: '/hero-portrait.png', assetFolder: 'hero', assetVariant: 'default', attributes: { strength: 10, agility: 9, intelligence: 8, spirit: 9, }, personality: '谨慎、克制、先看局势。', skills: [], adventureOpenings: {}, }; } describe('buildUserPrompt', () => { it('does not leak full custom-world backstory on first contact', () => { const profile = buildExpandedCustomWorldProfile( { id: 'prompt-world', name: '裂潮边城', subtitle: '旧案回响', summary: '一座在裂潮与旧案回响之间摇摇欲坠的边城。', tone: '紧张、克制、暗流涌动', playerGoal: '查清边城裂潮背后的封桥旧令', templateWorldType: 'WUXIA', majorFactions: ['巡边司', '潮商会'], coreConflicts: ['裂潮再度逼近边路', '封桥旧案再被人提起'], playableNpcs: [ { id: 'playable-1', name: '沈砺', title: '灰炬向导', role: '向导', description: '熟悉裂潮边路的灰炬向导。', backstory: '曾在旧撤离线里失去一整支同行队。', personality: '谨慎寡言,先看风向再开口。', motivation: '想查清旧撤离线为何再次失控。', combatStyle: '短弓牵制后贴近补刀。', initialAffinity: 18, relationshipHooks: ['旧撤离线', '名单'], tags: ['裂潮', '向导'], backstoryReveal: { publicSummary: '他只说自己熟悉边路。', chapters: [ { id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他总盯着风向和路标。', content: '他先把注意力放在边路是否还能走。', contextSnippet: '他总先谈路和风。' }, { id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '旧撤离线像在他身上留下了什么。', content: '那次撤离失控后,他一直没再离开这片边路。', contextSnippet: '撤离旧事还没过去。' }, { id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '名单上的名字让他一直不肯放手。', content: '他一直在比对那份回响名单与旧撤离线。', contextSnippet: '他一直在查名单。' }, { id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '他知道裂潮里有人故意改过路标。', content: '他怀疑有人借裂潮重启旧案。', contextSnippet: '有人在利用裂潮。' }, ], }, skills: [], initialItems: [], }, ], storyNpcs: [ { id: 'story-1', name: '梁砺', title: '断桥巡守', role: '巡守', description: '守着断桥与旧哨火的巡守。', backstory: '旧案爆发时,他是最后一个封桥的人。', personality: '警觉直接,不喜欢绕弯。', motivation: '不想让旧案再次借裂潮翻上来。', combatStyle: '长兵先压,再卡住路口。', initialAffinity: 6, relationshipHooks: ['封桥', '旧哨火'], tags: ['巡守', '断桥'], backstoryReveal: { publicSummary: '他只承认自己还在守桥。', chapters: [ { id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他只说桥还不能放开。', content: '他总先谈桥和路。', contextSnippet: '桥还不能放开。' }, { id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '封桥那夜明显留下了后劲。', content: '他始终忘不了那夜桥上的名单。', contextSnippet: '封桥旧事还压着他。' }, { id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '他像还在替谁守着一个错误。', content: '他一直替旧命令继续守线。', contextSnippet: '他还在守旧命令。' }, { id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '有人逼他在封桥和救人之间选过。', content: '那夜真正下封桥令的人还没有露面。', contextSnippet: '封桥命令另有来头。' }, ], }, skills: [], initialItems: [ { id: 'item-1', name: '旧哨铜钥', category: '稀有品', quantity: 1, rarity: 'rare', description: '钥身磨得发亮。', tags: ['旧哨火'], }, ], }, ], items: [], landmarks: [ { id: 'landmark-1', name: '断桥旧哨', description: '旧哨火和断桥一起守着边城北口。', dangerLevel: 'high', sceneNpcIds: ['story-1'], connections: [], }, ], }, '玩家想要一个裂潮边城与旧案回响交织的世界。', ); const npc = profile.storyNpcs[0]!; const visibilitySlice = buildEncounterVisibilitySlice({ narrativeProfile: npc.narrativeProfile, backstoryReveal: npc.backstoryReveal, disclosureStage: 'guarded', isFirstMeaningfulContact: true, seenBackstoryChapterIds: [], storyEngineMemory: { discoveredFactIds: [], activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [], resolvedScarIds: [], recentCarrierIds: [], }, activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [], }); const prompt = buildUserPrompt( WorldType.CUSTOM, createCharacter(), [], [], { playerHp: 30, playerMaxHp: 40, playerMana: 10, playerMaxMana: 20, inBattle: false, playerX: 0, playerFacing: 'right', playerAnimation: AnimationState.IDLE, skillCooldowns: {}, sceneId: 'custom-scene-landmark-1', sceneName: '断桥旧哨', sceneDescription: '风里尽是旧哨火和潮声。', encounterKind: 'npc', encounterId: npc.id, encounterName: npc.name, encounterDescription: npc.description, encounterContext: npc.role, encounterAffinity: npc.initialAffinity, encounterAffinityText: '对你仍有戒备,也在观察你会怎么试探。', encounterDisclosureStage: 'guarded', encounterWarmthStage: 'distant', encounterAnswerMode: 'situational_only', encounterAllowedTopics: ['眼前危险', '现场判断', '模糊钩子'], encounterBlockedTopics: ['完整来历', '真正目标', '旧事全貌'], isFirstMeaningfulContact: true, firstContactRelationStance: 'guarded', recentSharedEvent: '你们还只是刚刚真正把话对上。', talkPriority: '优先谈桥口、来意和眼前压力,不要直接摊开旧案全貌。', encounterCustomProfile: npc, encounterNarrativeProfile: npc.narrativeProfile, visibilitySlice, sceneNarrativeDirective: buildSceneNarrativeDirective({ sceneId: 'custom-scene-landmark-1', sceneName: '断桥旧哨', encounterId: npc.id, encounterName: npc.name, recentActions: [], activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [], visibilitySlice, encounterNarrativeProfile: npc.narrativeProfile, disclosureStage: 'guarded', isFirstMeaningfulContact: true, affinity: npc.initialAffinity, }), activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [], customWorldProfile: profile, }, ); expect(prompt).toContain(npc.narrativeProfile?.publicMask ?? ''); expect(prompt).toContain(npc.narrativeProfile?.immediatePressure ?? ''); expect(prompt).not.toContain(npc.backstory); expect(prompt).not.toContain(npc.backstoryReveal.chapters[3]!.content); expect(prompt).not.toContain(npc.initialItems[0]!.name); }); it('requires an empty encounter payload during non-pending follow-up reasoning such as post-battle continuation', () => { const prompt = buildUserPrompt( WorldType.WUXIA, createCharacter(), [], [ { text: '挥刀抢攻', options: [], historyRole: 'action', }, { text: '山道客已经败下阵来。', options: [], historyRole: 'result', }, ], { playerHp: 26, playerMaxHp: 40, playerMana: 8, playerMaxMana: 20, inBattle: false, playerX: 0, playerFacing: 'right', playerAnimation: AnimationState.IDLE, skillCooldowns: {}, sceneId: 'forest_road', sceneName: '山道', sceneDescription: '风从林梢压下来,地上还留着刚才交手的痕迹。', pendingSceneEncounter: false, }, '挥刀抢攻', ); expect(prompt).toContain('encounter 必须为 null'); expect(prompt).toContain('战斗结束后的续写'); }); it('does not feed mixed-language history and directive snippets back into story prompts', () => { const prompt = buildUserPrompt( WorldType.WUXIA, createCharacter(), [], [ { text: 'Move forward carefully.', options: [], historyRole: 'action', }, { text: 'The wind is cold. 你听见山道尽头有脚步声。', options: [], historyRole: 'result', }, ], { playerHp: 26, playerMaxHp: 40, playerMana: 8, playerMaxMana: 20, inBattle: false, playerX: 0, playerFacing: 'right', playerAnimation: AnimationState.ATTACK, skillCooldowns: {}, sceneId: 'forest_road', sceneName: '山道', sceneDescription: '风从林梢压下来。', pendingSceneEncounter: false, conversationSituation: 'post_battle_breath', conversationPressure: 'medium', recentSharedEvent: 'A fight just ended. Both sides are still catching their breath.', talkPriority: 'Focus on the most useful judgment, danger, and next step.', partyRelationshipNotes: 'Lan is becoming more open in private conversation.', recentChronicleSummary: 'Baseline summary from previous run.', sceneNarrativeDirective: { primaryPressure: 'Danger is still active near the camp.', activeThreadIds: ['thread-old-case'], foregroundActorIds: [], foregroundCarrierIds: [], revealBudget: 'low', emotionalCadence: 'tense', }, }, 'Move forward carefully.', ); expect(prompt).not.toContain('A fight just ended'); expect(prompt).not.toContain('Focus on the most useful judgment'); expect(prompt).not.toContain('Baseline summary'); expect(prompt).not.toContain('Move forward carefully'); expect(prompt).not.toContain('thread-old-case'); expect(prompt).not.toContain('Danger is still active'); expect(prompt).toContain('战后缓气'); expect(prompt).toContain('紧绷'); expect(prompt).toContain('这一轮的局势已经出现了新的变化。'); }); });