309
src/data/customWorldLibrary.ts
Normal file
309
src/data/customWorldLibrary.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
|
||||
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
|
||||
import {
|
||||
CustomWorldItem,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldNpcVisual,
|
||||
CustomWorldNpcVisualGear,
|
||||
CustomWorldNpcVisualGearType,
|
||||
CustomWorldNpcVisualRace,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
EquipmentSlotId,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
ItemUseProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {coerceWorldAttributeSchema} from './attributeValidation';
|
||||
|
||||
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
|
||||
const CUSTOM_WORLD_LIBRARY_VERSION = 1;
|
||||
const MAX_SAVED_CUSTOM_WORLDS = 12;
|
||||
const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic', 'legendary']);
|
||||
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
|
||||
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']);
|
||||
|
||||
type StoredCustomWorldLibrary = {
|
||||
version: number;
|
||||
profiles: CustomWorldProfile[];
|
||||
};
|
||||
|
||||
function toText(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function toOptionalNumber(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function toOptionalInteger(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined;
|
||||
}
|
||||
|
||||
function 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 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;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-playable-${index + 1}`),
|
||||
name,
|
||||
title: toText(value.title, '未命名角色'),
|
||||
description: toText(value.description),
|
||||
backstory: toText(value.backstory),
|
||||
personality: toText(value.personality),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
tags: toStringArray(value.tags),
|
||||
templateCharacterId: toText(value.templateCharacterId) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-story-${index + 1}`),
|
||||
name,
|
||||
role: toText(value.role, '未命名场景角色'),
|
||||
description: toText(value.description),
|
||||
motivation: toText(value.motivation),
|
||||
relationshipHooks: toStringArray(value.relationshipHooks),
|
||||
imageSrc: toText(value.imageSrc) || 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),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-landmark-${index + 1}`),
|
||||
name,
|
||||
description: toText(value.description),
|
||||
dangerLevel: toText(value.dangerLevel),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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 templateWorldType = value.templateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
: WorldType.WUXIA;
|
||||
const subtitle = toText(value.subtitle);
|
||||
const summary = toText(value.summary);
|
||||
const tone = toText(value.tone);
|
||||
const playerGoal = toText(value.playerGoal);
|
||||
const generatedAttributeSchema = generateWorldAttributeSchema({
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: name,
|
||||
settingText,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary || playerGoal || settingText || name],
|
||||
});
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||
settingText,
|
||||
name,
|
||||
subtitle,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),
|
||||
playableNpcs: Array.isArray(value.playableNpcs)
|
||||
? value.playableNpcs
|
||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
||||
: [],
|
||||
storyNpcs: Array.isArray(value.storyNpcs)
|
||||
? value.storyNpcs
|
||||
.map((entry, index) => normalizeStoryNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
|
||||
: [],
|
||||
items: Array.isArray(value.items)
|
||||
? value.items
|
||||
.map((entry, index) => normalizeItem(entry, index))
|
||||
.filter((entry): entry is CustomWorldItem => Boolean(entry))
|
||||
: [],
|
||||
landmarks: Array.isArray(value.landmarks)
|
||||
? value.landmarks
|
||||
.map((entry, index) => normalizeLandmark(entry, index))
|
||||
.filter((entry): entry is CustomWorldLandmark => Boolean(entry))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user