Files
Genarrative/src/services/storyEngine/goalDirector.test.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

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('陆清');
});
});