using System.Collections; using System.Linq; using System.Reflection; using BumpCombat.Combat; using BumpCombat.Constants; using BumpCombat.Defense; using BumpCombat.Defense.Comparison; using NUnit.Framework; using UnityEditor; using UnityEngine; using UnityEngine.TestTools; using Object = UnityEngine.Object; namespace BumpCombat.Tests { public sealed class Defense25DFacilityVisualPlayModeTests { private const string ComparisonSettingsPath = "Assets/_Project/Constants/Defense25DComparison/ComparisonSettings.asset"; private const string FacilitySettingsPath = "Assets/_Project/Constants/Defense25DComparison/FacilityVisualConstants.asset"; private Defense25DComparison world; private Defense25DComparison integratedWorld; private Defense25DComparisonSettings comparisonSettings; private Defense25DRunDefinition runDefinition; private Defense25DFacilityVisualConstants facilitySettings; private Defense25DFacilityVisuals visuals; private float previousTimeScale; [UnitySetUp] public IEnumerator SetUp() { previousTimeScale = Time.timeScale; Time.timeScale = 1f; comparisonSettings = Object.Instantiate( AssetDatabase.LoadAssetAtPath(ComparisonSettingsPath)); facilitySettings = AssetDatabase.LoadAssetAtPath(FacilitySettingsPath); Assert.That(comparisonSettings, Is.Not.Null); Assert.That(facilitySettings, Is.Not.Null); world = new GameObject("Facility visual test world").AddComponent(); world.Initialize(comparisonSettings); world.AutoSpawn = false; world.ReadInput = false; visuals = new GameObject("Facility visuals under test").AddComponent(); Assert.That(visuals.Bind(world, facilitySettings, 17, out string error), Is.True, error); yield return null; } [UnityTearDown] public IEnumerator TearDown() { Time.timeScale = 1f; if (visuals != null) visuals.Clear(); if (visuals != null) Object.Destroy(visuals.gameObject); if (integratedWorld != null) Object.Destroy(integratedWorld.gameObject); if (world != null) Object.Destroy(world.gameObject); if (comparisonSettings != null) Object.Destroy(comparisonSettings); if (runDefinition != null) Object.Destroy(runDefinition); Time.timeScale = previousTimeScale; yield return null; } [UnityTest] public IEnumerator PlaybackTimingCanScaleAfterPlayAndPlayResetsToPausedClock() { const string key = "Effects/Lightning/ImpactWave"; Sprite[] frames = DefenseTowerArt.Load(key); Assert.That(frames, Is.Not.Empty); Time.timeScale = 0f; var spedUp = CreateSequence("Unscaled sequence"); spedUp.Sequence.Play(spedUp.Renderer, key, frames, false); spedUp.Sequence.SetPlaybackTiming(.5f, useUnscaledTime: true); yield return new WaitForSecondsRealtime(.22f); Assert.That(spedUp.Sequence.IsFinished, Is.True, "The 0.30 second source strip must finish in 0.15 unscaled seconds at durationScale .5."); spedUp.Sequence.Play(spedUp.Renderer, key, frames, false); yield return new WaitForSecondsRealtime(.22f); Assert.That(spedUp.Sequence.IsFinished, Is.False, "A later Play on a completed sequence must restore its default pause-aware clock and unit duration scale."); } [UnityTest] public IEnumerator SpawnGateClearAndUpgradeReplacementUseOnlyDepthTestedSpriteLayers() { Vector3 anchor = new(2f, comparisonSettings.GroundY, -3f); visuals.PlayPortalSpawn(4, anchor); visuals.PlayPortalSpawn(4, anchor); visuals.PlayPortalSpawn(5, anchor); yield return null; Assert.That(ActiveRootCount(), Is.EqualTo(2), "A portal event is once per portal ID and generation."); Assert.That(visuals.GetComponentsInChildren(true), Is.Empty, "Facility art cannot add hitboxes or gameplay physics."); SpriteRenderer[] renderers = visuals.GetComponentsInChildren(true); Assert.That(renderers, Is.Not.Empty); Assert.That(renderers.All(renderer => renderer.sharedMaterial == comparisonSettings.CutoutMaterial), Is.True, "Facility layers must use the world's existing depth-tested cutout material."); Assert.That(renderers.All(renderer => renderer.color == Color.white), Is.True, "The original sprites retain their authored palette without a tint override."); Transform portalRoot = ActiveRoots().First(root => root.name.Contains("Portal Spawn")).transform; Transform groundRing = portalRoot.Find("Portal Spawn 4 Ground"); Transform verticalStrip = portalRoot.Find("Portal Spawn 4 Vertical"); Assert.That(groundRing.position.x, Is.EqualTo(anchor.x).Within(.001f)); Assert.That(groundRing.position.z, Is.EqualTo(anchor.z).Within(.001f)); Assert.That(groundRing.position.y, Is.EqualTo(anchor.y + facilitySettings.PortalSpawn.GroundYOffset).Within(.001f)); Assert.That(verticalStrip.position.y, Is.EqualTo(anchor.y + facilitySettings.PortalSpawn.VerticalHeight).Within(.001f)); visuals.Clear(); Assert.That(ActiveRootCount(), Is.Zero); Assert.That(visuals.Bind(world, facilitySettings, 18, out string error), Is.True, error); visuals.PlayPortalSpawn(4, anchor); Assert.That(ActiveRootCount(), Is.EqualTo(1), "Clear and rebind start a fresh generation gate."); visuals.Clear(); Assert.That(visuals.Bind(world, facilitySettings, 19, out error), Is.True, error); visuals.PlayTowerUpgrade(2, 2, anchor); GameObject priorUpgrade = ActiveRoots().Single(); visuals.PlayTowerUpgrade(2, 3, anchor); GameObject replacement = ActiveRoots().Single(); Assert.That(replacement, Is.Not.SameAs(priorUpgrade), "A successful upgrade replaces that pad's previous in-flight display instead of stacking it."); Assert.That(replacement.name, Does.Contain("Tower Upgrade")); yield return null; } [UnityTest] public IEnumerator NormalEffectsPauseButTerminalHomeBurstCompletesOnUnscaledTime() { Vector3 anchor = new(1f, comparisonSettings.GroundY, 2f); Time.timeScale = 0f; visuals.PlayPortalSpawn(1, anchor); visuals.PlayHomeBurst(anchor); visuals.PlayPortalBurst(10, anchor); yield return new WaitForSecondsRealtime(.95f); Assert.That(ActiveRoots().Any(root => root.name.Contains("Portal Spawn")), Is.True, "The normal portal opening effect follows the paused game clock."); Assert.That(ActiveRoots().Any(root => root.name.Contains("Home Burst")), Is.False, "The terminal home burst finishes while gameplay is paused."); Assert.That(ActiveRoots().Any(root => root.name.Contains("Portal Burst")), Is.False, "The terminal portal burst also finishes while gameplay is paused."); Time.timeScale = 1f; yield return new WaitForSeconds(.7f); Assert.That(ActiveRoots().Any(root => root.name.Contains("Portal Spawn")), Is.False); } [UnityTest] public IEnumerator AllFacilityEventApisUseTheirAuthoredOncePerRunRules() { Assert.That(facilitySettings.PortalSpawn.TargetDurationSeconds, Is.EqualTo(.6f).Within(.001f)); Assert.That(facilitySettings.PortalBurst.TargetDurationSeconds, Is.EqualTo(.5f).Within(.001f)); Assert.That(facilitySettings.HomeSpawn.TargetDurationSeconds, Is.EqualTo(.8f).Within(.001f)); Assert.That(facilitySettings.HomeBurst.TargetDurationSeconds, Is.EqualTo(.8f).Within(.001f)); Assert.That(facilitySettings.TowerPadSpawn.TargetDurationSeconds, Is.EqualTo(.4f).Within(.001f)); Assert.That(facilitySettings.TowerUpgrade.TargetDurationSeconds, Is.EqualTo(.45f).Within(.001f)); Vector3 anchor = new(-2f, comparisonSettings.GroundY, 1f); visuals.PlayPortalBurst(3, anchor); visuals.PlayPortalBurst(3, anchor); visuals.PlayPortalBurst(4, anchor); visuals.PlayHomeSpawn(anchor); visuals.PlayHomeSpawn(anchor); visuals.PlayPadSpawn(8, anchor); visuals.PlayPadSpawn(8, anchor); yield return null; Assert.That(ActiveRootCount(), Is.EqualTo(4), "Portal bursts are once per portal, while home and pad spawn are once per run and pad ID."); Assert.That(ActiveRoots().Count(root => root.name.Contains("Portal Burst")), Is.EqualTo(2)); Assert.That(ActiveRoots().Count(root => root.name.Contains("Home Spawn")), Is.EqualTo(1)); Assert.That(ActiveRoots().Count(root => root.name.Contains("Tower Pad Spawn")), Is.EqualTo(1)); Assert.That(ActiveRoots().Single(root => root.name.Contains("Home Spawn")).transform.childCount, Is.EqualTo(2), "Home spawn combines its authored ground ring and vertical strip."); } [UnityTest] public IEnumerator ActualLongRunSetupPortalOpenTowerUpgradeAndVictoryDriveFacilityEffects() { Time.timeScale = 0f; InitializeIntegratedLongRun(); Defense25DFacilityVisuals integratedVisuals = integratedWorld.GetComponent(); Assert.That(integratedVisuals, Is.Not.Null); Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Preparing)); Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Home Spawn")), Is.EqualTo(1)); Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Tower Pad Spawn")), Is.EqualTo(18)); integratedWorld.TowerManager.AddCurrency(10000); Vector2 pad = integratedWorld.TowerManager.GetPadPosition(0); integratedWorld.Player.ResetAt(pad); Assert.That(integratedWorld.TowerManager.TryBuildTowerAtPad(0, DefenseTowerKind.Direct), Is.True); Assert.That(integratedWorld.TowerManager.TryUpgradeTowerAtPad(0, DefenseTowerBranch.None), Is.True); Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Tower Upgrade Pad 0 T2")), Is.True, "The real tower manager success path must call the bound facility visual owner."); integratedWorld.AdvanceRunClock(runDefinition.PreparationSeconds); Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Running), "The 0.8 second setup effects cannot extend preparation."); Assert.That(integratedWorld.WaveDirector.IsPortalOpen(0), Is.True); Assert.That(integratedWorld.WaveDirector.IsPortalOpen(1), Is.True); Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Portal Spawn")), Is.EqualTo(2), "The actual portal-open timeline must start each opening effect once."); integratedWorld.WaveDirector.AdvanceTo(18f, integratedWorld.SpawnScheduledEnemy); Assert.That(integratedWorld.Enemies, Is.Not.Empty, "The first real wave composition must spawn while the opening effects are still paused."); Assert.That(integratedWorld.NotifyLongRunBossActivated(), Is.True); Assert.That(integratedWorld.BeginLongRunClearing(), Is.True); foreach (Defense25DEnemy enemy in integratedWorld.Enemies.ToArray()) integratedWorld.ResolveEnemy(enemy, DefenseEnemyResolution.Death); for (int choice = 0; integratedWorld.Progression.HasPendingReward && choice < 32; choice++) { string rewardToken = integratedWorld.Progression.CurrentRewardToken; Assert.That(integratedWorld.Progression.TryChooseReward(0, rewardToken), Is.True, $"The integrated victory path must resolve pending reward {rewardToken} through its public choice API."); } Assert.That(integratedWorld.Progression.HasPendingReward, Is.False, "All queued level-up choices must be completed before the victory gate can close."); string pendingMissions = string.Join(",", integratedWorld.PersonalMissionStates .Where(state => state.Status == Defense25DPersonalMissionStatus.RewardPending) .Select(state => $"{state.MissionId}:{state.MissionInstanceId}")); Assert.That(integratedWorld.RunCleared, Is.True, $"Victory gate state: phase={integratedWorld.RunPhase}, remaining={integratedWorld.RemainingEnemies}, " + $"supplied={integratedWorld.Ledger.SuppliedCount}, resolved={integratedWorld.Ledger.ResolvedCount}, " + $"level={integratedWorld.Progression.Level}, xp={integratedWorld.Progression.CurrentExperience}/" + $"{integratedWorld.Progression.RequiredExperience}, levelChoice={integratedWorld.Progression.HasPendingLevelChoice}, " + $"rewardCount={integratedWorld.Progression.RewardChoices.PendingRewardCount}, " + $"fieldRelic={integratedWorld.HasPendingFieldRelicReward}, dropChoice={integratedWorld.HasPendingArtifactDropChoice}, " + $"missionRewards={pendingMissions}."); Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Victory)); Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Portal Burst")), Is.EqualTo(2)); Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Home Burst")), Is.False, "Victory bursts open portals but does not destroy the home."); yield return new WaitForSecondsRealtime(.7f); Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Portal Burst")), Is.False, "Victory portal bursts complete on the unscaled terminal clock."); } [UnityTest] public IEnumerator ActualBaseArrivalDefeatStartsHomeAndOpenPortalBurstsImmediately() { Time.timeScale = 0f; InitializeIntegratedLongRun(); integratedWorld.AdvanceRunClock(runDefinition.PreparationSeconds); integratedWorld.WaveDirector.AdvanceTo(18f, integratedWorld.SpawnScheduledEnemy); Assert.That(integratedWorld.Enemies, Is.Not.Empty); float arrivalDamage = GameplayConstants.Current.Defense.ArrivalBaseDamage; FieldInfo baseHealth = typeof(Defense25DComparison).GetField( "k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(baseHealth, Is.Not.Null, "The fixture sets a boundary HP to exercise one actual arrival terminal transition."); baseHealth.SetValue(integratedWorld, arrivalDamage); integratedWorld.ResolveEnemy(integratedWorld.Enemies[0], DefenseEnemyResolution.Arrival); Defense25DFacilityVisuals integratedVisuals = integratedWorld.GetComponent(); Assert.That(integratedWorld.RunFailed, Is.True); Assert.That(integratedWorld.RunPhase, Is.EqualTo(Defense25DRunPhase.Defeat)); Assert.That(integratedWorld.BaseHealth, Is.Zero); Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Home Burst")), Is.EqualTo(1)); Assert.That(ActiveRoots(integratedVisuals).Count(root => root.name.Contains("Portal Burst")), Is.EqualTo(2)); yield return new WaitForSecondsRealtime(.9f); Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Home Burst")), Is.False, "Defeat is immediate while the home burst finishes on its unscaled display clock."); Assert.That(ActiveRoots(integratedVisuals).Any(root => root.name.Contains("Portal Burst")), Is.False); } private (DefenseSpriteSequence Sequence, SpriteRenderer Renderer) CreateSequence(string name) { var gameObject = new GameObject(name); SpriteRenderer renderer = gameObject.AddComponent(); DefenseSpriteSequence sequence = gameObject.AddComponent(); return (sequence, renderer); } private int ActiveRootCount() => ActiveRoots().Length; private GameObject[] ActiveRoots() => ActiveRoots(visuals); private static GameObject[] ActiveRoots(Defense25DFacilityVisuals owner) => owner.transform.Cast() .Where(child => child.gameObject.activeInHierarchy) .Select(child => child.gameObject) .ToArray(); private void InitializeIntegratedLongRun() { runDefinition = Object.Instantiate(AssetDatabase.LoadAssetAtPath( "Assets/_Project/Constants/Defense25DComparison/LongDefenseRun.asset")); Assert.That(runDefinition, Is.Not.Null); integratedWorld = new GameObject("Facility integrated long run under test") .AddComponent(); integratedWorld.InitializeLongRun(comparisonSettings, runDefinition, 23); integratedWorld.AutoSpawn = false; integratedWorld.ReadInput = false; } } }