270 lines
8.8 KiB
TypeScript
270 lines
8.8 KiB
TypeScript
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 { requestJson } from './apiClient';
|
||
import {requestChatMessageContent} from './llmClient';
|
||
import {parseJsonResponseText} from './llmParsers';
|
||
import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt';
|
||
import type {QuestIntent, QuestPreviewRequest} from './questTypes';
|
||
import {
|
||
buildFallbackActorNarrativeProfile,
|
||
normalizeActorNarrativeProfile,
|
||
} from './storyEngine/actorNarrativeProfile';
|
||
import { buildThemePackFromWorldProfile } from './storyEngine/themePack';
|
||
import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph';
|
||
|
||
const QUEST_DIRECTOR_TIMEOUT_MS = 12000;
|
||
|
||
function coerceString(value: unknown, fallback: string) {
|
||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||
}
|
||
|
||
function coerceQuestTitle(value: unknown, fallback: string) {
|
||
const title = coerceString(value, fallback)
|
||
.replace(/[《》「」“”"']/gu, '')
|
||
.replace(/[,。!?;:,.!?;:].*$/u, '')
|
||
.trim();
|
||
|
||
if (title.length <= 12) {
|
||
return title;
|
||
}
|
||
|
||
return fallback.length <= 12 ? fallback : fallback.slice(0, 10);
|
||
}
|
||
|
||
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 resolveIssuerNarrativeProfile(
|
||
state: GameState,
|
||
encounter: Encounter,
|
||
) {
|
||
if (encounter.narrativeProfile) {
|
||
return encounter.narrativeProfile;
|
||
}
|
||
if (!state.customWorldProfile) {
|
||
return null;
|
||
}
|
||
|
||
const role =
|
||
state.customWorldProfile.storyNpcs.find((npc) =>
|
||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||
)
|
||
?? state.customWorldProfile.playableNpcs.find((npc) =>
|
||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||
);
|
||
if (!role) {
|
||
return null;
|
||
}
|
||
|
||
const themePack =
|
||
state.customWorldProfile.themePack
|
||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||
const storyGraph =
|
||
state.customWorldProfile.storyGraph
|
||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||
|
||
return normalizeActorNarrativeProfile(
|
||
role.narrativeProfile,
|
||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||
);
|
||
}
|
||
|
||
function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent {
|
||
if (!rawIntent || typeof rawIntent !== 'object') {
|
||
return fallback;
|
||
}
|
||
|
||
const intent = rawIntent as Record<string, unknown>;
|
||
|
||
return {
|
||
title: coerceQuestTitle(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];
|
||
const issuerNarrativeProfile = resolveIssuerNarrativeProfile(state, encounter);
|
||
|
||
return {
|
||
worldType: state.worldType,
|
||
customWorldProfile: state.customWorldProfile ?? null,
|
||
actState: state.storyEngineMemory?.actState ?? 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,
|
||
issuerNarrativeProfile,
|
||
issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0),
|
||
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0),
|
||
activeThreadIds:
|
||
state.storyEngineMemory?.activeThreadIds?.slice(0, 4)
|
||
?? issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4)
|
||
?? [],
|
||
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);
|
||
|
||
if (typeof window !== 'undefined') {
|
||
try {
|
||
return await requestJson<QuestLogEntry | null>(
|
||
'/api/runtime/quests/generate',
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
},
|
||
'任务生成失败',
|
||
);
|
||
} catch (error) {
|
||
console.warn('[QuestDirector] backend quest generation failed, falling back', error);
|
||
}
|
||
}
|
||
|
||
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,
|
||
);
|
||
}
|
||
}
|