using System.Collections; using System.Collections.Generic; using System.Linq; using BumpCombat.Combat; using BumpCombat.Constants; using BumpCombat.Core; using BumpCombat.Defense; using BumpCombat.Defense.Comparison; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using NUnit.Framework; using UnityEditor; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.InputSystem.LowLevel; using UnityEngine.TestTools; using Object = UnityEngine.Object; namespace BumpCombat.Tests { public sealed class Defense25DLongRunRuntimePlayModeTests { private Defense25DComparisonSettings settings; private Defense25DComparison world; private Defense25DRunDefinition runDefinition; private Defense25DWaveDirectorConstants runWaveConstants; private float previousTimeScale; [UnitySetUp] public IEnumerator SetUp() { previousTimeScale = Time.timeScale; Time.timeScale = 1f; settings = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset")); world = new GameObject("Long run runtime under test").AddComponent(); yield return null; } [UnityTearDown] public IEnumerator TearDown() { if (world != null) Object.Destroy(world.gameObject); if (settings != null) Object.Destroy(settings); if (runDefinition != null) Object.Destroy(runDefinition); if (runWaveConstants != null) Object.Destroy(runWaveConstants); Time.timeScale = previousTimeScale; yield return null; } [UnityTest] public IEnumerator LongRunInitializesPreparationAndSkipsLegacyArtifactDrafts() { Defense25DRunDefinition definition = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset"); Assert.That(definition, Is.Not.Null); world.InitializeLongRun(settings, definition); world.AutoSpawn = false; world.ReadInput = false; Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing)); Assert.That(world.RunGeneration, Is.EqualTo(1)); Assert.That(world.PortalCount, Is.EqualTo(12)); Assert.That(world.CombatElapsed, Is.EqualTo(0f).Within(.001f)); Assert.That(world.PreparationRemaining, Is.EqualTo(definition.PreparationSeconds).Within(.001f)); Assert.That(world.Loadout.OwnedArtifactCount, Is.Zero); Assert.That(world.WaveDirector, Is.Not.Null); yield return null; } [UnityTest] public IEnumerator LongRunFacilityVisualsFollowOpenBuildUpgradeAndResetTransitions() { Time.timeScale = 0f; InitializeWorld(); Defense25DFacilityVisuals visuals = world.GetComponent(); Assert.That(visuals, Is.Not.Null, "Comparison owns the integrated facility visual lifecycle."); Assert.That(visuals.transform.Cast() .Count(item => item.gameObject.activeInHierarchy && item.name.StartsWith("Facility Home Spawn")), Is.EqualTo(1)); Assert.That(visuals.transform.Cast() .Count(item => item.gameObject.activeInHierarchy && item.name.StartsWith("Facility Tower Pad Spawn")), Is.EqualTo(18)); world.AdvanceRunClock(runDefinition.PreparationSeconds + 1f); Assert.That(visuals.transform.Cast() .Count(item => item.gameObject.activeInHierarchy && item.name.StartsWith("Facility Portal Spawn")), Is.EqualTo(2), "Opening the first two portals starts their effects without delaying scheduled supply."); world.Player.ResetAt(world.RunLayout.PadPositions[0]); Assert.That(world.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True); Assert.That(world.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); Assert.That(visuals.transform.Cast() .Count(item => item.gameObject.activeInHierarchy && item.name.StartsWith("Facility Tower Upgrade")), Is.EqualTo(1), "A successful manager upgrade emits one integrated visual."); world.ResetRun(); yield return null; Assert.That(visuals.transform.Cast() .Count(item => item.gameObject.activeInHierarchy && item.name.StartsWith("Facility Home Spawn")), Is.EqualTo(1)); Assert.That(visuals.transform.Cast() .Count(item => item.gameObject.activeInHierarchy && item.name.StartsWith("Facility Tower Pad Spawn")), Is.EqualTo(18)); Assert.That(visuals.transform.Cast() .Any(item => item.gameObject.activeInHierarchy && item.name.StartsWith("Facility Portal Spawn")), Is.False, "Reset clears prior generation effects and waits for each portal's new open event."); } [UnityTest] public IEnumerator LongRunArenaUsesRunLayoutBoundsWithoutLegacySummonPadExpansion() { Time.timeScale = 0f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); Assert.That(runDefinition.BattlefieldDefinitions[0].TryCreateLayout(1, out Defense25DRunLayout layout, out _), Is.True); GameObject south = new("South boundary"); south.transform.position = new Vector3(0f, 0f, layout.Bounds.yMin); south.transform.localScale = new Vector3(1f, 1f, 1f); BoxCollider southCollider = south.AddComponent(); southCollider.size = new Vector3(layout.Bounds.width, 1f, 1f); Vector3 originalPosition = south.transform.position; Vector3 originalScale = south.transform.localScale; world.InitializeLongRun(settings, runDefinition, 1); Assert.That(world.ArenaHalfExtents, Is.EqualTo(new Vector2(layout.Bounds.width, layout.Bounds.height) * .5f)); Assert.That(south.transform.position, Is.EqualTo(originalPosition)); Assert.That(south.transform.localScale, Is.EqualTo(originalScale)); Assert.That(southCollider.size.x, Is.EqualTo(layout.Bounds.width).Within(.001f)); Object.Destroy(south); yield return null; } [UnityTest] public IEnumerator ResolveBumpsHitsEveryClusteredActorBeyondThe128ColliderBuffer() { Time.timeScale = 0f; InitializeWorld(); world.AdvanceRunClock(runDefinition.PreparationSeconds); const int actorCount = 129; Vector2 targetPosition = world.Player.Position + Vector2.right * .35f; int spawnedNormalCount = world.Enemies.Count(enemy => enemy != null && enemy.SpawnContext.Source == Defense25DSpawnSource.Normal); System.Action spawnNormal = request => { if (request.Source != Defense25DSpawnSource.Normal || spawnedNormalCount >= actorCount) return; world.SpawnScheduledEnemy(request); spawnedNormalCount++; }; for (float scheduledThrough = 0f; scheduledThrough <= 720f && spawnedNormalCount < actorCount; scheduledThrough += 18f) { world.WaveDirector.AdvanceTo(scheduledThrough, spawnNormal); } Assert.That(spawnedNormalCount, Is.EqualTo(actorCount), "The real director/factory path must supply enough normal enemies for the overflow case."); List enemies = world.Enemies .Where(enemy => enemy != null && enemy.SpawnContext.Source == Defense25DSpawnSource.Normal) .Take(actorCount).ToList(); Assert.That(enemies, Has.Count.EqualTo(actorCount)); foreach (Defense25DEnemy enemy in enemies) enemy.Actor.ResetAt(targetPosition); Physics.SyncTransforms(); int clusteredActorCount = world.QueryEnemies(world.Player.Center, Vector2.one * (world.Player.Radius + .5f)).Count; Assert.That(clusteredActorCount, Is.EqualTo(actorCount), "The broadphase query must grow past its initial 128-collider capacity."); world.ResolveBumps(Vector2.right * world.Player.MoveSpeed); Assert.That(enemies[actorCount - 1].Actor.DamageEvents, Is.EqualTo(1), "The last clustered actor must receive the real combat attack."); Assert.That(enemies.Count(enemy => enemy.Actor.DamageEvents > 0), Is.EqualTo(actorCount), "No clustered actor may disappear from the real attack because a query buffer filled."); yield return null; } [UnityTest] public IEnumerator ResetCreatesANewSeededLayoutAndRefreshesRunConsumers() { Time.timeScale = 0f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); Defense25DBattlefieldDefinition battlefield = runDefinition.BattlefieldDefinitions[0]; int baseSeed = 1; Defense25DRunLayout expectedFirst = null; Defense25DRunLayout expectedSecond = null; bool foundDifferentLayouts = false; for (; baseSeed < 10000; baseSeed++) { Assert.That(battlefield.TryCreateLayout(baseSeed, out expectedFirst, out string firstError), Is.True, firstError); Assert.That(battlefield.TryCreateLayout(unchecked(baseSeed + 7919), out expectedSecond, out string secondError), Is.True, secondError); if (expectedFirst.HomePosition != expectedSecond.HomePosition || !Mathf.Approximately(expectedFirst.RotationDegrees, expectedSecond.RotationDegrees)) { foundDifferentLayouts = true; break; } } Assert.That(foundDifferentLayouts, Is.True, "The deterministic fixture must find two distinct valid generation layouts."); world.InitializeLongRun(settings, runDefinition, baseSeed); world.AutoSpawn = false; world.ReadInput = false; Defense25DRunLayout firstLayout = world.RunLayout; Assert.That(firstLayout.LayoutSeed, Is.EqualTo(world.CurrentRunSeed)); Assert.That(firstLayout.HomePosition, Is.EqualTo(expectedFirst.HomePosition)); world.ResetRun(); yield return null; Assert.That(world.RunLayout, Is.Not.SameAs(firstLayout)); Assert.That(world.CurrentRunSeed, Is.EqualTo(unchecked(firstLayout.LayoutSeed + 7919))); Assert.That(world.RunLayout.LayoutSeed, Is.EqualTo(world.CurrentRunSeed)); Assert.That(world.RunLayout.HomePosition, Is.EqualTo(expectedSecond.HomePosition)); Assert.That(world.RunLayout.RotationDegrees, Is.EqualTo(expectedSecond.RotationDegrees)); Assert.That(world.RunLayout.Bounds, Is.EqualTo(expectedSecond.Bounds)); Assert.That(world.ArenaHalfExtents, Is.EqualTo(new Vector2(expectedSecond.Bounds.width, expectedSecond.Bounds.height) * .5f)); Assert.That(world.Player.Position, Is.EqualTo(expectedSecond.PlayerStart)); Assert.That(world.TowerManager.PadCount, Is.EqualTo(expectedSecond.PadCount)); for (int pad = 0; pad < expectedSecond.PadCount; pad++) Assert.That(world.TowerManager.GetPadPosition(pad), Is.EqualTo(expectedSecond.PadPositions[pad])); for (int routeId = 0; routeId < expectedSecond.RouteCount; routeId++) Assert.That(world.GetRouteForLane(routeId), Is.EqualTo(expectedSecond.Routes[routeId].ToArray())); Transform markerRoot = world.transform.Find("Defense routes and build pads"); Assert.That(markerRoot, Is.Not.Null); Assert.That(Defense25DActor.Ground(markerRoot.Find("Shared base marker").position), Is.EqualTo(expectedSecond.HomePosition)); Assert.That(Defense25DActor.Ground(markerRoot.Find("Portal 1 marker").position), Is.EqualTo(expectedSecond.PortalPositions[0])); Assert.That(Defense25DActor.Ground(markerRoot.Find("Tower build pad 1").position), Is.EqualTo(expectedSecond.PadPositions[0])); yield return null; } [UnityTest] public IEnumerator LongRunKeyboardInputsStayInsideTheirOwnedCommandsAndModalGate() { Time.timeScale = 1f; InitializeWorld(); world.AdvanceRunClock(runDefinition.PreparationSeconds); world.ReadInput = true; world.Player.ResetAt(Vector2.zero); Keyboard previousKeyboard = Keyboard.current; InputSettings previousSettings = InputSystem.settings; InputSettings testSettings = Object.Instantiate(previousSettings); Keyboard keyboard = null; try { testSettings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView; testSettings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus; InputSystem.settings = testSettings; keyboard = InputSystem.AddDevice(); int generation = world.RunGeneration; Defense25DRunLayout layout = world.RunLayout; yield return HoldKey(keyboard, Key.R); Assert.That(world.RunGeneration, Is.EqualTo(generation), "R cannot bypass the HUD restart confirmation in a long run."); Assert.That(world.RunLayout, Is.SameAs(layout)); Vector2 beforeW = world.Player.Position; yield return HoldKey(keyboard, Key.W); Assert.That(world.Player.Position, Is.EqualTo(beforeW), "W remains disabled as a secondary movement binding."); Vector2 beforeUp = world.Player.Position; yield return HoldKey(keyboard, Key.UpArrow); Assert.That(world.Player.Position.y, Is.GreaterThan(beforeUp.y), "The authored arrow-key movement still works."); int pad = 0; Vector2 padPosition = world.TowerManager.GetPadPosition(pad); world.Player.ResetAt(padPosition); Assert.That(world.TowerManager.TryBuildTowerAtPad(pad, DefenseTowerKind.Direct), Is.True); Assert.That(world.TowerManager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True); Assert.That(world.TowerManager.TryUpgradeTowerAtPad(pad, DefenseTowerBranch.None), Is.True); Defense25DTower branchTower = world.TowerManager.GetTowerAtPad(pad); Assert.That(branchTower.Tier, Is.EqualTo(3)); yield return HoldKey(keyboard, Key.J); yield return HoldKey(keyboard, Key.K); Assert.That(branchTower.Tier, Is.EqualTo(3), "The HUD owns T3 branch selection in long-run mode."); yield return HoldKey(keyboard, Key.P); Assert.That(world.Paused, Is.True); yield return HoldKey(keyboard, Key.Escape); Assert.That(world.Paused, Is.False, "Esc resumes through the same guarded pause command when no HUD is bound."); ActiveArtifactDefinition cyclone = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone); Assert.That(world.Loadout.TryAddArtifact(cyclone), Is.True); world.Loadout.AddGauge(cyclone.NormalGaugeCost); world.AdvanceRunClock(70f); Defense25DArtifactDropState availableDrop = world.ArtifactDropStates.First(drop => drop.Status == Defense25DArtifactDropStatus.Available); world.Player.ResetAt(availableDrop.Position); InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.A)); yield return null; Assert.That(world.TryInteractWithArtifactDrop(availableDrop.DropId), Is.True); float gaugeBeforeBlockedRelease = world.Loadout.Gauge; InputSystem.QueueStateEvent(keyboard, new KeyboardState()); yield return null; Assert.That(world.HasPendingArtifactDropChoice, Is.True); Assert.That(world.Loadout.Gauge, Is.EqualTo(gaugeBeforeBlockedRelease), "Releasing A after a choice modal opens cannot trigger or consume an artifact."); Assert.That(world.CanToggleRunPause, Is.False, "A choice modal owns keyboard input and blocks pause/restart key leakage."); int generationWithModalOpen = world.RunGeneration; yield return HoldKey(keyboard, Key.P); yield return HoldKey(keyboard, Key.Escape); yield return HoldKey(keyboard, Key.R); Assert.That(world.Paused, Is.False, "Pause keys cannot leak through an active selection modal."); Assert.That(world.RunGeneration, Is.EqualTo(generationWithModalOpen), "R cannot restart through an active selection modal."); } finally { if (keyboard != null && keyboard.added) InputSystem.RemoveDevice(keyboard); InputSystem.settings = previousSettings; Object.Destroy(testSettings); if (previousKeyboard != null && previousKeyboard.added) previousKeyboard.MakeCurrent(); } } private static IEnumerator HoldKey(Keyboard keyboard, Key key) { float until = Time.realtimeSinceStartup + .12f; while (Time.realtimeSinceStartup < until) { InputSystem.QueueStateEvent(keyboard, new KeyboardState(key)); yield return null; } InputSystem.QueueStateEvent(keyboard, new KeyboardState()); yield return null; } [UnityTest] public IEnumerator LongRunUsesArtifactExecutorAndRejectsLegacyCycloneTicks() { Time.timeScale = 1f; InitializeWorld(); world.AdvanceRunClock(runDefinition.PreparationSeconds); world.AdvanceRunClock(18f); ActiveArtifactDefinition cyclone = world.Loadout.FindArtifact(ActiveArtifactEffect.Cyclone); Assert.That(world.Loadout.TryAddArtifact(cyclone), Is.True); world.Loadout.AddGauge(100f); Defense25DEnemy enemy = world.Enemies[0]; Vector2 targetPosition = world.Player.Position + CycloneGeometry.CenterOffset(cyclone.NormalRange, false, settings.PlayerVisualScale); enemy.Spawn(targetPosition); Physics.SyncTransforms(); float healthBefore = enemy.Actor.CurrentHealth; Assert.That(world.TryUseCyclone(false, Vector2.right), Is.False, "The old Comparison Cyclone command must not activate during a long run."); Assert.That(world.CycloneActive, Is.False); Assert.That(world.CycloneTicks, Is.Zero); Assert.That(world.CycloneDamageEvents, Is.Zero); Assert.That(world.Loadout.TryUse(false), Is.True, "The equipped artifact must start through the authoritative executor."); world.ArtifactExecutor.Step(.01f); Assert.That(enemy.Actor.CurrentHealth, Is.LessThan(healthBefore), "The executor cast should hit its actual 2.5D target."); Assert.That(world.CycloneTicks, Is.Zero, "The executor hit must not be counted as a legacy direct tick."); Assert.That(world.CycloneDamageEvents, Is.Zero); yield return new WaitForFixedUpdate(); Assert.That(world.CycloneTicks, Is.Zero, "FixedUpdate must not advance the legacy tick path in long-run mode."); Assert.That(world.CycloneDamageEvents, Is.Zero); } [UnityTest] public IEnumerator FirstGlobalWaveConsumesThePoolCompositionAndLedgerRemainsUncapped() { Defense25DRunDefinition definition = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset"); world.InitializeLongRun(settings, definition); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(definition.PreparationSeconds + 18f); int emittedCompositionCount = world.WaveDirector.GetCurrentPlan().Composition.SelectedEntries.Count; Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(emittedCompositionCount)); Assert.That(world.RemainingEnemies, Is.EqualTo(emittedCompositionCount)); Assert.That(world.Enemies.Count, Is.EqualTo(emittedCompositionCount)); Assert.That(world.Enemies.All(enemy => enemy.IsAvailable), Is.True); int nextSyntheticId = 10000; while (world.Ledger.SuppliedCount <= 16) Assert.That(world.Ledger.RegisterSupply(nextSyntheticId++), Is.True); Assert.That(world.Ledger.SuppliedCount, Is.GreaterThan(16)); yield return null; } [UnityTest] public IEnumerator DirectorGlobalEliteMidBossAndFinalBossRequestsCreateTypedActorsOnce() { Time.timeScale = 0f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); runWaveConstants = Object.Instantiate(runDefinition.WaveDirectorConstants); Defense25DThreatCurvePoint[] curve = runWaveConstants.ThreatCurve; for (int i = 0; i < curve.Length; i++) curve[i].ThreatBudget = .8f; runWaveConstants.ThreatCurve = curve; runDefinition.WaveDirectorConstants = runWaveConstants; world.InitializeLongRun(settings, runDefinition); world.AutoSpawn = false; world.ReadInput = false; List observed = new(); world.LongRunSpawnRequested += observed.Add; world.AdvanceRunClock(runDefinition.PreparationSeconds + 720f); Defense25DEnemy[] elites = world.Enemies.Where(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.GlobalElite).ToArray(); Defense25DEnemy[] midBosses = world.Enemies.Where(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.GlobalMidBoss).ToArray(); Defense25DEnemy[] finalBosses = world.Enemies.Where(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss).ToArray(); Assert.That(observed.Count(request => request.Source == Defense25DSpawnSource.GlobalElite), Is.EqualTo(elites.Length)); Assert.That(observed.Count(request => request.Source == Defense25DSpawnSource.GlobalMidBoss), Is.EqualTo(midBosses.Length)); Assert.That(observed.Count(request => request.Source == Defense25DSpawnSource.FinalBoss), Is.EqualTo(1)); Assert.That(elites.Any(enemy => enemy.BossCombat != null && enemy.BossCombat.IsBound), Is.True); Assert.That(midBosses.Any(enemy => enemy.BossCombat != null && enemy.BossCombat.IsBound), Is.True); Assert.That(finalBosses, Has.Length.EqualTo(1)); Assert.That(finalBosses[0].BossCombat, Is.Not.Null.And.Property("IsBound").True); Assert.That(finalBosses[0].LongRunDefinition, Is.SameAs(runDefinition.FinalBossDefinition)); Assert.That(finalBosses[0].MonsterCombat, Is.Null, "FinalBoss uses its dedicated visual/role route and is excluded from the normal pool adapter."); Assert.That(finalBosses[0].LongRunBossAnimator, Is.Not.Null); Assert.That(finalBosses[0].GetComponentsInChildren(true), Is.Empty, "Only the authored visual source/controller are copied; no 2D physics prefab components run in parallel."); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.BossActive)); Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(world.Enemies.Count)); Assert.That(world.Enemies.Select(enemy => enemy.SpawnContext.SpawnId).Distinct().Count(), Is.EqualTo(world.Enemies.Count)); Assert.That(elites.All(enemy => enemy.LongRunDefinition == runDefinition.GlobalEliteDefinition), Is.True); Assert.That(midBosses.All(enemy => enemy.LongRunDefinition == runDefinition.GlobalMidBossDefinition), Is.True); Assert.That(observed.Any(request => request.Source == Defense25DSpawnSource.GlobalElite && Mathf.Approximately(request.ActualStart, 90f)), Is.True); Assert.That(observed.Any(request => request.Source == Defense25DSpawnSource.GlobalMidBoss && Mathf.Approximately(request.ActualStart, 240f)), Is.True); Assert.That(observed.Count(request => request.Source == Defense25DSpawnSource.FinalBoss && Mathf.Approximately(request.ActualStart, 720f)), Is.EqualTo(1)); yield return null; } [UnityTest] public IEnumerator FinalBossStartsDesperationFromAppliedDamageWhileGroggyAndRegistersOwnedSummons() { Time.timeScale = 0f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); runWaveConstants = Object.Instantiate(runDefinition.WaveDirectorConstants); Defense25DThreatCurvePoint[] curve = runWaveConstants.ThreatCurve; for (int i = 0; i < curve.Length; i++) curve[i].ThreatBudget = .8f; runWaveConstants.ThreatCurve = curve; runDefinition.WaveDirectorConstants = runWaveConstants; world.InitializeLongRun(settings, runDefinition); world.AutoSpawn = false; world.ReadInput = false; List observed = new(); world.LongRunSpawnRequested += observed.Add; world.AdvanceRunClock(runDefinition.PreparationSeconds + 720f); Defense25DEnemy boss = world.Enemies.Single(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss); SpriteRenderer authoredBossRenderer = boss.LongRunBossAnimator.GetComponent(); Color authoredBossColor = authoredBossRenderer.color; Assert.That(boss.BossCombat.BeginGroggy(10f), Is.True); int suppliedBefore = world.Ledger.SuppliedCount; float before = boss.Actor.CurrentHealth; Assert.That(boss.Actor.TakeDamageWithResult(boss.Actor.MaxHealth, Vector2.right, 0f, EnemyDamageSource.Player, false, out float actualDamage), Is.True); Assert.That(actualDamage, Is.GreaterThan(0f)); Assert.That(boss.Actor.CurrentHealth, Is.GreaterThan(0f)); Assert.That(boss.Actor.CurrentHealth, Is.LessThan(before)); Assert.That(boss.BossCombat.DesperationStarted, Is.True, "The role starts desperation only after a real applied hit crosses its groggy threshold."); Color protectionColor = authoredBossRenderer.color; Assert.That(protectionColor, Is.Not.EqualTo(authoredBossColor), "The protection phase must tint the authored FinalBoss visual, not only its hidden actor renderer."); Assert.That(boss.BossCombat.PhaseSummonCount, Is.EqualTo(3)); Defense25DEnemy[] ownedPhaseSummons = world.Enemies.Where(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.BossSummon && enemy.SpawnContext.SummonOwnerSpawnId == boss.SpawnContext.SpawnId).ToArray(); Assert.That(ownedPhaseSummons, Has.Length.EqualTo(3)); Assert.That(ownedPhaseSummons.All(enemy => enemy.IsAvailable), Is.True); Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(suppliedBefore + 3)); Assert.That(observed.Count(request => request.Source == Defense25DSpawnSource.BossSummon), Is.EqualTo(3)); foreach (Defense25DEnemy summon in ownedPhaseSummons) { int remainingBefore = boss.BossCombat.PhaseSummonCount; world.ResolveEnemy(summon, DefenseEnemyResolution.Death); Assert.That(boss.BossCombat.PhaseSummonCount, Is.EqualTo(remainingBefore - 1), "A committed summon resolution is forwarded to its owning boss exactly once."); } Assert.That(boss.BossCombat.DesperationCompleted, Is.True); Assert.That(boss.BossCombat.IsGroggy, Is.True); Assert.That(authoredBossRenderer.color, Is.Not.EqualTo(authoredBossColor)); Assert.That(authoredBossRenderer.color, Is.Not.EqualTo(protectionColor), "The final groggy phase keeps a distinct visible tint after protection ends."); yield return null; } [UnityTest] public IEnumerator FinalBossAmbientSummonsUseTheSharedFactoryWithoutJoiningProtectionSet() { Time.timeScale = 0f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); runWaveConstants = Object.Instantiate(runDefinition.WaveDirectorConstants); Defense25DThreatCurvePoint[] curve = runWaveConstants.ThreatCurve; for (int i = 0; i < curve.Length; i++) curve[i].ThreatBudget = .8f; runWaveConstants.ThreatCurve = curve; runDefinition.WaveDirectorConstants = runWaveConstants; world.InitializeLongRun(settings, runDefinition); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(runDefinition.PreparationSeconds + runDefinition.FinalBossAtSeconds); Defense25DEnemy boss = world.Enemies.Single(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss); int priorSupplyCount = world.Ledger.SuppliedCount; bool crowdBatchSpawned = false; float crowdDeadline = GameplayConstants.Current.Enemies.CrowdSummonCooldown + GameplayConstants.Current.Enemies.GeneralSummonCooldown * 2f + 5f; int steps = Mathf.CeilToInt(crowdDeadline * 4f); for (int i = 0; i < steps && !crowdBatchSpawned; i++) { int before = world.Enemies.Count(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.BossSummon && enemy.SpawnContext.SummonOwnerSpawnId == boss.SpawnContext.SpawnId); boss.BossCombat.Step(.25f); int after = world.Enemies.Count(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.BossSummon && enemy.SpawnContext.SummonOwnerSpawnId == boss.SpawnContext.SpawnId); crowdBatchSpawned = after - before == 3; } Defense25DEnemy[] ownedAmbientSummons = world.Enemies.Where(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.BossSummon && enemy.SpawnContext.SummonOwnerSpawnId == boss.SpawnContext.SpawnId).ToArray(); Assert.That(crowdBatchSpawned, Is.True, "Crowd ambient callback commits three actors atomically."); Assert.That(ownedAmbientSummons.Length, Is.GreaterThanOrEqualTo(4), "The single-summon and crowd-summon role callbacks both use the shared factory."); Assert.That(world.Ledger.SuppliedCount, Is.GreaterThan(priorSupplyCount)); Assert.That(boss.BossCombat.PhaseSummonCount, Is.Zero, "Ambient owner IDs do not join the three phase shield IDs."); world.ResolveEnemy(ownedAmbientSummons[0], DefenseEnemyResolution.Death); Assert.That(boss.BossCombat.PhaseSummonCount, Is.Zero); yield return null; } [UnityTest] public IEnumerator FinalBossDeathClearsSupplyButVictoryWaitsForEnemiesAndQueuedRewards() { Time.timeScale = 0f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); runWaveConstants = Object.Instantiate(runDefinition.WaveDirectorConstants); Defense25DThreatCurvePoint[] curve = runWaveConstants.ThreatCurve; for (int i = 0; i < curve.Length; i++) curve[i].ThreatBudget = .8f; runWaveConstants.ThreatCurve = curve; runDefinition.WaveDirectorConstants = runWaveConstants; world.InitializeLongRun(settings, runDefinition); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(runDefinition.PreparationSeconds + runDefinition.FinalBossAtSeconds); Defense25DEnemy boss = world.Enemies.Single(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss); world.ResolveEnemy(boss, DefenseEnemyResolution.Death); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Clearing)); Assert.That(world.RunCleared, Is.False); Assert.That(world.WaveDirector.IsSupplyStopped, Is.True); Defense25DEnemy elite = world.Enemies.First(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.GlobalElite && enemy.IsAvailable); foreach (Defense25DEnemy remaining in world.Enemies.Where(enemy => enemy != boss && enemy != elite && enemy.IsAvailable).ToArray()) world.ResolveEnemy(remaining, DefenseEnemyResolution.Death); Assert.That(world.RemainingEnemies, Is.EqualTo(1), "The final Elite is deliberately still alive at this clearing checkpoint."); Assert.That(world.RunCleared, Is.False); ResolvePendingLongRunRewards(world); for (int attempt = 0; attempt < 16; attempt++) { float eliteExperience = world.Player.Evaluate(CharacterStat.ExperienceGain, elite.LongRunDefinition.ExperienceValue); if (world.Progression.CurrentExperience + eliteExperience < world.Progression.RequiredExperience - 1f) break; world.Progression.GrantKillExperience( world.Progression.RequiredExperience - world.Progression.CurrentExperience + 1); ResolvePendingLongRunRewards(world); } Assert.That(world.Progression.CurrentExperience + world.Player.Evaluate(CharacterStat.ExperienceGain, elite.LongRunDefinition.ExperienceValue), Is.LessThan(world.Progression.RequiredExperience - 1f), "The last Elite’s XP is configured not to trigger a level choice during the end-order regression."); world.ResolveEnemy(elite, DefenseEnemyResolution.Death); Assert.That(world.RemainingEnemies, Is.Zero, "The final Elite death leaves no live enemies before its reward is chosen."); Assert.That(world.Progression.RewardChoices.GetPendingRewardCount(Defense25DRewardSource.GlobalElite), Is.EqualTo(1)); Assert.That(world.RunCleared, Is.False, "XP changes and the Elite reward token resolve as one death transaction before victory evaluation."); Assert.That(world.Progression.HasPendingReward, Is.True); ResolvePendingLongRunRewards(world); Assert.That(world.Progression.HasPendingReward, Is.False); Assert.That(world.RunCleared, Is.True); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Victory)); Assert.That(world.RunEndReason, Is.EqualTo(Defense25DRunEndReason.FinalBossDefeated)); yield return null; } [UnityTest] public IEnumerator LongRunTerminalReasonDistinguishesFinalBossArrivalFromHomeDestruction() { Time.timeScale = 0f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); world.InitializeLongRun(settings, runDefinition, 31); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(runDefinition.PreparationSeconds + runDefinition.FinalBossAtSeconds); Defense25DEnemy finalBoss = world.Enemies.Single(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss); world.ResolveEnemy(finalBoss, DefenseEnemyResolution.Arrival); Assert.That(world.RunFailed, Is.True); Assert.That(world.RunEndReason, Is.EqualTo(Defense25DRunEndReason.FinalBossArrived)); Object.Destroy(world.gameObject); yield return null; world = new GameObject("Long run home failure under test").AddComponent(); yield return null; world.InitializeLongRun(settings, runDefinition, 31); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(runDefinition.PreparationSeconds + 18f); Defense25DEnemy normal = world.Enemies.First(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.Normal); typeof(Defense25DComparison).GetProperty(nameof(Defense25DComparison.BaseHealth)) .GetSetMethod(true).Invoke(world, new object[] { 1f }); world.ResolveEnemy(normal, DefenseEnemyResolution.Arrival); Assert.That(world.RunFailed, Is.True); Assert.That(world.RunEndReason, Is.EqualTo(Defense25DRunEndReason.BaseDestroyed)); yield return null; } [UnityTest] public IEnumerator LongRunDeathsGrantDefinitionExperienceAndGlobalBossRewardsOnceWhileArrivalsGrantNone() { Time.timeScale = 0f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); runWaveConstants = Object.Instantiate(runDefinition.WaveDirectorConstants); Defense25DThreatCurvePoint[] curve = runWaveConstants.ThreatCurve; for (int i = 0; i < curve.Length; i++) curve[i].ThreatBudget = .8f; runWaveConstants.ThreatCurve = curve; runDefinition.WaveDirectorConstants = runWaveConstants; world.InitializeLongRun(settings, runDefinition); world.AutoSpawn = false; world.ReadInput = false; ExperienceProgress expectedExperience = new(); expectedExperience.Reset(); world.AdvanceRunClock(runDefinition.PreparationSeconds + 18f); Defense25DEnemy normal = world.Enemies.First(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.Normal); world.ResolveEnemy(normal, DefenseEnemyResolution.Arrival); AssertProgressionMatches(expectedExperience, world.Progression, "Arrival must not grant XP."); Assert.That(world.Progression.RewardChoices.GetPendingRewardCount(Defense25DRewardSource.GlobalElite), Is.Zero); Assert.That(world.Progression.RewardChoices.GetPendingRewardCount(Defense25DRewardSource.GlobalMidBoss), Is.Zero); world.AdvanceRunClock(72f); Defense25DEnemy elite = world.Enemies.Single(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.GlobalElite); expectedExperience.AddEvaluated(world.Player.Evaluate(CharacterStat.ExperienceGain, elite.LongRunDefinition.ExperienceValue)); world.ResolveEnemy(elite, DefenseEnemyResolution.Death); AssertProgressionMatches(expectedExperience, world.Progression, "Global Elite death grants its EnemyDefinition experience exactly once."); Assert.That(world.Progression.RewardChoices.GetPendingRewardCount(Defense25DRewardSource.GlobalElite), Is.EqualTo(1)); world.ResolveEnemy(elite, DefenseEnemyResolution.Death); AssertProgressionMatches(expectedExperience, world.Progression, "A duplicate resolution cannot award XP twice."); Assert.That(world.Progression.RewardChoices.GetPendingRewardCount(Defense25DRewardSource.GlobalElite), Is.EqualTo(1)); world.AdvanceRunClock(150f); Defense25DEnemy midBoss = world.Enemies.Single(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.GlobalMidBoss); expectedExperience.AddEvaluated(world.Player.Evaluate(CharacterStat.ExperienceGain, midBoss.LongRunDefinition.ExperienceValue)); world.ResolveEnemy(midBoss, DefenseEnemyResolution.Death); AssertProgressionMatches(expectedExperience, world.Progression, "Global MidBoss death grants its EnemyDefinition experience exactly once."); Assert.That(world.Progression.RewardChoices.GetPendingRewardCount(Defense25DRewardSource.GlobalMidBoss), Is.EqualTo(1)); yield return null; } [UnityTest] public IEnumerator FinalBossUsesDefinitionDamageAndConsumesMultiAoeAttackCooldownGate() { Time.timeScale = 0f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); runWaveConstants = Object.Instantiate(runDefinition.WaveDirectorConstants); Defense25DThreatCurvePoint[] curve = runWaveConstants.ThreatCurve; for (int i = 0; i < curve.Length; i++) curve[i].ThreatBudget = .8f; runWaveConstants.ThreatCurve = curve; runDefinition.WaveDirectorConstants = runWaveConstants; settings.Enemy = AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Enemies/Slime.asset"); world.InitializeLongRun(settings, runDefinition); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(runDefinition.PreparationSeconds + runDefinition.FinalBossAtSeconds); Defense25DEnemy boss = world.Enemies.Single(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss); boss.Actor.ResetAt(world.Player.Position + Vector2.right * .05f); Assert.That(boss.BossCombat.CanUseAttackPattern(2), Is.True); for (int patternIndex = 0; patternIndex < 3; patternIndex++) { boss.Step(.01f); EnemyAttackPattern pattern = runDefinition.FinalBossDefinition.GetAttackPattern(patternIndex); float playerHealthBefore = world.Player.CurrentHealth; world.AdvanceRunClock(pattern.WarningDuration + .05f); boss.Step(.01f); if (patternIndex == 0) { Assert.That(playerHealthBefore - world.Player.CurrentHealth, Is.EqualTo(runDefinition.FinalBossDefinition.AttackDamage * pattern.DamageMultiplier) .Within(.01f), "The dedicated FinalBoss route uses authored definition damage, not comparison settings."); } world.AdvanceRunClock(pattern.ActiveDuration + pattern.RecoveryDuration + .1f); boss.Step(.01f); world.AdvanceRunClock(GameplayConstants.Current.Defense.PostAttackProgressSeconds + .02f); } Assert.That(boss.BossCombat.CanUseAttackPattern(2), Is.False, "Starting the authored MultiAoE pattern consumes the role cooldown gate."); yield return null; } [UnityTest] public IEnumerator ResetPoolsDynamicRunEnemiesAndRestartsSpawnIdsForTheNewGeneration() { Time.timeScale = 0f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); world.InitializeLongRun(settings, runDefinition, 1); world.AutoSpawn = false; world.ReadInput = false; string pooledDefinitionId = null; world.LongRunSpawnRequested += request => { if (pooledDefinitionId == null && request.Source == Defense25DSpawnSource.Normal) pooledDefinitionId = request.EnemyDefinitionId; }; world.AdvanceRunClock(runDefinition.PreparationSeconds + 18f); Assert.That(world.Enemies, Is.Not.Empty); Assert.That(pooledDefinitionId, Is.Not.Null); Assert.That(runDefinition.MonsterPoolDefinition.TryGetEntry(pooledDefinitionId, out Defense25DMonsterPoolEntry pooledEntry), Is.True); Defense25DEnemy firstRunEnemy = world.Enemies.First(enemy => ReferenceEquals(enemy.LongRunDefinition, pooledEntry.Definition)); int firstGeneration = firstRunEnemy.SpawnContext.RunGeneration; Assert.That(firstRunEnemy.SpawnContext.SpawnId, Is.Zero); world.ResetRun(); Assert.That(world.RunGeneration, Is.EqualTo(firstGeneration + 1)); Assert.That(world.Enemies, Is.Empty); Assert.That(world.Ledger.SuppliedCount, Is.Zero); yield return null; Assert.That(firstRunEnemy, Is.Not.Null, "Reset retires a dynamic enemy into the reusable pool instead of destroying its instance."); Assert.That(firstRunEnemy.IsAvailable, Is.False); Assert.That(firstRunEnemy.gameObject.activeSelf, Is.False, "A pooled instance remains registered but inactive between run generations."); Assert.That(firstRunEnemy.Actor.BodyCollider.enabled, Is.False, "The dormant pooled instance cannot participate in Physics3D queries."); world.AdvanceRunClock(runDefinition.PreparationSeconds + 18f); Assert.That(world.Enemies, Is.Not.Empty); Defense25DEnemy firstNextGenerationEnemy = world.Enemies.First(enemy => enemy.SpawnContext.RunGeneration == world.RunGeneration && enemy.SpawnContext.Source == Defense25DSpawnSource.Normal); Assert.That(firstNextGenerationEnemy.SpawnContext.SpawnId, Is.Zero, "The new generation restarts its ledger identity sequence at zero."); Assert.That(world.Ledger.SuppliedCount, Is.GreaterThan(0)); bool reusedSameSpeciesInstance = world.Enemies.Contains(firstRunEnemy) && firstRunEnemy.SpawnContext.RunGeneration == world.RunGeneration; int reusedSpawnId = reusedSameSpeciesInstance ? firstRunEnemy.SpawnContext.SpawnId : -1; world.LongRunSpawnRequested += request => { if (request.Source == Defense25DSpawnSource.Normal && request.EnemyDefinitionId == pooledDefinitionId && world.Enemies.Contains(firstRunEnemy) && firstRunEnemy.SpawnContext.RunGeneration == world.RunGeneration) { reusedSameSpeciesInstance = true; reusedSpawnId = firstRunEnemy.SpawnContext.SpawnId; } }; for (int second = 0; second < 240 && !reusedSameSpeciesInstance; second++) world.AdvanceRunClock(1f); Assert.That(reusedSameSpeciesInstance, Is.True, "The exact EnemyDefinitionId instance is reused when that species is naturally requested in the new generation."); Assert.That(firstRunEnemy.SpawnContext.RunGeneration, Is.EqualTo(world.RunGeneration)); Assert.That(firstRunEnemy.SpawnContext.SpawnId, Is.EqualTo(reusedSpawnId), "A reused actor may receive spawn ID zero when it is the first request in the generation."); Assert.That((firstRunEnemy.SpawnContext.RunGeneration, firstRunEnemy.SpawnContext.SpawnId), Is.Not.EqualTo((firstGeneration, 0)), "Reassignment changes the actor's generation-scoped identity even when the spawn ID restarts at zero."); } [UnityTest] public IEnumerator LargeAndSmallClockStepsKeepWaveEliteAndDropEventsInTheSameOrder() { Time.timeScale = 0f; List largeSteps = CaptureRunTimeline(useLargeStep: true); Object.Destroy(world.gameObject); yield return null; world = new GameObject("Long run small-step timeline under test").AddComponent(); yield return null; List smallSteps = CaptureRunTimeline(useLargeStep: false); Assert.That(smallSteps, Is.EqualTo(largeSteps)); Assert.That(largeSteps, Does.Contain("D0@60.00")); Assert.That(largeSteps, Does.Contain("D2@360.00")); Assert.That(largeSteps, Does.Contain("E@195.00")); Assert.That(largeSteps, Does.Contain("E@375.00")); Assert.That(largeSteps.IndexOf("D1@180.00"), Is.LessThan(largeSteps.IndexOf("E@195.00"))); Assert.That(largeSteps.IndexOf("D2@360.00"), Is.LessThan(largeSteps.IndexOf("E@375.00"))); } [UnityTest] public IEnumerator ArtifactDropInteractionUsesOnePhaseAndModalGate() { Time.timeScale = 0f; InitializeWorld(); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing)); Assert.That(world.CanOpenArtifactDropInteraction, Is.False); world.AdvanceRunClock(20f); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running)); Assert.That(world.CanOpenArtifactDropInteraction, Is.True); Assert.That(world.ToggleOwnedArtifactPicker(), Is.True); Assert.That(world.CanOpenArtifactDropInteraction, Is.False); Assert.That(world.ToggleOwnedArtifactPicker(), Is.True); Assert.That(world.CanOpenArtifactDropInteraction, Is.True); world.AdvanceRunClock(60f); Defense25DArtifactDropState drop = world.ArtifactDropStates[0]; world.Player.ResetAt(drop.Position); Assert.That(world.TryInteractWithArtifactDrop(drop.DropId), Is.True); Assert.That(world.CanOpenArtifactDropInteraction, Is.False); Assert.That(world.CurrentArtifactDropCandidates, Has.Count.EqualTo(2)); Assert.That(world.TryChooseArtifactDrop(0), Is.True); Assert.That(world.Loadout.OwnedArtifactCount, Is.EqualTo(1)); Assert.That(world.CanOpenArtifactDropInteraction, Is.True); yield return null; } [UnityTest] public IEnumerator UnclaimedArtifactDropsRemainUsableDuringBossButLockWhenClearingStarts() { Time.timeScale = 0f; InitializeWorld(); world.AdvanceRunClock(20f); Assert.That(world.CanOpenArtifactDropInteraction, Is.True); Assert.That(world.NotifyLongRunBossActivated(), Is.True); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.BossActive)); Assert.That(world.CanOpenArtifactDropInteraction, Is.True); world.AdvanceRunClock(60f); Defense25DArtifactDropState drop = world.ArtifactDropStates[0]; world.Player.ResetAt(drop.Position); Assert.That(world.TryInteractWithArtifactDrop(drop.DropId), Is.True); Assert.That(world.TryChooseArtifactDrop(0), Is.True); Assert.That(world.Loadout.OwnedArtifactCount, Is.EqualTo(1)); Assert.That(world.BeginLongRunClearing(), Is.True); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Clearing)); Assert.That(world.CanOpenArtifactDropInteraction, Is.False); yield return null; } [UnityTest] public IEnumerator PersonalMissionUsesTheSharedEnemyFactoryAndResolvesItsTargets() { Time.timeScale = 1f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); Defense25DPortalDefinition[] portals = runDefinition.PortalDefinitions; for (int i = 0; i < portals.Length; i++) portals[i] = Defense25DPortalDefinition.Create(i, 0f, portals[i].TelegraphSeconds, i, portals[i].HasDamageImmunity, portals[i].ImmuneTowerKindId); runDefinition.PortalDefinitions = portals; world.InitializeLongRun(settings, runDefinition); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(runDefinition.PreparationSeconds + 1f); Assert.That(world.TryStartPersonalMission(1), Is.True); Defense25DEnemy[] missionTargets = world.Enemies .Where(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.PersonalMission) .ToArray(); Assert.That(missionTargets, Has.Length.EqualTo(runDefinition.PersonalMissionRequiredPortalCounts[0])); Assert.That(missionTargets.Select(enemy => enemy.SpawnContext.PortalId).Distinct().Count(), Is.EqualTo(missionTargets.Length)); Assert.That(missionTargets.All(enemy => enemy.SpawnContext.IsMissionTarget), Is.True); foreach (Defense25DEnemy target in missionTargets) world.ResolveEnemy(target, DefenseEnemyResolution.Death); Assert.That(world.PersonalMissions.States[0].Status, Is.EqualTo(Defense25DPersonalMissionStatus.RewardPending)); Assert.That(world.PersonalMissions.States[0].RemainingTargetCount, Is.Zero); yield return null; } [UnityTest] public IEnumerator ClearingClosesEveryPendingSpecialSpawnCallback() { Time.timeScale = 0f; InitializeWorld(); List observed = new(); world.LongRunSpawnRequested += observed.Add; world.AdvanceRunClock(runDefinition.PreparationSeconds + 91f); Defense25DSpawnRequest eliteRequest = observed.Single(request => request.Source == Defense25DSpawnSource.GlobalElite && Mathf.Approximately(request.ActualStart, 90f)); Defense25DEnemy elite = world.Enemies.Single(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.GlobalElite && Mathf.Approximately(eliteRequest.ActualStart, 90f)); Assert.That(world.PendingLongRunSpecialSpawns, Is.Empty); Assert.That(elite, Is.Not.Null); Assert.That(elite.IsAvailable, Is.True); int suppliedBeforeClearing = world.Ledger.SuppliedCount; Assert.That(world.NotifyLongRunBossActivated(), Is.True); Assert.That(world.BeginLongRunClearing(), Is.True); Assert.That(world.PendingLongRunSpecialSpawns, Is.Empty); world.SpawnScheduledEnemy(eliteRequest); Assert.That(world.PendingLongRunSpecialSpawns, Is.Empty); Assert.That(world.Ledger.SuppliedCount, Is.EqualTo(suppliedBeforeClearing)); Assert.That(elite.IsAvailable, Is.True, "Clearing preserves an already-created GlobalElite."); Assert.That(observed.Count(request => request.Source == Defense25DSpawnSource.GlobalElite), Is.EqualTo(1)); yield return null; } [UnityTest] public IEnumerator HudRunCommandsExposeMissionStateAndRespectPhaseModalAndReliefGates() { Time.timeScale = 0f; runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); Defense25DPortalDefinition[] portals = runDefinition.PortalDefinitions; for (int i = 0; i < portals.Length; i++) portals[i] = Defense25DPortalDefinition.Create(i, 0f, portals[i].TelegraphSeconds, i, portals[i].HasDamageImmunity, portals[i].ImmuneTowerKindId); runDefinition.PortalDefinitions = portals; world.InitializeLongRun(settings, runDefinition); world.AutoSpawn = false; world.ReadInput = false; Assert.That(world.PersonalMissionStates, Has.Count.EqualTo(3)); Assert.That(world.CanStartPersonalMission(1, out _), Is.False, "Missions remain locked during preparation."); Assert.That(world.TryStartPersonalMission(1), Is.False); Assert.That(world.TryCallNextWaveEarly(out _), Is.False, "EarlyCall is unavailable before the first wave's spawn window ends."); world.AdvanceRunClock(runDefinition.PreparationSeconds + 18f); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running)); Assert.That(world.ToggleOwnedArtifactPicker(), Is.True); Assert.That(world.TryCallNextWaveEarly(out _), Is.False, "EarlyCall cannot be issued while another selection modal owns input."); Assert.That(world.ToggleOwnedArtifactPicker(), Is.True); world.WaveDirector.RegisterReliefWindow("hud-api-relief", world.CombatElapsed, 5f); Assert.That(world.CanStartPersonalMission(1, out _), Is.True, "Personal missions can be accepted during relief when their portals are open."); Assert.That(world.TryStartPersonalMission(1), Is.True); Assert.That(world.TryCallNextWaveEarly(out _), Is.False, "EarlyCall alone is locked during a scheduled relief window."); world.AdvanceRunClock(5f); Assert.That(world.NotifyLongRunBossActivated(), Is.True); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.BossActive)); int currencyBefore = world.TowerManager.ConstructionCurrency; Assert.That(world.TryCallNextWaveEarly(out EarlyCallResult result), Is.True); Assert.That(result.Succeeded, Is.True); Assert.That(world.TowerManager.ConstructionCurrency, Is.EqualTo(currencyBefore + result.RewardCurrency)); yield return null; } private List CaptureRunTimeline(bool useLargeStep) { InitializeWorld(); List events = new(); world.WaveDirector.WaveStarted += plan => events.Add($"W{plan.WaveId}@{plan.ActualStart:0.00}"); world.LongRunSpawnRequested += request => { if (request.Source == Defense25DSpawnSource.GlobalElite) events.Add($"E@{request.ActualStart:0.00}"); }; world.ArtifactDropSpawned += drop => events.Add($"D{drop.DropId}@{drop.AvailableAt:0.00}"); world.AdvanceRunClock(20f); if (useLargeStep) { world.AdvanceRunClock(400f); } else { for (int second = 0; second < 400; second++) world.AdvanceRunClock(1f); } return events; } private void InitializeWorld() { if (runDefinition != null) Object.Destroy(runDefinition); runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); Assert.That(runDefinition, Is.Not.Null); world.InitializeLongRun(settings, runDefinition); world.AutoSpawn = false; world.ReadInput = false; } private static void AssertProgressionMatches(ExperienceProgress expected, Defense25DProgression actual, string message) { Assert.That(actual.Level, Is.EqualTo(expected.Level), message); Assert.That(actual.CurrentExperience, Is.EqualTo(expected.CurrentExperience), message); Assert.That(actual.RequiredExperience, Is.EqualTo(expected.RequiredExperience), message); } private static void ResolvePendingLongRunRewards(Defense25DComparison run) { int attempts = 0; while (run.Progression.HasPendingReward && attempts++ < 128) { string token = run.Progression.CurrentRewardToken; bool resolved = false; for (int choice = 0; choice < run.Progression.CurrentChoices.Count; choice++) if (run.Progression.TryChooseReward(choice, token)) { resolved = true; break; } Assert.That(resolved, Is.True, $"A queued reward remained resolvable for token '{token}'."); } Assert.That(run.Progression.HasPendingReward, Is.False); } } }