Files
Genarrative/src/services/storyEngine/visibilityEngine.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

255 lines
8.4 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: [],
};
}
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;
}