Files
Genarrative/src/services/storyEngine/worldStoryGraph.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

379 lines
12 KiB
TypeScript

import type {
CustomWorldProfile,
StoryMotif,
StoryScar,
StoryThread,
ThemePack,
WorldStoryGraph,
} from '../../types';
function dedupeStrings(values: Array<string | null | undefined>, limit = 8) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
.slice(0, limit);
}
function slugify(value: string) {
const ascii = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/giu, '-')
.replace(/^-+|-+$/g, '');
return ascii || 'story';
}
function createId(prefix: string, label: string, index: number) {
return `${prefix}:${slugify(label)}:${index + 1}`;
}
function matchByText(
source: string,
items: Array<{ id: string; name?: string; title?: string }>,
) {
const text = source.trim();
if (!text) return [] as string[];
return dedupeStrings(
items.flatMap((item) => {
const candidates = [item.name ?? '', item.title ?? ''];
return candidates.some((candidate) => candidate && text.includes(candidate))
? [item.id]
: [];
}),
6,
);
}
function matchFactionIds(
text: string,
factions: string[] | undefined,
index: number,
) {
const normalizedFactions = factions?.filter(Boolean) ?? [];
const matched = normalizedFactions.filter((faction) => text.includes(faction));
if (matched.length > 0) {
return dedupeStrings(
matched.map((item, itemIndex) => `faction:${slugify(item)}:${itemIndex + 1}`),
4,
);
}
const fallbackFaction = normalizedFactions[index % Math.max(normalizedFactions.length, 1)];
return fallbackFaction
? [`faction:${slugify(fallbackFaction)}:${index + 1}`]
: [];
}
function buildThreadSummaryText(profile: CustomWorldProfile, conflict: string, index: number) {
const landmark = profile.landmarks[index % Math.max(profile.landmarks.length, 1)];
const landmarkText = landmark ? `,焦点常落在${landmark.name}` : '';
return `${conflict}${landmarkText}`;
}
function buildVisibleThreads(
profile: CustomWorldProfile,
themePack: ThemePack,
) {
const conflictSeeds = dedupeStrings([
...(profile.coreConflicts ?? []),
profile.summary,
profile.playerGoal,
], 4);
return conflictSeeds.map((conflict, index) => {
const sourceText = [
conflict,
profile.summary,
profile.playerGoal,
profile.majorFactions?.[index] ?? '',
].join(' ');
return {
id: createId('visible-thread', conflict, index),
title:
conflict.length <= 14
? conflict
: `${themePack.conflictForms[index % themePack.conflictForms.length] ?? '明线冲突'}线`,
visibility: 'visible',
summary: buildThreadSummaryText(profile, conflict, index),
conflictType:
themePack.conflictForms[index % themePack.conflictForms.length] ?? '冲突',
stakes: profile.playerGoal || profile.summary,
involvedFactionIds: matchFactionIds(conflict, profile.majorFactions, index),
involvedActorIds: matchByText(sourceText, profile.storyNpcs),
relatedLocationIds: matchByText(sourceText, profile.landmarks),
} satisfies StoryThread;
});
}
function buildHiddenThreads(
profile: CustomWorldProfile,
themePack: ThemePack,
visibleThreads: StoryThread[],
) {
const fallbackThreadIds = visibleThreads.map((thread) => thread.id);
return profile.storyNpcs.slice(0, 6).map((npc, index) => {
const visibleThread = visibleThreads[index % Math.max(visibleThreads.length, 1)];
const landmarkIds = matchByText(
[npc.description, npc.backstory, npc.motivation, ...npc.relationshipHooks].join(' '),
profile.landmarks,
);
return {
id: createId('hidden-thread', npc.name || npc.role || '暗线', index),
title: `${npc.name}的隐线`,
visibility: 'hidden',
summary: `${npc.name}并不只是${npc.role},他与${visibleThread?.title ?? '世界旧事'}之间还有一段未被说破的牵连。`,
conflictType:
themePack.conflictForms[(index + 1) % themePack.conflictForms.length] ?? '暗线纠葛',
stakes: npc.motivation || npc.backstory || profile.playerGoal,
involvedFactionIds: matchFactionIds(
[npc.role, npc.description, npc.backstory, ...npc.tags].join(' '),
profile.majorFactions,
index,
),
involvedActorIds: dedupeStrings([
npc.id,
...matchByText(
[npc.backstory, npc.motivation, ...npc.relationshipHooks].join(' '),
profile.storyNpcs,
),
], 4),
relatedLocationIds:
landmarkIds.length > 0
? landmarkIds
: visibleThread?.relatedLocationIds.length
? visibleThread.relatedLocationIds
: dedupeStrings(
profile.landmarks.slice(0, 2).map((landmark) => landmark.id),
2,
),
} satisfies StoryThread;
}).map((thread, index) => ({
...thread,
involvedFactionIds:
thread.involvedFactionIds.length > 0
? thread.involvedFactionIds
: fallbackThreadIds[index]
? [`echo:${fallbackThreadIds[index]}`]
: [],
}));
}
function buildScars(
profile: CustomWorldProfile,
hiddenThreads: StoryThread[],
) {
return profile.landmarks.slice(0, 8).map((landmark, index) => {
const hiddenThread = hiddenThreads[index % Math.max(hiddenThreads.length, 1)];
const relatedActors = dedupeStrings([
...landmark.sceneNpcIds,
...matchByText(landmark.description, profile.storyNpcs),
], 4);
return {
id: createId('scar', landmark.name, index),
title: `${landmark.name}留下的旧痕`,
pastEvent: `${landmark.name}曾卷入${hiddenThread?.title ?? profile.playerGoal}相关的旧事。`,
publicResidue: landmark.description,
hiddenTruth:
hiddenThread?.summary ??
`${landmark.name}表面的平静下,仍压着一段没人愿意先说破的往事。`,
relatedActorIds: relatedActors,
relatedLocationIds: [landmark.id],
} satisfies StoryScar;
});
}
function buildMotifs(
profile: CustomWorldProfile,
themePack: ThemePack,
) {
const seeds = dedupeStrings([
...themePack.institutionLexicon.slice(0, 4),
...themePack.tabooLexicon.slice(0, 4),
...themePack.artifactClasses.slice(0, 4),
...profile.majorFactions,
...profile.landmarks.map((landmark) => landmark.name),
], 16);
return seeds.map((label, index) => {
const semanticRole: StoryMotif['semanticRole'] =
themePack.institutionLexicon.includes(label)
? 'institution'
: themePack.tabooLexicon.includes(label)
? 'taboo'
: themePack.artifactClasses.includes(label)
? 'resource'
: index % 3 === 0
? 'memory'
: index % 3 === 1
? 'ruin'
: 'ritual';
return {
id: createId('motif', label, index),
label,
semanticRole,
lexicalHints: dedupeStrings([
label,
themePack.namingPatterns[index % themePack.namingPatterns.length],
themePack.clueForms[index % themePack.clueForms.length],
], 4),
} satisfies StoryMotif;
});
}
function normalizeThreadList(value: unknown, fallback: StoryThread[]) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
.map((item, index) => ({
id:
typeof item.id === 'string' && item.id.trim()
? item.id.trim()
: fallback[index]?.id ?? createId('thread', String(item.title ?? 'thread'), index),
title:
typeof item.title === 'string' && item.title.trim()
? item.title.trim()
: fallback[index]?.title ?? `线程 ${index + 1}`,
visibility:
item.visibility === 'hidden' || item.visibility === 'visible'
? item.visibility
: fallback[index]?.visibility ?? 'visible',
summary:
typeof item.summary === 'string' && item.summary.trim()
? item.summary.trim()
: fallback[index]?.summary ?? '',
conflictType:
typeof item.conflictType === 'string' && item.conflictType.trim()
? item.conflictType.trim()
: fallback[index]?.conflictType ?? '冲突',
stakes:
typeof item.stakes === 'string' && item.stakes.trim()
? item.stakes.trim()
: fallback[index]?.stakes ?? '',
involvedFactionIds: dedupeStrings(item.involvedFactionIds as string[], 6),
involvedActorIds: dedupeStrings(item.involvedActorIds as string[], 6),
relatedLocationIds: dedupeStrings(item.relatedLocationIds as string[], 6),
}) satisfies StoryThread)
.filter((thread) => thread.title && thread.summary);
return normalized.length > 0 ? normalized : fallback;
}
function normalizeScarList(value: unknown, fallback: StoryScar[]) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
.map((item, index) => ({
id:
typeof item.id === 'string' && item.id.trim()
? item.id.trim()
: fallback[index]?.id ?? createId('scar', String(item.title ?? 'scar'), index),
title:
typeof item.title === 'string' && item.title.trim()
? item.title.trim()
: fallback[index]?.title ?? `旧痕 ${index + 1}`,
pastEvent:
typeof item.pastEvent === 'string' && item.pastEvent.trim()
? item.pastEvent.trim()
: fallback[index]?.pastEvent ?? '',
publicResidue:
typeof item.publicResidue === 'string' && item.publicResidue.trim()
? item.publicResidue.trim()
: fallback[index]?.publicResidue ?? '',
hiddenTruth:
typeof item.hiddenTruth === 'string' && item.hiddenTruth.trim()
? item.hiddenTruth.trim()
: fallback[index]?.hiddenTruth ?? '',
relatedActorIds: dedupeStrings(item.relatedActorIds as string[], 6),
relatedLocationIds: dedupeStrings(item.relatedLocationIds as string[], 6),
}) satisfies StoryScar)
.filter((scar) => scar.title && scar.publicResidue);
return normalized.length > 0 ? normalized : fallback;
}
function normalizeMotifList(value: unknown, fallback: StoryMotif[]) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
.map((item, index) => ({
id:
typeof item.id === 'string' && item.id.trim()
? item.id.trim()
: fallback[index]?.id ?? createId('motif', String(item.label ?? 'motif'), index),
label:
typeof item.label === 'string' && item.label.trim()
? item.label.trim()
: fallback[index]?.label ?? `意象 ${index + 1}`,
semanticRole:
item.semanticRole === 'institution' ||
item.semanticRole === 'ritual' ||
item.semanticRole === 'technology' ||
item.semanticRole === 'taboo' ||
item.semanticRole === 'ruin' ||
item.semanticRole === 'memory' ||
item.semanticRole === 'resource' ||
item.semanticRole === 'creature'
? item.semanticRole
: fallback[index]?.semanticRole ?? 'memory',
lexicalHints: dedupeStrings(item.lexicalHints as string[], 4),
}) satisfies StoryMotif)
.filter((motif) => motif.label);
return normalized.length > 0 ? normalized : fallback;
}
export function buildFallbackWorldStoryGraph(
profile: CustomWorldProfile,
themePack: ThemePack,
) {
const visibleThreads = buildVisibleThreads(profile, themePack);
const hiddenThreads = buildHiddenThreads(profile, themePack, visibleThreads);
const scars = buildScars(profile, hiddenThreads);
const motifs = buildMotifs(profile, themePack);
return {
visibleThreads,
hiddenThreads,
scars,
motifs,
} satisfies WorldStoryGraph;
}
export async function generateWorldStoryGraphWithAi(
profile: CustomWorldProfile,
themePack: ThemePack,
) {
return buildFallbackWorldStoryGraph(profile, themePack);
}
export function normalizeWorldStoryGraph(
value: unknown,
fallback: WorldStoryGraph,
) {
if (!value || typeof value !== 'object') {
return fallback;
}
const item = value as Partial<WorldStoryGraph>;
return {
visibleThreads: normalizeThreadList(item.visibleThreads, fallback.visibleThreads),
hiddenThreads: normalizeThreadList(item.hiddenThreads, fallback.hiddenThreads),
scars: normalizeScarList(item.scars, fallback.scars),
motifs: normalizeMotifList(item.motifs, fallback.motifs),
};
}