379 lines
12 KiB
TypeScript
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),
|
|
};
|
|
}
|