Files
tiny_tackle_heroes/Assets/_Project/Tests/PlayMode/Defense25DRunScenePlayModeTests.cs
T
2026-09-28 11:07:17 +09:00

625 lines
34 KiB
C#

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<Defense25DRunSceneProfile>();
Defense25DComparison world = FindInScene<Defense25DComparison>();
Defense25DRunHud hud = FindInScene<Defense25DRunHud>();
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<Transform>(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<Defense25DComparison>();
Defense25DRunHud hud = FindInScene<Defense25DRunHud>();
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<Defense25DComparison>();
Defense25DRunHud hud = FindInScene<Defense25DRunHud>();
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<Transform>(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<Defense25DComparison>();
Defense25DRunHud hud = FindInScene<Defense25DRunHud>();
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<Defense25DComparison>();
world.AutoSpawn = false;
world.ReadInput = false;
world.AdvanceRunClock(world.RunDefinition.PreparationSeconds);
GameObject ground = loadedScene.GetRootGameObjects()
.SelectMany(root => root.GetComponentsInChildren<Transform>(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<Defense25DComparison>();
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<Defense25DEnemy, Vector2>();
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<Defense25DComparison>();
Defense25DRunHud hud = FindInScene<Defense25DRunHud>();
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<Scene, LoadSceneMode> beforeStart = null;
if (fixedSeed.HasValue)
{
beforeStart = (scene, _) =>
{
if (scene.path != ScenePath) return;
Defense25DComparison comparison = scene.GetRootGameObjects()
.SelectMany(root => root.GetComponentsInChildren<Defense25DComparison>(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<T>() where T : Component
{
return loadedScene.GetRootGameObjects()
.SelectMany(root => root.GetComponentsInChildren<T>(true))
.FirstOrDefault();
}
private static Button FindButton(Defense25DRunHud hud, string objectName)
{
return hud.GetComponentsInChildren<Button>(true)
.FirstOrDefault(button => button.gameObject.name == objectName);
}
}
}