192
src/services/questDirector.ts
Normal file
192
src/services/questDirector.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {getNpcDisclosureStage, getNpcWarmthStage} from '../data/npcInteractions';
|
||||
import {
|
||||
buildFallbackQuestIntent,
|
||||
compileQuestIntentToQuest,
|
||||
evaluateQuestOpportunity,
|
||||
} from '../data/questFlow';
|
||||
import type {
|
||||
Encounter,
|
||||
GameState,
|
||||
QuestLogEntry,
|
||||
} from '../types';
|
||||
import type {QuestGenerationContext} from './aiTypes';
|
||||
import {requestChatMessageContent} from './llmClient';
|
||||
import {parseJsonResponseText} from './llmParsers';
|
||||
import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt';
|
||||
import type {QuestIntent, QuestPreviewRequest} from './questTypes';
|
||||
|
||||
const QUEST_DIRECTOR_TIMEOUT_MS = 12000;
|
||||
|
||||
function coerceString(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function coerceStringArray(value: unknown, fallback: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const items = value
|
||||
.map(item => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
return items.length > 0 ? items : fallback;
|
||||
}
|
||||
|
||||
function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent {
|
||||
if (!rawIntent || typeof rawIntent !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const intent = rawIntent as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
title: coerceString(intent.title, fallback.title),
|
||||
description: coerceString(intent.description, fallback.description),
|
||||
summary: coerceString(intent.summary, fallback.summary),
|
||||
narrativeType: (
|
||||
typeof intent.narrativeType === 'string'
|
||||
&& ['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType)
|
||||
)
|
||||
? intent.narrativeType as QuestIntent['narrativeType']
|
||||
: fallback.narrativeType,
|
||||
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
|
||||
issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal),
|
||||
playerHook: coerceString(intent.playerHook, fallback.playerHook),
|
||||
worldReason: coerceString(intent.worldReason, fallback.worldReason),
|
||||
recommendedObjectiveKinds: coerceStringArray(intent.recommendedObjectiveKinds, fallback.recommendedObjectiveKinds)
|
||||
.filter(kind => [
|
||||
'defeat_hostile_npc',
|
||||
'inspect_treasure',
|
||||
'spar_with_npc',
|
||||
'talk_to_npc',
|
||||
'reach_scene',
|
||||
'deliver_item',
|
||||
].includes(kind)) as QuestIntent['recommendedObjectiveKinds'],
|
||||
urgency: (
|
||||
typeof intent.urgency === 'string'
|
||||
&& ['low', 'medium', 'high'].includes(intent.urgency)
|
||||
)
|
||||
? intent.urgency as QuestIntent['urgency']
|
||||
: fallback.urgency,
|
||||
intimacy: (
|
||||
typeof intent.intimacy === 'string'
|
||||
&& ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
|
||||
)
|
||||
? intent.intimacy as QuestIntent['intimacy']
|
||||
: fallback.intimacy,
|
||||
rewardTheme: (
|
||||
typeof intent.rewardTheme === 'string'
|
||||
&& ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme)
|
||||
)
|
||||
? intent.rewardTheme as QuestIntent['rewardTheme']
|
||||
: fallback.rewardTheme,
|
||||
followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQuestGenerationContextFromState(params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
}): QuestGenerationContext {
|
||||
const {state, encounter} = params;
|
||||
const issuerNpcId = encounter.id ?? encounter.npcName;
|
||||
const issuerState = state.npcStates[issuerNpcId];
|
||||
|
||||
return {
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile ?? null,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
currentSceneDescription: state.currentScenePreset?.description ?? null,
|
||||
issuerNpcId,
|
||||
issuerNpcName: encounter.npcName,
|
||||
issuerNpcContext: encounter.context,
|
||||
issuerAffinity: issuerState?.affinity ?? 0,
|
||||
issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0),
|
||||
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0),
|
||||
encounterKind: encounter.kind ?? 'npc',
|
||||
currentSceneTreasureHintCount: state.currentScenePreset?.treasureHints?.length ?? 0,
|
||||
currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? [])
|
||||
.filter(npc => Boolean(npc.hostile || npc.monsterPresetId))
|
||||
.map(npc => npc.id),
|
||||
recentStoryMoments: state.storyHistory.slice(-6),
|
||||
playerCharacter: state.playerCharacter,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
playerInventory: state.playerInventory,
|
||||
playerEquipment: state.playerEquipment,
|
||||
activeCompanions: state.companions,
|
||||
rosterCompanions: state.roster,
|
||||
currentQuestSummary: state.quests.map(quest => ({
|
||||
id: quest.id,
|
||||
title: quest.title,
|
||||
status: quest.status,
|
||||
issuerNpcId: quest.issuerNpcId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateQuestForNpcEncounter(params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
}): Promise<QuestLogEntry | null> {
|
||||
const {state, encounter} = params;
|
||||
const issuerNpcId = encounter.id ?? encounter.npcName;
|
||||
const request: QuestPreviewRequest = {
|
||||
issuerNpcId,
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: state.currentScenePreset,
|
||||
worldType: state.worldType,
|
||||
currentQuests: state.quests.map(quest => ({
|
||||
id: quest.id,
|
||||
issuerNpcId: quest.issuerNpcId,
|
||||
status: quest.status,
|
||||
})),
|
||||
context: buildQuestGenerationContextFromState({state, encounter}),
|
||||
origin: 'ai_compiled',
|
||||
};
|
||||
const opportunity = evaluateQuestOpportunity(request);
|
||||
if (!opportunity.shouldOffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackIntent = buildFallbackQuestIntent(request);
|
||||
|
||||
try {
|
||||
const content = await requestChatMessageContent(
|
||||
QUEST_INTENT_SYSTEM_PROMPT,
|
||||
buildQuestIntentPrompt({
|
||||
context: request.context!,
|
||||
scene: request.scene,
|
||||
opportunity,
|
||||
}),
|
||||
{
|
||||
timeoutMs: QUEST_DIRECTOR_TIMEOUT_MS,
|
||||
debugLabel: 'quest-intent',
|
||||
},
|
||||
);
|
||||
const parsed = parseJsonResponseText(content) as {intent?: unknown};
|
||||
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...request,
|
||||
origin: 'ai_compiled',
|
||||
},
|
||||
intent,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[QuestDirector] falling back to deterministic quest intent', error);
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...request,
|
||||
origin: 'fallback_builder',
|
||||
},
|
||||
fallbackIntent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user