392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
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('陆清');
|
|
});
|
|
});
|