import type { CustomWorldProfile, Encounter, GameState, InventoryItem, NpcPersistentState, } from '../../types'; import { buildFallbackActorNarrativeProfile, normalizeActorNarrativeProfile, } from './actorNarrativeProfile'; import { buildThemePackFromWorldProfile } from './themePack'; import { buildEncounterVisibilitySlice, createEmptyStoryEngineMemoryState, } from './visibilityEngine'; import { buildFallbackWorldStoryGraph } from './worldStoryGraph'; function dedupeStrings(values: Array, limit = 16) { return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))] .slice(-limit); } function resolveDisclosureStage(npcState: NpcPersistentState) { if (npcState.recruited || npcState.affinity >= 50) return 'deep' as const; if (npcState.affinity >= 30) return 'honest' as const; if (npcState.affinity >= 15) return 'partial' as const; return 'guarded' as const; } function resolveEncounterNarrativeProfile( customWorldProfile: CustomWorldProfile | null | undefined, encounter: Encounter, ) { if (encounter.narrativeProfile) { return encounter.narrativeProfile; } if (!customWorldProfile) { 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 null; } const themePack = customWorldProfile.themePack ?? buildThemePackFromWorldProfile(customWorldProfile); const storyGraph = customWorldProfile.storyGraph ?? buildFallbackWorldStoryGraph(customWorldProfile, themePack); return normalizeActorNarrativeProfile( role.narrativeProfile, buildFallbackActorNarrativeProfile(role, storyGraph, themePack), ); } export function syncNpcNarrativeState(params: { encounter: Encounter; npcState: NpcPersistentState; customWorldProfile?: CustomWorldProfile | null; storyEngineMemory?: GameState['storyEngineMemory']; }) { const { encounter, npcState, customWorldProfile } = params; if (encounter.kind !== 'npc') { return npcState; } const narrativeProfile = resolveEncounterNarrativeProfile( customWorldProfile, encounter, ); if (!narrativeProfile) { return npcState; } const storyEngineMemory = params.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); const activeThreadIds = storyEngineMemory.activeThreadIds.length > 0 ? storyEngineMemory.activeThreadIds : narrativeProfile.relatedThreadIds; const visibilitySlice = buildEncounterVisibilitySlice({ narrativeProfile, backstoryReveal: encounter.backstoryReveal ?? null, disclosureStage: resolveDisclosureStage(npcState), isFirstMeaningfulContact: npcState.firstMeaningfulContactResolved !== true, seenBackstoryChapterIds: npcState.seenBackstoryChapterIds ?? [], storyEngineMemory, activeThreadIds, }); return { ...npcState, revealedFacts: dedupeStrings([ ...(npcState.revealedFacts ?? []), ...visibilitySlice.sayableFactIds.filter((factId) => !factId.startsWith('chapter:')), ...visibilitySlice.inferredFactIds.filter( (factId) => factId === 'contradiction' || factId.startsWith('thread:') || factId.startsWith('scar:'), ), ], 20), seenBackstoryChapterIds: dedupeStrings([ ...(npcState.seenBackstoryChapterIds ?? []), ...visibilitySlice.sayableFactIds .filter((factId) => factId.startsWith('chapter:')) .map((factId) => factId.slice('chapter:'.length)), ], 8), }; } export function appendStoryEngineCarrierMemory( state: GameState, items: InventoryItem[], ) { const storyEngineMemory = state.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); const carriers = items.filter((item) => item.runtimeMetadata?.storyFingerprint); if (carriers.length <= 0) { return { ...state, storyEngineMemory, }; } const recentCarrierIds = dedupeStrings([ ...storyEngineMemory.recentCarrierIds, ...carriers.map((item) => item.id), ], 8); const scarIds = carriers.flatMap( (item) => item.runtimeMetadata?.storyFingerprint?.relatedScarIds ?? [], ); const threadIds = carriers.flatMap( (item) => item.runtimeMetadata?.storyFingerprint?.relatedThreadIds ?? [], ); const visibleClues = carriers.flatMap((item) => { const clue = item.runtimeMetadata?.storyFingerprint?.visibleClue; return clue ? [clue] : []; }); return { ...state, storyEngineMemory: { ...storyEngineMemory, recentCarrierIds, resolvedScarIds: dedupeStrings( [...storyEngineMemory.resolvedScarIds, ...scarIds], 10, ), activeThreadIds: dedupeStrings( [...storyEngineMemory.activeThreadIds, ...threadIds], 8, ), discoveredFactIds: dedupeStrings( [...storyEngineMemory.discoveredFactIds, ...visibleClues], 24, ), }, }; }