using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using BumpCombat.Constants; using BumpCombat.Defense.Comparison; using BumpCombat.Enemies; using NUnit.Framework; using UnityEngine; namespace BumpCombat.Tests { public sealed class Defense25DMonsterPoolCompositionTests { private static readonly string[][] SpeciesByTier = { new[] { "Slime", "Bat", "Skeleton", "Orc", "Hellbat", "Hellhound", "LavaSlime" }, new[] { "ArmoredSkeleton", "SkeletonArcher", "Werewolf", "Werebear", "ArmoredOrc", "BloodMonster_A", "BloodMonster_B", "EyeballMonster", "Ghostfire" }, new[] { "GreatswordSkeleton", "EliteOrc", "OrcRider", "Demon_A", "Demon_B", "Demon_C", "Demon_D", "Demon_E", "Demoness_A", "Demoness_B", "FlameGolem", "Minotaur" }, }; private readonly List created = new List(); [TearDown] public void TearDown() { foreach (UnityEngine.Object item in created) if (item != null) UnityEngine.Object.DestroyImmediate(item); created.Clear(); } [Test] public void ExplicitCatalogAndTierBoundariesValidate() { Defense25DMonsterPoolDefinition pool = MakePool(); Assert.That(pool.TryValidate(out string error), Is.True, error); Assert.That(pool.Tiers.Select(tier => tier.TierId), Is.EqualTo(new[] { 0, 1, 2 })); Assert.That(pool.Tiers.Select(tier => tier.FromCombatSeconds), Is.EqualTo(new[] { 0f, 180f, 360f })); Assert.That(pool.Tiers.SelectMany(tier => tier.Entries).Count(), Is.EqualTo(28)); Assert.That(pool.TryGetEntry("Necromancer", out _), Is.False); AssertTier(0f, 0); AssertTier(179.999f, 0); AssertTier(180f, 1); AssertTier(359.999f, 1); AssertTier(360f, 2); AssertInvalidTier(float.NaN); AssertInvalidTier(float.PositiveInfinity); AssertInvalidTier(-1f); } [TestCase(-1)] [TestCase(3)] [TestCase(4)] [TestCase(5)] public void DirectorAdapterRejectsNonNormalizedTierIds(int tierId) { Defense25DMonsterPoolDefinition pool = MakePool(); Assert.Throws(() => pool.GetDirectorEntriesForTier(tierId)); } [Test] public void CompositionUsesStableSeedNominalTierThreatBudgetAndBoundedOvershoot() { Defense25DMonsterPoolDefinition pool = MakePool(); Defense25DWaveComposition first = pool.BuildWaveComposition(8, 179.999f, 11f, 417); Defense25DWaveComposition replay = pool.BuildWaveComposition(8, 179.999f, 11f, 417); Defense25DWaveComposition changedSeed = pool.BuildWaveComposition(8, 179.999f, 11f, 418); Assert.That(first.TierId, Is.EqualTo(0)); Assert.That(first.Seed, Is.EqualTo(417)); Assert.That(first.EnemyDefinitionIds, Is.EqualTo(replay.EnemyDefinitionIds)); Assert.That(first.ThreatCosts, Is.EqualTo(replay.ThreatCosts)); Assert.That(first.EnemyDefinitionIds, Is.Not.EqualTo(changedSeed.EnemyDefinitionIds)); Assert.That(first.EnemyDefinitions.Count, Is.EqualTo(first.ThreatCosts.Count)); Assert.That(first.ComposedThreat, Is.GreaterThanOrEqualTo(first.ThreatBudget)); Assert.That(first.ComposedThreat - first.ThreatBudget, Is.LessThanOrEqualTo(first.SelectedEntries.Max(entry => entry.ThreatCost) + 0.0001f)); Assert.That(first.SelectedEntries.All(entry => SpeciesByTier[0].Contains(entry.EnemyDefinitionId)), Is.True); Defense25DWaveComposition tier2 = pool.BuildWaveComposition(9, 180f, 11f, 417); Defense25DWaveComposition tier3 = pool.BuildWaveComposition(10, 360f, 25f, 417); Assert.That(tier2.TierId, Is.EqualTo(1)); Assert.That(tier3.TierId, Is.EqualTo(2)); Assert.That(tier2.SelectedEntries.All(entry => SpeciesByTier[1].Contains(entry.EnemyDefinitionId)), Is.True); Assert.That(tier3.SelectedEntries.All(entry => SpeciesByTier[2].Contains(entry.EnemyDefinitionId)), Is.True); } [Test] public void DiversityConstraintRelaxesAndRecordsWhenNoCandidateFits() { Defense25DMonsterPoolDefinition pool = MakePool(); Defense25DWaveComposition composition = pool.BuildWaveComposition(0, 0f, 1f, 1); Assert.That(composition.DiversityConstraintRelaxed, Is.True); Assert.That(composition.SelectedEntries, Is.Not.Empty); Assert.That(composition.ComposedThreat, Is.GreaterThanOrEqualTo(1f)); Assert.That(composition.ComposedThreat - 1f, Is.LessThanOrEqualTo(composition.SelectedEntries.Max(entry => entry.ThreatCost) + 0.0001f)); } [Test] public void CompositionUsesEntryWeightsForReplacementDraws() { Defense25DMonsterPoolDefinition pool = MakePool(); Set(pool.Tiers[0].Entries[0], "weight", 100f); int slimeSelections = 0; for (int i = 0; i < 64; i++) { Defense25DWaveComposition composition = pool.BuildWaveComposition(i, 0f, 1f, i + 1000); if (composition.EnemyDefinitionIds[0] == "Slime") slimeSelections++; } Assert.That(slimeSelections, Is.GreaterThan(45)); } [Test] public void InvalidCatalogAndArgumentsAreReportedAsConfigurationErrors() { Defense25DMonsterPoolDefinition pool = MakePool(); Assert.Throws(() => pool.BuildWaveComposition(-1, 0f, 1f, 1)); Assert.Throws(() => pool.BuildWaveComposition(0, 0f, 0f, 1)); Assert.Throws(() => pool.BuildWaveComposition(0, float.NaN, 1f, 1)); Defense25DMonsterPoolEntry entry = pool.Tiers[0].Entries[0]; Set(entry, "threatCost", 0f); Assert.That(pool.TryValidate(out string error), Is.False); Assert.That(error, Does.Contain("finite positive Weight and ThreatCost")); Assert.Throws(() => pool.BuildWaveComposition(0, 0f, 1f, 1)); } [Test] public void CatalogRejectsExcludedHumanAndBadSourceReference() { Defense25DMonsterPoolDefinition pool = MakePool(); Defense25DMonsterPoolEntry entry = pool.Tiers[0].Entries[0]; Set(entry, "sourceCharacterId", "Archer"); Assert.That(pool.TryValidate(out string error), Is.False); Assert.That(error, Does.Contain("Excluded")); Set(entry, "sourceCharacterId", "Orc"); Assert.That(pool.TryValidate(out error), Is.False); Assert.That(error, Does.Contain("mismatched SourceCharacterId")); Set(entry, "sourceCharacterId", "Slime"); Set(entry, "enemyDefinitionId", "Warlock"); Assert.That(pool.TryValidate(out error), Is.False); Assert.That(error, Does.Contain("Excluded")); } private void AssertTier(float nominalStart, int expected) { Assert.That(Defense25DMonsterPoolDefinition.TryGetTierForNominalStart(nominalStart, out int tier), Is.True); Assert.That(tier, Is.EqualTo(expected)); } private void AssertInvalidTier(float nominalStart) { Assert.That(Defense25DMonsterPoolDefinition.TryGetTierForNominalStart(nominalStart, out int tier), Is.False); Assert.That(tier, Is.EqualTo(-1)); } private Defense25DMonsterPoolDefinition MakePool() { Defense25DMonsterPoolDefinition pool = ScriptableObject.CreateInstance(); created.Add(pool); float[] starts = { 0f, 180f, 360f }; float[] costs = { 1.1f, 1.8f, 3f }; Defense25DMonsterTierPool[] tiers = new Defense25DMonsterTierPool[3]; for (int tierIndex = 0; tierIndex < tiers.Length; tierIndex++) { Defense25DMonsterTierPool tier = new Defense25DMonsterTierPool(); Set(tier, "tierId", tierIndex); Set(tier, "fromCombatSeconds", starts[tierIndex]); Defense25DMonsterPoolEntry[] entries = new Defense25DMonsterPoolEntry[SpeciesByTier[tierIndex].Length]; for (int itemIndex = 0; itemIndex < entries.Length; itemIndex++) { string id = SpeciesByTier[tierIndex][itemIndex]; Defense25DMonsterAttackArchetype archetype = GetArchetype(id); Defense25DMonsterCombatProfile profile = new Defense25DMonsterCombatProfile(); Set(profile, "definitionBasisId", GetBasis(tierIndex, itemIndex)); Set(profile, "attackAction", "Attack"); Set(profile, "archetype", archetype); Set(profile, "attackShape", archetype == Defense25DMonsterAttackArchetype.Contact ? EnemyAttackShape.Body : archetype == Defense25DMonsterAttackArchetype.Melee ? EnemyAttackShape.Box : archetype == Defense25DMonsterAttackArchetype.Charge ? EnemyAttackShape.Cone : EnemyAttackShape.TargetCircle); Set(profile, "bodyRadius", 0.4f + itemIndex * 0.01f); Set(profile, "moveSpeed", 1.2f + itemIndex * 0.04f); Set(profile, "attackDamage", 6f + itemIndex); Set(profile, "warningSeconds", 0.5f); Set(profile, "activeSeconds", 0.18f); Set(profile, "recoverySeconds", 0.65f); Set(profile, "attackRange", 0.8f + itemIndex * 0.05f); Set(profile, "attackLength", archetype == Defense25DMonsterAttackArchetype.Charge ? 1.2f : 0.8f); Set(profile, "attackWidth", 0.6f); Set(profile, "attackRadius", archetype == Defense25DMonsterAttackArchetype.Area ? 1.1f : 0.5f); Set(profile, "projectileSpeed", archetype == Defense25DMonsterAttackArchetype.Projectile ? 4.5f : 0f); Set(profile, "projectileRadius", archetype == Defense25DMonsterAttackArchetype.Projectile ? 0.12f : 0f); EnemyDefinition definition = ScriptableObject.CreateInstance(); created.Add(definition); Defense25DMonsterPoolEntry entry = new Defense25DMonsterPoolEntry(); Set(entry, "definition", definition); GameObject visualPrefab = new GameObject(id + " Visual Fixture"); created.Add(visualPrefab); Set(entry, "visualPrefab", visualPrefab); Set(entry, "enemyDefinitionId", id); Set(entry, "sourceCharacterId", GetSourceId(id)); Set(entry, "weight", 1f + itemIndex % 3 * 0.25f); Set(entry, "threatCost", costs[tierIndex]); Set(entry, "combatProfile", profile); entries[itemIndex] = entry; } Set(tier, "entries", entries); tiers[tierIndex] = tier; } Set(pool, "tiers", tiers); return pool; } private static Defense25DMonsterAttackArchetype GetArchetype(string id) { return id switch { "Slime" => Defense25DMonsterAttackArchetype.Contact, "Bat" or "Hellbat" or "Hellhound" or "ArmoredOrc" or "OrcRider" or "Demon_B" or "Minotaur" => Defense25DMonsterAttackArchetype.Charge, "LavaSlime" or "Werebear" or "BloodMonster_B" or "Ghostfire" or "Demon_C" or "Demoness_B" or "FlameGolem" => Defense25DMonsterAttackArchetype.Area, "SkeletonArcher" or "BloodMonster_A" or "EyeballMonster" or "Demon_A" or "Demon_E" or "Demoness_A" => Defense25DMonsterAttackArchetype.Projectile, _ => Defense25DMonsterAttackArchetype.Melee, }; } private static string GetBasis(int tier, int index) { if (tier == 0) return new[] { "Slime", "Bat", "Skeleton", "Skeleton", "Bat", "Werewolf", "Slime" }[index]; if (tier == 1) return new[] { "ArmoredSkeleton", "SkeletonArcher", "Werewolf", "Werebear", "ArmoredSkeleton", "SkeletonArcher", "Werewolf", "SkeletonArcher", "Bat" }[index]; return new[] { "GreatswordSkeleton", "Werebear", "Werewolf", "SkeletonArcher", "GreatswordSkeleton", "Slime", "Werewolf", "SkeletonArcher", "SkeletonArcher", "Bat", "Werebear", "Werebear" }[index]; } private static string GetSourceId(string id) { return id switch { "LavaSlime" => "Lava Slime", "ArmoredSkeleton" => "Armored Skeleton", "SkeletonArcher" => "Skeleton Archer", "ArmoredOrc" => "Armored Orc", "BloodMonster_A" => "Blood Monster_A", "BloodMonster_B" => "Blood Monster_B", "EyeballMonster" => "Eyeball Monster", "GreatswordSkeleton" => "Greatsword Skeleton", "EliteOrc" => "Elite Orc", "OrcRider" => "Orc rider", "FlameGolem" => "Flame Golem", _ => id, }; } private static void Set(object instance, string name, object value) { FieldInfo field = instance.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(field, Is.Not.Null, $"Missing field {instance.GetType().Name}.{name}"); field.SetValue(instance, value); } } }