import { Character, ItemCatalogOverride, WorldType } from '../types'; import { CharacterPresetOverride } from './characterPresets'; import { MonsterPreset, MonsterPresetOverride } from './hostileNpcPresets'; import { SceneNpcPresetOverride, ScenePreset, ScenePresetOverride } from './scenePresets'; function pushError(errors: string[], message: string) { errors.push(message); } function isPositiveNumber(value: number | undefined) { return typeof value === 'number' && Number.isFinite(value) && value > 0; } function isKnownGender(value: unknown): value is 'male' | 'female' { return value === 'male' || value === 'female'; } function isNonEmptyStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every(item => typeof item === 'string' && item.trim().length > 0); } function validateBuildBuffs(errors: string[], ownerId: string, label: string, buffs: unknown) { if (!Array.isArray(buffs)) { pushError(errors, `${ownerId} ${label} must be an array.`); return; } buffs.forEach((buff, index) => { if (!buff || typeof buff !== 'object') { pushError(errors, `${ownerId} ${label}[${index}] must be an object.`); return; } const typedBuff = buff as { name?: unknown; tags?: unknown; durationTurns?: unknown; }; if (typeof typedBuff.name !== 'string' || !typedBuff.name.trim()) { pushError(errors, `${ownerId} ${label}[${index}] is missing a valid name.`); } if (!isNonEmptyStringArray(typedBuff.tags)) { pushError(errors, `${ownerId} ${label}[${index}].tags must be a non-empty string array.`); } if (typeof typedBuff.durationTurns !== 'number' || !Number.isFinite(typedBuff.durationTurns) || typedBuff.durationTurns <= 0) { pushError(errors, `${ownerId} ${label}[${index}].durationTurns must be > 0.`); } }); } export function validateCharacterOverrides( overrideMap: Record, characters: Character[], scenesByWorld: Partial>>>, ) { const errors: string[] = []; const validCharacterIds = new Set(characters.map(character => character.id)); const validSceneIdsByWorld = { [WorldType.WUXIA]: new Set((scenesByWorld[WorldType.WUXIA] ?? []).map(scene => scene.id)), [WorldType.XIANXIA]: new Set((scenesByWorld[WorldType.XIANXIA] ?? []).map(scene => scene.id)), [WorldType.CUSTOM]: new Set((scenesByWorld[WorldType.CUSTOM] ?? []).map(scene => scene.id)), }; Object.entries(overrideMap).forEach(([characterId, override]) => { if (!validCharacterIds.has(characterId)) { pushError(errors, `未知角色覆盖:${characterId}`); return; } if (override.gender !== undefined && !isKnownGender(override.gender)) { pushError(errors, `${characterId} gender must be "male" or "female".`); } if (override.combatTags !== undefined && !isNonEmptyStringArray(override.combatTags)) { pushError(errors, `${characterId} combatTags must be a non-empty string array.`); } if (override.skills) { override.skills.forEach((skill, index) => { const skillLabel = `${characterId} skill ${skill.id || index + 1}`; if (!skill.id?.trim()) pushError(errors, `${skillLabel} is missing id.`); if (!skill.name?.trim()) pushError(errors, `${skillLabel} is missing name.`); if (!isPositiveNumber(skill.range)) pushError(errors, `${skillLabel} range must be > 0.`); if (typeof skill.damage !== 'number' || skill.damage < 0) pushError(errors, `${skillLabel} damage must be >= 0.`); if (typeof skill.manaCost !== 'number' || skill.manaCost < 0) pushError(errors, `${skillLabel} manaCost must be >= 0.`); if (typeof skill.cooldownTurns !== 'number' || skill.cooldownTurns < 0) pushError(errors, `${skillLabel} cooldownTurns must be >= 0.`); if (skill.buildBuffs !== undefined) { validateBuildBuffs(errors, characterId, `skill ${skill.id || index + 1} buildBuffs`, skill.buildBuffs); } }); } if (override.sceneBindings) { Object.entries(override.sceneBindings).forEach(([world, binding]) => { if (!binding) return; const worldType = world as WorldType; const validSceneIds = validSceneIdsByWorld[worldType]; if (binding.homeSceneId && !validSceneIds.has(binding.homeSceneId)) { pushError(errors, `${characterId} has invalid homeSceneId for ${worldType}: ${binding.homeSceneId}`); } (binding.npcSceneIds ?? []).forEach(sceneId => { if (!validSceneIds.has(sceneId)) { pushError(errors, `${characterId} has invalid npcSceneId for ${worldType}: ${sceneId}`); } }); }); } }); return errors; } export function validateMonsterOverrides( overrideMap: Record, monsters: MonsterPreset[], ) { const errors: string[] = []; const validMonsterIds = new Set(monsters.map(monster => monster.id)); Object.entries(overrideMap).forEach(([monsterId, override]) => { if (!validMonsterIds.has(monsterId)) { pushError(errors, `未知怪物覆盖:${monsterId}`); return; } Object.entries(override.baseStats ?? {}).forEach(([key, value]) => { const numericValue = typeof value === 'number' ? value : undefined; if (!isPositiveNumber(numericValue)) { pushError(errors, `${monsterId} baseStats.${key} must be > 0.`); } }); if (override.combatTags !== undefined && !isNonEmptyStringArray(override.combatTags)) { pushError(errors, `${monsterId} combatTags must be a non-empty string array.`); } Object.entries(override.animations ?? {}).forEach(([animation, rawConfig]) => { const config = rawConfig as { frames?: number; fps?: number } | undefined; if (!config) return; if (!isPositiveNumber(config.frames)) pushError(errors, `${monsterId} ${animation}.frames must be > 0.`); if (!isPositiveNumber(config.fps)) pushError(errors, `${monsterId} ${animation}.fps must be > 0.`); }); }); return errors; } export function validateSceneOverrides( overrideMap: Record, scenes: ScenePreset[], _monstersByWorld: Partial>, ) { const errors: string[] = []; const sceneById = new Map(scenes.map(scene => [scene.id, scene])); const validSceneIds = new Set(scenes.map(scene => scene.id)); Object.entries(overrideMap).forEach(([sceneId, override]) => { const scene = sceneById.get(sceneId); if (!scene) { pushError(errors, `未知场景覆盖:${sceneId}`); return; } if (override.forwardSceneId && !validSceneIds.has(override.forwardSceneId)) { pushError(errors, `${sceneId} has invalid forwardSceneId: ${override.forwardSceneId}`); } (override.connectedSceneIds ?? []).forEach(targetSceneId => { if (!validSceneIds.has(targetSceneId)) { pushError(errors, `${sceneId} has invalid connectedSceneId: ${targetSceneId}`); } }); }); return errors; } export function validateSceneNpcOverrides( overrideMap: Record, validNpcIds: string[], characters: Character[], ) { const errors: string[] = []; const npcIdSet = new Set(validNpcIds); const characterIdSet = new Set(characters.map(character => character.id)); Object.entries(overrideMap).forEach(([npcId, override]) => { if (!npcIdSet.has(npcId)) { pushError(errors, `未知场景角色覆盖:${npcId}`); return; } if (override.gender !== undefined && !isKnownGender(override.gender)) { pushError(errors, `${npcId} gender must be "male" or "female".`); } if (override.characterId && !characterIdSet.has(override.characterId)) { pushError(errors, `${npcId} has invalid characterId: ${override.characterId}`); } }); return errors; } export function validateItemOverrides( overrideMap: Record, validItemIds: string[], ) { const errors: string[] = []; const itemIdSet = new Set(validItemIds); Object.entries(overrideMap).forEach(([itemId, override]) => { if (!itemIdSet.has(itemId)) { pushError(errors, `未知物品覆盖:${itemId}`); return; } if (override.name !== undefined && !override.name.trim()) { pushError(errors, `${itemId} name cannot be empty.`); } if (override.category !== undefined && !override.category.trim()) { pushError(errors, `${itemId} category cannot be empty.`); } if (override.description !== undefined && !override.description.trim()) { pushError(errors, `${itemId} description cannot be empty.`); } if (override.tags !== undefined && !isNonEmptyStringArray(override.tags)) { pushError(errors, `${itemId} tags must be a non-empty string array.`); } if (override.buildProfile?.tags !== undefined && !isNonEmptyStringArray(override.buildProfile.tags)) { pushError(errors, `${itemId} buildProfile.tags must be a non-empty string array.`); } if (override.buildProfile?.craftTags !== undefined && !isNonEmptyStringArray(override.buildProfile.craftTags)) { pushError(errors, `${itemId} buildProfile.craftTags must be a non-empty string array.`); } if (override.useProfile?.buildBuffs !== undefined) { validateBuildBuffs(errors, itemId, 'useProfile.buildBuffs', override.useProfile.buildBuffs); } }); return errors; }