This commit is contained in:
2026-04-16 15:45:00 +08:00
parent 6363267bca
commit 91b63675eb
43 changed files with 5652 additions and 853 deletions

View File

@@ -421,8 +421,11 @@ export function resolveEncounterRecruitCharacter(
return getCharacterById(resolveFallbackRecruitTemplateCharacterId(source));
}
export function getCharacterEquipment(character: Character) {
const runtimeProfile = getRuntimeCustomWorldProfile();
export function getCharacterEquipment(
character: Character,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
const runtimeProfile = customWorldProfile;
if (runtimeProfile) {
const starterEquipment = buildCustomWorldStarterEquipmentItems(character, runtimeProfile);
const toRarityLabel = (rarity: InventoryItem['rarity'] | undefined) => ({
@@ -492,9 +495,13 @@ export function getCharacterEquipment(character: Character) {
];
}
export function getInventoryItems(character: Character, worldType: WorldType | null) {
if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) {
return buildCustomWorldStarterInventoryItems(character).map(item => ({
export function getInventoryItems(
character: Character,
worldType: WorldType | null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
if (worldType === WorldType.CUSTOM && customWorldProfile) {
return buildCustomWorldStarterInventoryItems(character, customWorldProfile).map(item => ({
category: item.category,
name: item.name,
quantity: item.quantity,

View File

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

View File

@@ -385,6 +385,7 @@ export function normalizeCustomWorldLandmarks(params: {
description: landmark.description,
dangerLevel: landmark.dangerLevel,
imageSrc: landmark.imageSrc,
narrativeResidues: landmark.narrativeResidues,
sceneNpcIds: resolveSceneNpcIdsForLandmark(
landmark,
storyNpcs,
@@ -407,6 +408,7 @@ export function syncCustomWorldLandmarkConnections(
return normalizeCustomWorldLandmarks({
landmarks: landmarks.map((landmark) => ({
...landmark,
narrativeResidues: landmark.narrativeResidues,
sceneNpcIds: landmark.sceneNpcIds,
connections: landmark.connections.map((connection) => ({
targetLandmarkId: connection.targetLandmarkId,

View File

@@ -1,4 +1,4 @@
import { Character, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types';
import { Character, CustomWorldProfile, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types';
import { normalizeBuildRole, normalizeBuildTags } from './buildTags';
import type { CharacterEquipmentItem } from './characterPresets';
import { getCharacterEquipment, getCharacterMaxHp, getCharacterMaxMana } from './characterPresets';
@@ -201,9 +201,12 @@ export function isInventoryItemEquippable(item: InventoryItem) {
return getEquipmentSlotFromItem(item) !== null;
}
export function buildInitialEquipmentLoadout(character: Character) {
export function buildInitialEquipmentLoadout(
character: Character,
customWorldProfile: CustomWorldProfile | null = null,
) {
const loadout = createEmptyEquipmentLoadout();
const starterEquipment = getCharacterEquipment(character);
const starterEquipment = getCharacterEquipment(character, customWorldProfile);
starterEquipment.forEach((equipmentItem, index) => {
const inferredSlot = inferSlotFromText(`${equipmentItem.slot} ${equipmentItem.item}`)

View File

@@ -439,12 +439,19 @@ function mergeInventory(items: InventoryItem[]) {
function buildCharacterInventory(
character: Character,
worldType: WorldType | null,
customWorldProfile = getRuntimeCustomWorldProfile(),
) {
if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) {
return sortInventoryItems(buildCustomWorldStarterInventoryItems(character));
if (worldType === WorldType.CUSTOM && customWorldProfile) {
return sortInventoryItems(
buildCustomWorldStarterInventoryItems(character, customWorldProfile),
);
}
const packItems = getInventoryItems(character, worldType).map((item) =>
const packItems = getInventoryItems(
character,
worldType,
customWorldProfile,
).map((item) =>
buildInventoryItem('player', item.category, item.name, item.quantity),
);
return sortInventoryItems(mergeInventory(packItems));
@@ -453,10 +460,17 @@ function buildCharacterInventory(
function buildCharacterNpcInventory(
character: Character,
worldType: WorldType | null,
customWorldProfile = getRuntimeCustomWorldProfile(),
) {
if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) {
const starterEquipment = buildCustomWorldStarterEquipmentItems(character);
const starterInventory = buildCustomWorldStarterInventoryItems(character);
if (worldType === WorldType.CUSTOM && customWorldProfile) {
const starterEquipment = buildCustomWorldStarterEquipmentItems(
character,
customWorldProfile,
);
const starterInventory = buildCustomWorldStarterInventoryItems(
character,
customWorldProfile,
);
return sortInventoryItems(
mergeInventory([
...(Object.values(starterEquipment).filter(Boolean) as InventoryItem[]),
@@ -465,7 +479,7 @@ function buildCharacterNpcInventory(
);
}
const equipmentItems = getCharacterEquipment(character).map((item) =>
const equipmentItems = getCharacterEquipment(character, customWorldProfile).map((item) =>
buildInventoryItem(
`npc-${character.id}`,
item.slot,
@@ -1503,8 +1517,9 @@ export function removeInventoryItem(
export function buildInitialPlayerInventory(
character: Character,
worldType: WorldType | null,
customWorldProfile = getRuntimeCustomWorldProfile(),
) {
return buildCharacterInventory(character, worldType);
return buildCharacterInventory(character, worldType, customWorldProfile);
}
function buildMonsterPresetInventory(
@@ -1547,7 +1562,11 @@ export function buildInitialNpcState(
? (() => {
const character = getCharacterById(encounter.characterId);
return character
? buildCharacterNpcInventory(character, worldType)
? buildCharacterNpcInventory(
character,
worldType,
state?.customWorldProfile ?? getRuntimeCustomWorldProfile(),
)
: buildRoleInventory(encounter, worldType, state);
})()
: encounter.monsterPresetId