using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; using BumpCombat.Defense; using BumpCombat.Defense.Comparison; using BumpCombat.Constants; using BumpCombat.Player; using NUnit.Framework; using UnityEngine; using UnityEditor; using UnityEngine.TestTools; namespace BumpCombat.Tests { public sealed class Defense25DTowerPlayModeTests { private Defense25DComparison world; private Defense25DComparisonSettings settings; [UnitySetUp] public IEnumerator SetUp() { settings = UnityEngine.Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset")); world = new GameObject("2.5D tower test").AddComponent(); world.Initialize(settings); world.AutoSpawn = false; world.ReadInput = false; yield return null; } [UnityTearDown] public IEnumerator TearDown() { if (world != null) UnityEngine.Object.Destroy(world.gameObject); if (settings != null) UnityEngine.Object.Destroy(settings); yield return null; } [Test] public void EachTowerFamilyCanBeBuiltOnAnEmptyPadWithItsConfiguredCost() { Type managerType = typeof(Defense25DComparison).Assembly.GetType( "BumpCombat.Defense.Comparison.Defense25DTowerManager"); Assert.That(managerType, Is.Not.Null, "The 2.5D run needs a tower manager that owns pads, construction currency, and tower instances."); Component manager = world.GetComponent(managerType); Assert.That(manager, Is.Not.Null); MethodInfo build = managerType.GetMethod("TryBuildTowerAtPad", new[] { typeof(int), typeof(DefenseTowerKind) }); MethodInfo getTower = managerType.GetMethod("GetTowerAtPad", new[] { typeof(int) }); Assert.That(build, Is.Not.Null); Assert.That(getTower, Is.Not.Null); foreach (DefenseTowerKind kind in new[] { DefenseTowerKind.Direct, DefenseTowerKind.Summon, DefenseTowerKind.Lightning, DefenseTowerKind.Cold, DefenseTowerKind.Seismic, DefenseTowerKind.Empowering, }) { world.ResetRun(); world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0)); Physics.SyncTransforms(); Assert.That(build.Invoke(manager, new object[] { 0, kind }), Is.EqualTo(true), kind.ToString()); object tower = getTower.Invoke(manager, new object[] { 0 }); Assert.That(tower, Is.Not.Null, kind.ToString()); Type towerType = tower.GetType(); Assert.That(towerType.GetProperty("Kind").GetValue(tower), Is.EqualTo(kind)); Assert.That(towerType.GetProperty("Tier").GetValue(tower), Is.EqualTo(1)); Assert.That(towerType.GetProperty("Branch").GetValue(tower), Is.EqualTo(DefenseTowerBranch.None)); Assert.That(managerType.GetProperty("ConstructionCurrency").GetValue(manager), Is.EqualTo(GameplayConstants.Current.Defense.InitialConstructionCurrency - BuildCost(kind))); } } [Test] public void TowerEfficiencyIsZeroUntilARealTowerIsBuilt() { world.Player.ResetAt(world.Settings.TowerPosition); Assert.That(world.TowerEfficiency, Is.EqualTo(0f)); world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0)); Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True); Assert.That(world.TowerEfficiency, Is.EqualTo(1f)); } [Test] public void ImbuingIntervalUsesFirstConstructedSupportEvenWhenPadOrderIsReversed() { Defense25DTowerManager manager = world.TowerManager; manager.AddCurrency(10000); Defense25DTower firstBuilt = BuildTower(4, DefenseTowerKind.Empowering); UpgradeToTierFourImbuing(manager, 4); Defense25DTower secondBuilt = BuildTower(0, DefenseTowerKind.Empowering); UpgradeToTierFourImbuing(manager, 0); Defense25DTower receiver = BuildTower(2, DefenseTowerKind.Direct); SetTowerPosition(firstBuilt, Vector2.zero); SetTowerPosition(secondBuilt, new Vector2(6.5f, 0f)); SetTowerPosition(receiver, new Vector2(3.25f, 0f)); world.Player.ResetAt(Vector2.zero); manager.Step(0f); Assert.That(manager.TowersInConstructionOrder[0], Is.SameAs(firstBuilt)); Assert.That(manager.TowersInConstructionOrder[1], Is.SameAs(secondBuilt)); Assert.That(receiver.AttackIntervalMultiplier, Is.EqualTo(GameplayConstants.Current.Defense.ImbuingAttackIntervalMultiplier).Within(.001f), "The first built in-range Imbuing tower wins before the later, out-of-sight source."); } [Test] public void NearestTowerBuildUsesTheClosestEmptyPadWithinInteractionRange() { Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0); world.Player.ResetAt(pad); MethodInfo buildNearest = typeof(Defense25DComparison).GetMethod("TryBuildNearestTower", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(buildNearest, Is.Not.Null); Assert.That(buildNearest.Invoke(world, new object[] { DefenseTowerKind.Direct }), Is.EqualTo(true)); Assert.That(world.TowerManager.GetTowerAtPad(0), Is.Not.Null); } [Test] public void EveryTowerFamilyCanReachBothTierFourBranches() { Type managerType = typeof(Defense25DComparison).Assembly.GetType( "BumpCombat.Defense.Comparison.Defense25DTowerManager"); Component manager = world.GetComponent(managerType); MethodInfo build = managerType.GetMethod("TryBuildTowerAtPad", new[] { typeof(int), typeof(DefenseTowerKind) }); MethodInfo upgrade = managerType.GetMethod("TryUpgradeTowerAtPad", new[] { typeof(int), typeof(DefenseTowerBranch) }); MethodInfo addCurrency = managerType.GetMethod("AddCurrency"); MethodInfo getTower = managerType.GetMethod("GetTowerAtPad"); foreach (DefenseTowerKind kind in System.Enum.GetValues(typeof(DefenseTowerKind))) { foreach (DefenseTowerBranch branch in new[] { DefenseTowerBranch.First, DefenseTowerBranch.Second }) { world.ResetRun(); world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0)); addCurrency.Invoke(manager, new object[] { 1000 }); Assert.That(build.Invoke(manager, new object[] { 0, kind }), Is.EqualTo(true), kind.ToString()); Assert.That(upgrade.Invoke(manager, new object[] { 0, DefenseTowerBranch.None }), Is.EqualTo(true)); Assert.That(upgrade.Invoke(manager, new object[] { 0, DefenseTowerBranch.None }), Is.EqualTo(true)); Assert.That(upgrade.Invoke(manager, new object[] { 0, branch }), Is.EqualTo(true)); object tower = getTower.Invoke(manager, new object[] { 0 }); Assert.That(tower.GetType().GetProperty("Tier").GetValue(tower), Is.EqualTo(4)); Assert.That(tower.GetType().GetProperty("Branch").GetValue(tower), Is.EqualTo(branch)); if (kind == DefenseTowerKind.Summon) { int expected = branch == DefenseTowerBranch.First ? 1 : GameplayConstants.Current.Defense.SummonUnitCount + 4; Assert.That(world.Summons.Count, Is.EqualTo(expected), branch.ToString()); } } } } [Test] public void DirectFireballDealsDamageOnlyAfterItsSourceFlightDuration() { world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(0)); Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True); Defense25DTower tower = world.TowerManager.GetTowerAtPad(0); Defense25DEnemy target = null; for (int i = 0; i < 2; i++) { Defense25DEnemy candidate = world.SpawnNextEnemy(); if (!world.IsTowerImmuneToEnemy(DefenseTowerKind.Direct, candidate)) { target = candidate; break; } } Assert.That(target, Is.Not.Null); target.Actor.ResetAt(tower.Position + Vector2.right); Physics.SyncTransforms(); float health = target.Actor.CurrentHealth; tower.StepParityAttacks(0f); Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "launch must not apply damage"); tower.StepParityAttacks(.17f); Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "projectile is still in flight"); tower.StepParityAttacks(.01f); Assert.That(target.Actor.CurrentHealth, Is.LessThan(health), "impact callback applies damage"); } [Test] public void ScoutAttackDamagesAfterOriginalLaunchAndFlightTiming() { Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0); world.Player.ResetAt(pad); Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True); Defense25DTower tower = world.TowerManager.GetTowerAtPad(0); world.TowerManager.AddCurrency(1000); Assert.That(world.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); Assert.That(world.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); Assert.That(world.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.Second), Is.True); Defense25DSummon scout = world.Summons[0]; Defense25DEnemy target = world.SpawnNextEnemy(); if (world.IsTowerImmuneToEnemy(DefenseTowerKind.Summon, target)) target = world.SpawnNextEnemy(); target.Actor.ResetAt(tower.Position + Vector2.right); Physics.SyncTransforms(); float health = target.Actor.CurrentHealth; Assert.That(tower.TryAttackSummon(scout, target), Is.True); world.TowerManager.Step(.47f); Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "the scout's projectile has not launched"); world.TowerManager.Step(.01f); Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "the projectile has only just launched"); world.TowerManager.Step(.159f); Assert.That(target.Actor.CurrentHealth, Is.EqualTo(health), "the projectile is still in flight"); world.TowerManager.Step(.001f); Assert.That(target.Actor.CurrentHealth, Is.LessThan(health)); } [UnityTest] public IEnumerator OwnedSummonsKeepAttackingTheirTowerCommandTarget() { Vector2 pad = GameplayConstants.Current.Defense.GetBuildPadPosition(0); world.Player.ResetAt(pad); Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Summon), Is.True); Defense25DTower tower = world.TowerManager.GetTowerAtPad(0); Defense25DSummon summon = world.Summons[0]; Defense25DEnemy target = world.SpawnNextEnemy(); if (world.IsTowerImmuneToEnemy(DefenseTowerKind.Summon, target)) target = world.SpawnNextEnemy(); target.Actor.ResetAt(summon.Actor.Position + Vector2.left * .9f); target.ApplyStun(2f); Physics.SyncTransforms(); float health = target.Actor.CurrentHealth; tower.Step(0f); summon.Step(0f); Assert.That(summon.IsAttacking, Is.True); for (int i = 0; i < 60 && target.Actor.CurrentHealth >= health; i++) yield return new WaitForFixedUpdate(); Assert.That(target.Actor.CurrentHealth, Is.LessThan(health)); } [UnityTest] public IEnumerator BothPortalsSupplyTheirOwnRoutesAndRewardsGateVictory() { int addedArtifacts = 0; foreach (ActiveArtifactDefinition artifact in world.Loadout.Catalog) { if (world.Loadout.TryAddArtifact(artifact)) addedArtifacts++; if (addedArtifacts == 3) break; } Assert.That(addedArtifacts, Is.EqualTo(3), "The route/reward fixture needs three eligible owned artifacts."); int expectedPerPortal = GameplayConstants.Current.Defense.PortalEnemyCount; int totalExpected = expectedPerPortal * 2; List supplied = new(); for (int i = 0; i < totalExpected; i++) { Defense25DEnemy enemy = world.SpawnNextEnemy(); Assert.That(enemy, Is.Not.Null, $"supply {i + 1}/{totalExpected}"); supplied.Add(enemy); } Assert.That(world.SpawnNextEnemy(), Is.Null); PropertyInfo laneProperty = typeof(Defense25DEnemy).GetProperty("Lane"); Assert.That(laneProperty, Is.Not.Null); for (int i = 0; i < supplied.Count; i++) { int lane = (int)laneProperty.GetValue(supplied[i]); Assert.That(lane, Is.EqualTo(i % 2), $"alternating portal supply #{i}"); Assert.That(Vector2.Distance(supplied[i].Actor.Position, GameplayConstants.Current.Defense.GetPortalPosition(lane)), Is.LessThan(.001f)); } foreach (Defense25DEnemy enemy in supplied) enemy.Actor.TakeDamage(10000f, Vector2.zero); yield return new WaitForSeconds(.1f); Assert.That(world.IsRunning, Is.True, "both field rewards must be collected before victory"); MethodInfo collect = typeof(Defense25DComparison).GetMethod("TryCollectRewardAtLane"); Assert.That(collect, Is.Not.Null); for (int lane = 0; lane < 2; lane++) { Vector2 pickup = GameplayConstants.Current.Defense.GetPortalPosition(lane) + Vector2.up * 1.2f; world.Player.ResetAt(pickup); Assert.That(collect.Invoke(world, new object[] { lane }), Is.EqualTo(true), $"reward lane {lane}"); Assert.That(world.IsRunning, Is.True, "The run must remain active while an earned relic choice is pending."); Assert.That(world.HasPendingFieldRelicReward, Is.True, $"field relic choice lane {lane}"); Assert.That(world.Paused, Is.True, "The earned field choice must pause the run before the next pickup."); Assert.That(world.ResolveFieldRelicChoice(world.PendingFieldRelicChoices[0]), Is.True, $"resolve field relic choice lane {lane}"); } Assert.That(world.IsRunning, Is.False); Assert.That(typeof(Defense25DComparison).GetProperty("RunCleared").GetValue(world), Is.EqualTo(true)); } [Test] public void SummonVariantsUseAuditedCoreScaleForVisualColliderAndFoot() { world.TowerManager.AddCurrency(10000); Defense25DTower slimeTower = BuildTower(1, DefenseTowerKind.Summon); AssertSummonVariant(slimeTower, world.Settings.SummonVisualScale); UpgradeToTierFourImbuing(world.TowerManager, 1); AssertSummonVariant(slimeTower, 1.55f); Defense25DTower scoutTower = BuildTower(2, DefenseTowerKind.Summon); UpgradeToTierFourImbuing(world.TowerManager, 2, DefenseTowerBranch.Second); AssertSummonVariant(scoutTower, 1.15f); } [UnityTest] public IEnumerator TowerImmunityIsResolvedForEachPortalLane() { Defense25DEnemy upper = world.SpawnNextEnemy(); Defense25DEnemy lower = world.SpawnNextEnemy(); Assert.That(upper.Lane, Is.EqualTo(0)); Assert.That(lower.Lane, Is.EqualTo(1)); var tuning = GameplayConstants.Current.Defense; Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(0), upper), Is.True); Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(1), lower), Is.True); Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(0), lower), Is.False); Assert.That(world.IsTowerImmuneToEnemy(tuning.GetPortalImmuneTowerKind(1), upper), Is.False); yield return null; } private static int BuildCost(DefenseTowerKind kind) { var tuning = GameplayConstants.Current.Defense; return kind switch { DefenseTowerKind.Direct => tuning.DirectTowerCost, DefenseTowerKind.Summon => tuning.SummonTowerCost, DefenseTowerKind.Lightning => tuning.LightningTowerCost, DefenseTowerKind.Cold => tuning.ColdTowerCost, DefenseTowerKind.Seismic => tuning.SeismicTowerCost, DefenseTowerKind.Empowering => tuning.EmpoweringTowerCost, _ => -1, }; } private Defense25DTower BuildTower(int pad, DefenseTowerKind kind) { world.Player.ResetAt(GameplayConstants.Current.Defense.GetBuildPadPosition(pad)); Physics.SyncTransforms(); Assert.That(world.TowerManager.TryBuildTowerAtPad(pad, kind), Is.True, $"pad={pad} kind={kind}"); return world.TowerManager.GetTowerAtPad(pad); } private static void UpgradeToTierFourImbuing(Defense25DTowerManager manager, int pad, DefenseTowerBranch finalBranch = DefenseTowerBranch.First) { Assert.That(manager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True); Assert.That(manager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True); Assert.That(manager.TryUpgradeTowerAtPad(pad, finalBranch), Is.True); } private void SetTowerPosition(Defense25DTower tower, Vector2 position) { typeof(Defense25DTower).GetProperty(nameof(Defense25DTower.Position)) .SetValue(tower, position); tower.transform.position = Defense25DActor.OnGround(position, world.Settings.GroundY); } private void AssertSummonVariant(Defense25DTower tower, float scale) { Defense25DSummon[] units = world.Summons.Where(unit => unit.Owner == tower).ToArray(); Assert.That(units.Length, Is.EqualTo(tower.TargetSummonCount)); float coreDiameter = scale > 1.4f ? Defense25DComparisonSettings.GiantSlimeBodyCoreDiameterPixels : scale > world.Settings.SummonVisualScale ? Defense25DComparisonSettings.LavaSlimeBodyCoreDiameterPixels : Defense25DComparisonSettings.SlimeBodyCoreDiameterPixels; float radius = coreDiameter * scale / Defense25DComparisonSettings.SummonBodyPixelsPerUnit * .5f; foreach (Defense25DSummon summon in units) { Defense25DSprite visual = summon.Actor.Visual; Assert.That(visual.VisualScale, Is.EqualTo(scale).Within(.001f)); float expectedFootOffset = scale > 1.4f ? 0f : world.Settings.SummonFootOffset * scale / world.Settings.SummonVisualScale; Assert.That(visual.FootVisualOffset, Is.EqualTo(expectedFootOffset).Within(.001f)); Assert.That(summon.Actor.Radius, Is.EqualTo(radius).Within(.001f)); Assert.That(summon.Actor.BodyCollider.radius, Is.EqualTo(radius).Within(.001f)); Assert.That(summon.Actor.BodyCollider.height, Is.EqualTo(2f * radius).Within(.001f)); Assert.That(visual.transform.Find("Ground shadow"), Is.Null, "The comparison actor keeps its existing authored sprite and has no added ground-shadow cylinder."); } } } }