This commit is contained in:
391
src/services/storyEngine/goalDirector.test.ts
Normal file
391
src/services/storyEngine/goalDirector.test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
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<QuestLogEntry> & Pick<QuestLogEntry, 'id' | 'title'>): 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('陆清');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user