343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import type {QuestGenerationContext} from '../services/aiTypes';
|
||
import {
|
||
buildFallbackActorNarrativeProfile,
|
||
normalizeActorNarrativeProfile,
|
||
} from '../services/storyEngine/actorNarrativeProfile';
|
||
import { buildThemePackFromWorldProfile } from '../services/storyEngine/themePack';
|
||
import { buildFallbackWorldStoryGraph } from '../services/storyEngine/worldStoryGraph';
|
||
import type {
|
||
EquipmentLoadout,
|
||
GameState,
|
||
RuntimeItemGenerationChannel,
|
||
RuntimeItemGenerationContext,
|
||
ScenePresetInfo,
|
||
} from '../types';
|
||
import {getCharacterCombatTags, getTimedBuildBuffTags, normalizeBuildTags} from './buildTags';
|
||
|
||
type GapDefinition = {
|
||
id: string;
|
||
tags: string[];
|
||
};
|
||
|
||
const BUILD_GAP_DEFINITIONS: GapDefinition[] = [
|
||
{id: 'survival_gap', tags: ['守御', '护体', '回复', '续战']},
|
||
{id: 'mana_gap', tags: ['法力', '冷却', '护持', '过载']},
|
||
{id: 'finisher_gap', tags: ['爆发', '重击', '追击', '压制']},
|
||
{id: 'mobility_gap', tags: ['突进', '快袭', '风行', '游击']},
|
||
{id: 'control_gap', tags: ['控场', '符阵', '镇邪', '反击']},
|
||
];
|
||
|
||
function dedupeStrings(values: Array<string | null | undefined | false>) {
|
||
return [...new Set(
|
||
values
|
||
.map(value => typeof value === 'string' ? value.trim() : '')
|
||
.filter(Boolean),
|
||
)];
|
||
}
|
||
|
||
function collectLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
|
||
if (!loadout) return [] as string[];
|
||
|
||
return normalizeBuildTags([
|
||
...(loadout.weapon?.buildProfile?.tags ?? []),
|
||
loadout.weapon?.buildProfile?.role ?? '',
|
||
...(loadout.armor?.buildProfile?.tags ?? []),
|
||
loadout.armor?.buildProfile?.role ?? '',
|
||
...(loadout.relic?.buildProfile?.tags ?? []),
|
||
loadout.relic?.buildProfile?.role ?? '',
|
||
], 6);
|
||
}
|
||
|
||
function buildSceneTags(scene: Pick<ScenePresetInfo, 'name' | 'description' | 'treasureHints'> | null) {
|
||
if (!scene) return [] as string[];
|
||
|
||
const seedParts = dedupeStrings([
|
||
scene.name,
|
||
...(scene.treasureHints ?? []),
|
||
]);
|
||
|
||
return seedParts
|
||
.flatMap(part => part.split(/[、,。;:\s/]+/u))
|
||
.map(part => part.trim())
|
||
.filter(part => part.length >= 2)
|
||
.slice(0, 8);
|
||
}
|
||
|
||
function buildRecentStorySummary(lines: string[]) {
|
||
if (lines.length <= 0) return '最近没有形成稳定的事件线索。';
|
||
return lines.join(' / ');
|
||
}
|
||
|
||
function buildRecentStoryLines(storyHistory: GameState['storyHistory']) {
|
||
return storyHistory
|
||
.slice(-4)
|
||
.map(moment => moment.text.trim())
|
||
.filter(Boolean)
|
||
.slice(-3);
|
||
}
|
||
|
||
function derivePlayerBuildGaps(playerBuildTags: string[]) {
|
||
const tagSet = new Set(playerBuildTags);
|
||
return BUILD_GAP_DEFINITIONS
|
||
.filter(definition => definition.tags.filter(tag => tagSet.has(tag)).length <= 0)
|
||
.map(definition => definition.id)
|
||
.slice(0, 3);
|
||
}
|
||
|
||
function resolveRelatedNpcNarrativeProfile(params: {
|
||
customWorldProfile: GameState['customWorldProfile'];
|
||
encounter: GameState['currentEncounter'];
|
||
}) {
|
||
const { customWorldProfile, encounter } = params;
|
||
if (!customWorldProfile || !encounter || encounter.kind !== 'npc') {
|
||
return null;
|
||
}
|
||
|
||
const role =
|
||
customWorldProfile.storyNpcs.find((npc) =>
|
||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||
)
|
||
?? customWorldProfile.playableNpcs.find((npc) =>
|
||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||
);
|
||
if (!role) {
|
||
return encounter.narrativeProfile ?? null;
|
||
}
|
||
|
||
const themePack =
|
||
customWorldProfile.themePack ?? buildThemePackFromWorldProfile(customWorldProfile);
|
||
const storyGraph =
|
||
customWorldProfile.storyGraph
|
||
?? buildFallbackWorldStoryGraph(customWorldProfile, themePack);
|
||
|
||
return normalizeActorNarrativeProfile(
|
||
role.narrativeProfile,
|
||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||
);
|
||
}
|
||
|
||
function resolveActiveThreadIds(params: {
|
||
customWorldProfile: GameState['customWorldProfile'];
|
||
relatedNpcNarrativeProfile: RuntimeItemGenerationContext['relatedNpcNarrativeProfile'];
|
||
storyEngineMemory?: GameState['storyEngineMemory'] | QuestGenerationContext['activeThreadIds'];
|
||
}) {
|
||
const threadSource = params.storyEngineMemory;
|
||
if (Array.isArray(threadSource) && threadSource.length > 0) {
|
||
return threadSource.slice(0, 4);
|
||
}
|
||
|
||
if (
|
||
threadSource &&
|
||
!Array.isArray(threadSource) &&
|
||
threadSource.activeThreadIds?.length
|
||
) {
|
||
return threadSource.activeThreadIds.slice(0, 4);
|
||
}
|
||
|
||
if (params.relatedNpcNarrativeProfile?.relatedThreadIds.length) {
|
||
return params.relatedNpcNarrativeProfile.relatedThreadIds.slice(0, 4);
|
||
}
|
||
|
||
if (!params.customWorldProfile) {
|
||
return [];
|
||
}
|
||
|
||
const themePack =
|
||
params.customWorldProfile.themePack
|
||
?? buildThemePackFromWorldProfile(params.customWorldProfile);
|
||
const storyGraph =
|
||
params.customWorldProfile.storyGraph
|
||
?? buildFallbackWorldStoryGraph(params.customWorldProfile, themePack);
|
||
|
||
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
|
||
}
|
||
|
||
function buildBaseRuntimeContext(params: {
|
||
worldType: GameState['worldType'];
|
||
customWorldProfile: GameState['customWorldProfile'];
|
||
scene: Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'treasureHints'> | null;
|
||
encounter: GameState['currentEncounter'];
|
||
relatedNpcState: GameState['npcStates'][string] | null;
|
||
storyEngineMemory?: GameState['storyEngineMemory'] | QuestGenerationContext['activeThreadIds'];
|
||
storyHistory: GameState['storyHistory'];
|
||
playerCharacterId: string;
|
||
playerBuildTags: string[];
|
||
playerEquipmentTags: string[];
|
||
generationChannel: RuntimeItemGenerationChannel;
|
||
}) {
|
||
const {
|
||
worldType,
|
||
customWorldProfile,
|
||
scene,
|
||
encounter,
|
||
relatedNpcState,
|
||
storyEngineMemory,
|
||
storyHistory,
|
||
playerCharacterId,
|
||
playerBuildTags,
|
||
playerEquipmentTags,
|
||
generationChannel,
|
||
} = params;
|
||
const recentStoryLines = buildRecentStoryLines(storyHistory);
|
||
const relatedNpcNarrativeProfile = resolveRelatedNpcNarrativeProfile({
|
||
customWorldProfile,
|
||
encounter,
|
||
});
|
||
const activeThreadIds = resolveActiveThreadIds({
|
||
customWorldProfile,
|
||
relatedNpcNarrativeProfile,
|
||
storyEngineMemory,
|
||
});
|
||
|
||
return {
|
||
worldType,
|
||
customWorldProfile,
|
||
sceneId: scene?.id ?? null,
|
||
sceneName: scene?.name ?? null,
|
||
sceneDescription: scene?.description ?? null,
|
||
sceneTags: buildSceneTags(scene),
|
||
treasureHints: [...(scene?.treasureHints ?? [])],
|
||
encounter: encounter ?? null,
|
||
encounterNpcId: encounter?.id ?? encounter?.characterId ?? encounter?.monsterPresetId ?? encounter?.npcName ?? null,
|
||
encounterNpcName: encounter?.npcName ?? null,
|
||
encounterContextText: encounter?.context ?? null,
|
||
relatedNpcState,
|
||
relatedNpcNarrativeProfile,
|
||
relatedScene: scene,
|
||
recentStorySummary: buildRecentStorySummary(recentStoryLines),
|
||
recentActions: recentStoryLines,
|
||
activeThreadIds,
|
||
playerCharacterId,
|
||
playerBuildTags,
|
||
playerBuildGaps: derivePlayerBuildGaps(playerBuildTags),
|
||
playerEquipmentTags,
|
||
generationChannel,
|
||
} satisfies RuntimeItemGenerationContext;
|
||
}
|
||
|
||
export function buildLooseRuntimeItemGenerationContext(params: {
|
||
worldType: GameState['worldType'];
|
||
customWorldProfile?: GameState['customWorldProfile'];
|
||
scene?: Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'treasureHints'> | null;
|
||
encounter?: GameState['currentEncounter'];
|
||
relatedNpcState?: GameState['npcStates'][string] | null;
|
||
storyHistory?: GameState['storyHistory'];
|
||
playerCharacterId?: string;
|
||
playerBuildTags?: string[];
|
||
playerEquipmentTags?: string[];
|
||
generationChannel: RuntimeItemGenerationChannel;
|
||
}) {
|
||
return buildBaseRuntimeContext({
|
||
worldType: params.worldType,
|
||
customWorldProfile: params.customWorldProfile ?? null,
|
||
scene: params.scene ?? null,
|
||
encounter: params.encounter ?? null,
|
||
relatedNpcState: params.relatedNpcState ?? null,
|
||
storyEngineMemory: params.customWorldProfile?.storyGraph?.visibleThreads.map((thread) => thread.id) ?? [],
|
||
storyHistory: params.storyHistory ?? [],
|
||
playerCharacterId: params.playerCharacterId ?? 'runtime-loose-player',
|
||
playerBuildTags: params.playerBuildTags ?? [],
|
||
playerEquipmentTags: params.playerEquipmentTags ?? [],
|
||
generationChannel: params.generationChannel,
|
||
});
|
||
}
|
||
|
||
export function buildRuntimeItemGenerationContext(params: {
|
||
state: GameState;
|
||
generationChannel: RuntimeItemGenerationChannel;
|
||
encounter?: GameState['currentEncounter'];
|
||
scene?: GameState['currentScenePreset'];
|
||
}) {
|
||
const {state, generationChannel} = params;
|
||
const encounter = params.encounter ?? state.currentEncounter;
|
||
const scene = params.scene ?? state.currentScenePreset;
|
||
const relatedNpcState = encounter
|
||
? state.npcStates[encounter.id ?? encounter.npcName] ?? null
|
||
: null;
|
||
const playerBuildTags = state.playerCharacter
|
||
? normalizeBuildTags([
|
||
...getCharacterCombatTags(state.playerCharacter),
|
||
...collectLoadoutBuildTags(state.playerEquipment),
|
||
...getTimedBuildBuffTags(state.activeBuildBuffs),
|
||
], 6)
|
||
: [];
|
||
|
||
return buildBaseRuntimeContext({
|
||
worldType: state.worldType,
|
||
customWorldProfile: state.customWorldProfile,
|
||
scene,
|
||
encounter,
|
||
relatedNpcState,
|
||
storyEngineMemory: state.storyEngineMemory,
|
||
storyHistory: state.storyHistory,
|
||
playerCharacterId: state.playerCharacter?.id ?? 'unknown-player',
|
||
playerBuildTags,
|
||
playerEquipmentTags: collectLoadoutBuildTags(state.playerEquipment),
|
||
generationChannel,
|
||
});
|
||
}
|
||
|
||
export function buildQuestRuntimeItemGenerationContext(params: {
|
||
context: QuestGenerationContext;
|
||
generationChannel?: RuntimeItemGenerationChannel;
|
||
issuerNpcId: string;
|
||
issuerNpcName: string;
|
||
roleText: string;
|
||
scene?: Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'treasureHints'> | null;
|
||
}) {
|
||
const {
|
||
context,
|
||
issuerNpcId,
|
||
issuerNpcName,
|
||
roleText,
|
||
scene,
|
||
generationChannel = 'quest_reward',
|
||
} = params;
|
||
const playerBuildTags = context.playerCharacter
|
||
? normalizeBuildTags([
|
||
...getCharacterCombatTags(context.playerCharacter),
|
||
...collectLoadoutBuildTags(context.playerEquipment),
|
||
], 6)
|
||
: [];
|
||
|
||
return buildBaseRuntimeContext({
|
||
worldType: context.worldType,
|
||
customWorldProfile: context.customWorldProfile ?? null,
|
||
scene: scene ?? (
|
||
context.currentSceneName
|
||
? {
|
||
id: context.currentSceneId ?? '',
|
||
name: context.currentSceneName,
|
||
description: context.currentSceneDescription ?? '',
|
||
treasureHints: [],
|
||
}
|
||
: null
|
||
),
|
||
encounter: {
|
||
id: issuerNpcId,
|
||
kind: 'npc',
|
||
npcName: issuerNpcName,
|
||
npcDescription: roleText,
|
||
npcAvatar: '',
|
||
context: roleText,
|
||
},
|
||
relatedNpcState: context.issuerAffinity == null
|
||
? null
|
||
: {
|
||
affinity: context.issuerAffinity,
|
||
helpUsed: false,
|
||
chattedCount: 0,
|
||
giftsGiven: 0,
|
||
inventory: [],
|
||
recruited: false,
|
||
revealedFacts: [],
|
||
},
|
||
storyEngineMemory: context.activeThreadIds,
|
||
storyHistory: context.recentStoryMoments ?? [],
|
||
playerCharacterId: context.playerCharacter?.id ?? 'quest-player',
|
||
playerBuildTags,
|
||
playerEquipmentTags: collectLoadoutBuildTags(context.playerEquipment),
|
||
generationChannel,
|
||
});
|
||
}
|