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

1092 lines
61 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Defense25DComparisonSettings>(
"Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"));
world = new GameObject("Long run runtime under test").AddComponent<Defense25DComparison>();
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<Defense25DRunDefinition>(
"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<Defense25DFacilityVisuals>();
Assert.That(visuals, Is.Not.Null, "Comparison owns the integrated facility visual lifecycle.");
Assert.That(visuals.transform.Cast<Transform>()
.Count(item => item.gameObject.activeInHierarchy && item.name.StartsWith("Facility Home Spawn")), Is.EqualTo(1));
Assert.That(visuals.transform.Cast<Transform>()
.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<Transform>()
.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<Transform>()
.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<Transform>()
.Count(item => item.gameObject.activeInHierarchy && item.name.StartsWith("Facility Home Spawn")), Is.EqualTo(1));
Assert.That(visuals.transform.Cast<Transform>()
.Count(item => item.gameObject.activeInHierarchy && item.name.StartsWith("Facility Tower Pad Spawn")), Is.EqualTo(18));
Assert.That(visuals.transform.Cast<Transform>()
.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<Defense25DRunDefinition>(
"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<BoxCollider>();
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<Defense25DSpawnRequest> 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<Defense25DEnemy> 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<Defense25DRunDefinition>(
"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<Keyboard>();
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<Defense25DRunDefinition>(
"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<Defense25DRunDefinition>(
"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<Defense25DSpawnRequest> 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<Collider2D>(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<Defense25DRunDefinition>(
"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<Defense25DSpawnRequest> 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<SpriteRenderer>();
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<Defense25DRunDefinition>(
"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<Defense25DRunDefinition>(
"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<Defense25DRunDefinition>(
"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<Defense25DComparison>();
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<Defense25DRunDefinition>(
"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<Defense25DRunDefinition>(
"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<EnemyDefinition>(
"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<Defense25DRunDefinition>(
"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<string> largeSteps = CaptureRunTimeline(useLargeStep: true);
Object.Destroy(world.gameObject);
yield return null;
world = new GameObject("Long run small-step timeline under test").AddComponent<Defense25DComparison>();
yield return null;
List<string> 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<Defense25DRunDefinition>(
"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<Defense25DSpawnRequest> 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<Defense25DRunDefinition>(
"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<string> CaptureRunTimeline(bool useLargeStep)
{
InitializeWorld();
List<string> 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<Defense25DRunDefinition>(
"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);
}
}
}