初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

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