141 lines
4.8 KiB
TypeScript
141 lines
4.8 KiB
TypeScript
import {
|
|
buildFallbackQuestIntent,
|
|
compileQuestIntentToQuest,
|
|
evaluateQuestOpportunity,
|
|
} from '../../../src/data/questFlow.js';
|
|
import { parseJsonResponseText } from '../../../src/services/llmParsers.js';
|
|
import { buildQuestGenerationContextFromState } from '../../../src/services/questDirector.js';
|
|
import { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT } from '../../../src/services/questPrompt.js';
|
|
import type { QuestIntent, QuestPreviewRequest } from '../../../src/services/questTypes.js';
|
|
import type { GameState } from '../../../src/types/game.js';
|
|
import type { Encounter } from '../../../src/types/scene.js';
|
|
import type { QuestLogEntry } from '../../../src/types/story.js';
|
|
import type { UpstreamLlmClient } from './llmClient.js';
|
|
|
|
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 async function generateQuestForNpcEncounter(
|
|
llmClient: UpstreamLlmClient,
|
|
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: QuestLogEntry) => ({
|
|
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 llmClient.requestMessageContent({
|
|
systemPrompt: QUEST_INTENT_SYSTEM_PROMPT,
|
|
userPrompt: buildQuestIntentPrompt({
|
|
context: request.context!,
|
|
scene: request.scene,
|
|
opportunity,
|
|
}),
|
|
});
|
|
const parsed = parseJsonResponseText(content) as { intent?: unknown };
|
|
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
|
|
return compileQuestIntentToQuest(
|
|
{
|
|
...request,
|
|
origin: 'ai_compiled',
|
|
},
|
|
intent,
|
|
);
|
|
} catch {
|
|
return compileQuestIntentToQuest(
|
|
{
|
|
...request,
|
|
origin: 'fallback_builder',
|
|
},
|
|
fallbackIntent,
|
|
);
|
|
}
|
|
}
|