Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
169
src/services/storyEngine/echoMemory.ts
Normal file
169
src/services/storyEngine/echoMemory.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
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,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user