295 lines
9.4 KiB
TypeScript
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,
|
|
),
|
|
};
|
|
}
|