#nullable enable using System; using System.Collections.Generic; using System.Linq; using SideScrollerGame.Content.Definitions; namespace SideScrollerGame.Content.Validation; public sealed class ContentValidator { public ContentValidationResult Validate(ContentRegistry registry) { List messages = new(); ValidateDuplicateIds(registry, messages); ValidateDifficulties(registry, messages); ValidateCameraPaths(registry, messages); ValidateLayers(registry, messages); ValidateBehaviors(registry, messages); ValidateEnemies(registry, messages); ValidateClusters(registry, messages); ValidateCollectibles(registry, messages); ValidateWeapons(registry, messages); ValidateSpecialWeapons(registry, messages); ValidateSquadronMateTypes(registry, messages); ValidateMissions(registry, messages); return new ContentValidationResult(messages); } private static void ValidateDuplicateIds(ContentRegistry registry, List messages) { foreach (IGrouping group in registry.AllDefinitions().GroupBy(definition => definition.Id)) { if (string.IsNullOrWhiteSpace(group.Key)) { AddError(messages, "content.id.empty", "A definition has an empty id."); } else if (group.Count() > 1) { string kinds = string.Join(", ", group.Select(definition => definition.Kind)); AddError(messages, "content.id.duplicate", $"Duplicate definition id '{group.Key}' appears in {kinds}.", group.Key); } } } private static void ValidateDifficulties(ContentRegistry registry, List messages) { foreach (DifficultyDefinition difficulty in registry.DifficultyDefinitions) { if (difficulty.HeroStartingShieldCharges < 0) { AddError(messages, "difficulty.shields.invalid", $"Difficulty '{difficulty.Id}' has negative starting shields.", difficulty.Id); } if (difficulty.HeroRetryCount < 0) { AddError(messages, "difficulty.retries.invalid", $"Difficulty '{difficulty.Id}' has negative retry count.", difficulty.Id); } ValidatePositiveMultiplier(messages, difficulty.Id, "EnemyHealthMultiplier", difficulty.Modifiers.EnemyHealthMultiplier); ValidatePositiveMultiplier(messages, difficulty.Id, "EnemyProjectileSpeedMultiplier", difficulty.Modifiers.EnemyProjectileSpeedMultiplier); ValidatePositiveMultiplier(messages, difficulty.Id, "EnemyFireCadenceMultiplier", difficulty.Modifiers.EnemyFireCadenceMultiplier); ValidatePositiveMultiplier(messages, difficulty.Id, "EnemySpawnDensityMultiplier", difficulty.Modifiers.EnemySpawnDensityMultiplier); ValidatePositiveMultiplier(messages, difficulty.Id, "ClusterSpawnIntervalMultiplier", difficulty.Modifiers.ClusterSpawnIntervalMultiplier); ValidatePositiveMultiplier(messages, difficulty.Id, "BossHealthMultiplier", difficulty.Modifiers.BossHealthMultiplier); ValidatePositiveMultiplier(messages, difficulty.Id, "BossPhaseTimingMultiplier", difficulty.Modifiers.BossPhaseTimingMultiplier); ValidatePositiveMultiplier(messages, difficulty.Id, "CollectibleDropRateMultiplier", difficulty.Modifiers.CollectibleDropRateMultiplier); ValidatePositiveMultiplier(messages, difficulty.Id, "SpecialWeaponInitialAmmoMultiplier", difficulty.Modifiers.SpecialWeaponInitialAmmoMultiplier); ValidatePositiveMultiplier(messages, difficulty.Id, "ScoreMultiplier", difficulty.Modifiers.ScoreMultiplier); } } private static void ValidateCameraPaths(ContentRegistry registry, List messages) { foreach (CameraPathDefinition cameraPath in registry.CameraPathDefinitions) { if (cameraPath.Points.Count == 0) { AddError(messages, "camera.points.empty", $"Camera path '{cameraPath.Id}' has no points.", cameraPath.Id); } if (cameraPath.DefaultSpeed <= 0.0) { AddError(messages, "camera.speed.invalid", $"Camera path '{cameraPath.Id}' has non-positive default speed.", cameraPath.Id); } foreach (CameraPathPointDefinition point in cameraPath.Points) { if (point.TimeSeconds < 0.0) { AddError(messages, "camera.point.time.invalid", $"Camera path '{cameraPath.Id}' has a point before mission start.", cameraPath.Id); } if (point.Speed <= 0.0) { AddError(messages, "camera.point.speed.invalid", $"Camera path '{cameraPath.Id}' has a point with non-positive speed.", cameraPath.Id); } } } } private static void ValidateLayers(ContentRegistry registry, List messages) { foreach (LevelLayerDefinition layer in registry.LevelLayerDefinitions) { if (layer.ScrollFactor < 0.0) { AddError(messages, "layer.scroll.invalid", $"Layer '{layer.Id}' has negative scroll factor.", layer.Id); } } } private static void ValidateBehaviors(ContentRegistry registry, List messages) { foreach (EnemyBehaviorDefinition behavior in registry.EnemyBehaviorDefinitions) { if (behavior.Tracks.Count == 0) { AddError(messages, "behavior.tracks.empty", $"Behavior '{behavior.Id}' has no tracks.", behavior.Id); } foreach (BehaviorTrackDefinition track in behavior.Tracks) { if (track.Events.Count == 0) { AddError(messages, "behavior.track.events.empty", $"Behavior '{behavior.Id}' track '{track.Id}' has no events.", behavior.Id); } foreach (BehaviorEventDefinition behaviorEvent in track.Events) { if (behaviorEvent.StartSeconds < 0.0) { AddError(messages, "behavior.event.start.invalid", $"Behavior '{behavior.Id}' has an event before mission start.", behavior.Id); } if (behaviorEvent.DurationSeconds < 0.0) { AddError(messages, "behavior.event.duration.invalid", $"Behavior '{behavior.Id}' has an event with negative duration.", behavior.Id); } } } } } private static void ValidateEnemies(ContentRegistry registry, List messages) { foreach (EnemyTypeDefinition enemyType in registry.EnemyTypeDefinitions) { if (enemyType.Health <= 0) { AddError(messages, "enemy.health.invalid", $"Enemy '{enemyType.Id}' has non-positive health.", enemyType.Id); } if (enemyType.ScoreValue < 0) { AddError(messages, "enemy.score.invalid", $"Enemy '{enemyType.Id}' has negative score value.", enemyType.Id); } if (enemyType.BehaviorIds.Count == 0) { AddError(messages, "enemy.behaviors.empty", $"Enemy '{enemyType.Id}' has no behavior references.", enemyType.Id); } foreach (string behaviorId in enemyType.BehaviorIds) { if (!registry.EnemyBehaviors.ContainsKey(behaviorId)) { AddError(messages, "enemy.behavior.missing", $"Enemy '{enemyType.Id}' references missing behavior '{behaviorId}'.", enemyType.Id); } } foreach (string collectibleId in enemyType.DropCollectibleIds) { if (!registry.Collectibles.ContainsKey(collectibleId)) { AddError(messages, "enemy.collectible.missing", $"Enemy '{enemyType.Id}' references missing drop collectible '{collectibleId}'.", enemyType.Id); } } } } private static void ValidateClusters(ContentRegistry registry, List messages) { foreach (EnemyClusterDefinition cluster in registry.EnemyClusterDefinitions) { if (cluster.Spawns.Count == 0) { AddError(messages, "cluster.spawns.empty", $"Cluster '{cluster.Id}' has no spawns.", cluster.Id); } if (cluster.CompletionRewardPoints < 0) { AddError(messages, "cluster.reward.invalid", $"Cluster '{cluster.Id}' has negative completion reward.", cluster.Id); } foreach (SpawnScheduleEntryDefinition spawn in cluster.Spawns) { if (spawn.SpawnTimeSeconds < 0.0) { AddError(messages, "cluster.spawn.time.invalid", $"Cluster '{cluster.Id}' has a spawn before mission start.", cluster.Id); } if (!registry.EnemyTypes.ContainsKey(spawn.EnemyTypeId)) { AddError(messages, "cluster.enemy.missing", $"Cluster '{cluster.Id}' references missing enemy type '{spawn.EnemyTypeId}'.", cluster.Id); } } } } private static void ValidateCollectibles(ContentRegistry registry, List messages) { foreach (CollectibleDefinition collectible in registry.CollectibleDefinitions) { if (collectible.Value < 0) { AddError(messages, "collectible.value.invalid", $"Collectible '{collectible.Id}' has negative value.", collectible.Id); } if (collectible.Kind == CollectibleKind.PrimaryWeapon && !ReferencedContentExists(registry.Weapons, collectible.ReferencedContentId)) { AddError(messages, "collectible.weapon.missing", $"Collectible '{collectible.Id}' references missing primary weapon '{collectible.ReferencedContentId}'.", collectible.Id); } if (collectible.Kind == CollectibleKind.SecondaryWeapon && !ReferencedContentExists(registry.Weapons, collectible.ReferencedContentId)) { AddError(messages, "collectible.weapon.missing", $"Collectible '{collectible.Id}' references missing secondary weapon '{collectible.ReferencedContentId}'.", collectible.Id); } if (collectible.Kind == CollectibleKind.SpecialAmmo && !ReferencedContentExists(registry.SpecialWeapons, collectible.ReferencedContentId)) { AddError(messages, "collectible.special.missing", $"Collectible '{collectible.Id}' references missing special weapon '{collectible.ReferencedContentId}'.", collectible.Id); } if (collectible.Kind == CollectibleKind.SquadronMate && !ReferencedContentExists(registry.SquadronMateTypes, collectible.ReferencedContentId)) { AddError(messages, "collectible.squadron.missing", $"Collectible '{collectible.Id}' references missing squadron mate type '{collectible.ReferencedContentId}'.", collectible.Id); } } } private static void ValidateWeapons(ContentRegistry registry, List messages) { foreach (WeaponDefinition weapon in registry.WeaponDefinitions) { if (weapon.Damage <= 0) { AddError(messages, "weapon.damage.invalid", $"Weapon '{weapon.Id}' has non-positive damage.", weapon.Id); } if (weapon.FireCadenceSeconds <= 0.0) { AddError(messages, "weapon.cadence.invalid", $"Weapon '{weapon.Id}' has non-positive fire cadence.", weapon.Id); } if (weapon.ProjectileSpeed <= 0.0) { AddError(messages, "weapon.speed.invalid", $"Weapon '{weapon.Id}' has non-positive projectile speed.", weapon.Id); } if (weapon.ProjectileCount <= 0) { AddError(messages, "weapon.projectiles.invalid", $"Weapon '{weapon.Id}' has non-positive projectile count.", weapon.Id); } } } private static void ValidateSpecialWeapons(ContentRegistry registry, List messages) { foreach (SpecialWeaponDefinition specialWeapon in registry.SpecialWeaponDefinitions) { if (specialWeapon.InitialAmmo < 0) { AddError(messages, "special.ammo.invalid", $"Special weapon '{specialWeapon.Id}' has negative initial ammo.", specialWeapon.Id); } if (specialWeapon.AmmoPickupAmount < 0) { AddError(messages, "special.pickup.invalid", $"Special weapon '{specialWeapon.Id}' has negative pickup ammo.", specialWeapon.Id); } if (specialWeapon.Damage <= 0) { AddError(messages, "special.damage.invalid", $"Special weapon '{specialWeapon.Id}' has non-positive damage.", specialWeapon.Id); } } } private static void ValidateSquadronMateTypes(ContentRegistry registry, List messages) { foreach (SquadronMateTypeDefinition squadronMateType in registry.SquadronMateTypeDefinitions) { if (squadronMateType.Spacing < 0.0) { AddError(messages, "squadron.spacing.invalid", $"Squadron mate type '{squadronMateType.Id}' has negative spacing.", squadronMateType.Id); } if (squadronMateType.MovementSmoothing < 0.0) { AddError(messages, "squadron.smoothing.invalid", $"Squadron mate type '{squadronMateType.Id}' has negative movement smoothing.", squadronMateType.Id); } } } private static void ValidateMissions(ContentRegistry registry, List messages) { foreach (MissionDefinition mission in registry.MissionDefinitions) { RequireReference(messages, mission.Id, mission.DefaultDifficultyId, registry.Difficulties, "mission.difficulty.missing", "default difficulty"); RequireReference(messages, mission.Id, mission.CameraPathId, registry.CameraPaths, "mission.camera.missing", "camera path"); RequireReferences(messages, mission.Id, mission.BackgroundLayerIds, registry.LevelLayers, "mission.background.missing", "background layer"); RequireReferences(messages, mission.Id, mission.ForegroundLayerIds, registry.LevelLayers, "mission.foreground.missing", "foreground layer"); RequireReferences(messages, mission.Id, mission.ClusterIds, registry.EnemyClusters, "mission.cluster.missing", "cluster"); RequireReferences(messages, mission.Id, mission.CollectibleIds, registry.Collectibles, "mission.collectible.missing", "collectible"); RequireReferences(messages, mission.Id, mission.SpecialWeaponIds, registry.SpecialWeapons, "mission.special.missing", "special weapon"); if (mission.BackgroundLayerIds.Count == 0) { AddError(messages, "mission.background.empty", $"Mission '{mission.Id}' has no background layers.", mission.Id); } if (mission.ForegroundLayerIds.Count == 0) { AddError(messages, "mission.foreground.empty", $"Mission '{mission.Id}' has no foreground layers.", mission.Id); } if (mission.ClusterIds.Count == 0) { AddError(messages, "mission.clusters.empty", $"Mission '{mission.Id}' has no clusters.", mission.Id); } if (mission.TimelineMarkers.Count == 0) { AddError(messages, "mission.markers.empty", $"Mission '{mission.Id}' has no timeline markers.", mission.Id); } } } private static void ValidatePositiveMultiplier(List messages, string definitionId, string fieldName, double value) { if (value <= 0.0 || double.IsNaN(value) || double.IsInfinity(value)) { AddError(messages, "difficulty.multiplier.invalid", $"Difficulty '{definitionId}' has non-positive {fieldName}.", definitionId); } } private static bool ReferencedContentExists(IReadOnlyDictionary definitions, string? id) { return !string.IsNullOrWhiteSpace(id) && definitions.ContainsKey(id); } private static void RequireReferences(List messages, string ownerId, IEnumerable referenceIds, IReadOnlyDictionary definitions, string code, string referenceKind) { foreach (string referenceId in referenceIds) { RequireReference(messages, ownerId, referenceId, definitions, code, referenceKind); } } private static void RequireReference(List messages, string ownerId, string referenceId, IReadOnlyDictionary definitions, string code, string referenceKind) { if (!definitions.ContainsKey(referenceId)) { AddError(messages, code, $"Definition '{ownerId}' references missing {referenceKind} '{referenceId}'.", ownerId); } } private static void AddError(List messages, string code, string message, string? definitionId = null) { messages.Add(new ContentValidationMessage(ContentValidationSeverity.Error, code, message, definitionId)); } }