Files
Genarrative/src/services/storyEngine/visibilityEngine.ts
2026-04-28 19:36:39 +08:00

316 lines
11 KiB
TypeScript

import type {
ActorNarrativeProfile,
CharacterBackstoryRevealConfig,
NpcDisclosureStage,
StoryEngineMemoryState,
VisibilitySlice,
} from '../../types';
type EncounterVisibilityParams = {
narrativeProfile?: ActorNarrativeProfile | null;
backstoryReveal?: CharacterBackstoryRevealConfig | null;
disclosureStage?: NpcDisclosureStage | null;
isFirstMeaningfulContact?: boolean;
seenBackstoryChapterIds?: string[] | null;
storyEngineMemory?: StoryEngineMemoryState | null;
activeThreadIds?: string[] | null;
};
type QuestVisibilityParams = {
issuerNarrativeProfile?: ActorNarrativeProfile | null;
storyEngineMemory?: StoryEngineMemoryState | null;
activeThreadIds?: string[] | null;
};
type CarrierVisibilityParams = {
activeThreadIds?: string[] | null;
storyEngineMemory?: StoryEngineMemoryState | null;
storyFingerprint?: {
visibleClue?: string;
witnessMark?: string;
unresolvedQuestion?: string;
currentAppearanceReason?: string;
} | null;
};
function dedupeStrings(values: Array<string | null | undefined>, limit = 12) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
return {
discoveredFactIds: [],
inferredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
openedSceneChapterIds: [],
currentSceneActState: null,
recentSignalIds: [],
recentCompanionReactions: [],
currentChapter: null,
currentJourneyBeatId: null,
currentJourneyBeat: null,
companionArcStates: [],
worldMutations: [],
chronicle: [],
factionTensionStates: [],
currentCampEvent: null,
currentSetpieceDirective: null,
continueGameDigest: null,
campaignState: null,
actState: null,
consequenceLedger: [],
companionResolutions: [],
endingState: null,
authorialConstraintPack: null,
branchBudgetStatus: null,
narrativeQaReport: null,
narrativeCodex: [],
};
}
export function normalizeStoryEngineMemoryState(
memory?: Partial<StoryEngineMemoryState> | null,
): StoryEngineMemoryState {
const empty = createEmptyStoryEngineMemoryState();
if (!memory) return empty;
// 后端投影或旧存档可能只带增量字段,前端消费前统一补齐数组字段。
return {
...empty,
...memory,
discoveredFactIds: Array.isArray(memory.discoveredFactIds)
? memory.discoveredFactIds
: empty.discoveredFactIds,
inferredFactIds: Array.isArray(memory.inferredFactIds)
? memory.inferredFactIds
: empty.inferredFactIds,
activeThreadIds: Array.isArray(memory.activeThreadIds)
? memory.activeThreadIds
: empty.activeThreadIds,
resolvedScarIds: Array.isArray(memory.resolvedScarIds)
? memory.resolvedScarIds
: empty.resolvedScarIds,
recentCarrierIds: Array.isArray(memory.recentCarrierIds)
? memory.recentCarrierIds
: empty.recentCarrierIds,
openedSceneChapterIds: Array.isArray(memory.openedSceneChapterIds)
? memory.openedSceneChapterIds
: empty.openedSceneChapterIds,
recentSignalIds: Array.isArray(memory.recentSignalIds)
? memory.recentSignalIds
: empty.recentSignalIds,
recentCompanionReactions: Array.isArray(memory.recentCompanionReactions)
? memory.recentCompanionReactions
: empty.recentCompanionReactions,
companionArcStates: Array.isArray(memory.companionArcStates)
? memory.companionArcStates
: empty.companionArcStates,
worldMutations: Array.isArray(memory.worldMutations)
? memory.worldMutations
: empty.worldMutations,
chronicle: Array.isArray(memory.chronicle)
? memory.chronicle
: empty.chronicle,
factionTensionStates: Array.isArray(memory.factionTensionStates)
? memory.factionTensionStates
: empty.factionTensionStates,
consequenceLedger: Array.isArray(memory.consequenceLedger)
? memory.consequenceLedger
: empty.consequenceLedger,
companionResolutions: Array.isArray(memory.companionResolutions)
? memory.companionResolutions
: empty.companionResolutions,
narrativeCodex: Array.isArray(memory.narrativeCodex)
? memory.narrativeCodex
: empty.narrativeCodex,
simulationRunResults: Array.isArray(memory.simulationRunResults)
? memory.simulationRunResults
: empty.simulationRunResults,
};
}
function buildBaseFactIds(
narrativeProfile?: ActorNarrativeProfile | null,
backstoryReveal?: CharacterBackstoryRevealConfig | null,
) {
return dedupeStrings([
narrativeProfile ? 'publicMask' : null,
narrativeProfile ? 'firstContactMask' : null,
narrativeProfile ? 'visibleLine' : null,
narrativeProfile ? 'hiddenLine' : null,
narrativeProfile ? 'contradiction' : null,
narrativeProfile ? 'debtOrBurden' : null,
narrativeProfile ? 'taboo' : null,
narrativeProfile ? 'immediatePressure' : null,
...(narrativeProfile?.relatedThreadIds ?? []).map((id) => `thread:${id}`),
...(narrativeProfile?.relatedScarIds ?? []).map((id) => `scar:${id}`),
...(backstoryReveal?.chapters ?? []).map((chapter) => `chapter:${chapter.id}`),
]);
}
function resolveUnlockedChapterIds(
backstoryReveal?: CharacterBackstoryRevealConfig | null,
disclosureStage?: NpcDisclosureStage | null,
seenBackstoryChapterIds?: string[] | null,
) {
const explicitlySeen = new Set(
(seenBackstoryChapterIds ?? []).filter((chapterId) => typeof chapterId === 'string'),
);
return (backstoryReveal?.chapters ?? [])
.filter((chapter, index) => {
if (explicitlySeen.has(chapter.id)) return true;
if (disclosureStage === 'partial') return index <= 0;
if (disclosureStage === 'honest') return index <= 1;
if (disclosureStage === 'deep') return true;
return false;
})
.map((chapter) => `chapter:${chapter.id}`);
}
export function buildEncounterVisibilitySlice(
params: EncounterVisibilityParams,
) {
const memory = normalizeStoryEngineMemoryState(params.storyEngineMemory);
const factIds = buildBaseFactIds(params.narrativeProfile, params.backstoryReveal);
const unlockedChapterIds = resolveUnlockedChapterIds(
params.backstoryReveal,
params.disclosureStage,
params.seenBackstoryChapterIds,
);
const activeThreadFactIds = dedupeStrings([
...(params.activeThreadIds ?? []).map((id) => `thread:${id}`),
...(memory.activeThreadIds ?? []).map((id) => `thread:${id}`),
], 6);
const sayableFactIds = dedupeStrings([
'publicMask',
'firstContactMask',
'visibleLine',
'immediatePressure',
...unlockedChapterIds,
...(params.disclosureStage === 'honest' || params.disclosureStage === 'deep'
? activeThreadFactIds
: activeThreadFactIds.slice(0, 1)),
], 10);
const inferredFactIds = dedupeStrings([
'contradiction',
...activeThreadFactIds,
...(params.narrativeProfile?.reactionHooks.length
? params.narrativeProfile.reactionHooks.map((_, index) => `reaction:${index + 1}`)
: []),
], 8);
const forbiddenFactIds = dedupeStrings([
'hiddenLine',
'debtOrBurden',
'taboo',
...(params.narrativeProfile?.relatedScarIds ?? []).map((id) => `scar:${id}`),
...(params.backstoryReveal?.chapters ?? [])
.map((chapter) => `chapter:${chapter.id}`)
.filter((id) => !unlockedChapterIds.includes(id)),
], 12);
if (params.isFirstMeaningfulContact) {
return {
factIds,
sayableFactIds: sayableFactIds.filter((factId) =>
['publicMask', 'firstContactMask', 'visibleLine', 'immediatePressure'].includes(
factId,
) || factId.startsWith('chapter:'),
),
inferredFactIds: inferredFactIds.filter((factId) =>
factId === 'contradiction' || factId.startsWith('thread:'),
),
forbiddenFactIds,
misdirectionHints: dedupeStrings([
params.narrativeProfile?.contradiction
? '对方会先拿表层说辞遮住真正的牵连。'
: null,
params.narrativeProfile?.taboo
? `提到${params.narrativeProfile.taboo}时,对方会本能地把话题拨开。`
: null,
], 3),
} satisfies VisibilitySlice;
}
return {
factIds,
sayableFactIds,
inferredFactIds,
forbiddenFactIds,
misdirectionHints: dedupeStrings([
params.narrativeProfile?.contradiction
? '可让模型写出“这句话不全对”的缝隙感,但不要直接盖章真相。'
: null,
params.disclosureStage === 'guarded'
? '优先谈眼前压力与表层理由,不要主动摊开完整来历。'
: null,
], 3),
} satisfies VisibilitySlice;
}
export function buildQuestVisibilitySlice(
params: QuestVisibilityParams,
) {
const memory = normalizeStoryEngineMemoryState(params.storyEngineMemory);
const narrativeProfile = params.issuerNarrativeProfile;
const factIds = dedupeStrings([
narrativeProfile ? 'publicMask' : null,
narrativeProfile ? 'visibleLine' : null,
narrativeProfile ? 'immediatePressure' : null,
narrativeProfile ? 'contradiction' : null,
...(params.activeThreadIds ?? []).map((id) => `thread:${id}`),
...(memory.activeThreadIds ?? []).map((id) => `thread:${id}`),
]);
return {
factIds,
sayableFactIds: dedupeStrings([
'publicMask',
'visibleLine',
'immediatePressure',
...(params.activeThreadIds ?? []).map((id) => `thread:${id}`),
], 8),
inferredFactIds: dedupeStrings(['contradiction'], 2),
forbiddenFactIds: dedupeStrings([
narrativeProfile?.hiddenLine ? 'hiddenLine' : null,
narrativeProfile?.taboo ? 'taboo' : null,
], 4),
misdirectionHints: dedupeStrings([
narrativeProfile?.contradiction
? '任务发布者会给出能说得过去的表层理由,但不必一次把全部牵连说透。'
: null,
], 2),
} satisfies VisibilitySlice;
}
export function buildCarrierVisibilitySlice(
params: CarrierVisibilityParams,
) {
const factIds = dedupeStrings([
params.storyFingerprint?.visibleClue ? 'visibleClue' : null,
params.storyFingerprint?.witnessMark ? 'witnessMark' : null,
params.storyFingerprint?.unresolvedQuestion ? 'unresolvedQuestion' : null,
params.storyFingerprint?.currentAppearanceReason ? 'currentAppearanceReason' : null,
...(params.activeThreadIds ?? []).map((id) => `thread:${id}`),
], 8);
return {
factIds,
sayableFactIds: dedupeStrings([
'visibleClue',
'currentAppearanceReason',
...(params.activeThreadIds ?? []).map((id) => `thread:${id}`),
], 6),
inferredFactIds: dedupeStrings(['witnessMark', 'unresolvedQuestion'], 4),
forbiddenFactIds: [],
misdirectionHints: dedupeStrings([
params.storyFingerprint?.unresolvedQuestion
? '保留一点未完成的问题感,不要把物件背后的旧事一次解释到底。'
: null,
], 2),
} satisfies VisibilitySlice;
}