import type { CustomWorldProfile, StoryMotif, StoryScar, StoryThread, ThemePack, WorldStoryGraph, } from '../../types'; function dedupeStrings(values: Array, 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 => 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 => 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 => 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; 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), }; }