This commit is contained in:
254
src/services/storyEngine/visibilityEngine.ts
Normal file
254
src/services/storyEngine/visibilityEngine.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
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 = params.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
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 = params.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user