374 lines
18 KiB
C#
374 lines
18 KiB
C#
#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<ContentValidationMessage> 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<ContentValidationMessage> messages)
|
|
{
|
|
foreach (IGrouping<string, DefinitionEntry> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<TDefinition>(IReadOnlyDictionary<string, TDefinition> definitions, string? id)
|
|
{
|
|
return !string.IsNullOrWhiteSpace(id) && definitions.ContainsKey(id);
|
|
}
|
|
|
|
private static void RequireReferences<TDefinition>(List<ContentValidationMessage> messages, string ownerId, IEnumerable<string> referenceIds, IReadOnlyDictionary<string, TDefinition> definitions, string code, string referenceKind)
|
|
{
|
|
foreach (string referenceId in referenceIds)
|
|
{
|
|
RequireReference(messages, ownerId, referenceId, definitions, code, referenceKind);
|
|
}
|
|
}
|
|
|
|
private static void RequireReference<TDefinition>(List<ContentValidationMessage> messages, string ownerId, string referenceId, IReadOnlyDictionary<string, TDefinition> definitions, string code, string referenceKind)
|
|
{
|
|
if (!definitions.ContainsKey(referenceId))
|
|
{
|
|
AddError(messages, code, $"Definition '{ownerId}' references missing {referenceKind} '{referenceId}'.", ownerId);
|
|
}
|
|
}
|
|
|
|
private static void AddError(List<ContentValidationMessage> messages, string code, string message, string? definitionId = null)
|
|
{
|
|
messages.Add(new ContentValidationMessage(ContentValidationSeverity.Error, code, message, definitionId));
|
|
}
|
|
} |