Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
378
src/services/storyEngine/worldStoryGraph.ts
Normal file
378
src/services/storyEngine/worldStoryGraph.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user