using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; using BumpCombat.Constants; using BumpCombat.Defense; using BumpCombat.Defense.Comparison; using BumpCombat.Enemies; using NUnit.Framework; using UnityEditor; using UnityEngine; using UnityEngine.TestTools; using Object = UnityEngine.Object; namespace BumpCombat.Tests { public sealed class Defense25DBossPlayModeTests { private const string SettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; private const string RunDefinitionPath = "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset"; private const string BossDefinitionPath = "Assets/_Project/Constants/Enemies/Necromancer.asset"; private Defense25DComparisonSettings settings; private Defense25DRunDefinition runDefinition; private Defense25DComparison world; private GameObject bossObject; [UnitySetUp] public IEnumerator SetUp() { settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath(SettingsPath)); runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath(RunDefinitionPath)); world = new GameObject("Boss combat owner test world").AddComponent(); yield return null; world.InitializeLongRun(settings, runDefinition, 17); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(runDefinition.PreparationSeconds + 1f); Assert.That(world.NotifyLongRunBossActivated(), Is.True); } [UnityTearDown] public IEnumerator TearDown() { if (bossObject != null) Object.Destroy(bossObject); if (world != null) Object.Destroy(world.gameObject); if (settings != null) Object.Destroy(settings); if (runDefinition != null) Object.Destroy(runDefinition); yield return null; } [Test] public void NecromancerProtectionAndDesperationDamageGateMatchesTheExistingBossContract() { Assert.That(Defense25DBossCombat.FilterNecromancerDamage( currentHealth: 80f, maximumHealth: 100f, requestedDamage: 40f, isGroggy: true, desperationStarted: false, protectionActive: false, desperationHealthFraction: .25f), Is.EqualTo(40f).Within(.001f)); Assert.That(Defense25DBossCombat.FilterNecromancerDamage( currentHealth: 20f, maximumHealth: 100f, requestedDamage: 40f, isGroggy: true, desperationStarted: false, protectionActive: false, desperationHealthFraction: .25f), Is.Zero); Assert.That(Defense25DBossCombat.FilterNecromancerDamage( currentHealth: 80f, maximumHealth: 100f, requestedDamage: 40f, isGroggy: false, desperationStarted: true, protectionActive: true, desperationHealthFraction: .25f), Is.Zero); } [UnityTest] public IEnumerator LowHealthWithoutAnActiveGroggyCycleDoesNotStartProtectionPhase() { EnemyDefinition definition = AssetDatabase.LoadAssetAtPath(BossDefinitionPath); Assert.That(definition, Is.Not.Null); Defense25DEnemy boss = CreateBoss(definition); int phaseSpawnRequests = 0; var combat = new Defense25DBossCombat(); Assert.That(combat.Bind(world, boss, definition, _ => { phaseSpawnRequests++; return new[] { 101, 102, 103 }; }), Is.True); Assert.That(boss.BindBossCombat(combat), Is.True); Assert.That(boss.Actor.TakeDamage(58f, Vector2.right), Is.True); combat.Step(.02f); Assert.That(boss.Actor.CurrentHealth, Is.EqualTo(2f).Within(.01f)); Assert.That(combat.DesperationStarted, Is.False, "HP alone must not trigger the original groggy-gated desperation transition."); Assert.That(phaseSpawnRequests, Is.Zero); yield return null; } [UnityTest] public IEnumerator GroggyDamageStartsOneProtectionPhaseAndOnlyItsThreeOwnedDeathsReleaseIt() { EnemyDefinition definition = AssetDatabase.LoadAssetAtPath(BossDefinitionPath); Defense25DEnemy boss = CreateBoss(definition); int phaseRequests = 0; List protectionChanges = new(); List groggyChanges = new(); List summonInterrupts = new(); int[] phaseIds = { 901, 902, 903 }; var combat = new Defense25DBossCombat(); Assert.That(combat.Bind(world, boss, definition, _ => { phaseRequests++; return phaseIds; }, protectionChanges.Add, (_, _) => new[] { 990 }, groggyChanges.Add, summonInterrupts.Add), Is.True); Assert.That(boss.BindBossCombat(combat), Is.True); Assert.That(combat.BeginGroggy(4f), Is.True); float healthBefore = boss.Actor.CurrentHealth; Assert.That(boss.Actor.TakeDamageWithResult(100f, Vector2.right, 0f, EnemyDamageSource.Player, false, out float damageApplied), Is.True); combat.NotifyDamageApplied(healthBefore, damageApplied); Assert.That(boss.Actor.CurrentHealth, Is.EqualTo(3f).Within(.01f)); Assert.That(combat.DesperationStarted, Is.True); Assert.That(combat.DesperationPending, Is.False); Assert.That(combat.PhaseSummonCount, Is.EqualTo(3)); Assert.That(combat.IsDamageBlocked, Is.True); Assert.That(combat.IsGroggy, Is.False); Assert.That(phaseRequests, Is.EqualTo(1)); Assert.That(combat.FilterIncomingDamage(boss.Actor.CurrentHealth, 20f), Is.Zero); Assert.That(protectionChanges, Is.EqualTo(new[] { true })); Assert.That(groggyChanges, Is.EqualTo(new[] { true, false })); Assert.That(summonInterrupts, Is.EqualTo(new[] { true })); Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, 990, 9001), Is.False, "Ambient summon IDs must never enter the protected phase set."); Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration + 1, phaseIds[0], 9001), Is.False); Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, phaseIds[0], 44), Is.False); Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, phaseIds[0], 9001), Is.True); Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, phaseIds[1], 9001), Is.True); Assert.That(combat.PhaseSummonCount, Is.EqualTo(1)); Assert.That(combat.IsDamageBlocked, Is.True); Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, phaseIds[2], 9001), Is.True); Assert.That(combat.DesperationCompleted, Is.True); Assert.That(combat.IsDamageBlocked, Is.False); Assert.That(combat.IsGroggy, Is.True); Assert.That(protectionChanges, Is.EqualTo(new[] { true, false })); Assert.That(groggyChanges, Is.EqualTo(new[] { true, false, true })); Assert.That(combat.FilterIncomingDamage(boss.Actor.CurrentHealth, 20f), Is.EqualTo(20f)); float postProtectionGroggy = combat.GroggyTimeRemaining; Assert.That(postProtectionGroggy, Is.GreaterThan(1f)); combat.Step(postProtectionGroggy - .1f); Assert.That(combat.IsGroggy, Is.True); combat.Step(.2f); Assert.That(combat.IsGroggy, Is.False); Assert.That(groggyChanges, Is.EqualTo(new[] { true, false, true, false })); yield return null; } [UnityTest] public IEnumerator AmbientSummonPurposesHaveSeparateCooldownsAndDoNotExtendProtection() { EnemyDefinition definition = AssetDatabase.LoadAssetAtPath(BossDefinitionPath); Defense25DEnemy boss = CreateBoss(definition); List crowdRequests = new(); int phaseRequests = 0; var combat = new Defense25DBossCombat(); Assert.That(combat.Bind(world, boss, definition, _ => { phaseRequests++; return new[] { 901, 902, 903 }; }, null, (_, crowd) => { crowdRequests.Add(crowd); return crowd ? new[] { 202, 203, 204 } : new[] { 201 }; }), Is.True); Assert.That(boss.BindBossCombat(combat), Is.True); float start = combat.CombatElapsed; float generalCooldown = GameplayConstants.Current.Enemies.GeneralSummonCooldown; float crowdCooldown = GameplayConstants.Current.Enemies.CrowdSummonCooldown; float summonLock = GameplayConstants.Current.Enemies.SummonVisualDuration; combat.Step(generalCooldown); Assert.That(crowdRequests, Is.EqualTo(new[] { false })); Assert.That(combat.IsAttackLocked, Is.True); Assert.That(combat.NotifyOwnedSummonResolved(world.RunGeneration, 201, 9001), Is.False); Assert.That(combat.PhaseSummonCount, Is.Zero); Assert.That(combat.IsDamageBlocked, Is.False); combat.Step(10f, paused: true); Assert.That(combat.CombatElapsed, Is.EqualTo(start + generalCooldown).Within(.001f)); combat.Step(Mathf.Max(0f, summonLock - .01f)); Assert.That(combat.IsAttackLocked, Is.True); Assert.That(crowdRequests, Is.EqualTo(new[] { false })); combat.Step(.02f); Assert.That(combat.IsAttackLocked, Is.False); float elapsedSinceBind = combat.CombatElapsed - start; combat.Step(Mathf.Max(.01f, crowdCooldown - elapsedSinceBind + .01f)); Assert.That(crowdRequests, Is.EqualTo(new[] { false, true })); Assert.That(phaseRequests, Is.Zero); Assert.That(combat.PhaseSummonCount, Is.Zero); yield return null; } [UnityTest] public IEnumerator MultiAoeCooldownAndGroggyTimerUseOnlyUnpausedRunDelta() { EnemyDefinition definition = AssetDatabase.LoadAssetAtPath(BossDefinitionPath); Defense25DEnemy boss = CreateBoss(definition); var combat = new Defense25DBossCombat(); Assert.That(combat.Bind(world, boss, definition, _ => new[] { 901, 902, 903 }), Is.True); Assert.That(boss.BindBossCombat(combat), Is.True); Assert.That(combat.CanUseAttackPattern(2), Is.True); Assert.That(combat.CanUseAttackPattern(0), Is.True); float start = combat.CombatElapsed; float cooldown = combat.MultiAoeCooldown; combat.NotifyAttackPatternUsed(2); Assert.That(combat.CanUseAttackPattern(2), Is.False); combat.Step(20f, paused: true); Assert.That(combat.CombatElapsed, Is.EqualTo(start).Within(.001f)); Assert.That(combat.CanUseAttackPattern(2), Is.False); combat.Step(cooldown - .01f); Assert.That(combat.CanUseAttackPattern(2), Is.False); combat.Step(.02f); Assert.That(combat.CanUseAttackPattern(2), Is.True); Assert.That(combat.BeginGroggy(1f), Is.True); combat.Step(10f, paused: true); Assert.That(combat.GroggyTimeRemaining, Is.EqualTo(1f).Within(.001f)); combat.Step(.5f); Assert.That(combat.GroggyTimeRemaining, Is.EqualTo(.5f).Within(.001f)); combat.Step(.6f); Assert.That(combat.IsGroggy, Is.False); yield return null; } [UnityTest] public IEnumerator OwnerDeathAndGenerationChangeCancelBoundStateWithoutDeletingOtherFactoryActors() { EnemyDefinition definition = AssetDatabase.LoadAssetAtPath(BossDefinitionPath); Defense25DEnemy boss = CreateBoss(definition); int protectionOffCalls = 0; var combat = new Defense25DBossCombat(); Assert.That(combat.Bind(world, boss, definition, _ => new[] { 901, 902, 903 }, value => { if (!value) protectionOffCalls++; }), Is.True); Assert.That(boss.BindBossCombat(combat), Is.True); Assert.That(combat.BeginGroggy(10f), Is.True); world.ResetRun(); combat.Step(.1f); Assert.That(combat.IsBound, Is.False); Assert.That(combat.IsGroggy, Is.False); Assert.That(protectionOffCalls, Is.EqualTo(1)); Assert.That(boss != null, Is.True, "Cancelling the owner must not destroy factory-owned actors or the boss GameObject."); yield return null; } [UnityTest] public IEnumerator OwnerDeathIsObservedAndCancelsItsRunClockState() { EnemyDefinition definition = AssetDatabase.LoadAssetAtPath(BossDefinitionPath); Defense25DEnemy boss = CreateBoss(definition); var combat = new Defense25DBossCombat(); Assert.That(combat.Bind(world, boss, definition, _ => new[] { 901, 902, 903 }), Is.True); Assert.That(boss.BindBossCombat(combat), Is.True); Assert.That(boss.Actor.TakeDamage(100f, Vector2.right), Is.True); Assert.That(boss.Actor.IsAlive, Is.False); combat.Step(.1f); Assert.That(combat.IsBound, Is.False); Assert.That(combat.DesperationStarted, Is.False); yield return null; } [UnityTest] public IEnumerator FinalBossAmbientSummonFromWorldFixedUpdateWaitsUntilNextEnemyTick() { float previousTimeScale = Time.timeScale; GameplayConstants previousConstants = GameplayConstants.Current; GameplayConstants testConstants = null; EnemyConstants testEnemyConstants = null; Defense25DWaveDirectorConstants testWaveConstants = null; Time.timeScale = 0f; try { Object.Destroy(world.gameObject); world = null; yield return null; testConstants = Object.Instantiate(previousConstants); testEnemyConstants = Object.Instantiate(previousConstants.Enemies); testEnemyConstants.GeneralSummonCooldown = 0f; FieldInfo enemyField = typeof(GameplayConstants).GetField("enemies", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(enemyField, Is.Not.Null); enemyField.SetValue(testConstants, testEnemyConstants); GameplayConstants.SetCurrentForTests(testConstants); testWaveConstants = Object.Instantiate(runDefinition.WaveDirectorConstants); Defense25DThreatCurvePoint[] threatCurve = testWaveConstants.ThreatCurve; for (int i = 0; i < threatCurve.Length; i++) threatCurve[i].ThreatBudget = .8f; testWaveConstants.ThreatCurve = threatCurve; runDefinition.WaveDirectorConstants = testWaveConstants; world = new GameObject("Boss summon world Step integration test") .AddComponent(); world.InitializeLongRun(settings, runDefinition, 17); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(runDefinition.PreparationSeconds + runDefinition.FinalBossAtSeconds); Defense25DEnemy boss = world.Enemies.Single(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss && enemy.IsAvailable); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.BossActive)); Assert.That(boss.BossCombat, Is.Not.Null); Assert.That(boss.BossCombat.IsBound, Is.True); Assert.That(world.Enemies.Any(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.BossSummon), Is.False); int countBeforeWorldTick = world.Enemies.Count; Exception firstTickError = InvokeWorldFixedUpdate(world); Assert.That(firstTickError, Is.Null, "The actual world enemy iteration must tolerate the FinalBoss ambient factory adding to Enemies. " + firstTickError); Defense25DEnemy[] ownedSummons = world.Enemies.Where(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.BossSummon && enemy.SpawnContext.SummonOwnerSpawnId == boss.SpawnContext.SpawnId).ToArray(); Assert.That(world.Enemies.Count, Is.EqualTo(countBeforeWorldTick + 1), "The real FinalBoss general-summon callback should append one factory-created enemy."); Assert.That(ownedSummons, Has.Length.EqualTo(1)); Assert.That(ownedSummons[0].Actor.LastMove, Is.EqualTo(Vector2.zero), "A summon appended during this tick must not also receive Enemy.Step in the same tick."); Assert.That(ownedSummons[0].MonsterCombat.IsAttackInProgress, Is.False); Exception nextTickError = InvokeWorldFixedUpdate(world); Assert.That(nextTickError, Is.Null, nextTickError != null ? nextTickError.ToString() : string.Empty); Assert.That(ownedSummons[0].Actor.LastMove.sqrMagnitude > .000001f || ownedSummons[0].MonsterCombat.IsAttackInProgress, Is.True, "The factory-created summon should begin its normal Enemy.Step behavior on the next world tick."); } finally { GameplayConstants.SetCurrentForTests(previousConstants); if (testEnemyConstants != null) Object.Destroy(testEnemyConstants); if (testConstants != null) Object.Destroy(testConstants); if (testWaveConstants != null) Object.Destroy(testWaveConstants); Time.timeScale = previousTimeScale; } yield return null; } private static Exception InvokeWorldFixedUpdate(Defense25DComparison comparison) { MethodInfo fixedUpdate = typeof(Defense25DComparison).GetMethod("FixedUpdate", BindingFlags.Instance | BindingFlags.NonPublic); if (fixedUpdate == null) return new MissingMethodException(typeof(Defense25DComparison).FullName, "FixedUpdate"); try { fixedUpdate.Invoke(comparison, null); return null; } catch (TargetInvocationException exception) { return exception.InnerException ?? exception; } } private Defense25DEnemy CreateBoss(EnemyDefinition definition) { bossObject = new GameObject("Necromancer test actor"); Defense25DActor actor = bossObject.AddComponent(); actor.Configure(world, ComparisonActorKind.Enemy); Defense25DEnemy boss = bossObject.AddComponent(); boss.Configure(world, actor, 9001, 0); actor.SetEnemyDefinition(definition); boss.SetSpawnContext(new Defense25DEnemySpawnContext(world.RunGeneration, 9001, 0, 0, Defense25DSpawnSource.FinalBoss)); boss.Spawn(world.RunLayout.Routes[0][0]); return boss; } } }