170 lines
5.0 KiB
TypeScript
170 lines
5.0 KiB
TypeScript
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<string | null | undefined>, 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,
|
|
),
|
|
},
|
|
};
|
|
}
|