Files
Genarrative/src/services/storyEngine/echoMemory.ts
高物 ddcb5d5c8c
Some checks failed
CI / verify (push) Has been cancelled
Rework story engine flow and reorganize project docs
2026-04-06 23:19:00 +08:00

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