Files
Genarrative/server-node/src/services/questService.ts
2026-04-10 15:37:02 +08:00

141 lines
4.9 KiB
TypeScript

import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/story.js';
import {
QUEST_INTIMACY_LEVELS,
QUEST_NARRATIVE_TYPES,
QUEST_OBJECTIVE_KINDS,
QUEST_REWARD_THEMES,
QUEST_URGENCY_LEVELS,
} from '../../../packages/shared/src/contracts/story.js';
import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import {
buildFallbackQuestIntent,
compileQuestIntentToQuest,
evaluateQuestOpportunity,
buildQuestGenerationContextFromState,
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,
} from '../bridges/legacyQuestRuntimeBridge.js';
import type { UpstreamLlmClient } from './llmClient.js';
type QuestPreviewRequest = Parameters<typeof evaluateQuestOpportunity>[0];
type QuestIntent = ReturnType<typeof buildFallbackQuestIntent>;
type QuestGenerationInput = Parameters<typeof buildQuestGenerationContextFromState>[0];
type QuestGenerationState = QuestGenerationInput['state'];
type QuestGenerationEncounter = QuestGenerationInput['encounter'];
type QuestLogEntry = ReturnType<typeof compileQuestIntentToQuest>;
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' &&
QUEST_NARRATIVE_TYPES.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) => QUEST_OBJECTIVE_KINDS.includes(kind)) as QuestIntent['recommendedObjectiveKinds'],
urgency:
typeof intent.urgency === 'string' &&
QUEST_URGENCY_LEVELS.includes(intent.urgency)
? (intent.urgency as QuestIntent['urgency'])
: fallback.urgency,
intimacy:
typeof intent.intimacy === 'string' &&
QUEST_INTIMACY_LEVELS.includes(intent.intimacy)
? (intent.intimacy as QuestIntent['intimacy'])
: fallback.intimacy,
rewardTheme:
typeof intent.rewardTheme === 'string' &&
QUEST_REWARD_THEMES.includes(intent.rewardTheme)
? (intent.rewardTheme as QuestIntent['rewardTheme'])
: fallback.rewardTheme,
followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks),
};
}
export async function generateQuestForNpcEncounter(
llmClient: UpstreamLlmClient,
params: QuestGenerationRequest<QuestGenerationState, QuestGenerationEncounter>,
): 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 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,
);
}
}