Files
Genarrative/src/services/customWorldBuilder.ts
高物 0981d6ee1b
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-11 15:43:32 +08:00

295 lines
9.4 KiB
TypeScript

import {
buildCustomWorldPlayableNpcAttributeProfile,
buildCustomWorldStoryNpcAttributeProfile,
buildItemAttributeResonance,
} from '../data/attributeProfileGenerator';
import { mergeCustomWorldPlayableNpcTags } from '../data/customWorldBuildTags';
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
import { CustomWorldProfile, WorldType } from '../types';
import { normalizeCustomWorldProfile } from './customWorld';
import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
import { resolveCustomWorldCompatibilityTemplateWorldType } from './customWorldTheme';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from './storyEngine/actorNarrativeProfile';
import { compileCampaignFromWorldProfile } from './storyEngine/campaignPackCompiler';
import { buildKnowledgeGraph } from './storyEngine/knowledgeGraph';
import { registerScenarioPack } from './storyEngine/scenarioPackRegistry';
import { buildSceneNarrativeResidues } from './storyEngine/sceneResidueCompiler';
import {
buildThemePackFromWorldProfile,
normalizeThemePack,
} from './storyEngine/themePack';
import { buildThreadContractsFromProfile } from './storyEngine/threadContract';
import {
buildFallbackWorldStoryGraph,
normalizeWorldStoryGraph,
} from './storyEngine/worldStoryGraph';
const PLAYABLE_TEMPLATE_CHARACTER_IDS = [
'sword-princess',
'archer-hero',
'girl-hero',
'punch-hero',
'fighter-4',
] as const;
function pickCyclic<T>(items: readonly T[], index: number, label: string): T {
const item = items[index % items.length];
if (item === undefined) {
throw new Error(`Missing ${label}`);
}
return item;
}
function getPlayableTemplateCharacterId(index: number) {
return pickCyclic(
PLAYABLE_TEMPLATE_CHARACTER_IDS,
index,
'playable template character id',
);
}
function normalizeTags(tags: string[], fallbackTags: string[] = []) {
return [
...new Set(
[...tags, ...fallbackTags].map((tag) => tag.trim()).filter(Boolean),
),
].slice(0, 5);
}
function normalizeHooks(hooks: string[]) {
const normalized = [
...new Set(hooks.map((hook) => hook.trim()).filter(Boolean)),
];
if (normalized.length > 0) {
return normalized.slice(0, 3);
}
return ['掌握关键线索'];
}
function clampText(value: string, maxLength: number) {
const normalized = value.trim().replace(/\s+/g, ' ');
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function slugify(value: string) {
const ascii = value
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '');
if (ascii) {
return ascii.slice(0, 24);
}
return 'entry';
}
function createEntryId(prefix: string, label: string, index: number) {
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
}
function dedupeByName<T extends { name: string }>(items: T[]) {
const seen = new Set<string>();
return items.filter((item) => {
const key = item.name.trim();
if (!key || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
export interface CustomWorldBuilderOptions {}
export function buildExpandedCustomWorldProfile(
raw: unknown,
settingText: string,
_options: CustomWorldBuilderOptions = {},
): CustomWorldProfile {
const profile = normalizeCustomWorldProfile(raw, settingText);
const attributeSchema = profile.attributeSchema;
const playableNpcs = dedupeByName(profile.playableNpcs)
.slice(0, PLAYABLE_TEMPLATE_CHARACTER_IDS.length)
.map((npc, index) => {
const templateCharacterId =
npc.templateCharacterId ?? getPlayableTemplateCharacterId(index);
return {
...npc,
id: npc.id || createEntryId('playable-npc', npc.name, index),
templateCharacterId,
tags: mergeCustomWorldPlayableNpcTags(profile, npc, {
templateCharacterId,
maxCount: 5,
}),
attributeProfile:
npc.attributeProfile ??
buildCustomWorldPlayableNpcAttributeProfile(npc, attributeSchema),
};
});
const storyNpcs = dedupeByName(profile.storyNpcs).map((npc, index) => ({
...npc,
id: npc.id || createEntryId('story-npc', npc.name, index),
description: clampText(npc.description, 72),
motivation: clampText(npc.motivation, 72),
relationshipHooks: normalizeHooks(npc.relationshipHooks),
attributeProfile:
npc.attributeProfile ??
buildCustomWorldStoryNpcAttributeProfile(npc, attributeSchema),
}));
const storyNpcIdByReference = new Map<string, string>();
storyNpcs.forEach((npc) => {
storyNpcIdByReference.set(npc.id, npc.id);
storyNpcIdByReference.set(npc.name, npc.id);
});
profile.storyNpcs.forEach((npc) => {
const nextNpc = storyNpcs.find((entry) => entry.name === npc.name);
if (!nextNpc) {
return;
}
storyNpcIdByReference.set(npc.id, nextNpc.id);
storyNpcIdByReference.set(npc.name, nextNpc.id);
});
const landmarkDrafts = dedupeByName(profile.landmarks).map((landmark, index) => ({
...landmark,
id: landmark.id || createEntryId('landmark', landmark.name, index),
description: clampText(landmark.description, 96),
dangerLevel:
landmark.dangerLevel ||
(resolveCustomWorldCompatibilityTemplateWorldType(profile) === WorldType.XIANXIA
? 'high'
: 'medium'),
}));
const landmarkIdByReference = new Map<string, string>();
landmarkDrafts.forEach((landmark) => {
landmarkIdByReference.set(landmark.id, landmark.id);
landmarkIdByReference.set(landmark.name, landmark.id);
});
profile.landmarks.forEach((landmark) => {
const nextLandmark = landmarkDrafts.find(
(entry) => entry.name === landmark.name,
);
if (!nextLandmark) {
return;
}
landmarkIdByReference.set(landmark.id, nextLandmark.id);
landmarkIdByReference.set(landmark.name, nextLandmark.id);
});
const landmarks = normalizeCustomWorldLandmarks({
landmarks: landmarkDrafts.map((landmark) => ({
...landmark,
sceneNpcIds: landmark.sceneNpcIds.map(
(npcId) => storyNpcIdByReference.get(npcId) ?? npcId,
),
connections: landmark.connections.map((connection) => ({
targetLandmarkId:
landmarkIdByReference.get(connection.targetLandmarkId) ??
connection.targetLandmarkId,
relativePosition: connection.relativePosition,
summary: connection.summary,
})),
})),
storyNpcs,
});
const items = dedupeByName(profile.items).map((item, index) => ({
...item,
id: item.id || createEntryId('item', item.name, index),
description: clampText(item.description, 72),
tags: normalizeTags(item.tags),
attributeResonance:
item.attributeResonance ?? buildItemAttributeResonance(item),
}));
const baseExpandedProfile = {
...profile,
playableNpcs,
storyNpcs,
items,
landmarks,
} satisfies CustomWorldProfile;
const themePack = normalizeThemePack(
profile.themePack,
buildThemePackFromWorldProfile(baseExpandedProfile),
);
const storyGraph = normalizeWorldStoryGraph(
profile.storyGraph,
buildFallbackWorldStoryGraph(baseExpandedProfile, themePack),
);
const enrichedPlayableNpcs = playableNpcs.map((npc) => ({
...npc,
narrativeProfile: normalizeActorNarrativeProfile(
npc.narrativeProfile,
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
),
}));
const enrichedStoryNpcs = storyNpcs.map((npc) => ({
...npc,
narrativeProfile: normalizeActorNarrativeProfile(
npc.narrativeProfile,
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
),
}));
const landmarksWithResidues = landmarks.map((landmark) => ({
...landmark,
narrativeResidues:
landmark.narrativeResidues && landmark.narrativeResidues.length > 0
? landmark.narrativeResidues
: buildSceneNarrativeResidues({
sceneId: landmark.id,
sceneName: landmark.name,
profile: {
...baseExpandedProfile,
playableNpcs: enrichedPlayableNpcs,
storyNpcs: enrichedStoryNpcs,
storyGraph,
themePack,
},
}),
}));
const profileWithNarrative = {
...baseExpandedProfile,
playableNpcs: enrichedPlayableNpcs,
storyNpcs: enrichedStoryNpcs,
themePack,
storyGraph,
landmarks: landmarksWithResidues,
} satisfies CustomWorldProfile;
const knowledgeFacts =
profile.knowledgeFacts && profile.knowledgeFacts.length > 0
? profile.knowledgeFacts
: buildKnowledgeGraph(profileWithNarrative);
const threadContracts =
profile.threadContracts && profile.threadContracts.length > 0
? profile.threadContracts
: buildThreadContractsFromProfile(profileWithNarrative);
const compiledPacks = compileCampaignFromWorldProfile({
profile: {
...profileWithNarrative,
knowledgeFacts,
threadContracts,
},
});
registerScenarioPack(compiledPacks.scenarioPack);
const finalizedProfile = {
...profileWithNarrative,
knowledgeFacts,
threadContracts,
scenarioPackId: profile.scenarioPackId ?? compiledPacks.scenarioPack.id,
campaignPackId: profile.campaignPackId ?? compiledPacks.campaignPack.id,
} satisfies CustomWorldProfile;
return {
...finalizedProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
finalizedProfile.ownedSettingLayers,
finalizedProfile,
),
};
}