using System; using System.Collections; using System.Collections.Generic; using System.Reflection; using BumpCombat.Combat; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using NUnit.Framework; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace BumpCombat.PlayModeTests { public sealed class ArtifactGroggyPlayModeRegressionTests { private static readonly BindingFlags NonPublicInstance = BindingFlags.Instance | BindingFlags.NonPublic; private bool previousGrantCatalogForTests; private bool previousProductionModeForTests; private float previousTimeScale; [SetUp] public void SetUp() { previousGrantCatalogForTests = ArtifactRewardController.GrantCatalogForTests; previousProductionModeForTests = RunManager.ForceProductionModeForTests; previousTimeScale = Time.timeScale; ArtifactRewardController.GrantCatalogForTests = true; RunManager.ForceProductionModeForTests = false; Time.timeScale = 1f; } [TearDown] public void TearDown() { ArtifactRewardController.GrantCatalogForTests = previousGrantCatalogForTests; RunManager.ForceProductionModeForTests = previousProductionModeForTests; Time.timeScale = previousTimeScale; } [UnityTest] public IEnumerator BarePrefabsUseFiveSevenAndTenSecondGroggyWindows() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerHealth player = UnityEngine.Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); director.enabled = false; player.GrantInvulnerability(60f); yield return AssertDefaultDuration( director, EnemyKind.ArmoredSkeleton, RunTimedEvent.Elite, 5f, "Elite"); yield return AssertDefaultDuration( director, EnemyKind.GreatswordSkeleton, RunTimedEvent.MidBoss, 7f, "MidBoss"); yield return AssertDefaultDuration( director, EnemyKind.Necromancer, RunTimedEvent.FinalBoss, 10f, "FinalBoss"); } [UnityTest] public IEnumerator InitialShieldUsesOwnedColorsAndDevelopmentKeepsRandomAndRgbCycle() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerHealth player = UnityEngine.Object.FindAnyObjectByType(); ActiveArtifactController artifacts = player?.GetComponent(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); director.enabled = false; player.GrantInvulnerability(60f); EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, EnemyKind.ArmoredSkeleton), new Vector2(20f, 20f), Quaternion.identity); yield return WaitForReady(enemy); List ownedArtifacts = GetOwnedArtifacts(artifacts); ownedArtifacts.Clear(); ActiveArtifactDefinition redArtifact = FindCatalogArtifact(artifacts, ArtifactColor.Red); ActiveArtifactDefinition blueArtifact = FindCatalogArtifact(artifacts, ArtifactColor.Blue); Assert.That(artifacts.TryAddArtifact(redArtifact), Is.True); Assert.That(artifacts.TryAddArtifact(blueArtifact), Is.True); UnityEngine.Random.State previousRandomState = UnityEngine.Random.state; try { UnityEngine.Random.InitState(8137); int expectedIndex = UnityEngine.Random.Range(0, 2); ArtifactColor expectedColor = expectedIndex == 0 ? ArtifactColor.Red : ArtifactColor.Blue; UnityEngine.Random.InitState(8137); SetPrivateField(enemy, "initialShieldColorSelected", false); InvokePrivateNoArgs(enemy, "InitializeInitialShieldColor"); Assert.That(enemy.ShieldColor, Is.EqualTo(expectedColor)); } finally { UnityEngine.Random.state = previousRandomState; } ownedArtifacts.Clear(); SetPrivateField(enemy, "initialShieldColorSelected", false); InvokePrivateNoArgs(enemy, "InitializeInitialShieldColor"); Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Green)); SetPrivateField(enemy, "configuredGroggyDuration", 0.02f); ArtifactColor[] expectedDevelopmentCycle = { ArtifactColor.Red, ArtifactColor.Blue, ArtifactColor.Green, }; for (int i = 0; i < expectedDevelopmentCycle.Length; i++) { Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 4100 + i), Is.EqualTo(EnemyArtifactContactResult.Shielded)); yield return WaitUntilNotGroggy(enemy, 1f); Assert.That( enemy.ShieldColor, Is.EqualTo(expectedDevelopmentCycle[i])); } UnityEngine.Object.Destroy(enemy.gameObject); } [UnityTest] public IEnumerator ProductionShieldRotationSkipsUnownedColorsAndUsesNewRewards() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerHealth player = UnityEngine.Object.FindAnyObjectByType(); ActiveArtifactController artifacts = player?.GetComponent(); RunManager runManager = RunManager.Instance; Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); Assert.That(runManager, Is.Not.Null); director.enabled = false; player.GrantInvulnerability(60f); EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, EnemyKind.ArmoredSkeleton), new Vector2(20f, 20f), Quaternion.identity); yield return WaitForReady(enemy); SetPrivateField(runManager, "selectedRunMode", RunMode.Production); SetPrivateField(runManager, "hasSelectedRunMode", true); Assert.That(runManager.IsProductionRun, Is.True); List ownedArtifacts = GetOwnedArtifacts(artifacts); ownedArtifacts.Clear(); ActiveArtifactDefinition greenArtifact = FindCatalogArtifact(artifacts, ArtifactColor.Green); ActiveArtifactDefinition redArtifact = FindCatalogArtifact(artifacts, ArtifactColor.Red); ActiveArtifactDefinition blueArtifact = FindCatalogArtifact(artifacts, ArtifactColor.Blue); Assert.That(artifacts.TryAddArtifact(greenArtifact), Is.True); SetPrivateField(enemy, "initialShieldColorSelected", false); InvokePrivateNoArgs(enemy, "InitializeInitialShieldColor"); Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Green)); SetPrivateField(enemy, "configuredGroggyDuration", 0.02f); Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 4201), Is.EqualTo(EnemyArtifactContactResult.Shielded)); yield return WaitUntilNotGroggy(enemy, 1f); Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Green)); Assert.That(artifacts.TryAddArtifact(blueArtifact), Is.True); Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 4202), Is.EqualTo(EnemyArtifactContactResult.Shielded)); yield return WaitUntilNotGroggy(enemy, 1f); Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Blue)); Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 4203), Is.EqualTo(EnemyArtifactContactResult.Shielded)); yield return WaitUntilNotGroggy(enemy, 1f); Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Green)); ownedArtifacts.Clear(); Assert.That(artifacts.TryAddArtifact(greenArtifact), Is.True); SetPrivateField(enemy, "shieldColor", ArtifactColor.Green); Assert.That(artifacts.TryAddArtifact(redArtifact), Is.True); Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 4204), Is.EqualTo(EnemyArtifactContactResult.Shielded)); yield return WaitUntilNotGroggy(enemy, 1f); Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Red)); Assert.That(artifacts.TryAddArtifact(blueArtifact), Is.True); Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 4205), Is.EqualTo(EnemyArtifactContactResult.Shielded)); yield return WaitUntilNotGroggy(enemy, 1f); Assert.That(enemy.ShieldColor, Is.EqualTo(ArtifactColor.Blue)); UnityEngine.Object.Destroy(enemy.gameObject); } [UnityTest] public IEnumerator GroggyTimerPausesWithScaledTimeAndExpiresByActualState() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerHealth player = UnityEngine.Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); director.enabled = false; player.GrantInvulnerability(60f); EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, EnemyKind.ArmoredSkeleton), new Vector2(20f, 20f), Quaternion.identity); yield return WaitForReady(enemy); SetPrivateField(enemy, "configuredGroggyDuration", 0.25f); Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 101), Is.EqualTo(EnemyArtifactContactResult.Shielded)); Assert.That(enemy.IsGroggy, Is.True); float beforePause = enemy.GroggyTimeRemaining; Time.timeScale = 0f; yield return new WaitForSecondsRealtime(0.2f); Assert.That(enemy.IsGroggy, Is.True); Assert.That( enemy.GroggyTimeRemaining, Is.EqualTo(beforePause).Within(0.025f)); Time.timeScale = 1f; yield return WaitUntilNotGroggy(enemy, 2f); Assert.That(enemy.IsGroggy, Is.False); Assert.That(enemy.IsDamageInvulnerable, Is.True); Assert.That(enemy.GroggyQualifyingContactCount, Is.Zero); UnityEngine.Object.Destroy(enemy.gameObject); } [UnityTest] public IEnumerator GroggyOpeningContactCancelsAnActiveEnemyAttack() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerController player = UnityEngine.Object.FindAnyObjectByType(); PlayerHealth health = player?.GetComponent(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); Assert.That(health, Is.Not.Null); director.enabled = false; health.GrantInvulnerability(60f); EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, EnemyKind.ArmoredSkeleton), (Vector2)player.transform.position + Vector2.right * 0.75f, Quaternion.identity); yield return WaitForReady(enemy); Assert.That( enemy.TryReposition( (Vector2)player.transform.position + Vector2.right * 0.75f), Is.True); Physics2D.SyncTransforms(); float deadline = Time.realtimeSinceStartup + 3f; while (Time.realtimeSinceStartup < deadline && enemy.State != EnemyState.Warning && enemy.State != EnemyState.Active) { yield return new WaitForFixedUpdate(); } Assert.That( enemy.State, Is.EqualTo(EnemyState.Warning).Or.EqualTo(EnemyState.Active)); SetPrivateField(enemy, "configuredGroggyDuration", 0.3f); Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 102), Is.EqualTo(EnemyArtifactContactResult.Shielded)); Assert.That(enemy.IsGroggy, Is.True); Assert.That(enemy.State, Is.EqualTo(EnemyState.Chase)); Assert.That(enemy.StateTimeRemaining, Is.EqualTo(0f)); UnityEngine.Object.Destroy(enemy.gameObject); } [UnityTest] public IEnumerator PulseRingShieldContactCountsButDoesNotRepelOrStun() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerController player = UnityEngine.Object.FindAnyObjectByType(); PlayerHealth health = player?.GetComponent(); ActiveArtifactController artifacts = player?.GetComponent(); CombatFeedback feedback = UnityEngine.Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); Assert.That(health, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); Assert.That(feedback, Is.Not.Null); director.enabled = false; health.GrantInvulnerability(60f); DisableSceneEnemies(); artifacts.DebugGrantCatalogArtifacts(); SelectArtifact(artifacts, ActiveArtifactEffect.Pulse); EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, EnemyKind.ArmoredSkeleton), player.transform.position, Quaternion.identity); yield return WaitForReady(enemy); enemy.enabled = false; SetPrivateField( enemy, "shieldColor", artifacts.CurrentArtifact.ArtifactColor); Assert.That( enemy.ShieldColor, Is.EqualTo(artifacts.CurrentArtifact.ArtifactColor)); Rigidbody2D enemyBody = enemy.GetComponent(); Rigidbody2D playerBody = player.GetComponent(); Vector2 contactPosition = playerBody.position + Vector2.right * 0.8f; enemyBody.position = contactPosition; enemyBody.linearVelocity = Vector2.zero; Physics2D.SyncTransforms(); float healthBefore = enemy.CurrentHealth; Assert.That(artifacts.TryUseCurrent(false), Is.True); yield return null; AssertArtifactImpactVisual( feedback, ActiveArtifactEffect.Pulse, false); Assert.That(enemy.IsGroggy, Is.True); Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1)); Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore)); Assert.That(enemy.IsStunned, Is.False); Assert.That( enemyBody.position, Is.EqualTo(contactPosition), "A shielded PR contact must not repel the target."); UnityEngine.Object.Destroy(enemy.gameObject); } [UnityTest] public IEnumerator MatchingShieldContactsRenderAllSixNormalAndChargedImpactsWithoutDamage() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerController player = UnityEngine.Object.FindAnyObjectByType(); PlayerHealth health = player?.GetComponent(); ActiveArtifactController artifacts = player?.GetComponent(); CombatFeedback feedback = UnityEngine.Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); Assert.That(health, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); Assert.That(feedback, Is.Not.Null); director.enabled = false; health.GrantInvulnerability(60f); DisableSceneEnemies(); artifacts.DebugGrantCatalogArtifacts(); ActiveArtifactEffect[] effects = { ActiveArtifactEffect.Dash, ActiveArtifactEffect.Pulse, ActiveArtifactEffect.Phoenix, ActiveArtifactEffect.Cyclone, ActiveArtifactEffect.ThunderCrash, ActiveArtifactEffect.ChainLightning, }; int validHitEvents = 0; System.Action observeHit = result => { if (result.Attacker == player.gameObject) { validHitEvents++; } }; CombatEvents.OnValidHit += observeHit; try { foreach (ActiveArtifactEffect effect in effects) { SelectArtifact(artifacts, effect); ActiveArtifactDefinition definition = artifacts.CurrentArtifact; Assert.That(definition, Is.Not.Null); foreach (bool charged in new[] { false, true }) { EnemyController enemy = SpawnImpactEnemy( director, EnemyKind.ArmoredSkeleton, player); yield return WaitForReady(enemy); enemy.enabled = false; SetPrivateField(enemy, "shieldColor", definition.ArtifactColor); float healthBefore = enemy.CurrentHealth; Assert.That( InvokeApplyArtifactHit( artifacts, enemy, definition, charged, 1000 + (int)effect * 2 + (charged ? 1 : 0)), Is.False, "A shield contact has no HP damage to report."); Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore)); Assert.That(enemy.LastAppliedDamage, Is.EqualTo(0f)); Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1)); Assert.That(enemy.IsGroggy, Is.True); Assert.That(enemy.LastArtifactContactCounted, Is.True); Assert.That(validHitEvents, Is.Zero); AssertArtifactImpactVisual(feedback, effect, charged); // Each impact frame advances on Update; allow batch-run // frame scheduling while still bounding cleanup time. float cleanupDeadline = Time.realtimeSinceStartup + 1f; while (feedback.ActiveArtifactImpactVisualCount > 0 && Time.realtimeSinceStartup < cleanupDeadline) { yield return null; } Assert.That(feedback.ActiveArtifactImpactVisualCount, Is.Zero); UnityEngine.Object.Destroy(enemy.gameObject); yield return null; } } } finally { CombatEvents.OnValidHit -= observeHit; } } [UnityTest] public IEnumerator ShieldImpactRequiresNewMatchingContactAndRejectsWrongDuplicateAndProtectedContacts() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerController player = UnityEngine.Object.FindAnyObjectByType(); PlayerHealth health = player?.GetComponent(); ActiveArtifactController artifacts = player?.GetComponent(); CombatFeedback feedback = UnityEngine.Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); Assert.That(health, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); Assert.That(feedback, Is.Not.Null); director.enabled = false; health.GrantInvulnerability(60f); DisableSceneEnemies(); artifacts.DebugGrantCatalogArtifacts(); SelectArtifact(artifacts, ActiveArtifactEffect.Pulse); ActiveArtifactDefinition definition = artifacts.CurrentArtifact; Assert.That(definition, Is.Not.Null); EnemyController duplicateTarget = SpawnImpactEnemy( director, EnemyKind.GreatswordSkeleton, player); yield return WaitForReady(duplicateTarget); duplicateTarget.enabled = false; SetPrivateField(duplicateTarget, "shieldColor", definition.ArtifactColor); Assert.That( InvokeApplyArtifactHit( artifacts, duplicateTarget, definition, false, 2001), Is.False); Assert.That(duplicateTarget.GroggyQualifyingContactCount, Is.EqualTo(1)); Assert.That(duplicateTarget.LastArtifactContactCounted, Is.True); AssertArtifactImpactVisual(feedback, definition.Effect, false); yield return new WaitForSecondsRealtime(0.2f); Assert.That(feedback.ActiveArtifactImpactVisualCount, Is.Zero); Assert.That( InvokeApplyArtifactHit( artifacts, duplicateTarget, definition, false, 2001), Is.False, "The same cast must remain suppressed after the first shield contact."); Assert.That(duplicateTarget.GroggyQualifyingContactCount, Is.EqualTo(1)); Assert.That(duplicateTarget.LastArtifactContactCounted, Is.False); Assert.That(feedback.ActiveArtifactImpactVisualCount, Is.Zero); EnemyController wrongColorTarget = SpawnImpactEnemy( director, EnemyKind.GreatswordSkeleton, player); yield return WaitForReady(wrongColorTarget); wrongColorTarget.enabled = false; SetPrivateField( wrongColorTarget, "shieldColor", definition.ArtifactColor == ArtifactColor.Green ? ArtifactColor.Red : ArtifactColor.Green); Assert.That( InvokeApplyArtifactHit( artifacts, wrongColorTarget, definition, false, 2002), Is.False); Assert.That(wrongColorTarget.GroggyQualifyingContactCount, Is.Zero); Assert.That(wrongColorTarget.LastArtifactContactCounted, Is.False); Assert.That(feedback.ActiveArtifactImpactVisualCount, Is.Zero); EnemyController protectedTarget = SpawnImpactEnemy( director, EnemyKind.Necromancer, player); yield return WaitForReady(protectedTarget); protectedTarget.enabled = false; SetPrivateField(protectedTarget, "shieldColor", definition.ArtifactColor); NecromancerBossController protection = protectedTarget.GetComponent(); Assert.That(protection, Is.Not.Null); SetPrivateField(protection, "desperationPending", true); Assert.That(protectedTarget.IsDamageInvulnerable, Is.True); Assert.That( InvokeApplyArtifactHit( artifacts, protectedTarget, definition, false, 2003), Is.False); Assert.That(protectedTarget.GroggyQualifyingContactCount, Is.Zero); Assert.That(protectedTarget.LastArtifactContactCounted, Is.False); Assert.That(feedback.ActiveArtifactImpactVisualCount, Is.Zero); UnityEngine.Object.Destroy(duplicateTarget.gameObject); UnityEngine.Object.Destroy(wrongColorTarget.gameObject); UnityEngine.Object.Destroy(protectedTarget.gameObject); yield return null; } [UnityTest] public IEnumerator MatchingShieldDashContactsRenderNormalAndChargedImpactsWithoutBumpEvent() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerController player = UnityEngine.Object.FindAnyObjectByType(); PlayerHealth health = player?.GetComponent(); ActiveArtifactController artifacts = player?.GetComponent(); BumpCombatResolver resolver = player?.GetComponent(); CombatFeedback feedback = UnityEngine.Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); Assert.That(health, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); Assert.That(resolver, Is.Not.Null); Assert.That(feedback, Is.Not.Null); director.enabled = false; health.GrantInvulnerability(60f); DisableSceneEnemies(); artifacts.DebugGrantCatalogArtifacts(); SelectArtifact(artifacts, ActiveArtifactEffect.Dash); ActiveArtifactDefinition definition = artifacts.CurrentArtifact; Assert.That(definition, Is.Not.Null); EnemyController enemy = SpawnImpactEnemy( director, EnemyKind.GreatswordSkeleton, player); yield return WaitForReady(enemy); enemy.enabled = false; SetPrivateField(enemy, "shieldColor", definition.ArtifactColor); float healthBefore = enemy.CurrentHealth; int validHitEvents = 0; System.Action observeHit = result => validHitEvents++; CombatEvents.OnValidHit += observeHit; try { Assert.That( InvokeResolveEnemy( resolver, enemy, player, definition, false, 3001), Is.False); Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore)); Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(1)); AssertArtifactImpactVisual( feedback, definition.Effect, false); Assert.That(validHitEvents, Is.Zero); yield return new WaitForSecondsRealtime(0.2f); Assert.That(feedback.ActiveArtifactImpactVisualCount, Is.Zero); Assert.That( InvokeResolveEnemy( resolver, enemy, player, definition, true, 3002), Is.False); Assert.That(enemy.CurrentHealth, Is.EqualTo(healthBefore)); Assert.That(enemy.GroggyQualifyingContactCount, Is.EqualTo(2)); Assert.That(enemy.IsGroggy, Is.True); AssertArtifactImpactVisual( feedback, definition.Effect, true); Assert.That(validHitEvents, Is.Zero); } finally { CombatEvents.OnValidHit -= observeHit; UnityEngine.Object.Destroy(enemy.gameObject); } yield return null; } [UnityTest] public IEnumerator ActiveArtifactController_AllSixEffectsRegisterOneCastContact() { yield return LoadCombatScene(); SpawnDirector director = UnityEngine.Object.FindAnyObjectByType(); PlayerController player = UnityEngine.Object.FindAnyObjectByType(); PlayerHealth health = player?.GetComponent(); ActiveArtifactController artifacts = player?.GetComponent(); DashController dash = player?.GetComponent(); Assert.That(director, Is.Not.Null); Assert.That(player, Is.Not.Null); Assert.That(health, Is.Not.Null); Assert.That(artifacts, Is.Not.Null); director.enabled = false; health.GrantInvulnerability(60f); DisableSceneEnemies(); SetPlayerDirection(player, Vector2.right); EnemyKind targetKind = EnemyKind.GreatswordSkeleton; ActiveArtifactEffect[] effects = { ActiveArtifactEffect.Dash, ActiveArtifactEffect.Pulse, ActiveArtifactEffect.Phoenix, ActiveArtifactEffect.Cyclone, ActiveArtifactEffect.ThunderCrash, ActiveArtifactEffect.ChainLightning, }; List failures = new(); Rigidbody2D playerBody = player.GetComponent(); CircleCollider2D playerCollider = player.GetComponent(); foreach (ActiveArtifactEffect effect in effects) { artifacts.DebugGrantCatalogArtifacts(); SelectArtifact(artifacts, effect); ActiveArtifactDefinition definition = artifacts.CurrentArtifact; if (definition == null) { failures.Add($"{effect}: the catalog did not select a definition."); continue; } EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, targetKind), player.transform.position + Vector3.right, Quaternion.identity); yield return WaitForReady(enemy); enemy.enabled = false; SetPrivateField(enemy, "shieldColor", definition.ArtifactColor); Assert.That(enemy.ShieldColor, Is.EqualTo(definition.ArtifactColor)); Vector2 playerStart = playerBody.position; Rigidbody2D enemyBody = enemy.GetComponent(); Vector2 targetPosition = GetTargetPosition( effect, definition, playerStart); enemyBody.position = targetPosition; enemyBody.linearVelocity = Vector2.zero; Physics2D.SyncTransforms(); Vector2 targetStart = enemyBody.position; Bounds playerBoundsBefore = playerCollider != null ? playerCollider.bounds : default; Bounds targetBoundsBefore = GetCombinedColliderBounds(enemy); bool started = artifacts.TryUseCurrent(false); bool finished = false; bool contactObserved = false; if (started) { yield return WaitForArtifactToFinish( artifacts, dash, 2f, value => finished = value); yield return WaitForArtifactContact( enemy, 2f, value => contactObserved = value); } if (!started || !finished || !contactObserved || enemy.GroggyQualifyingContactCount != 1 || enemy.IsGroggy) { failures.Add( DescribeArtifactContact( effect, definition, started, finished, playerStart, targetPosition, targetStart, playerBody, playerCollider, playerBoundsBefore, enemy, enemyBody, targetBoundsBefore)); } UnityEngine.Object.Destroy(enemy.gameObject); yield return null; } Assert.That( failures, Is.Empty, string.Join(Environment.NewLine, failures)); } private static IEnumerator AssertDefaultDuration( SpawnDirector director, EnemyKind kind, RunTimedEvent expectedTier, float expectedDuration, string label) { EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, kind), new Vector2(20f, 20f), Quaternion.identity); yield return WaitForReady(enemy); Assert.That(enemy.GroggyTier, Is.EqualTo(expectedTier), label); switch (expectedTier) { case RunTimedEvent.Elite: Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 201), Is.EqualTo(EnemyArtifactContactResult.Shielded)); break; case RunTimedEvent.MidBoss: Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 202), Is.EqualTo(EnemyArtifactContactResult.Shielded)); Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 203), Is.EqualTo(EnemyArtifactContactResult.Shielded)); break; case RunTimedEvent.FinalBoss: Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 204), Is.EqualTo(EnemyArtifactContactResult.Shielded)); Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 205), Is.EqualTo(EnemyArtifactContactResult.Shielded)); Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 206), Is.EqualTo(EnemyArtifactContactResult.Shielded)); Assert.That( enemy.RegisterArtifactContact(enemy.ShieldColor, 207), Is.EqualTo(EnemyArtifactContactResult.Shielded)); break; } Assert.That(enemy.IsGroggy, Is.True, label); Assert.That(enemy.GroggyTimeRemaining, Is.EqualTo(expectedDuration).Within(0.05f), label); UnityEngine.Object.Destroy(enemy.gameObject); yield return null; } private static EnemyController SpawnImpactEnemy( SpawnDirector director, EnemyKind kind, PlayerController player) { EnemyController enemy = UnityEngine.Object.Instantiate( FindCatalogPrefab(director, kind), player.transform.position + Vector3.right * 2f, Quaternion.identity); return enemy; } private static bool InvokeApplyArtifactHit( ActiveArtifactController artifacts, EnemyController enemy, ActiveArtifactDefinition definition, bool charged, int castIdentity) { MethodInfo method = typeof(ActiveArtifactController).GetMethod( "ApplyArtifactHit", NonPublicInstance); Assert.That(method, Is.Not.Null); return (bool)method.Invoke( artifacts, new object[] { enemy, definition, 0f, Vector2.right, 0f, true, charged, 0f, castIdentity, }); } private static bool InvokeResolveEnemy( BumpCombatResolver resolver, EnemyController enemy, PlayerController player, ActiveArtifactDefinition definition, bool charged, int castIdentity) { MethodInfo method = typeof(BumpCombatResolver).GetMethod( "ResolveEnemy", NonPublicInstance); Assert.That(method, Is.Not.Null); return (bool)method.Invoke( resolver, new object[] { enemy, true, enemy.GroundAnchorPosition, (Vector2)player.transform.position, Vector2.right, (HitSide?)HitSide.Front, charged, (float?)0f, (float?)0f, definition.ArtifactId, definition.DamageTag, (ArtifactColor?)definition.ArtifactColor, castIdentity, (ActiveArtifactEffect?)definition.Effect, }); } private static void AssertArtifactImpactVisual( CombatFeedback feedback, ActiveArtifactEffect effect, bool charged) { string effectName = effect switch { ActiveArtifactEffect.Phoenix => "SearingRay", ActiveArtifactEffect.ChainLightning => "Arc", _ => effect.ToString(), }; string mode = charged ? "Charged" : "Normal"; GameObject visual = GameObject.Find( $"Artifact Impact {effectName} {mode}"); Assert.That(visual, Is.Not.Null); SpriteRenderer renderer = visual.GetComponent(); Assert.That(renderer, Is.Not.Null); Sprite[] expectedFrames = Resources.LoadAll( CombatFeedback.GetArtifactImpactResourcePath(effect, charged)); Array.Sort( expectedFrames, (left, right) => string.CompareOrdinal(left.name, right.name)); Assert.That( expectedFrames.Length, Is.EqualTo(CombatFeedback.ArtifactImpactFrameCount)); Assert.That(renderer.sprite, Is.SameAs(expectedFrames[0])); Assert.That(renderer.color, Is.EqualTo(Color.white)); Assert.That( visual.transform.localScale, Is.EqualTo(Vector3.one * 2f)); Assert.That(feedback.ActiveArtifactImpactVisualCount, Is.EqualTo(1)); } private static IEnumerator LoadCombatScene() { AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype"); while (!load.isDone) { yield return null; } yield return null; yield return null; yield return null; // These cases verify contacts and feedback, not mission rewards. // Mission completion can otherwise pause an artifact mid-execution. RunMissionTracker missions = UnityEngine.Object.FindAnyObjectByType(); if (missions != null) { missions.enabled = false; SetPrivateField(missions, "runHasStarted", false); } } private static IEnumerator WaitForReady(EnemyController enemy) { float deadline = Time.realtimeSinceStartup + 3f; while (Time.realtimeSinceStartup < deadline && enemy != null && (enemy.MaximumHealth <= 0f || enemy.State == EnemyState.Spawn)) { yield return null; } Assert.That(enemy, Is.Not.Null); Assert.That(enemy.MaximumHealth, Is.GreaterThan(0f)); Assert.That(enemy.State, Is.Not.EqualTo(EnemyState.Spawn)); } private static IEnumerator WaitUntilNotGroggy( EnemyController enemy, float timeout) { float deadline = Time.realtimeSinceStartup + timeout; while (Time.realtimeSinceStartup < deadline && enemy != null && enemy.IsGroggy) { yield return null; } Assert.That(enemy, Is.Not.Null); Assert.That(enemy.IsGroggy, Is.False); } private static IEnumerator WaitForArtifactToFinish( ActiveArtifactController artifacts, DashController dash, float timeout, Action completed) { float deadline = Time.realtimeSinceStartup + timeout; while (Time.realtimeSinceStartup < deadline && (artifacts.IsExecutingArtifact || (dash != null && dash.IsDashing))) { yield return null; } completed?.Invoke( !artifacts.IsExecutingArtifact && (dash == null || !dash.IsDashing)); yield return new WaitForSecondsRealtime(0.05f); } private static IEnumerator WaitForArtifactContact( EnemyController enemy, float timeout, Action completed) { float deadline = Time.realtimeSinceStartup + timeout; while (Time.realtimeSinceStartup < deadline && enemy != null && enemy.GroggyQualifyingContactCount < 1) { yield return null; } completed?.Invoke( enemy != null && enemy.GroggyQualifyingContactCount >= 1); } private static string DescribeArtifactContact( ActiveArtifactEffect effect, ActiveArtifactDefinition definition, bool started, bool finished, Vector2 playerStart, Vector2 requestedTargetPosition, Vector2 targetStart, Rigidbody2D playerBody, CircleCollider2D playerCollider, Bounds playerBoundsBefore, EnemyController enemy, Rigidbody2D enemyBody, Bounds targetBoundsBefore) { Vector2 playerEnd = playerBody != null ? playerBody.position : Vector2.zero; Bounds playerBoundsAfter = playerCollider != null ? playerCollider.bounds : default; Bounds targetBoundsAfter = GetCombinedColliderBounds(enemy); Vector2 targetEnd = enemyBody != null ? enemyBody.position : Vector2.zero; string targetBody = enemyBody == null ? "missing" : $"type={enemyBody.bodyType},simulated={enemyBody.simulated},velocity={FormatVector(enemyBody.linearVelocity)}"; return $"{effect} contact regression: started={started},finished={finished}," + $"contacts={enemy.GroggyQualifyingContactCount},groggy={enemy.IsGroggy}," + $"normalRange={definition.NormalRange:0.###}," + $"playerStart={FormatVector(playerStart)},playerEnd={FormatVector(playerEnd)}," + $"requestedTarget={FormatVector(requestedTargetPosition)}," + $"targetStart={FormatVector(targetStart)},targetEnd={FormatVector(targetEnd)}," + $"playerCollider={DescribePlayerCollider(playerCollider)}," + $"playerBoundsBefore={FormatBounds(playerBoundsBefore)}," + $"playerBoundsAfter={FormatBounds(playerBoundsAfter)}," + $"targetBoundsBefore={FormatBounds(targetBoundsBefore)}," + $"targetBoundsAfter={FormatBounds(targetBoundsAfter)}," + $"targetBody=({targetBody})"; } private static string DescribePlayerCollider(CircleCollider2D collider) { if (collider == null) { return "missing"; } Vector3 scale = collider.transform.lossyScale; return $"localRadius={collider.radius:0.###},offset={FormatVector(collider.offset)}," + $"lossyScale={FormatVector(scale)}," + $"worldRadiusX={collider.radius * Mathf.Abs(scale.x):0.###}"; } private static Bounds GetCombinedColliderBounds(EnemyController enemy) { Collider2D[] colliders = enemy != null ? enemy.GetComponentsInChildren(true) : Array.Empty(); if (colliders.Length == 0) { return default; } Bounds combined = colliders[0].bounds; for (int i = 1; i < colliders.Length; i++) { combined.Encapsulate(colliders[i].bounds); } return combined; } private static string FormatBounds(Bounds bounds) { if (bounds.size == Vector3.zero) { return "none"; } return $"center={FormatVector(bounds.center)},size={FormatVector(bounds.size)}"; } private static string FormatVector(Vector2 value) { return $"({value.x:0.###},{value.y:0.###})"; } private static string FormatVector(Vector3 value) { return $"({value.x:0.###},{value.y:0.###},{value.z:0.###})"; } private static void DisableSceneEnemies() { EnemyController[] enemies = UnityEngine.Object.FindObjectsByType( FindObjectsInactive.Exclude, FindObjectsSortMode.None); foreach (EnemyController enemy in enemies) { enemy.gameObject.SetActive(false); } } private static void SelectArtifact( ActiveArtifactController artifacts, ActiveArtifactEffect effect) { for (int i = 0; i < artifacts.OwnedArtifactCount; i++) { if (artifacts.CurrentArtifact != null && artifacts.CurrentArtifact.Effect == effect) { return; } Assert.That(artifacts.SelectNext(), Is.True); } Assert.Fail($"Artifact {effect} was not found in the debug catalog."); } private static Vector2 GetTargetPosition( ActiveArtifactEffect effect, ActiveArtifactDefinition definition, Vector2 playerPosition) { switch (effect) { case ActiveArtifactEffect.Dash: return playerPosition + Vector2.right * 0.65f; case ActiveArtifactEffect.Pulse: case ActiveArtifactEffect.Cyclone: return playerPosition + Vector2.right * 0.2f; case ActiveArtifactEffect.Phoenix: return playerPosition + Vector2.right * definition.NormalRange * 0.5f; case ActiveArtifactEffect.ThunderCrash: // Use a fixed interior contact point for this smoke test; // the exact edge is covered by the Thunder Crash range tests. return playerPosition + new Vector2(0.9f, 0.225f); case ActiveArtifactEffect.ChainLightning: return playerPosition + Vector2.right * 0.8f; default: return playerPosition + Vector2.right * 0.2f; } } private static EnemyController FindCatalogPrefab( SpawnDirector director, EnemyKind kind) { string[] fieldNames = { "enemyPrefabs", "elitePrefabs", "midBossPrefabs", "finalBossPrefabs", }; foreach (string fieldName in fieldNames) { FieldInfo field = typeof(SpawnDirector).GetField( fieldName, NonPublicInstance); EnemyController[] prefabs = field?.GetValue(director) as EnemyController[]; if (prefabs == null) { continue; } foreach (EnemyController prefab in prefabs) { if (prefab != null && prefab.Definition != null && prefab.Definition.Kind == kind) { return prefab; } } } Assert.Fail($"Could not find the {kind} catalog prefab."); return null; } private static void SetPlayerDirection( PlayerController player, Vector2 direction) { SetPrivateField(player, "k__BackingField", direction); SetPrivateField(player, "k__BackingField", direction); } private static void SetPrivateField( object target, string fieldName, object value) { FieldInfo field = target.GetType().GetField( fieldName, NonPublicInstance); Assert.That(field, Is.Not.Null, $"Missing private field {fieldName}."); field.SetValue(target, value); } private static void InvokePrivateNoArgs(object target, string methodName) { MethodInfo method = target.GetType().GetMethod( methodName, NonPublicInstance); Assert.That(method, Is.Not.Null, $"Missing private method {methodName}."); method.Invoke(target, null); } private static List GetOwnedArtifacts( ActiveArtifactController artifacts) { FieldInfo field = typeof(ActiveArtifactController).GetField( "ownedArtifacts", NonPublicInstance); Assert.That(field, Is.Not.Null); List ownedArtifacts = field.GetValue(artifacts) as List; Assert.That(ownedArtifacts, Is.Not.Null); return ownedArtifacts; } private static ActiveArtifactDefinition FindCatalogArtifact( ActiveArtifactController artifacts, ArtifactColor color) { for (int i = 0; i < artifacts.CatalogCount; i++) { ActiveArtifactDefinition definition = artifacts.GetCatalogArtifactAt(i); if (definition != null && definition.ArtifactColor == color) { return definition; } } Assert.Fail($"Could not find a {color} catalog artifact."); return null; } } }