625 lines
34 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|