This commit is contained in:
287
src/services/customWorldBuilder.ts
Normal file
287
src/services/customWorldBuilder.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
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 = getPlayableTemplateCharacterId(index);
|
||||
return {
|
||||
...npc,
|
||||
id: npc.id || createEntryId('playable-npc', npc.name, index),
|
||||
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),
|
||||
}));
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user