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,
|
|
});
|
|
}
|