import { describe, expect, it } from 'vitest'; import type { CampEvent, ChapterState, GameState, JourneyBeat, QuestLogEntry, SetpieceDirective, StoryOption, } from '../../types'; import { AnimationState } from '../../types'; import { annotateStoryOptionsWithGoalAffordance, buildGoalHandoffFromState, buildGoalStackState, createGoalPulseSnapshot, deriveGoalPulseEvent, describeGoalStackForPrompt, sortQuestsForGoalPanel, } from './goalDirector'; function createQuest(overrides: Partial & Pick): QuestLogEntry { return { id: overrides.id, issuerNpcId: overrides.issuerNpcId ?? `${overrides.id}-issuer`, issuerNpcName: overrides.issuerNpcName ?? '林朔', sceneId: overrides.sceneId ?? 'scene-ruins', chapterId: overrides.chapterId ?? null, title: overrides.title, description: overrides.description ?? `${overrides.title} 的说明`, summary: overrides.summary ?? `${overrides.title} 的摘要`, objective: overrides.objective ?? { kind: 'inspect_treasure', targetSceneId: 'scene-ruins', requiredCount: 1, }, progress: overrides.progress ?? 0, status: overrides.status ?? 'active', reward: overrides.reward ?? { affinityBonus: 10, currency: 20, items: [], }, rewardText: overrides.rewardText ?? '奖励已准备', narrativeBinding: overrides.narrativeBinding, steps: overrides.steps, activeStepId: overrides.activeStepId, threadId: overrides.threadId ?? null, completionNotified: overrides.completionNotified ?? false, }; } function createSceneDirective() { return { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right' as const, scrollWorld: false, monsterChanges: [], }; } describe('goalDirector', () => { it('uses the ready-to-turn-in quest as the current goal and immediate step', () => { const chapterState: ChapterState = { id: 'chapter-1', title: '封桥旧案', theme: '封桥旧案', primaryThreadIds: ['thread-bridge'], stage: 'expansion', chapterSummary: '桥上的旧案正被重新翻开。', }; const journeyBeat: JourneyBeat = { id: 'beat-1', beatType: 'investigation', title: '追查旧桥异动', triggerThreadIds: ['thread-bridge'], recommendedSceneIds: ['scene-bridge'], emotionalGoal: '把线索从零散异常收束成可追查的方向。', }; const setpieceDirective: SetpieceDirective = { id: 'setpiece-1', title: '桥门对峙', setpieceType: 'boss_prelude', relatedThreadIds: ['thread-bridge'], sceneFocusId: 'scene-bridge', dramaticQuestion: '旧桥另一侧到底是谁在阻拦真相?', }; const currentCampEvent: CampEvent = { id: 'camp-1', eventType: 'private_talk', title: '夜谈未尽之事', participantCharacterIds: ['companion-1'], triggerReason: '同伴对桥上的旧案起了新的疑心。', relatedThreadIds: ['thread-bridge'], }; const readyQuest = createQuest({ id: 'quest-ready', title: '回报遗迹调查', status: 'ready_to_turn_in', issuerNpcName: '陆清', threadId: 'thread-bridge', narrativeBinding: { origin: 'ai_compiled', narrativeType: 'investigation', dramaticNeed: '必须确认遗迹的异动来源。', issuerGoal: '拿到调查结果后继续推进旧桥线索。', playerHook: '你已经掌握了最关键的现场信息。', worldReason: '如果再拖下去,线索会继续散掉。', followupHooks: [], }, rewardText: '回去找陆清交付调查结果。', }); const sideQuest = createQuest({ id: 'quest-side', title: '整理营地补给', status: 'active', narrativeBinding: { origin: 'fallback_builder', narrativeType: 'relationship', dramaticNeed: '营地气氛有些不稳。', issuerGoal: '先把补给和情绪都稳住。', playerHook: '这能让后续推进更从容。', worldReason: '大家都还没完全从上一段冲突里缓过来。', followupHooks: [], }, }); const goalStack = buildGoalStackState({ quests: [sideQuest, readyQuest], worldType: null, chapterState, journeyBeat, setpieceDirective, currentCampEvent, currentSceneName: '断桥旧哨', }); expect(goalStack.northStarGoal?.sourceKind).toBe('setpiece'); expect(goalStack.activeGoal?.sourceKind).toBe('quest'); expect(goalStack.activeGoal?.sourceId).toBe('quest-ready'); expect(goalStack.immediateStepGoal?.title).toContain('陆清'); expect(goalStack.supportGoals.some((goal) => goal.sourceId === 'quest-side')).toBe(true); expect(goalStack.supportGoals.some((goal) => goal.sourceKind === 'relationship')).toBe(true); const sortedQuestIds = sortQuestsForGoalPanel([sideQuest, readyQuest], goalStack).map((quest) => quest.id); expect(sortedQuestIds[0]).toBe('quest-ready'); }); it('falls back to the current journey beat when no quest is active', () => { const chapterState: ChapterState = { id: 'chapter-2', title: '山门前夜', theme: '山门风声', primaryThreadIds: ['thread-gate'], stage: 'opening', chapterSummary: '风声刚起,矛盾还在缓慢聚拢。', }; const journeyBeat: JourneyBeat = { id: 'beat-2', beatType: 'approach', title: '接近山门真相', triggerThreadIds: ['thread-gate'], recommendedSceneIds: ['scene-gate'], emotionalGoal: '先把前情、威胁和方向重新拢到一起。', }; const goalStack = buildGoalStackState({ quests: [], worldType: null, chapterState, journeyBeat, currentSceneName: '山门外缘', }); expect(goalStack.northStarGoal?.sourceKind).toBe('chapter'); expect(goalStack.activeGoal?.sourceKind).toBe('journey_beat'); expect(goalStack.immediateStepGoal?.nextStepText).toContain('前往'); expect(goalStack.immediateStepGoal?.nextStepText).toContain('scene-gate'); expect(describeGoalStackForPrompt(goalStack)).toContain('当前玩家任务推进'); }); it('prefers the current scene chapter quest over unrelated ready quests', () => { const currentSceneQuest = createQuest({ id: 'quest-chapter-scene-court', title: '查明宫苑内庭', sceneId: 'scene-court', chapterId: 'chapter:scene:scene-court', status: 'active', }); const unrelatedReadyQuest = createQuest({ id: 'quest-ready-other', title: '回报断桥调查', sceneId: 'scene-bridge', status: 'ready_to_turn_in', }); const goalStack = buildGoalStackState({ quests: [unrelatedReadyQuest, currentSceneQuest], worldType: null, currentSceneId: 'scene-court', currentSceneName: '宫苑内庭', }); expect(goalStack.activeGoal?.sourceId).toBe('quest-chapter-scene-court'); }); it('annotates options with advance/support affordances and builds quest reward handoff', () => { const readyQuest = createQuest({ id: 'quest-ready', title: '回报遗迹调查', status: 'ready_to_turn_in', issuerNpcName: '陆清', narrativeBinding: { origin: 'ai_compiled', narrativeType: 'investigation', dramaticNeed: '必须确认遗迹的异动来源。', issuerGoal: '拿到调查结果后继续推进旧桥线索。', playerHook: '你已经掌握了最关键的现场信息。', worldReason: '如果再拖下去,线索会继续散掉。', followupHooks: [], }, }); const goalStack = buildGoalStackState({ quests: [readyQuest], worldType: null, currentSceneName: '断桥旧哨', }); const options: StoryOption[] = [ { functionId: 'npc.quest_turn_in', actionText: '把调查结果告诉陆清', visuals: createSceneDirective(), interaction: { kind: 'npc', npcId: 'quest-ready-issuer', action: 'quest_turn_in', questId: 'quest-ready', }, }, { functionId: 'idle_explore_forward', actionText: '继续向前探查', visuals: createSceneDirective(), }, ]; const annotated = annotateStoryOptionsWithGoalAffordance(options, goalStack); expect(annotated[0]?.goalAffordance?.relation).toBe('advance'); expect(annotated[0]?.goalAffordance?.label).toBe('推进当前任务'); expect(annotated[1]?.goalAffordance).toBeNull(); const state = { worldType: null, customWorldProfile: null, playerCharacter: null, runtimeStats: { playTimeMs: 0, lastPlayTickAt: null, hostileNpcsDefeated: 0, questsAccepted: 0, itemsUsed: 0, scenesTraveled: 0, }, currentScene: 'Story', storyHistory: [], storyEngineMemory: { discoveredFactIds: [], inferredFactIds: [], activeThreadIds: [], resolvedScarIds: [], recentCarrierIds: [], recentSignalIds: [], recentCompanionReactions: [], currentChapter: null, currentJourneyBeatId: null, currentJourneyBeat: null, companionArcStates: [], worldMutations: [], chronicle: [], factionTensionStates: [], currentCampEvent: null, currentSetpieceDirective: null, continueGameDigest: null, }, chapterState: null, characterChats: {}, animationState: AnimationState.IDLE, currentEncounter: null, npcInteractionActive: false, currentScenePreset: { id: 'scene-ruins', name: '断桥旧哨', description: '', imageSrc: '', }, sceneHostileNpcs: [], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: false, playerHp: 0, playerMaxHp: 0, playerMana: 0, playerMaxMana: 0, playerSkillCooldowns: {}, activeBuildBuffs: [], activeCombatEffects: [], playerCurrency: 0, playerInventory: [], playerEquipment: { weapon: null, armor: null, relic: null, }, npcStates: {}, quests: [readyQuest], roster: [], companions: [], currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, } satisfies GameState; const handoff = buildGoalHandoffFromState(state); expect(handoff?.title).toContain('陆清'); expect(handoff?.detail).toContain('结果'); }); it('derives pulse events for newly accepted and newly ready quests', () => { const acceptedQuest = createQuest({ id: 'quest-accepted', title: '追查桥上的雾信', status: 'active', issuerNpcName: '陆清', summary: '先去断桥边确认最新痕迹。', }); const acceptedGoalStack = buildGoalStackState({ quests: [acceptedQuest], worldType: null, currentSceneName: '断桥旧哨', }); const acceptPulse = deriveGoalPulseEvent({ previous: createGoalPulseSnapshot([], acceptedGoalStack), quests: [acceptedQuest], goalStack: acceptedGoalStack, }); expect(acceptPulse?.pulseType).toBe('progress'); expect(acceptPulse?.title).toContain('接取'); const readyQuest = createQuest({ id: 'quest-ready', title: '回报遗迹调查', status: 'ready_to_turn_in', issuerNpcName: '陆清', summary: '带着结果回去向陆清交待。', }); const readyGoalStack = buildGoalStackState({ quests: [readyQuest], worldType: null, currentSceneName: '断桥旧哨', }); const readyPulse = deriveGoalPulseEvent({ previous: createGoalPulseSnapshot( [ { ...readyQuest, status: 'active', }, ], readyGoalStack, ), quests: [readyQuest], goalStack: readyGoalStack, }); expect(readyPulse?.pulseType).toBe('ready_to_turn_in'); expect(readyPulse?.detail).toContain('陆清'); }); });