Files
Genarrative/src/data/customWorldLibrary.ts
T
2026-04-27 22:50:18 +08:00

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);
}