1
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
|
||||
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
|
||||
import {
|
||||
isRecord,
|
||||
readStoredJson,
|
||||
writeStoredJson,
|
||||
} from '../persistence/storage';
|
||||
import { generateWorldAttributeSchema } from '../services/attributeSchemaGenerator';
|
||||
import { buildFallbackCustomWorldCampScene } from '../services/customWorldCamp';
|
||||
import {
|
||||
buildCustomWorldAnchorPackFromIntent,
|
||||
@@ -26,16 +30,23 @@ import {
|
||||
CustomWorldRoleInitialItem,
|
||||
CustomWorldRoleSkill,
|
||||
EquipmentSlotId,
|
||||
ItemAttributeResonance,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
ItemUseProfile,
|
||||
KnowledgeFact,
|
||||
RoleAttributeProfile,
|
||||
SceneNarrativeResidue,
|
||||
ThemePack,
|
||||
ThreadContract,
|
||||
WorldType,
|
||||
WorldStoryGraph,
|
||||
} from '../types';
|
||||
import {
|
||||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
|
||||
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
|
||||
} from './affinityLevels';
|
||||
import {coerceWorldAttributeSchema} from './attributeValidation';
|
||||
import { coerceWorldAttributeSchema } from './attributeValidation';
|
||||
import {
|
||||
type CustomWorldLandmarkDraft,
|
||||
normalizeCustomWorldLandmarks,
|
||||
@@ -48,11 +59,30 @@ 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 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 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 CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
|
||||
'武器',
|
||||
'护甲',
|
||||
@@ -65,7 +95,12 @@ 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;
|
||||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [
|
||||
'表层来意',
|
||||
'旧事裂痕',
|
||||
'隐藏执念',
|
||||
'最终底牌',
|
||||
] as const;
|
||||
|
||||
type CustomWorldRoleFallbackSource = {
|
||||
name: string;
|
||||
@@ -92,25 +127,43 @@ function toText(value: unknown, fallback = '') {
|
||||
function toStringArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
.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;
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function toOptionalInteger(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined;
|
||||
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));
|
||||
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) {
|
||||
@@ -125,7 +178,7 @@ function splitNarrativeSentences(text: string) {
|
||||
if (!normalized) return [];
|
||||
|
||||
const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu);
|
||||
return (matches ?? [normalized]).map(item => item.trim()).filter(Boolean);
|
||||
return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
|
||||
@@ -143,12 +196,17 @@ function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function buildFallbackBackstoryReveal(source: CustomWorldRoleFallbackSource): CharacterBackstoryRevealConfig {
|
||||
const normalizedBackstory = source.backstory.trim() || `${source.name}对自己的过去仍有保留。`;
|
||||
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 backstoryDetail =
|
||||
backstorySentences.slice(0, 2).join('') || normalizedBackstory;
|
||||
const publicSummary =
|
||||
source.description.trim() || truncateText(normalizedBackstory, 42);
|
||||
const fallbackContents = [
|
||||
source.description.trim() || backstoryLead,
|
||||
backstoryDetail,
|
||||
@@ -163,17 +221,28 @@ function buildFallbackBackstoryReveal(source: CustomWorldRoleFallbackSource): Ch
|
||||
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),
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,21 +262,38 @@ function normalizeBackstoryReveal(
|
||||
return {
|
||||
publicSummary: toText(value.publicSummary, fallback.publicSummary),
|
||||
privateChatUnlockAffinity:
|
||||
typeof value.privateChatUnlockAffinity === 'number' && Number.isFinite(value.privateChatUnlockAffinity)
|
||||
? normalizeInitialAffinity(value.privateChatUnlockAffinity, DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY)
|
||||
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;
|
||||
}),
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -217,19 +303,28 @@ function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
|
||||
{
|
||||
id: 'saved-role-skill-1',
|
||||
name: `${nameSeed}起手`,
|
||||
summary: truncateText(source.combatStyle || `${source.name}擅长稳住局面。`, 36),
|
||||
summary: truncateText(
|
||||
source.combatStyle || `${source.name}擅长稳住局面。`,
|
||||
36,
|
||||
),
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: 'saved-role-skill-2',
|
||||
name: `${nameSeed}变招`,
|
||||
summary: truncateText(source.personality || `${source.name}习惯在周旋中找破绽。`, 36),
|
||||
summary: truncateText(
|
||||
source.personality || `${source.name}习惯在周旋中找破绽。`,
|
||||
36,
|
||||
),
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: 'saved-role-skill-3',
|
||||
name: `${nameSeed}底牌`,
|
||||
summary: truncateText(source.motivation || `${source.name}会在关键时刻亮出压箱手段。`, 36),
|
||||
summary: truncateText(
|
||||
source.motivation || `${source.name}会在关键时刻亮出压箱手段。`,
|
||||
36,
|
||||
),
|
||||
style: '爆发终结',
|
||||
},
|
||||
] satisfies CustomWorldRoleSkill[];
|
||||
@@ -241,18 +336,23 @@ function normalizeRoleSkills(
|
||||
) {
|
||||
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, '常用')),
|
||||
} satisfies CustomWorldRoleSkill))
|
||||
.filter(entry => entry.name)
|
||||
.slice(0, 3)
|
||||
.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, '常用')),
|
||||
}) satisfies CustomWorldRoleSkill,
|
||||
)
|
||||
.filter((entry) => entry.name)
|
||||
.slice(0, 3)
|
||||
: [];
|
||||
|
||||
return normalized.length > 0 ? normalized : buildFallbackRoleSkills(fallbackSource);
|
||||
return normalized.length > 0
|
||||
? normalized
|
||||
: buildFallbackRoleSkills(fallbackSource);
|
||||
}
|
||||
|
||||
function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
|
||||
@@ -264,7 +364,10 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: truncateText(source.combatStyle || `${source.name}随身携带的主要作战物件。`, 36),
|
||||
description: truncateText(
|
||||
source.combatStyle || `${source.name}随身携带的主要作战物件。`,
|
||||
36,
|
||||
),
|
||||
tags: source.tags.slice(0, 2),
|
||||
},
|
||||
{
|
||||
@@ -273,7 +376,10 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: truncateText(source.personality || `${source.name}为长期行动准备的基础补给。`, 36),
|
||||
description: truncateText(
|
||||
source.personality || `${source.name}为长期行动准备的基础补给。`,
|
||||
36,
|
||||
),
|
||||
tags: source.relationshipHooks.slice(0, 2),
|
||||
},
|
||||
{
|
||||
@@ -282,7 +388,12 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: truncateText(source.backstory || source.motivation || `${source.name}不愿随意交出的信物。`, 36),
|
||||
description: truncateText(
|
||||
source.backstory ||
|
||||
source.motivation ||
|
||||
`${source.name}不愿随意交出的信物。`,
|
||||
36,
|
||||
),
|
||||
tags: [...source.tags, ...source.relationshipHooks].slice(0, 3),
|
||||
},
|
||||
] satisfies CustomWorldRoleInitialItem[];
|
||||
@@ -294,23 +405,29 @@ function normalizeRoleInitialItems(
|
||||
) {
|
||||
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),
|
||||
} satisfies CustomWorldRoleInitialItem))
|
||||
.filter(entry => entry.name)
|
||||
.slice(0, 3)
|
||||
.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),
|
||||
}) satisfies CustomWorldRoleInitialItem,
|
||||
)
|
||||
.filter((entry) => entry.name)
|
||||
.slice(0, 3)
|
||||
: [];
|
||||
|
||||
return normalized.length > 0
|
||||
@@ -319,17 +436,24 @@ function normalizeRoleInitialItems(
|
||||
}
|
||||
|
||||
function normalizeEquipmentSlot(value: unknown) {
|
||||
return typeof value === 'string' && EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
|
||||
? value as EquipmentSlotId
|
||||
return typeof value === 'string' &&
|
||||
EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
|
||||
? (value as EquipmentSlotId)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeCustomWorldNpcVisualGear(value: unknown): CustomWorldNpcVisualGear | 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 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;
|
||||
@@ -341,12 +465,16 @@ function normalizeCustomWorldNpcVisualGear(value: unknown): CustomWorldNpcVisual
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | undefined {
|
||||
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;
|
||||
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;
|
||||
|
||||
@@ -357,8 +485,14 @@ function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | u
|
||||
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),
|
||||
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),
|
||||
@@ -407,9 +541,9 @@ function normalizeGeneratedAnimationMap(value: unknown) {
|
||||
});
|
||||
|
||||
return entries.length > 0
|
||||
? Object.fromEntries(entries) as Partial<
|
||||
? (Object.fromEntries(entries) as Partial<
|
||||
Record<AnimationState, CharacterAnimationConfig>
|
||||
>
|
||||
>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -423,7 +557,9 @@ function normalizeItemStatProfile(value: unknown): ItemStatProfile | null {
|
||||
incomingDamageMultiplier: toOptionalNumber(value.incomingDamageMultiplier),
|
||||
};
|
||||
|
||||
return Object.values(profile).some(entry => entry !== undefined) ? profile : null;
|
||||
return Object.values(profile).some((entry) => entry !== undefined)
|
||||
? profile
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeItemUseProfile(value: unknown): ItemUseProfile | null {
|
||||
@@ -435,10 +571,15 @@ function normalizeItemUseProfile(value: unknown): ItemUseProfile | null {
|
||||
cooldownReduction: toOptionalNumber(value.cooldownReduction),
|
||||
};
|
||||
|
||||
return Object.values(profile).some(entry => entry !== undefined) ? profile : null;
|
||||
return Object.values(profile).some((entry) => entry !== undefined)
|
||||
? profile
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayableNpc | null {
|
||||
function normalizePlayableNpc(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CustomWorldPlayableNpc | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
@@ -456,13 +597,14 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
|
||||
personality: toText(value.personality),
|
||||
motivation: toText(value.motivation, toText(value.description)),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
|
||||
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,
|
||||
return {
|
||||
id: toText(value.id, `saved-playable-${index + 1}`),
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
description: fallbackSource.description,
|
||||
@@ -470,21 +612,37 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
|
||||
personality: fallbackSource.personality,
|
||||
motivation: fallbackSource.motivation,
|
||||
combatStyle: fallbackSource.combatStyle,
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
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),
|
||||
templateCharacterId: toText(value.templateCharacterId) || undefined,
|
||||
};
|
||||
}
|
||||
initialAffinity: normalizeInitialAffinity(
|
||||
value.initialAffinity,
|
||||
DEFAULT_PLAYABLE_INITIAL_AFFINITY,
|
||||
),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
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 {
|
||||
function normalizeStoryNpc(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CustomWorldNpc | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
@@ -502,13 +660,14 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
|
||||
personality: toText(value.personality),
|
||||
motivation: toText(value.motivation),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
|
||||
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,
|
||||
return {
|
||||
id: toText(value.id, `saved-story-${index + 1}`),
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
description: fallbackSource.description,
|
||||
@@ -516,28 +675,43 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
|
||||
personality: fallbackSource.personality,
|
||||
motivation: fallbackSource.motivation,
|
||||
combatStyle: fallbackSource.combatStyle,
|
||||
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
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),
|
||||
visual: normalizeCustomWorldNpcVisual(value.visual),
|
||||
};
|
||||
}
|
||||
initialAffinity: normalizeInitialAffinity(
|
||||
value.initialAffinity,
|
||||
DEFAULT_STORY_NPC_INITIAL_AFFINITY,
|
||||
),
|
||||
relationshipHooks: fallbackSource.relationshipHooks,
|
||||
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;
|
||||
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 {
|
||||
@@ -549,15 +723,25 @@ function normalizeItem(value: unknown, index: number): CustomWorldItem | null {
|
||||
tags: toStringArray(value.tags),
|
||||
iconSrc: toText(value.iconSrc) || undefined,
|
||||
sourcePath: toText(value.sourcePath) || undefined,
|
||||
origin: value.origin === 'generated' || value.origin === 'catalog' ? value.origin : 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 {
|
||||
function normalizeLandmark(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CustomWorldLandmark | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
@@ -569,6 +753,10 @@ function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark |
|
||||
description: toText(value.description),
|
||||
dangerLevel: toText(value.dangerLevel),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
narrativeResidues:
|
||||
preserveStructuredRecordArray<SceneNarrativeResidue>(
|
||||
value.narrativeResidues,
|
||||
) ?? undefined,
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
};
|
||||
@@ -578,7 +766,12 @@ function normalizeCampScene(
|
||||
value: unknown,
|
||||
fallbackProfile: Pick<
|
||||
CustomWorldProfile,
|
||||
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
|
||||
| 'name'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'settingText'
|
||||
| 'templateWorldType'
|
||||
>,
|
||||
) {
|
||||
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
|
||||
@@ -655,6 +848,12 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
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,
|
||||
@@ -670,18 +869,18 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary || playerGoal || settingText || name],
|
||||
majorFactions,
|
||||
coreConflicts: resolvedCoreConflicts,
|
||||
});
|
||||
const storyNpcs = Array.isArray(value.storyNpcs)
|
||||
? value.storyNpcs
|
||||
.map((entry, index) => normalizeStoryNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
|
||||
.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))
|
||||
.map((entry, index) => normalizeLandmarkDraft(entry, index))
|
||||
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
|
||||
: [];
|
||||
|
||||
const normalizedProfile = {
|
||||
@@ -694,27 +893,34 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
compatibilityTemplateWorldType,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary || playerGoal || settingText || name],
|
||||
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),
|
||||
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))
|
||||
.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))
|
||||
.map((entry, index) => normalizeItem(entry, index))
|
||||
.filter((entry): entry is CustomWorldItem => Boolean(entry))
|
||||
: [],
|
||||
camp,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: landmarkDrafts,
|
||||
storyNpcs,
|
||||
}),
|
||||
themePack: null,
|
||||
storyGraph: null,
|
||||
themePack: preserveStructuredRecord<ThemePack>(value.themePack),
|
||||
storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph),
|
||||
knowledgeFacts:
|
||||
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
|
||||
threadContracts:
|
||||
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
|
||||
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
|
||||
anchorPack:
|
||||
value.anchorPack && typeof value.anchorPack === 'object'
|
||||
@@ -733,9 +939,12 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
? value.generationMode
|
||||
: 'full',
|
||||
generationStatus:
|
||||
value.generationStatus === 'key_only' || value.generationStatus === 'complete'
|
||||
value.generationStatus === 'key_only' ||
|
||||
value.generationStatus === 'complete'
|
||||
? value.generationStatus
|
||||
: 'complete',
|
||||
scenarioPackId: toText(value.scenarioPackId) || null,
|
||||
campaignPackId: toText(value.campaignPackId) || null,
|
||||
} satisfies CustomWorldProfile;
|
||||
|
||||
return {
|
||||
@@ -747,9 +956,15 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldProfileRecord(
|
||||
value: unknown,
|
||||
): CustomWorldProfile | null {
|
||||
return normalizeProfile(value);
|
||||
}
|
||||
|
||||
function writeProfiles(profiles: CustomWorldProfile[]) {
|
||||
const normalizedProfiles = profiles
|
||||
.map(profile => normalizeProfile(profile))
|
||||
.map((profile) => normalizeProfile(profile))
|
||||
.filter((profile): profile is CustomWorldProfile => Boolean(profile))
|
||||
.slice(0, MAX_SAVED_CUSTOM_WORLDS);
|
||||
|
||||
@@ -772,13 +987,17 @@ 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)) {
|
||||
parse: (value) => {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.version !== CUSTOM_WORLD_LIBRARY_VERSION ||
|
||||
!Array.isArray(value.profiles)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.profiles
|
||||
.map(profile => normalizeProfile(profile))
|
||||
.map((profile) => normalizeProfile(profile))
|
||||
.filter((profile): profile is CustomWorldProfile => Boolean(profile))
|
||||
.slice(0, MAX_SAVED_CUSTOM_WORLDS);
|
||||
},
|
||||
@@ -789,7 +1008,9 @@ export function readSavedCustomWorldProfiles() {
|
||||
export function upsertSavedCustomWorldProfile(profile: CustomWorldProfile) {
|
||||
const nextProfiles = [
|
||||
profile,
|
||||
...readSavedCustomWorldProfiles().filter(savedProfile => savedProfile.id !== profile.id),
|
||||
...readSavedCustomWorldProfiles().filter(
|
||||
(savedProfile) => savedProfile.id !== profile.id,
|
||||
),
|
||||
];
|
||||
return writeProfiles(nextProfiles);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user