1092 lines
61 KiB
C#
1092 lines
61 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|