Files
Genarrative/src/data/customWorldLibrary.ts
高物 8a7bd90458
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 11:30:19 +08:00

1192 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { EightAnchorContent } from '../../packages/shared/src/contracts/customWorldAgent';
import {
isRecord,
readStoredJson,
writeStoredJson,
} from '../persistence/storage';
import { generateWorldAttributeSchema } from '../services/attributeSchemaGenerator';
import { buildFallbackCustomWorldCampScene } from '../services/customWorldCamp';
import {
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
normalizeCustomWorldCreatorIntent,
normalizeCustomWorldLockState,
} from '../services/customWorldCreatorIntent';
import { normalizeCustomWorldOwnedSettingLayers } from '../services/customWorldOwnedSettingLayers';
import {
AnimationState,
CharacterAnimationConfig,
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
CustomWorldItem,
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldNpcVisual,
CustomWorldNpcVisualGear,
CustomWorldNpcVisualGearType,
CustomWorldNpcVisualRace,
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldRoleInitialItem,
CustomWorldRoleRelation,
CustomWorldRoleSkill,
EquipmentSlotId,
ItemAttributeResonance,
ItemRarity,
ItemStatProfile,
ItemUseProfile,
KnowledgeFact,
RoleAttributeProfile,
SceneNarrativeResidue,
SceneActBlueprint,
SceneChapterBlueprint,
ThemePack,
ThreadContract,
WorldStoryGraph,
WorldType,
} from '../types';
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
} from './affinityLevels';
import { coerceWorldAttributeSchema } from './attributeValidation';
import {
type CustomWorldLandmarkDraft,
normalizeCustomWorldLandmarks,
} from './customWorldSceneGraph';
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
const CUSTOM_WORLD_LIBRARY_VERSION = 1;
const MAX_SAVED_CUSTOM_WORLDS = 12;
const MIN_CUSTOM_WORLD_AFFINITY = -40;
const MAX_CUSTOM_WORLD_AFFINITY = 90;
const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
const ITEM_RARITIES = new Set<ItemRarity>([
'common',
'uncommon',
'rare',
'epic',
'legendary',
]);
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
const ANIMATION_STATES = new Set<AnimationState>(Object.values(AnimationState));
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>([
'human',
'elf',
'orc',
'goblin',
]);
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES =
new Set<CustomWorldNpcVisualGearType>([
'cloth',
'leather',
'metal',
'melee',
'magic',
'ranged',
]);
const SCENE_ACT_STAGES = new Set([
'opening',
'expansion',
'turning_point',
'climax',
'aftermath',
] as const);
const SCENE_ACT_ADVANCE_RULES = new Set([
'after_primary_contact',
'after_active_step_complete',
'after_chapter_resolution',
] as const);
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
'武器',
'护甲',
'饰品',
'消耗品',
'材料',
'稀有品',
'专属物品',
'专属物',
]);
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES =
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [
'表层来意',
'旧事裂痕',
'隐藏执念',
'最终底牌',
] as const;
type CustomWorldRoleFallbackSource = {
name: string;
title: string;
role: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
relationshipHooks: string[];
tags: string[];
};
type StoredCustomWorldLibrary = {
version: number;
profiles: CustomWorldProfile[];
};
function toText(value: unknown, fallback = '') {
return typeof value === 'string' ? value.trim() : fallback;
}
function toStringArray(value: unknown) {
return Array.isArray(value)
? value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
: [];
}
function toOptionalNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function toOptionalInteger(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: undefined;
}
function preserveStructuredRecord<T>(value: unknown): T | null {
return isRecord(value) ? (value as T) : null;
}
function preserveStructuredRecordArray<T>(value: unknown): T[] | null {
return Array.isArray(value)
? (value.filter((entry): entry is Record<string, unknown> => isRecord(entry)) as T[])
: null;
}
function normalizeInitialAffinity(value: unknown, fallback: number) {
const resolved =
typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: fallback;
return Math.max(
MIN_CUSTOM_WORLD_AFFINITY,
Math.min(MAX_CUSTOM_WORLD_AFFINITY, resolved),
);
}
function truncateText(value: string, maxLength: number) {
const normalized = value.trim().replace(/\s+/g, ' ');
if (!normalized) return '';
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function splitNarrativeSentences(text: string) {
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) return [];
const matches = normalized.match(/[^!?]+[!?]?/gu);
return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean);
}
function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
const category = toText(value);
if (CUSTOM_WORLD_ROLE_ITEM_CATEGORIES.has(category)) {
return category === '专属物' ? '专属物品' : category;
}
if (/||||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/|||||/u.test(category)) return '';
if (/||||/u.test(category)) return '';
return fallback;
}
function buildFallbackBackstoryReveal(
source: CustomWorldRoleFallbackSource,
): CharacterBackstoryRevealConfig {
const normalizedBackstory =
source.backstory.trim() || `${source.name}对自己的过去仍有保留。`;
const backstorySentences = splitNarrativeSentences(normalizedBackstory);
const backstoryLead = backstorySentences[0] ?? normalizedBackstory;
const backstoryDetail =
backstorySentences.slice(0, 2).join('') || normalizedBackstory;
const publicSummary =
source.description.trim() || truncateText(normalizedBackstory, 42);
const fallbackContents = [
source.description.trim() || backstoryLead,
backstoryDetail,
source.motivation.trim()
? `${source.name}真正挂念的,是:${source.motivation.trim()}`
: `${source.name}的决定与“${truncateText(backstoryLead, 24)}”直接相关。`,
source.personality.trim()
? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}`
: `${source.name}仍把最深的筹码藏在过去里。`,
];
return {
publicSummary,
privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map(
(affinityRequired, index) =>
({
id: `saved-backstory-${index + 1}`,
title:
CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ??
`背景片段${index + 1}`,
affinityRequired,
teaser: truncateText(
fallbackContents[index] ?? normalizedBackstory,
22,
),
content: truncateText(
fallbackContents[index] ?? normalizedBackstory,
72,
),
contextSnippet: truncateText(
`${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`,
48,
),
}) satisfies CharacterBackstoryChapter,
),
};
}
function normalizeBackstoryReveal(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const fallback = buildFallbackBackstoryReveal(fallbackSource);
if (!isRecord(value)) {
return fallback;
}
const rawChapters = Array.isArray(value.chapters)
? value.chapters.filter(isRecord)
: [];
return {
publicSummary: toText(value.publicSummary, fallback.publicSummary),
privateChatUnlockAffinity:
typeof value.privateChatUnlockAffinity === 'number' &&
Number.isFinite(value.privateChatUnlockAffinity)
? normalizeInitialAffinity(
value.privateChatUnlockAffinity,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
)
: fallback.privateChatUnlockAffinity,
chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map(
(defaultAffinity, index) => {
const rawChapter = rawChapters[index];
const fallbackChapter = fallback.chapters[index];
return {
id: rawChapter
? toText(rawChapter.id, fallbackChapter?.id)
: (fallbackChapter?.id ?? `saved-backstory-${index + 1}`),
title: rawChapter
? toText(rawChapter.title, fallbackChapter?.title)
: (fallbackChapter?.title ?? `背景片段${index + 1}`),
affinityRequired:
fallbackChapter?.affinityRequired ?? defaultAffinity,
teaser: rawChapter
? toText(rawChapter.teaser, fallbackChapter?.teaser)
: (fallbackChapter?.teaser ?? ''),
content: rawChapter
? toText(rawChapter.content, fallbackChapter?.content)
: (fallbackChapter?.content ?? ''),
contextSnippet: rawChapter
? toText(rawChapter.contextSnippet, fallbackChapter?.contextSnippet)
: (fallbackChapter?.contextSnippet ?? ''),
} satisfies CharacterBackstoryChapter;
},
),
} satisfies CharacterBackstoryRevealConfig;
}
function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
const nameSeed = source.title || source.role || source.name || '角色';
return [
{
id: 'saved-role-skill-1',
name: `${nameSeed}起手`,
summary: truncateText(
source.combatStyle || `${source.name}擅长稳住局面。`,
36,
),
style: '起手压制',
},
{
id: 'saved-role-skill-2',
name: `${nameSeed}变招`,
summary: truncateText(
source.personality || `${source.name}习惯在周旋中找破绽。`,
36,
),
style: '机动周旋',
},
{
id: 'saved-role-skill-3',
name: `${nameSeed}底牌`,
summary: truncateText(
source.motivation || `${source.name}会在关键时刻亮出压箱手段。`,
36,
),
style: '爆发终结',
},
] satisfies CustomWorldRoleSkill[];
}
function normalizeRoleRelations(value: unknown, fallbackHooks: string[]) {
const normalized = Array.isArray(value)
? value
.filter(isRecord)
.map(
(entry, index) =>
({
id: toText(entry.id, `saved-role-relation-${index + 1}`),
targetRoleId: toText(entry.targetRoleId),
summary: toText(entry.summary),
}) satisfies CustomWorldRoleRelation,
)
.filter((entry) => entry.summary)
.slice(0, 8)
: [];
if (normalized.length > 0) {
return normalized;
}
return fallbackHooks
.map((summary, index) => ({
id: `saved-role-relation-${index + 1}`,
targetRoleId: '',
summary,
}))
.slice(0, 8);
}
function normalizeRoleSkills(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const normalized = Array.isArray(value)
? value
.filter(isRecord)
.map(
(entry, index) =>
({
id: toText(entry.id, `saved-role-skill-${index + 1}`),
name: toText(entry.name),
summary: toText(entry.summary, toText(entry.description)),
style: toText(entry.style, toText(entry.category, '常用')),
actionPromptText: toText(entry.actionPromptText) || undefined,
actionPreviewConfig:
normalizeCharacterAnimationConfig(entry.actionPreviewConfig) ??
undefined,
}) satisfies CustomWorldRoleSkill,
)
.filter((entry) => entry.name)
.slice(0, 3)
: [];
return normalized.length > 0
? normalized
: buildFallbackRoleSkills(fallbackSource);
}
function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
const itemSeed = source.title || source.role || source.name || '角色';
return [
{
id: 'saved-role-item-1',
name: `${itemSeed}常备武具`,
category: '武器',
quantity: 1,
rarity: 'rare',
description: truncateText(
source.combatStyle || `${source.name}随身携带的主要作战物件。`,
36,
),
tags: source.tags.slice(0, 2),
},
{
id: 'saved-role-item-2',
name: `${itemSeed}补给包`,
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: truncateText(
source.personality || `${source.name}为长期行动准备的基础补给。`,
36,
),
tags: source.relationshipHooks.slice(0, 2),
},
{
id: 'saved-role-item-3',
name: `${itemSeed}私人物件`,
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: truncateText(
source.backstory ||
source.motivation ||
`${source.name}不愿随意交出的信物。`,
36,
),
tags: [...source.tags, ...source.relationshipHooks].slice(0, 3),
},
] satisfies CustomWorldRoleInitialItem[];
}
function normalizeRoleInitialItems(
value: unknown,
fallbackSource: CustomWorldRoleFallbackSource,
) {
const normalized = Array.isArray(value)
? value
.filter(isRecord)
.map(
(entry, index) =>
({
id: toText(entry.id, `saved-role-item-${index + 1}`),
name: toText(entry.name),
category: normalizeRoleItemCategory(entry.category),
quantity:
typeof entry.quantity === 'number' &&
Number.isFinite(entry.quantity)
? Math.max(1, Math.min(99, Math.round(entry.quantity)))
: 1,
rarity:
typeof entry.rarity === 'string' &&
ITEM_RARITIES.has(entry.rarity as ItemRarity)
? (entry.rarity as ItemRarity)
: 'rare',
description: toText(entry.description),
tags: toStringArray(entry.tags),
iconSrc: toText(entry.iconSrc) || undefined,
}) satisfies CustomWorldRoleInitialItem,
)
.filter((entry) => entry.name)
.slice(0, 3)
: [];
return normalized.length > 0
? normalized
: buildFallbackRoleInitialItems(fallbackSource);
}
function normalizeEquipmentSlot(value: unknown) {
return typeof value === 'string' &&
EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
? (value as EquipmentSlotId)
: null;
}
function normalizeCustomWorldNpcVisualGear(
value: unknown,
): CustomWorldNpcVisualGear | null {
if (!isRecord(value)) return null;
const type =
typeof value.type === 'string' &&
CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES.has(
value.type as CustomWorldNpcVisualGearType,
)
? (value.type as CustomWorldNpcVisualGearType)
: null;
const file = toText(value.file);
if (!type || !file) return null;
return {
type,
file,
frameIndex: toOptionalInteger(value.frameIndex) ?? 0,
};
}
function normalizeCustomWorldNpcVisual(
value: unknown,
): CustomWorldNpcVisual | undefined {
if (!isRecord(value)) return undefined;
const race =
typeof value.race === 'string' &&
CUSTOM_WORLD_NPC_VISUAL_RACES.has(value.race as CustomWorldNpcVisualRace)
? (value.race as CustomWorldNpcVisualRace)
: null;
if (!race) return undefined;
return {
race,
bodyColor: toText(value.bodyColor, 'black'),
headIndex: Math.max(1, toOptionalInteger(value.headIndex) ?? 1),
hairColorIndex: Math.max(1, toOptionalInteger(value.hairColorIndex) ?? 1),
hairStyleFrame: Math.max(0, toOptionalInteger(value.hairStyleFrame) ?? 0),
facialHairEnabled: Boolean(value.facialHairEnabled),
facialHairColorIndex: Math.max(
1,
toOptionalInteger(value.facialHairColorIndex) ?? 1,
),
facialHairStyleFrame: Math.max(
0,
toOptionalInteger(value.facialHairStyleFrame) ?? 0,
),
headgear: normalizeCustomWorldNpcVisualGear(value.headgear),
mainHand: normalizeCustomWorldNpcVisualGear(value.mainHand),
offHand: normalizeCustomWorldNpcVisualGear(value.offHand),
};
}
function normalizeCharacterAnimationConfig(
value: unknown,
): CharacterAnimationConfig | null {
if (!isRecord(value)) return null;
const folder = toText(value.folder);
const prefix = toText(value.prefix);
const frames = Math.max(1, toOptionalInteger(value.frames) ?? 0);
if (!folder || !prefix || frames <= 0) {
return null;
}
const startFrame = toOptionalInteger(value.startFrame);
const extension = toText(value.extension);
const file = toText(value.file);
const basePath = toText(value.basePath);
const frameWidth = toOptionalInteger(value.frameWidth);
const frameHeight = toOptionalInteger(value.frameHeight);
const fps = toOptionalNumber(value.fps);
const loop = typeof value.loop === 'boolean' ? value.loop : undefined;
const previewVideoPath = toText(value.previewVideoPath);
return {
folder,
prefix,
frames,
...(startFrame ? { startFrame: Math.max(1, startFrame) } : {}),
...(extension ? { extension } : {}),
...(file ? { file } : {}),
...(basePath ? { basePath } : {}),
...(frameWidth ? { frameWidth: Math.max(1, frameWidth) } : {}),
...(frameHeight ? { frameHeight: Math.max(1, frameHeight) } : {}),
...(fps ? { fps: Math.max(1, fps) } : {}),
...(typeof loop === 'boolean' ? { loop } : {}),
...(previewVideoPath ? { previewVideoPath } : {}),
};
}
function normalizeGeneratedAnimationMap(value: unknown) {
if (!isRecord(value)) return undefined;
const entries = Object.entries(value).flatMap(([key, rawConfig]) => {
if (!ANIMATION_STATES.has(key as AnimationState)) {
return [];
}
const config = normalizeCharacterAnimationConfig(rawConfig);
return config ? [[key as AnimationState, config] as const] : [];
});
return entries.length > 0
? (Object.fromEntries(entries) as Partial<
Record<AnimationState, CharacterAnimationConfig>
>)
: undefined;
}
function normalizeItemStatProfile(value: unknown): ItemStatProfile | null {
if (!isRecord(value)) return null;
const profile: ItemStatProfile = {
maxHpBonus: toOptionalNumber(value.maxHpBonus),
maxManaBonus: toOptionalNumber(value.maxManaBonus),
outgoingDamageBonus: toOptionalNumber(value.outgoingDamageBonus),
incomingDamageMultiplier: toOptionalNumber(value.incomingDamageMultiplier),
};
return Object.values(profile).some((entry) => entry !== undefined)
? profile
: null;
}
function normalizeItemUseProfile(value: unknown): ItemUseProfile | null {
if (!isRecord(value)) return null;
const profile: ItemUseProfile = {
hpRestore: toOptionalNumber(value.hpRestore),
manaRestore: toOptionalNumber(value.manaRestore),
cooldownReduction: toOptionalNumber(value.cooldownReduction),
};
return Object.values(profile).some((entry) => entry !== undefined)
? profile
: null;
}
function normalizePlayableNpc(
value: unknown,
index: number,
): CustomWorldPlayableNpc | null {
if (!isRecord(value)) return null;
const name = toText(value.name);
if (!name) return null;
const title = toText(value.title, toText(value.role, '未命名角色'));
const role = toText(value.role, title);
const relationshipHooks = toStringArray(value.relationshipHooks);
const relations = normalizeRoleRelations(value.relations, relationshipHooks);
const relationSummaries = relations
.map((entry) => entry.summary)
.filter(Boolean)
.slice(0, 8);
const tags = toStringArray(value.tags);
const fallbackSource = {
name,
title,
role,
description: toText(value.description),
backstory: toText(value.backstory),
personality: toText(value.personality),
motivation: toText(value.motivation, toText(value.description)),
combatStyle: toText(value.combatStyle),
relationshipHooks:
relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
return {
id: toText(value.id, `saved-playable-${index + 1}`),
name,
title,
role,
description: fallbackSource.description,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,
combatStyle: fallbackSource.combatStyle,
initialAffinity: normalizeInitialAffinity(
value.initialAffinity,
DEFAULT_PLAYABLE_INITIAL_AFFINITY,
),
relationshipHooks:
relationSummaries.length > 0
? relationSummaries
: fallbackSource.relationshipHooks,
relations,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(
value.backstoryReveal,
fallbackSource,
),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
attributeProfile:
preserveStructuredRecord<RoleAttributeProfile>(value.attributeProfile) ??
undefined,
narrativeProfile:
preserveStructuredRecord<CustomWorldPlayableNpc['narrativeProfile']>(
value.narrativeProfile,
) ?? undefined,
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
function normalizeStoryNpc(
value: unknown,
index: number,
): CustomWorldNpc | null {
if (!isRecord(value)) return null;
const name = toText(value.name);
if (!name) return null;
const title = toText(value.title, toText(value.role, '未命名场景角色'));
const role = toText(value.role, title);
const relationshipHooks = toStringArray(value.relationshipHooks);
const relations = normalizeRoleRelations(value.relations, relationshipHooks);
const relationSummaries = relations
.map((entry) => entry.summary)
.filter(Boolean)
.slice(0, 8);
const tags = toStringArray(value.tags);
const fallbackSource = {
name,
title,
role,
description: toText(value.description),
backstory: toText(value.backstory),
personality: toText(value.personality),
motivation: toText(value.motivation),
combatStyle: toText(value.combatStyle),
relationshipHooks:
relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
return {
id: toText(value.id, `saved-story-${index + 1}`),
name,
title,
role,
description: fallbackSource.description,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,
combatStyle: fallbackSource.combatStyle,
initialAffinity: normalizeInitialAffinity(
value.initialAffinity,
DEFAULT_STORY_NPC_INITIAL_AFFINITY,
),
relationshipHooks:
relationSummaries.length > 0
? relationSummaries
: fallbackSource.relationshipHooks,
relations,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(
value.backstoryReveal,
fallbackSource,
),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
attributeProfile:
preserveStructuredRecord<RoleAttributeProfile>(value.attributeProfile) ??
undefined,
narrativeProfile:
preserveStructuredRecord<CustomWorldNpc['narrativeProfile']>(
value.narrativeProfile,
) ?? undefined,
visual: normalizeCustomWorldNpcVisual(value.visual),
};
}
function normalizeItem(value: unknown, index: number): CustomWorldItem | null {
if (!isRecord(value)) return null;
const name = toText(value.name);
const category = toText(value.category);
const rarity =
typeof value.rarity === 'string' &&
ITEM_RARITIES.has(value.rarity as ItemRarity)
? (value.rarity as ItemRarity)
: null;
if (!name || !category || !rarity) return null;
return {
id: toText(value.id, `saved-item-${index + 1}`),
name,
category,
rarity,
description: toText(value.description),
tags: toStringArray(value.tags),
iconSrc: toText(value.iconSrc) || undefined,
sourcePath: toText(value.sourcePath) || undefined,
origin:
value.origin === 'generated' || value.origin === 'catalog'
? value.origin
: undefined,
equipmentSlotId: normalizeEquipmentSlot(value.equipmentSlotId),
statProfile: normalizeItemStatProfile(value.statProfile),
useProfile: normalizeItemUseProfile(value.useProfile),
value: toOptionalNumber(value.value),
attributeResonance:
preserveStructuredRecord<ItemAttributeResonance>(
value.attributeResonance,
) ?? undefined,
};
}
function normalizeLandmark(
value: unknown,
index: number,
): CustomWorldLandmark | null {
if (!isRecord(value)) return null;
const name = toText(value.name);
if (!name) return null;
return {
id: toText(value.id, `saved-landmark-${index + 1}`),
name,
description: toText(value.description),
dangerLevel: toText(value.dangerLevel),
imageSrc: toText(value.imageSrc) || undefined,
narrativeResidues:
preserveStructuredRecordArray<SceneNarrativeResidue>(
value.narrativeResidues,
) ?? undefined,
sceneNpcIds: [],
connections: [],
};
}
function normalizeCampScene(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
>,
) {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
if (!isRecord(value)) {
return fallback;
}
return {
name: toText(value.name, fallback.name),
description: toText(value.description, fallback.description),
dangerLevel: toText(value.dangerLevel, fallback.dangerLevel),
imageSrc: toText(value.imageSrc) || undefined,
};
}
function normalizeLandmarkDraft(
value: unknown,
index: number,
): CustomWorldLandmarkDraft | null {
if (!isRecord(value)) return null;
const normalizedLandmark = normalizeLandmark(value, index);
if (!normalizedLandmark) {
return null;
}
const rawConnections = Array.isArray(value.connections)
? value.connections.filter(isRecord)
: [];
return {
...normalizedLandmark,
sceneNpcIds: toStringArray(value.sceneNpcIds),
sceneNpcNames: [
...toStringArray(value.sceneNpcNames),
...toStringArray(value.npcNames),
...(Array.isArray(value.npcs)
? value.npcs
.filter(isRecord)
.map((item) => toText(item.name))
.filter(Boolean)
: []),
],
connections: rawConnections.map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) || toText(connection.position),
summary: toText(connection.summary) || toText(connection.description),
})),
};
}
function normalizeSceneActStageCoverage(value: unknown) {
const stageCoverage = Array.isArray(value)
? value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry): entry is SceneActBlueprint['stageCoverage'][number] =>
SCENE_ACT_STAGES.has(entry as never),
)
: [];
return [...new Set(stageCoverage)];
}
function normalizeSceneActBlueprint(
value: unknown,
index: number,
sceneId: string,
): SceneActBlueprint | null {
if (!isRecord(value)) {
return null;
}
const encounterNpcIds = toStringArray(value.encounterNpcIds);
const stageCoverage = normalizeSceneActStageCoverage(value.stageCoverage);
const advanceRule = toText(value.advanceRule);
const title = toText(value.title);
const summary = toText(value.summary);
if (!title && !summary && encounterNpcIds.length === 0) {
return null;
}
return {
id: toText(value.id, `saved-scene-act-${sceneId}-${index + 1}`),
sceneId,
title: title || `${index + 1}`,
summary: summary || title || `围绕${sceneId}继续推进`,
stageCoverage:
stageCoverage.length > 0
? stageCoverage
: index === 0
? ['opening']
: ['climax', 'aftermath'],
backgroundImageSrc: toText(value.backgroundImageSrc) || undefined,
encounterNpcIds,
primaryNpcId: toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
linkedThreadIds: toStringArray(value.linkedThreadIds),
advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never)
? (advanceRule as SceneActBlueprint['advanceRule'])
: 'after_active_step_complete',
actGoal: toText(value.actGoal),
transitionHook: toText(value.transitionHook),
};
}
function normalizeSceneChapterBlueprints(value: unknown) {
if (!Array.isArray(value)) {
return null;
}
const normalized = value
.filter(isRecord)
.map((entry, index) => {
const sceneId = toText(entry.sceneId);
if (!sceneId) {
return null;
}
const acts = Array.isArray(entry.acts)
? entry.acts
.map((act, actIndex) =>
normalizeSceneActBlueprint(act, actIndex, sceneId),
)
.filter((act): act is SceneActBlueprint => Boolean(act))
: [];
return {
id: toText(entry.id, `saved-scene-chapter-${sceneId}-${index + 1}`),
sceneId,
title: toText(entry.title, toText(entry.sceneName, sceneId)),
summary: toText(entry.summary),
linkedThreadIds: toStringArray(entry.linkedThreadIds),
linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds),
acts,
} satisfies SceneChapterBlueprint;
})
.filter((entry): entry is SceneChapterBlueprint => Boolean(entry));
return normalized.length > 0 ? normalized : null;
}
function normalizeProfile(value: unknown): CustomWorldProfile | null {
if (!isRecord(value)) return null;
const name = toText(value.name);
const settingText = toText(value.settingText, toText(value.summary, name));
if (!name) return null;
const compatibilityTemplateWorldType =
value.compatibilityTemplateWorldType === WorldType.XIANXIA
? WorldType.XIANXIA
: value.compatibilityTemplateWorldType === WorldType.WUXIA
? WorldType.WUXIA
: value.templateWorldType === WorldType.XIANXIA
? WorldType.XIANXIA
: WorldType.WUXIA;
const templateWorldType = compatibilityTemplateWorldType;
const subtitle = toText(value.subtitle);
const summary = toText(value.summary);
const tone = toText(value.tone);
const playerGoal = toText(value.playerGoal);
const majorFactions = toStringArray(value.majorFactions);
const coreConflicts = toStringArray(value.coreConflicts);
const resolvedCoreConflicts =
coreConflicts.length > 0
? coreConflicts
: [summary || playerGoal || settingText || name];
const camp = normalizeCampScene(value.camp, {
name,
summary,
tone,
playerGoal,
settingText,
templateWorldType,
});
const generatedAttributeSchema = generateWorldAttributeSchema({
worldType: WorldType.CUSTOM,
worldName: name,
settingText,
summary,
tone,
playerGoal,
majorFactions,
coreConflicts: resolvedCoreConflicts,
});
const storyNpcs = Array.isArray(value.storyNpcs)
? value.storyNpcs
.map((entry, index) => normalizeStoryNpc(entry, index))
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
: [];
const landmarkDrafts = Array.isArray(value.landmarks)
? value.landmarks
.map((entry, index) => normalizeLandmarkDraft(entry, index))
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
: [];
const normalizedProfile = {
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
settingText,
name,
subtitle,
summary,
tone,
playerGoal,
templateWorldType,
compatibilityTemplateWorldType,
majorFactions,
coreConflicts: resolvedCoreConflicts,
attributeSchema: coerceWorldAttributeSchema(
value.attributeSchema,
generatedAttributeSchema,
),
playableNpcs: Array.isArray(value.playableNpcs)
? value.playableNpcs
.map((entry, index) => normalizePlayableNpc(entry, index))
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
: [],
storyNpcs,
items: Array.isArray(value.items)
? value.items
.map((entry, index) => normalizeItem(entry, index))
.filter((entry): entry is CustomWorldItem => Boolean(entry))
: [],
camp,
landmarks: normalizeCustomWorldLandmarks({
landmarks: landmarkDrafts,
storyNpcs,
}),
themePack: preserveStructuredRecord<ThemePack>(value.themePack),
storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph),
knowledgeFacts:
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
threadContracts:
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
value.sceneChapterBlueprints,
),
anchorContent: preserveStructuredRecord<EightAnchorContent>(
value.anchorContent,
),
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
anchorPack:
value.anchorPack && typeof value.anchorPack === 'object'
? (value.anchorPack as CustomWorldAnchorPack)
: buildCustomWorldAnchorPackFromIntent(
normalizeCustomWorldCreatorIntent(value.creatorIntent),
),
lockState:
value.lockState && isRecord(value.lockState)
? normalizeCustomWorldLockState(value.lockState)
: deriveCustomWorldLockStateFromIntent(
normalizeCustomWorldCreatorIntent(value.creatorIntent),
),
generationMode:
value.generationMode === 'fast' || value.generationMode === 'full'
? value.generationMode
: 'full',
generationStatus:
value.generationStatus === 'key_only' ||
value.generationStatus === 'complete'
? value.generationStatus
: 'complete',
scenarioPackId: toText(value.scenarioPackId) || null,
campaignPackId: toText(value.campaignPackId) || null,
} satisfies CustomWorldProfile;
return {
...normalizedProfile,
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
value.ownedSettingLayers,
normalizedProfile,
),
};
}
export function normalizeCustomWorldProfileRecord(
value: unknown,
): CustomWorldProfile | null {
return normalizeProfile(value);
}
function writeProfiles(profiles: CustomWorldProfile[]) {
const normalizedProfiles = profiles
.map((profile) => normalizeProfile(profile))
.filter((profile): profile is CustomWorldProfile => Boolean(profile))
.slice(0, MAX_SAVED_CUSTOM_WORLDS);
if (typeof window === 'undefined') {
return normalizedProfiles;
}
const payload: StoredCustomWorldLibrary = {
version: CUSTOM_WORLD_LIBRARY_VERSION,
profiles: normalizedProfiles,
};
writeStoredJson({
key: CUSTOM_WORLD_LIBRARY_STORAGE_KEY,
value: payload,
});
return normalizedProfiles;
}
export function readSavedCustomWorldProfiles() {
return (
readStoredJson({
key: CUSTOM_WORLD_LIBRARY_STORAGE_KEY,
parse: (value) => {
if (
!isRecord(value) ||
value.version !== CUSTOM_WORLD_LIBRARY_VERSION ||
!Array.isArray(value.profiles)
) {
return null;
}
return value.profiles
.map((profile) => normalizeProfile(profile))
.filter((profile): profile is CustomWorldProfile => Boolean(profile))
.slice(0, MAX_SAVED_CUSTOM_WORLDS);
},
}) ?? ([] as CustomWorldProfile[])
);
}
export function upsertSavedCustomWorldProfile(profile: CustomWorldProfile) {
const nextProfiles = [
profile,
...readSavedCustomWorldProfiles().filter(
(savedProfile) => savedProfile.id !== profile.id,
),
];
return writeProfiles(nextProfiles);
}