using System.Collections; using System.IO; using System.Linq; using BumpCombat.Constants; using BumpCombat.Defense; using BumpCombat.Defense.Comparison; using NUnit.Framework; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; using UnityEngine.UI; namespace BumpCombat.Tests { public sealed class Defense25DRunScenePlayModeTests { private const string ScenePath = "Assets/_Project/Scenes/Defense25DRun.unity"; private Scene loadedScene; private Scene previousActiveScene; private float previousTimeScale; private int previousScreenWidth; private int previousScreenHeight; private bool restoreScreenResolution; private float previousFixedDeltaTime; private bool restoreFixedDeltaTime; [UnityTearDown] public IEnumerator TearDown() { if (previousActiveScene.IsValid() && previousActiveScene.isLoaded) SceneManager.SetActiveScene(previousActiveScene); if (loadedScene.IsValid() && loadedScene.isLoaded) { AsyncOperation unload = SceneManager.UnloadSceneAsync(loadedScene); if (unload != null) yield return unload; } loadedScene = default; previousActiveScene = default; Time.timeScale = previousTimeScale; if (restoreFixedDeltaTime) { Time.fixedDeltaTime = previousFixedDeltaTime; restoreFixedDeltaTime = false; } if (restoreScreenResolution && previousScreenWidth > 0 && previousScreenHeight > 0) { Screen.SetResolution(previousScreenWidth, previousScreenHeight, FullScreenMode.Windowed); restoreScreenResolution = false; yield return null; } } [UnityTest] public IEnumerator SavedRunSceneLoadsLongRunHudAndConfirmedRestart() { previousTimeScale = Time.timeScale; previousActiveScene = SceneManager.GetActiveScene(); AsyncOperation load = EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, new LoadSceneParameters(LoadSceneMode.Additive)); Assert.That(load, Is.Not.Null, $"Could not load saved scene {ScenePath}."); yield return load; loadedScene = SceneManager.GetSceneByPath(ScenePath); Assert.That(loadedScene.IsValid() && loadedScene.isLoaded, Is.True, $"Scene did not reload from {ScenePath}."); SceneManager.SetActiveScene(loadedScene); yield return null; yield return null; Defense25DRunSceneProfile profile = FindInScene(); Defense25DComparison world = FindInScene(); Defense25DRunHud hud = FindInScene(); Assert.That(profile, Is.Not.Null); Assert.That(profile.RunDefinition, Is.Not.Null); Assert.That(profile.RunDefinition.PreparationSeconds, Is.EqualTo(20f).Within(.001f)); Assert.That(world, Is.Not.Null); Assert.That(world.IsLongRun, Is.True, "The saved scene must enter through InitializeLongRun."); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing)); Assert.That(world.PreparationRemaining, Is.GreaterThan(0f).And.LessThanOrEqualTo(20f)); Assert.That(world.PortalCount, Is.EqualTo(12)); Assert.That(world.RunLayout, Is.Not.Null); Assert.That(world.RunLayout.PadCount, Is.EqualTo(18)); Assert.That(world.GetComponentsInChildren(true) .Count(transform => transform.name.StartsWith("Tower build pad ")), Is.EqualTo(18)); Assert.That(world.View, Is.Not.Null); Assert.That(hud, Is.Not.Null); SerializedObject serializedWorld = new(world); Assert.That(serializedWorld.FindProperty("runHud").objectReferenceValue, Is.SameAs(hud)); Assert.That(hud.IsLayoutValid, Is.True); world.AutoSpawn = false; world.ReadInput = false; int generationBeforeRestart = world.RunGeneration; Button pause = FindButton(hud, "Pause Toggle"); Button restart = FindButton(hud, "Restart Run"); Assert.That(pause, Is.Not.Null); Assert.That(restart, Is.Not.Null); pause.onClick.Invoke(); Assert.That(world.Paused, Is.True); restart.onClick.Invoke(); Assert.That(hud.IsRestartConfirmationPending, Is.True, "An active run restart must wait for explicit confirmation."); Assert.That(world.RunGeneration, Is.EqualTo(generationBeforeRestart)); FindButton(hud, "Cancel Restart").onClick.Invoke(); Assert.That(hud.IsRestartConfirmationPending, Is.False); Assert.That(world.RunGeneration, Is.EqualTo(generationBeforeRestart)); restart.onClick.Invoke(); Assert.That(hud.IsRestartConfirmationPending, Is.True); FindButton(hud, "Confirm Restart").onClick.Invoke(); Assert.That(hud.IsRestartConfirmationPending, Is.False); Assert.That(world.RunGeneration, Is.EqualTo(generationBeforeRestart + 1)); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing)); Assert.That(world.PreparationRemaining, Is.EqualTo(20f).Within(.001f)); world.AdvanceRunClock(20f); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running)); Assert.That(world.PreparationRemaining, Is.EqualTo(0f).Within(.001f)); Assert.That(world.CurrentGlobalWaveId, Is.GreaterThan(0)); } [UnityTest] public IEnumerator CapturesSavedRunHudAtFhdAndQhdScreenResolutions() { previousTimeScale = Time.timeScale; previousScreenWidth = Screen.width; previousScreenHeight = Screen.height; restoreScreenResolution = previousScreenWidth > 0 && previousScreenHeight > 0; previousActiveScene = SceneManager.GetActiveScene(); AsyncOperation load = EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, new LoadSceneParameters(LoadSceneMode.Additive)); Assert.That(load, Is.Not.Null, $"Could not load saved scene {ScenePath}."); yield return load; loadedScene = SceneManager.GetSceneByPath(ScenePath); Assert.That(loadedScene.IsValid() && loadedScene.isLoaded, Is.True, $"Scene did not reload from {ScenePath}."); SceneManager.SetActiveScene(loadedScene); yield return null; yield return null; Defense25DComparison world = FindInScene(); Defense25DRunHud hud = FindInScene(); Assert.That(world, Is.Not.Null); Assert.That(hud, Is.Not.Null); Assert.That(hud.IsLayoutValid, Is.True); world.AutoSpawn = false; world.ReadInput = false; string outputDirectory = System.Environment.GetEnvironmentVariable("BUMPCOMBAT_TASK14_CAPTURE_DIR"); if (string.IsNullOrWhiteSpace(outputDirectory)) outputDirectory = Path.Combine(Path.GetTempPath(), "BumpCombatTask14Captures"); Directory.CreateDirectory(outputDirectory); yield return CaptureAtResolution(outputDirectory, 1920, 1080); yield return CaptureAtResolution(outputDirectory, 2560, 1440); } [UnityTest] public IEnumerator SavedSceneFastForwardsClockThroughWaveLedgerBossClearAndRestart() { yield return LoadSavedRunSceneForTransitionTest(); Defense25DComparison world = FindInScene(); Defense25DRunHud hud = FindInScene(); world.AutoSpawn = false; world.ReadInput = false; int generation = world.RunGeneration; Assert.That(world.PortalCount, Is.EqualTo(12)); Assert.That(world.CombatElapsed, Is.EqualTo(0f).Within(.001f)); world.AdvanceRunClock(world.PreparationRemaining); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running)); Assert.That(world.CombatElapsed, Is.EqualTo(0f).Within(.001f)); world.AdvanceRunClock(720f); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.BossActive), "The saved scene reaches the configured 720-second final boss on its own run timeline."); Assert.That(world.CombatElapsed, Is.EqualTo(720f).Within(.01f)); Assert.That(world.CurrentGlobalWaveId, Is.EqualTo(27), "The 720-second saved-scene timeline should have supplied wave 27."); Assert.That(world.ArtifactDropStates.Count, Is.EqualTo(3), "The long-run profile owns three scheduled artifact chests."); Assert.That(world.ArtifactDropStates.All(drop => drop.Status == Defense25DArtifactDropStatus.Available), Is.True, "All three chest ledger entries should be available by the 720-second boss event."); Defense25DEnemy finalBoss = world.Enemies.FirstOrDefault(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss && enemy.IsAvailable); Assert.That(finalBoss, Is.Not.Null); Defense25DEnemy[] residualEnemies = world.Enemies .Where(enemy => enemy != finalBoss && enemy.IsAvailable).ToArray(); Assert.That(residualEnemies, Is.Not.Empty, "At least one pre-boss enemy remains so the clearing phase is exercised separately."); world.ResolveEnemy(finalBoss, DefenseEnemyResolution.Death); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Clearing)); Assert.That(residualEnemies.Any(enemy => enemy.IsAvailable), Is.True, "Final-boss death starts clearing while residual enemies remain."); foreach (Defense25DEnemy residual in residualEnemies) if (residual != null && residual.IsAvailable) world.ResolveEnemy(residual, DefenseEnemyResolution.Death); Defense25DProgression progression = world.Progression; hud.RefreshState(); bool hudChoiceModalVisible = hud.GetComponentsInChildren(true) .Any(transform => transform.name == "Run Choice Modal" && transform.gameObject.activeSelf); string missionStates = string.Join(";", world.PersonalMissionStates.Select(state => $"{state.MissionId}:{state.Status}:{state.MissionInstanceId}:{state.RewardToken}")); TestContext.WriteLine( $"Clear gates after boss/residual commands: RemainingEnemies={world.RemainingEnemies}; " + $"ActiveLedgerEnemies={world.Enemies.Count(enemy => enemy.IsAvailable)}; " + $"HasPendingFieldRelicReward={world.HasPendingFieldRelicReward}; " + $"FieldRelicChoices={string.Join(",", world.PendingFieldRelicChoices)}; " + $"HasPendingArtifactDropChoice={world.HasPendingArtifactDropChoice}; " + $"CanFinishRunClearPresentation={world.CanFinishRunClearPresentation}; " + $"RewardQueuePending={progression?.RewardChoices?.PendingRewardCount ?? 0}; " + $"ProgressionLevelChoice={progression?.HasPendingLevelChoice ?? false}; " + $"CurrentChoiceCount={progression?.CurrentChoices.Count ?? 0}; HudChoiceModalVisible={hudChoiceModalVisible}; " + $"CurrentReward={progression?.CurrentRewardSource}:{progression?.CurrentRewardToken}; " + $"Missions={missionStates}; BossDefeated={!finalBoss.IsAvailable}; Phase={world.RunPhase}"); Assert.That(world.RemainingEnemies, Is.Zero); int resolvedFieldRelics = 0; while (world.HasPendingFieldRelicReward) { Assert.That(world.PendingFieldRelicChoices, Is.Not.Empty, "A pending field-relic gate must expose a real selectable choice."); Assert.That(world.ResolveFieldRelicChoice(world.PendingFieldRelicChoices[0]), Is.True, "Resolve the field-relic gate through its public choice command."); resolvedFieldRelics++; } int resolvedProgressionChoices = 0; while (progression.RewardChoices.PendingRewardCount > 0) { int pendingBefore = progression.RewardChoices.PendingRewardCount; string rewardToken = progression.CurrentRewardToken; Assert.That(progression.TryChooseModifier(0, rewardToken), Is.True, $"Resolve the current {progression.CurrentRewardSource} reward through its public choice command."); Assert.That(progression.RewardChoices.PendingRewardCount, Is.LessThan(pendingBefore), "Each selected reward must leave the actual pending queue."); resolvedProgressionChoices++; } TestContext.WriteLine( $"Resolved live clear gates through public UI commands: progression={resolvedProgressionChoices}; " + $"fieldRelics={resolvedFieldRelics}; RemainingEnemies={world.RemainingEnemies}; " + $"missionRewardPending={world.PersonalMissionStates.Any(state => state.Status == Defense25DPersonalMissionStatus.RewardPending)}"); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Victory)); Assert.That(world.RunCleared, Is.True); Assert.That(world.IsRunning, Is.False); hud.RefreshState(); Button restart = FindButton(hud, "Restart Run"); Assert.That(restart, Is.Not.Null); Assert.That(restart.interactable, Is.True, "The result-screen restart must be immediately available after Victory."); restart.onClick.Invoke(); Assert.That(hud.IsRestartConfirmationPending, Is.False, "A result-screen restart does not open the in-progress confirmation dialog."); Assert.That(world.RunGeneration, Is.EqualTo(generation + 1)); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing)); Assert.That(world.PreparationRemaining, Is.EqualTo(20f).Within(.001f)); } [UnityTest] public IEnumerator SavedSceneFinalBossArrivalProducesDefeatAndImmediateRestart() { yield return LoadSavedRunSceneForTransitionTest(); Defense25DComparison world = FindInScene(); Defense25DRunHud hud = FindInScene(); world.AutoSpawn = false; world.ReadInput = false; int generation = world.RunGeneration; world.AdvanceRunClock(world.RunDefinition.PreparationSeconds + 720f); Defense25DEnemy finalBoss = world.Enemies.FirstOrDefault(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss && enemy.IsAvailable); Assert.That(finalBoss, Is.Not.Null); world.ResolveEnemy(finalBoss, DefenseEnemyResolution.Arrival); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Defeat)); Assert.That(world.RunFailed, Is.True); Assert.That(world.IsRunning, Is.False); hud.RefreshState(); Button restart = FindButton(hud, "Restart Run"); Assert.That(restart, Is.Not.Null); Assert.That(restart.interactable, Is.True, "The result-screen restart must be immediately available after Defeat."); restart.onClick.Invoke(); Assert.That(hud.IsRestartConfirmationPending, Is.False); Assert.That(world.RunGeneration, Is.EqualTo(generation + 1)); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing)); Assert.That(world.PreparationRemaining, Is.EqualTo(20f).Within(.001f)); } [UnityTest] public IEnumerator SavedRunGroundPlaneDoesNotBlockMovementAtCapturedSeed() { const int seed = 1655779489; yield return LoadSavedRunSceneForTransitionTest(seed); Defense25DComparison world = FindInScene(); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(world.RunDefinition.PreparationSeconds); GameObject ground = loadedScene.GetRootGameObjects() .SelectMany(root => root.GetComponentsInChildren(true)) .Select(item => item.gameObject) .FirstOrDefault(item => item.name == "Defense25DRun Ground"); Assert.That(ground, Is.Not.Null); Assert.That(ground.layer, Is.EqualTo(LayerMask.NameToLayer("Default")), "The walkable floor collider must stay in the scene but outside the movement obstacle mask."); Defense25DEnemy enemy = world.Enemies.FirstOrDefault(candidate => candidate != null && candidate.IsAvailable); Assert.That(enemy, Is.Not.Null, $"Seed {seed} should open the first portal and spawn a real enemy."); Physics.SyncTransforms(); Vector2 displacement = Vector2.right * .1f; string cast = DescribeMovementCast(enemy.Actor, world.Settings, displacement); Vector2 moved = enemy.Actor.Move(displacement); TestContext.Progress.WriteLine( $"RUNSCENE_GROUND_MOVE_PROBE seed={world.CurrentRunSeed} portal={enemy.SpawnContext.PortalId} " + $"origin={enemy.Actor.Position} moved={moved} cast={cast}"); Assert.That(moved.x, Is.GreaterThan(.09f), $"A real long-run actor should move horizontally across the walkable ground. " + $"seed={world.CurrentRunSeed}, portal={enemy.SpawnContext.PortalId}, cast={cast}"); } [UnityTest] public IEnumerator SavedScenePlayerDeathKeepsLongRunEnemyMovingOnFixedFrames() { const int seed = 1655779489; yield return LoadSavedRunSceneForTransitionTest(seed); Time.timeScale = 1f; Defense25DComparison world = FindInScene(); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(world.RunDefinition.PreparationSeconds); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running)); var trackedPositions = new System.Collections.Generic.Dictionary(); foreach (Defense25DEnemy enemy in world.Enemies) if (enemy != null && enemy.IsAvailable) trackedPositions[enemy] = enemy.Actor.Position; Assert.That(trackedPositions.Count, Is.GreaterThan(0), "The saved scene should have active long-run enemies after preparation."); float gameTimeBeforeDeath = world.GameTime; float reviveDelay = GameplayConstants.Current.Defense.PlayerReviveDelaySeconds; Assert.That(world.Player.TakeDamage(world.Player.MaxHealth + 1000f, Vector2.right), Is.True); Assert.That(world.Player.IsAlive, Is.False); int ticks = 0; bool enemyMoved = false; string movementDiagnostics = string.Empty; float moveObservationEnd = gameTimeBeforeDeath + Mathf.Min(.75f, reviveDelay * .5f); while (world.GameTime < moveObservationEnd && ticks++ < 240) { yield return new WaitForFixedUpdate(); movementDiagnostics = string.Join(" | ", world.Enemies .Where(enemy => enemy != null && enemy.IsAvailable) .Select(enemy => $"{enemy.SpawnContext.Source}/portal{enemy.SpawnContext.PortalId}/" + $"wave{enemy.SpawnContext.GlobalWaveId}/spawn{enemy.SpawnContext.SpawnId}:" + $"pos={enemy.Actor.Position},lastMove={enemy.Actor.LastMove},blocked={enemy.Actor.MovementBlocked}," + $"blocker={enemy.Actor.LastMovementBlocker?.name ?? "none"},waypoint={enemy.WaypointIndex}," + $"speed={enemy.Actor.MoveSpeed:F2},stunned={enemy.IsStunned},attacking={enemy.IsAttacking}," + $"speciesAttack={enemy.MonsterCombat?.IsAttackInProgress}," + $"bodyCollider={enemy.Actor.BodyCollider.enabled}")); foreach (Defense25DEnemy enemy in world.Enemies) { if (enemy == null || !enemy.IsAvailable) continue; if (!trackedPositions.TryGetValue(enemy, out Vector2 startPosition)) { trackedPositions[enemy] = enemy.Actor.Position; continue; } if (Vector2.Distance(startPosition, enemy.Actor.Position) > .01f) { enemyMoved = true; break; } } if (enemyMoved) break; } if (enemyMoved) Debug.Log($"RUNSCENE_PLAYER_DEATH_MOVEMENT seed={world.CurrentRunSeed}, " + $"portals={string.Join(",", world.RunLayout.PortalPositions)}, " + $"activePortals={string.Join(",", world.ActiveWavePortalIds)}; {movementDiagnostics}"); Assert.That(world.Player.IsAlive, Is.False, "The player should remain dead during the route-movement observation window."); Assert.That(enemyMoved, Is.True, $"At least one active long-run enemy must move on actual fixed frames while the player is dead; " + $"seed={world.CurrentRunSeed}, layoutBounds={world.RunLayout.Bounds}, " + $"portals={string.Join(",", world.RunLayout.PortalPositions)}, " + $"activePortals={string.Join(",", world.ActiveWavePortalIds)}, " + $"paused={world.Paused}, running={world.IsRunning}, timescale={Time.timeScale}, " + $"gameTimeBefore={gameTimeBeforeDeath:F3}, gameTimeAfter={world.GameTime:F3}, " + $"fixedDelta={Time.fixedDeltaTime:F3}, trackedEnemies={trackedPositions.Count}; {movementDiagnostics}."); Assert.That(ticks, Is.LessThan(240), "The Unity fixed-frame observation should reach its game-time target."); } [UnityTest] public IEnumerator SavedScenePlayerDeathRevivesAndDefeatRejectsDelayedCallbacks() { yield return LoadSavedRunSceneForTransitionTest(); Time.timeScale = 0f; previousFixedDeltaTime = Time.fixedDeltaTime; restoreFixedDeltaTime = true; Time.fixedDeltaTime = .25f; Defense25DComparison world = FindInScene(); Defense25DRunHud hud = FindInScene(); world.AutoSpawn = false; world.ReadInput = false; world.AdvanceRunClock(world.RunDefinition.PreparationSeconds); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running)); Defense25DEnemy movingEnemy = world.Enemies.FirstOrDefault(enemy => enemy.IsAvailable); Assert.That(movingEnemy, Is.Not.Null, "The saved scene should have active long-run enemies after preparation."); float baseHealthBeforeArrival = world.BaseHealth; float reviveDelay = GameplayConstants.Current.Defense.PlayerReviveDelaySeconds; Assert.That(world.Player.TakeDamage(world.Player.MaxHealth + 1000f, Vector2.right), Is.True); Assert.That(world.Player.IsAlive, Is.False); float reviveAt = world.GameTime + reviveDelay; int ticks = 0; while (world.GameTime + Time.fixedDeltaTime < reviveAt - .001f && ticks++ < 200) world.SendMessage("FixedUpdate", SendMessageOptions.RequireReceiver); Assert.That(world.Player.IsAlive, Is.False, "The player should remain dead before the configured revive delay."); world.ResolveEnemy(movingEnemy, DefenseEnemyResolution.Arrival); Assert.That(world.BaseHealth, Is.LessThan(baseHealthBeforeArrival), "An enemy arrival must damage the base while the player is dead."); while (!world.Player.IsAlive && ticks++ < 220) world.SendMessage("FixedUpdate", SendMessageOptions.RequireReceiver); Assert.That(world.Player.IsAlive, Is.True, "The saved scene should revive the player after the configured delay."); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Running)); float invulnerabilityEnd = world.GameTime + GameplayConstants.Current.Defense.PlayerReviveInvulnerabilitySeconds + .25f; while (world.GameTime < invulnerabilityEnd && ticks++ < 240) world.SendMessage("FixedUpdate", SendMessageOptions.RequireReceiver); Assert.That(world.Player.TakeDamage(world.Player.MaxHealth + 1000f, Vector2.left), Is.True); Assert.That(world.Player.IsAlive, Is.False); float untilBoss = world.RunDefinition.FinalBossAtSeconds + 1f - world.CombatElapsed; if (untilBoss > 0f) world.AdvanceRunClock(untilBoss); Defense25DEnemy finalBoss = world.Enemies.FirstOrDefault(enemy => enemy.SpawnContext.Source == Defense25DSpawnSource.FinalBoss && enemy.IsAvailable); Defense25DEnemy delayedRewardEnemy = world.Enemies.FirstOrDefault(enemy => enemy.IsAvailable && enemy.SpawnContext.Source is Defense25DSpawnSource.GlobalElite or Defense25DSpawnSource.GlobalMidBoss); Assert.That(finalBoss, Is.Not.Null); Assert.That(delayedRewardEnemy, Is.Not.Null, "The delayed callback regression requires an unresolved elite or mid-boss reward source."); foreach (Defense25DEnemy enemy in world.Enemies .Where(enemy => enemy.IsAvailable && enemy != finalBoss && enemy != delayedRewardEnemy) .ToArray()) { if (world.BaseHealth <= 0f) break; world.ResolveEnemy(enemy, DefenseEnemyResolution.Arrival); } Assert.That(world.BaseHealth, Is.Zero, "Unresolved enemy arrivals should reduce the saved scene base to zero."); Assert.That(world.RunFailed, Is.True); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Defeat)); Assert.That(world.IsRunning, Is.False); Assert.That(world.Player.IsAlive, Is.False); int generationAtDefeat = world.RunGeneration; int killCurrencyAtDefeat = world.KillCurrency; int constructionCurrencyAtDefeat = world.TowerManager.ConstructionCurrency; bool pendingRewardAtDefeat = world.Progression != null && world.Progression.HasPendingReward; world.AdvanceRunClock(reviveDelay + 1f); world.SendMessage("FixedUpdate", SendMessageOptions.RequireReceiver); Assert.That(world.Player.IsAlive, Is.False, "A delayed revive callback must not revive the player after Defeat."); world.ResolveEnemy(delayedRewardEnemy, DefenseEnemyResolution.Death); Assert.That(world.KillCurrency, Is.EqualTo(killCurrencyAtDefeat)); Assert.That(world.TowerManager.ConstructionCurrency, Is.EqualTo(constructionCurrencyAtDefeat)); Assert.That(world.Progression == null || world.Progression.HasPendingReward == pendingRewardAtDefeat, Is.True, "A delayed boss-death callback must not enqueue a reward after Defeat."); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Defeat)); Assert.That(world.RunGeneration, Is.EqualTo(generationAtDefeat)); hud.RefreshState(); Button restart = FindButton(hud, "Restart Run"); Assert.That(restart, Is.Not.Null); restart.onClick.Invoke(); Assert.That(hud.IsRestartConfirmationPending, Is.False); Assert.That(world.RunGeneration, Is.EqualTo(generationAtDefeat + 1)); Assert.That(world.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing)); Assert.That(world.Player.IsAlive, Is.True); int postRestartKillCurrency = world.KillCurrency; int postRestartConstructionCurrency = world.TowerManager.ConstructionCurrency; world.ResolveEnemy(delayedRewardEnemy, DefenseEnemyResolution.Death); Assert.That(world.KillCurrency, Is.EqualTo(postRestartKillCurrency), "A callback from the previous generation must not change the restarted run."); Assert.That(world.TowerManager.ConstructionCurrency, Is.EqualTo(postRestartConstructionCurrency)); } private IEnumerator LoadSavedRunSceneForTransitionTest(int? fixedSeed = null) { previousTimeScale = Time.timeScale; Time.timeScale = 0f; previousActiveScene = SceneManager.GetActiveScene(); UnityEngine.Events.UnityAction beforeStart = null; if (fixedSeed.HasValue) { beforeStart = (scene, _) => { if (scene.path != ScenePath) return; Defense25DComparison comparison = scene.GetRootGameObjects() .SelectMany(root => root.GetComponentsInChildren(true)) .FirstOrDefault(); Assert.That(comparison, Is.Not.Null, "The saved run scene must contain its Comparison bootstrap."); var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; typeof(Defense25DComparison).GetField("useFixedLongRunSeed", flags) .SetValue(comparison, true); typeof(Defense25DComparison).GetField("longRunLayoutSeed", flags) .SetValue(comparison, fixedSeed.Value); }; SceneManager.sceneLoaded += beforeStart; } AsyncOperation load = EditorSceneManager.LoadSceneAsyncInPlayMode(ScenePath, new LoadSceneParameters(LoadSceneMode.Additive)); Assert.That(load, Is.Not.Null, $"Could not load saved scene {ScenePath}."); yield return load; if (beforeStart != null) SceneManager.sceneLoaded -= beforeStart; loadedScene = SceneManager.GetSceneByPath(ScenePath); Assert.That(loadedScene.IsValid() && loadedScene.isLoaded, Is.True, $"Scene did not reload from {ScenePath}."); SceneManager.SetActiveScene(loadedScene); yield return null; yield return null; } private static string DescribeMovementCast(Defense25DActor actor, Defense25DComparisonSettings settings, Vector2 displacement) { CapsuleCollider capsule = actor.BodyCollider; float cylinder = Mathf.Max(0f, capsule.height * .5f - capsule.radius); Vector3 center = Defense25DActor.OnGround(actor.Center, settings.GroundY + capsule.center.y); RaycastHit[] hits = Physics.CapsuleCastAll(center + Vector3.up * cylinder, center - Vector3.up * cylinder, capsule.radius, Defense25DActor.OnGround(displacement.normalized), displacement.magnitude + settings.MovementSkin, Defense25DComparisonSettings.TerrainMask | Defense25DComparisonSettings.ActorMask, QueryTriggerInteraction.Ignore); return string.Join("; ", hits.Select(hit => $"{hit.collider.name}:distance={hit.distance:F6},normal={hit.normal},bounds={hit.collider.bounds}")); } private static IEnumerator CaptureAtResolution(string outputDirectory, int width, int height) { Screen.SetResolution(width, height, FullScreenMode.Windowed); for (int frame = 0; frame < 60 && (Screen.width != width || Screen.height != height); frame++) yield return null; string filePath = Path.Combine(outputDirectory, $"defense25d-run-hud-{width}x{height}.png"); if (File.Exists(filePath)) File.Delete(filePath); ScreenCapture.CaptureScreenshot(filePath); for (int frame = 0; frame < 120 && (!File.Exists(filePath) || new FileInfo(filePath).Length == 0); frame++) yield return null; Assert.That(File.Exists(filePath), Is.True, $"ScreenCapture did not create {filePath}."); Texture2D capture = new(2, 2, TextureFormat.RGBA32, false); try { Assert.That(capture.LoadImage(File.ReadAllBytes(filePath)), Is.True, $"ScreenCapture output is not a readable PNG: {filePath}."); TestContext.Progress.WriteLine( $"DEFENSE25D_HUD_CAPTURE requested={width}x{height} screen={Screen.width}x{Screen.height} " + $"pixels={capture.width}x{capture.height} path={filePath}"); Assert.That(Screen.width, Is.EqualTo(width), "Screen.SetResolution did not reach the requested width."); Assert.That(Screen.height, Is.EqualTo(height), "Screen.SetResolution did not reach the requested height."); Assert.That(capture.width, Is.EqualTo(width), "Captured frame width does not match the requested resolution."); Assert.That(capture.height, Is.EqualTo(height), "Captured frame height does not match the requested resolution."); } finally { Object.Destroy(capture); } } private T FindInScene() where T : Component { return loadedScene.GetRootGameObjects() .SelectMany(root => root.GetComponentsInChildren(true)) .FirstOrDefault(); } private static Button FindButton(Defense25DRunHud hud, string objectName) { return hud.GetComponentsInChildren