1297 lines
41 KiB
TypeScript
1297 lines
41 KiB
TypeScript
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 { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences';
|
|
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,
|
|
SceneActBlueprint,
|
|
SceneChapterBlueprint,
|
|
SceneNarrativeResidue,
|
|
ThemePack,
|
|
ThreadContract,
|
|
WorldStoryGraph,
|
|
WorldType,
|
|
} from '../types';
|
|
import {
|
|
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
|
|
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
|
|
} from './affinityLevels';
|
|
import { coerceWorldAttributeSchema } from './attributeValidation';
|
|
import { normalizeCustomWorldSceneRelativePosition } from './customWorldSceneGraph';
|
|
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 toRecordArray(value: unknown) {
|
|
return Array.isArray(value)
|
|
? value.filter(
|
|
(entry): entry is Record<string, unknown> =>
|
|
Boolean(entry) && typeof entry === 'object',
|
|
)
|
|
: [];
|
|
}
|
|
|
|
function toStringArray(value: unknown) {
|
|
return Array.isArray(value)
|
|
? value
|
|
.filter((item): item is string => typeof item === 'string')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
}
|
|
|
|
function toUniqueStringArray(value: unknown) {
|
|
return [...new Set(toStringArray(value))];
|
|
}
|
|
|
|
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 publicMask = toText(value.publicMask) || toText(value.publicIdentity);
|
|
const currentPressure = toText(value.currentPressure) || toText(value.hiddenHook);
|
|
const relationToPlayer = toText(value.relationToPlayer);
|
|
const fallbackSource = {
|
|
name,
|
|
title,
|
|
role,
|
|
description: toText(value.description) || publicMask,
|
|
backstory: toText(value.backstory) || currentPressure,
|
|
personality: toText(value.personality) || publicMask,
|
|
motivation:
|
|
toText(value.motivation) || relationToPlayer || currentPressure,
|
|
combatStyle: toText(value.combatStyle),
|
|
relationshipHooks:
|
|
relationshipHooks.length > 0
|
|
? relationshipHooks
|
|
: [relationToPlayer, currentPressure, ...tags].filter(Boolean).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,
|
|
visualDescription: toText(value.visualDescription) || publicMask || undefined,
|
|
actionDescription: toText(value.actionDescription) || currentPressure || undefined,
|
|
sceneVisualDescription:
|
|
toText(value.sceneVisualDescription) || currentPressure || undefined,
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 publicMask = toText(value.publicMask) || toText(value.publicIdentity);
|
|
const currentPressure = toText(value.currentPressure) || toText(value.hiddenHook);
|
|
const relationToPlayer = toText(value.relationToPlayer);
|
|
const fallbackSource = {
|
|
name,
|
|
title,
|
|
role,
|
|
description: toText(value.description) || publicMask,
|
|
backstory: toText(value.backstory) || currentPressure,
|
|
personality: toText(value.personality) || publicMask,
|
|
motivation:
|
|
toText(value.motivation) || relationToPlayer || currentPressure,
|
|
combatStyle: toText(value.combatStyle),
|
|
relationshipHooks:
|
|
relationshipHooks.length > 0
|
|
? relationshipHooks
|
|
: [relationToPlayer, currentPressure, ...tags].filter(Boolean).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,
|
|
visualDescription: toText(value.visualDescription) || publicMask || undefined,
|
|
actionDescription: toText(value.actionDescription) || currentPressure || undefined,
|
|
sceneVisualDescription:
|
|
toText(value.sceneVisualDescription) || currentPressure || undefined,
|
|
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),
|
|
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 {
|
|
id: toText(value.id, fallback.id),
|
|
name: toText(value.name, fallback.name),
|
|
description: toText(value.description, fallback.description),
|
|
visualDescription: toText(value.visualDescription) || undefined,
|
|
imageSrc: toText(value.imageSrc) || undefined,
|
|
sceneNpcIds: toStringArray(value.sceneNpcIds),
|
|
connections: toRecordArray(value.connections)
|
|
.map((connection) => ({
|
|
targetLandmarkId: toText(connection.targetLandmarkId),
|
|
relativePosition: normalizeCustomWorldSceneRelativePosition(
|
|
toText(connection.relativePosition) ||
|
|
toText(connection.position) ||
|
|
'forward',
|
|
),
|
|
summary: toText(connection.summary) || toText(connection.description),
|
|
}))
|
|
.filter((connection) => connection.targetLandmarkId),
|
|
narrativeResidues: null,
|
|
};
|
|
}
|
|
|
|
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: toUniqueStringArray([
|
|
...toStringArray(value.actNPCNames),
|
|
...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: normalizeCustomWorldSceneRelativePosition(
|
|
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,
|
|
profileRoles?: {
|
|
playableNpcs: CustomWorldPlayableNpc[];
|
|
storyNpcs: CustomWorldNpc[];
|
|
} | null,
|
|
): SceneActBlueprint | null {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
const encounterNpcIds = toStringArray(value.encounterNpcIds).map((npcId) =>
|
|
resolveCustomWorldRoleIdReference(profileRoles, npcId),
|
|
);
|
|
const stageCoverage = normalizeSceneActStageCoverage(value.stageCoverage);
|
|
const advanceRule = toText(value.advanceRule);
|
|
const title = toText(value.title);
|
|
const summary = toText(value.summary);
|
|
const primaryNpcId = resolveCustomWorldRoleIdReference(
|
|
profileRoles,
|
|
toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
|
|
);
|
|
const oppositeNpcId = resolveCustomWorldRoleIdReference(
|
|
profileRoles,
|
|
toText(value.oppositeNpcId, primaryNpcId),
|
|
);
|
|
|
|
if (!title && !summary && encounterNpcIds.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: toText(value.id, `saved-scene-act-${sceneId}-${index + 1}`),
|
|
sceneId: toText(value.sceneId, sceneId),
|
|
title: title || `第 ${index + 1} 幕`,
|
|
summary: summary || title || `围绕${sceneId}继续推进`,
|
|
stageCoverage:
|
|
stageCoverage.length > 0
|
|
? stageCoverage
|
|
: index === 0
|
|
? ['opening']
|
|
: ['climax', 'aftermath'],
|
|
backgroundImageSrc: toText(value.backgroundImageSrc) || undefined,
|
|
backgroundPromptText: toText(value.backgroundPromptText) || undefined,
|
|
backgroundAssetId: toText(value.backgroundAssetId) || undefined,
|
|
encounterNpcIds,
|
|
primaryNpcId,
|
|
oppositeNpcId,
|
|
eventDescription: toText(
|
|
value.eventDescription,
|
|
oppositeNpcId
|
|
? `第 ${index + 1} 幕中,玩家与${oppositeNpcId}正面接触,推动当前场景事件升级。`
|
|
: `第 ${index + 1} 幕中,玩家处理当前场景的关键事件。`,
|
|
),
|
|
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,
|
|
profileRoles?: {
|
|
playableNpcs: CustomWorldPlayableNpc[];
|
|
storyNpcs: CustomWorldNpc[];
|
|
} | null,
|
|
) {
|
|
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,
|
|
profileRoles,
|
|
),
|
|
)
|
|
.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),
|
|
sceneTaskDescription: toText(
|
|
entry.sceneTaskDescription,
|
|
`首次进入${toText(entry.title, toText(entry.sceneName, sceneId))}时,确认当前场景核心任务与关键角色。`,
|
|
),
|
|
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 creatorIntentRecord = isRecord(value.creatorIntent)
|
|
? value.creatorIntent
|
|
: null;
|
|
const worldHook = toText(
|
|
value.worldHook,
|
|
toText(creatorIntentRecord?.worldHook, toText(value.summary, settingText || name)),
|
|
);
|
|
const playerPremise = toText(
|
|
value.playerPremise,
|
|
toText(creatorIntentRecord?.playerPremise, 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 playableNpcs = Array.isArray(value.playableNpcs)
|
|
? value.playableNpcs
|
|
.map((entry, index) => normalizePlayableNpc(entry, index))
|
|
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
|
: [];
|
|
const normalizedProfile = {
|
|
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
|
settingText,
|
|
name,
|
|
subtitle,
|
|
summary,
|
|
tone,
|
|
playerGoal,
|
|
worldHook,
|
|
playerPremise,
|
|
templateWorldType,
|
|
compatibilityTemplateWorldType,
|
|
majorFactions,
|
|
coreConflicts: resolvedCoreConflicts,
|
|
attributeSchema: coerceWorldAttributeSchema(
|
|
value.attributeSchema,
|
|
generatedAttributeSchema,
|
|
),
|
|
playableNpcs,
|
|
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,
|
|
{
|
|
playableNpcs,
|
|
storyNpcs,
|
|
},
|
|
),
|
|
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);
|
|
}
|