using System.Collections; using System.Reflection; using BumpCombat.Core; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using BumpCombat.UI; using NUnit.Framework; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.InputSystem.LowLevel; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace BumpCombat.PlayModeTests { public sealed class GuardSystemPlayModeTests { private Keyboard virtualKeyboard; private InputSettings.BackgroundBehavior previousBackgroundBehavior; private InputSettings.EditorInputBehaviorInPlayMode previousEditorInputBehavior; private bool previousRunInBackground; private bool inputSettingsCaptured; [SetUp] public void SetUp() { ArtifactRewardController.GrantCatalogForTests = true; RunManager.ForceProductionModeForTests = false; Time.timeScale = 1f; } [TearDown] public void TearDown() { if (virtualKeyboard != null && virtualKeyboard.added) { InputSystem.QueueStateEvent(virtualKeyboard, new KeyboardState()); InputSystem.RemoveDevice(virtualKeyboard); } virtualKeyboard = null; if (inputSettingsCaptured) { InputSystem.settings.backgroundBehavior = previousBackgroundBehavior; InputSystem.settings.editorInputBehaviorInPlayMode = previousEditorInputBehavior; Application.runInBackground = previousRunInBackground; inputSettingsCaptured = false; } ArtifactRewardController.GrantCatalogForTests = false; RunManager.ForceProductionModeForTests = false; Time.timeScale = 1f; } [UnityTest] public IEnumerator DInput_ActivatesGuardAndBlocksDamageWithoutHurtOrKnockback() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); Keyboard keyboard = BeginVirtualKeyboardInput(); PlayerHealth health = FindHealth(); float startingHealth = health.CurrentHealth; bool damageEventRaised = false; health.OnDamageTaken += _ => damageEventRaised = true; InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D)); yield return null; Assert.That(health.IsGuarding, Is.True); Assert.That(health.GuardCooldownRemaining, Is.EqualTo(10f).Within(0.05f)); Assert.That(health.GuardCooldownNormalized, Is.EqualTo(1f).Within(0.01f)); Assert.That(health.GuardReadinessNormalized, Is.EqualTo(0f).Within(0.01f)); Assert.That( health.TryTakeDamage(25f, Vector2.right, 1.1f), Is.False); Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth)); Assert.That(health.IsHurtMovementLocked, Is.False); Assert.That(health.GetComponent().IsDamageKnockbackActive, Is.False); Assert.That(damageEventRaised, Is.False); InputSystem.QueueStateEvent(keyboard, new KeyboardState()); yield return new WaitForSeconds(health.GuardDuration + 0.05f); Assert.That(health.IsGuarding, Is.False); Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True); Assert.That(health.CurrentHealth, Is.LessThan(startingHealth)); Assert.That(health.IsHurtMovementLocked, Is.True); } [UnityTest] public IEnumerator Guard_DefaultDurationRemainsActiveAtPointSixSecondsThenExpires() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); PlayerHealth health = FindHealth(); Assert.That(health.GuardDuration, Is.EqualTo(PlayerHealth.DefaultGuardDuration)); Assert.That(health.GuardDuration, Is.EqualTo(1f)); Assert.That(health.TryActivateGuard(), Is.True); yield return new WaitForSeconds(0.6f); Assert.That(health.IsGuarding, Is.True, "The default one-second guard must still be active after 0.6 seconds."); yield return new WaitForSeconds(health.GuardDuration - 0.6f + 0.05f); Assert.That(health.IsGuarding, Is.False, "The default guard must expire after its one-second duration."); } [UnityTest] public IEnumerator DInput_ActivatesBeforeDamageInTheSameInputUpdate() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); Keyboard keyboard = BeginVirtualKeyboardInput(); PlayerHealth health = FindHealth(); float startingHealth = health.CurrentHealth; InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D)); InputSystem.Update(); Assert.That( health.TryTakeDamage(25f, Vector2.right, 1.1f), Is.False, "A same-frame attack must see D's processed input edge before health.Update runs."); Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth)); Assert.That(health.IsGuarding, Is.True); Assert.That(health.IsHurtMovementLocked, Is.False); Assert.That(health.GetComponent().IsDamageKnockbackActive, Is.False); } [UnityTest] public IEnumerator GuardImpactSignal_OnlyFiresForPositiveGuardBlockedDamage() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); PlayerHealth health = FindHealth(); int guardBlockedCount = 0; int guardImpactCount = 0; Vector2 impactDirection = Vector2.zero; float artifactGaugeBefore = health.GetComponent().CurrentGauge; health.OnGuardAttackBlocked += _ => guardBlockedCount++; health.OnGuardImpact += direction => { guardImpactCount++; impactDirection = direction; }; Assert.That(health.TryActivateGuard(), Is.True); Assert.That( health.GuardSuccessCooldownRechargePercent, Is.EqualTo(PlayerHealth.DefaultGuardSuccessCooldownRechargePercent)); Assert.That(health.TryTakeDamage(0f, Vector2.left, 1.1f), Is.False); Assert.That(guardBlockedCount, Is.Zero); Assert.That(guardImpactCount, Is.Zero); Assert.That(health.GuardCooldownRemaining, Is.EqualTo(health.GuardCooldownDuration).Within(0.05f)); Assert.That( health.TryTakeDamage(25f, new Vector2(3f, 4f), 1.1f), Is.False); Assert.That(guardBlockedCount, Is.EqualTo(1)); Assert.That(guardImpactCount, Is.EqualTo(1)); Assert.That(impactDirection, Is.EqualTo(new Vector2(0.6f, 0.8f))); float cooldownAfterFirstBlock = health.GuardCooldownRemaining; Assert.That(cooldownAfterFirstBlock, Is.EqualTo(health.GuardCooldownDuration * 0.3f).Within(0.05f)); Assert.That(health.GuardReadinessNormalized, Is.EqualTo(0.7f).Within(0.01f)); Assert.That(health.TryTakeDamage(0f, Vector2.left, 1.1f), Is.False); Assert.That(guardBlockedCount, Is.EqualTo(1)); Assert.That(guardImpactCount, Is.EqualTo(1)); Assert.That(health.TryTakeDamage(25f, Vector2.left, 1.1f), Is.False); Assert.That(guardBlockedCount, Is.EqualTo(2)); Assert.That(guardImpactCount, Is.EqualTo(2)); Assert.That(health.GuardCooldownRemaining, Is.EqualTo(cooldownAfterFirstBlock).Within(0.05f), "Only the first positive-damage block in an activation recharges the guard."); Assert.That( health.GetComponent().CurrentGauge, Is.EqualTo(artifactGaugeBefore), "Guard readiness is independent from the shared artifact gauge."); health.GrantInvulnerability(health.GuardDuration + 0.5f); yield return new WaitForSeconds(health.GuardDuration + 0.05f); Assert.That(health.IsGuarding, Is.False); Assert.That(health.IsInvulnerable, Is.True); Assert.That(health.TryTakeDamage(25f, Vector2.left, 1.1f), Is.False); Assert.That(guardBlockedCount, Is.EqualTo(2)); Assert.That(guardImpactCount, Is.EqualTo(2)); } [UnityTest] public IEnumerator GuardRecharge_UsesConfiguredPercentOncePerActivationAndClampsToReady() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); PlayerHealth health = FindHealth(); SetPrivateField(health, "guardCooldownDuration", 0.4f); SetPrivateField(health, "guardSuccessCooldownRechargePercent", 25f); Assert.That(health.TryActivateGuard(), Is.True); Assert.That( health.TryTakeDamage(25f, Vector2.right, 1.1f), Is.False); Assert.That(health.GuardCooldownRemaining, Is.EqualTo(0.3f).Within(0.05f)); Assert.That(health.TryTakeDamage(25f, Vector2.left, 1.1f), Is.False); Assert.That(health.GuardCooldownRemaining, Is.EqualTo(0.3f).Within(0.05f), "Repeated blocks in one activation must not recharge again."); yield return new WaitForSeconds(health.GuardDuration + 0.05f); Assert.That(health.IsGuarding, Is.False); Assert.That(health.TryActivateGuard(), Is.True, "A new activation is available after its configured cooldown has elapsed."); SetPrivateField(health, "guardSuccessCooldownRechargePercent", 0f); Assert.That( health.TryTakeDamage(25f, Vector2.right, 1.1f), Is.False); Assert.That(health.GuardCooldownRemaining, Is.EqualTo(health.GuardCooldownDuration).Within(0.05f), "A zero-percent setting must leave the cooldown unchanged."); yield return new WaitForSeconds(health.GuardDuration + 0.05f); Assert.That(health.TryActivateGuard(), Is.True); SetPrivateField(health, "guardSuccessCooldownRechargePercent", 100f); Assert.That( health.TryTakeDamage(25f, Vector2.right, 1.1f), Is.False); Assert.That(health.GuardCooldownRemaining, Is.EqualTo(0f).Within(0.01f)); Assert.That(health.GuardReadinessNormalized, Is.EqualTo(1f).Within(0.01f)); Assert.That(health.IsGuarding, Is.True, "Cooldown recharge must not end the current guard window."); Assert.That(health.TryActivateGuard(), Is.False, "A ready cooldown must not allow a second guard during the active window."); } [UnityTest] public IEnumerator Guard_BlocksRealMeleeAndProjectileHitsDuringActiveWindow() { yield return LoadCombatPrototype(); EnsureRunStarted(); SpawnDirector director = Object.FindAnyObjectByType(); PlayerHealth health = FindHealth(); Assert.That(director, Is.Not.Null); director.enabled = false; DisableCombatActors(); Rigidbody2D playerBody = health.GetComponent(); Assert.That(playerBody, Is.Not.Null); playerBody.position = new Vector2(3f, 0f); Physics2D.SyncTransforms(); EnemyController prefab = FindCatalogPrefab(director, EnemyKind.Skeleton); Assert.That(prefab, Is.Not.Null); GameObject instance = Object.Instantiate( prefab.gameObject, new Vector2(-2f, 0f), Quaternion.identity); EnemyController enemy = instance.GetComponent(); EnemyAttack attack = instance.GetComponent(); Assert.That(enemy, Is.Not.Null); Assert.That(attack, Is.Not.Null); yield return null; enemy.enabled = false; Assert.That(health.TryActivateGuard(), Is.True); float startingHealth = health.CurrentHealth; attack.BeginWarning(Vector2.right, health.transform.position, 0.7f); attack.Activate(); playerBody.position = new Vector2(-1.5f, 0f); Physics2D.SyncTransforms(); attack.TickActive(0.05f); Assert.That(health.IsGuarding, Is.True); Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth)); Assert.That(health.IsHurtMovementLocked, Is.False); Assert.That(health.GetComponent().IsDamageKnockbackActive, Is.False); attack.EndAttack(cancelled: true); enemy.enabled = true; Collider2D playerCollider = health.GetComponent(); Assert.That(playerCollider, Is.Not.Null); Vector2 playerHitPoint = playerCollider.bounds.center; EnemyProjectile projectile = EnemyProjectile.Launch( enemy, playerHitPoint - Vector2.right * 2f, Vector2.right, 25f, 500f, 10f, 0.06f, false); int projectileFrames = 0; while (projectile != null && projectileFrames++ < 10) { yield return null; } Assert.That( projectile == null, Is.True, "The projectile should hit and resolve."); Assert.That(health.IsGuarding, Is.True); Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth)); Assert.That(health.IsHurtMovementLocked, Is.False); Assert.That(health.GetComponent().IsDamageKnockbackActive, Is.False); enemy.enabled = false; yield return new WaitForSeconds(health.GuardDuration + 0.05f); Assert.That(health.IsGuarding, Is.False); float healthBeforeUnguardedMelee = health.CurrentHealth; attack.BeginWarning(Vector2.right, health.transform.position, 0.7f); attack.Activate(); attack.TickActive(0.05f); Assert.That( health.CurrentHealth, Is.LessThan(healthBeforeUnguardedMelee), "The same melee setup must deal damage after guard expires."); attack.EndAttack(cancelled: true); Object.Destroy(instance); } [UnityTest] public IEnumerator DamageInvulnerability_ProtectsHurtRecoveryAndExpiresAfterOneSecond() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); PlayerHealth health = FindHealth(); float startingHealth = health.CurrentHealth; Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True); float damageTime = Time.time; while (Time.time < damageTime + 0.51f) { yield return null; } Assert.That(health.IsHurtMovementLocked, Is.False); Assert.That(health.IsInvulnerable, Is.True); Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.False); Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth - 1f)); while (Time.time < damageTime + health.InvulnerabilityDuration + 0.05f) { yield return null; } Assert.That(health.IsInvulnerable, Is.False); Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True); Assert.That(health.CurrentHealth, Is.EqualTo(startingHealth - 2f)); } [UnityTest] public IEnumerator DamageInvulnerability_AllowsMovementAndArtifactAfterHurtUnlock() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); Keyboard keyboard = BeginVirtualKeyboardInput(); PlayerController player = FindHealth().GetComponent(); PlayerHealth health = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); Rigidbody2D body = player.GetComponent(); Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True); while (health.IsHurtMovementLocked) { yield return null; } Vector2 positionBeforeMovement = body.position; InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.RightArrow)); yield return new WaitForFixedUpdate(); yield return new WaitForFixedUpdate(); InputSystem.QueueStateEvent(keyboard, new KeyboardState()); Assert.That(body.position.x, Is.GreaterThan(positionBeforeMovement.x)); Assert.That(health.IsInvulnerable, Is.True); Assert.That(artifacts.TryUseCurrent(false), Is.True); } [UnityTest] public IEnumerator Guard_CooldownStartsOnActivationAndRejectsReactivation() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); PlayerHealth health = FindHealth(); Assert.That(health.GuardCooldownRemaining, Is.EqualTo(0f)); Assert.That(health.TryActivateGuard(), Is.True); Assert.That(health.TryActivateGuard(), Is.False); Assert.That(health.GuardCooldownRemaining, Is.GreaterThan(9.9f)); yield return null; Assert.That(health.GuardCooldownRemaining, Is.LessThan(10f)); Assert.That(health.GuardCooldownRemaining, Is.GreaterThan(9.8f)); } [UnityTest] public IEnumerator ProductionGuard_UnlocksWithRunManagerAndBecomesReadyImmediately() { ArtifactRewardController.GrantCatalogForTests = false; yield return LoadCombatPrototype(); DisableCombatActors(); RunManager runManager = RunManager.Instance; PlayerHealth health = FindHealth(); RunTutorialController tutorials = Object.FindAnyObjectByType(); Assert.That(runManager, Is.Not.Null); Assert.That(tutorials, Is.Not.Null); Assert.That(runManager.IsTitleScreen, Is.True); Assert.That(runManager.BeginRun(RunMode.Production), Is.True); Assert.That(runManager.IsProductionRun, Is.True); Assert.That(runManager.IsGuardUnlocked, Is.False); Assert.That(health.IsGuardAvailable, Is.False); Assert.That(health.IsGuarding, Is.False); Assert.That(health.GuardReadinessNormalized, Is.EqualTo(0f).Within(0.01f)); Assert.That(health.TryActivateGuard(), Is.False); runManager.UnlockGuard(); Assert.That(runManager.IsGuardUnlocked, Is.True); Assert.That(health.IsGuardAvailable, Is.True); Assert.That(health.GuardReadinessNormalized, Is.EqualTo(1f)); Assert.That(tutorials.DebugIsVisible, Is.True); Assert.That(tutorials.DebugCurrentTutorial, Is.EqualTo(RunTutorialController.TutorialKind.Guard)); Assert.That(health.TryActivateGuard(), Is.False, "The guard tutorial modal must keep gameplay input locked until continued."); Assert.That(tutorials.DebugContinue(), Is.True); Assert.That(health.TryActivateGuard(), Is.True); Assert.That(health.GuardReadinessNormalized, Is.EqualTo(0f).Within(0.0001f)); } [UnityTest] public IEnumerator Guard_HeldInputDoesNotRepeatAfterCooldownAndRepressActivates() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); Keyboard keyboard = BeginVirtualKeyboardInput(); PlayerHealth health = FindHealth(); InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D)); yield return null; Assert.That(health.IsGuarding, Is.True); Time.timeScale = 10f; float deadline = Time.realtimeSinceStartup + 2f; while (health.GuardCooldownRemaining > 0.1f && Time.realtimeSinceStartup < deadline) { yield return null; } Assert.That(health.GuardCooldownRemaining, Is.LessThanOrEqualTo(0.1f)); Assert.That(health.IsGuarding, Is.False); Time.timeScale = 1f; while (health.GuardCooldownRemaining > 0f) { yield return null; } Assert.That(health.IsGuarding, Is.False); InputSystem.QueueStateEvent(keyboard, new KeyboardState()); yield return null; InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D)); yield return null; Assert.That(health.IsGuarding, Is.True); } [UnityTest] public IEnumerator Guard_OverlapsIndependentGrantInvulnerabilityWithoutHurtLock() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); PlayerHealth health = FindHealth(); Assert.That(health.TryActivateGuard(), Is.True); health.GrantInvulnerability(health.GuardDuration + 0.5f); Assert.That(health.IsHurtMovementLocked, Is.False); yield return new WaitForSeconds(health.GuardDuration + 0.05f); Assert.That(health.IsGuarding, Is.False); Assert.That(health.IsInvulnerable, Is.True); yield return new WaitForSeconds(0.5f); Assert.That(health.IsInvulnerable, Is.False); } [UnityTest] public IEnumerator Guard_IsUnavailableDuringHurtPauseAndAfterDeath() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); Keyboard keyboard = BeginVirtualKeyboardInput(); PlayerHealth health = FindHealth(); Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True); Assert.That(health.IsHurtMovementLocked, Is.True); Assert.That(health.TryActivateGuard(), Is.False); yield return new WaitForSeconds(health.InvulnerabilityDuration + 0.05f); Assert.That(health.IsHurtMovementLocked, Is.False); Assert.That(RunManager.Instance.TryOpenPause(), Is.True); InputSystem.QueueStateEvent(keyboard, new KeyboardState(Key.D)); yield return null; Assert.That(health.IsGuarding, Is.False); Assert.That(RunManager.Instance.ClosePause(), Is.True); InputSystem.QueueStateEvent(keyboard, new KeyboardState()); yield return null; Assert.That( health.TryTakeDamage(health.CurrentHealth + 1f, Vector2.zero, 0f), Is.True); Assert.That(health.CurrentHealth, Is.Zero); Assert.That(health.TryActivateGuard(), Is.False); } [UnityTest] public IEnumerator Guard_TimersPauseWithScaledGameTime() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); PlayerHealth health = FindHealth(); Assert.That(health.TryActivateGuard(), Is.True); Assert.That(RunManager.Instance.TryOpenPause(), Is.True); yield return new WaitForSecondsRealtime(0.7f); Assert.That(health.IsGuarding, Is.True); Assert.That(health.GuardCooldownRemaining, Is.GreaterThan(9.8f)); Assert.That(RunManager.Instance.ClosePause(), Is.True); yield return new WaitForSeconds(health.GuardDuration + 0.05f); Assert.That(health.IsGuarding, Is.False); } [UnityTest] public IEnumerator Guard_DisablingClearsActiveGuardWithoutResettingCooldown() { yield return LoadCombatPrototype(); DisableCombatActors(); EnsureRunStarted(); PlayerHealth health = FindHealth(); Assert.That(health.TryActivateGuard(), Is.True); health.enabled = false; Assert.That(health.IsGuarding, Is.False); float remainingCooldown = health.GuardCooldownRemaining; Assert.That(remainingCooldown, Is.GreaterThan(9.8f)); health.enabled = true; Assert.That(health.IsGuarding, Is.False); Assert.That(health.TryActivateGuard(), Is.False); yield return null; } private static PlayerHealth FindHealth() { PlayerHealth health = Object.FindAnyObjectByType(); Assert.That(health, Is.Not.Null); return health; } private static void SetPrivateField(object target, string fieldName, object value) { FieldInfo field = target.GetType().GetField( fieldName, BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(field, Is.Not.Null, $"Expected private field '{fieldName}'."); field.SetValue(target, value); } 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, BindingFlags.Instance | BindingFlags.NonPublic); 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; } } } return null; } private static void EnsureRunStarted() { RunManager runManager = RunManager.Instance; Assert.That(runManager, Is.Not.Null); if (runManager.IsTitleScreen) { Assert.That(runManager.BeginRun(), Is.True); } } private Keyboard BeginVirtualKeyboardInput() { inputSettingsCaptured = true; previousBackgroundBehavior = InputSystem.settings.backgroundBehavior; previousEditorInputBehavior = InputSystem.settings.editorInputBehaviorInPlayMode; previousRunInBackground = Application.runInBackground; InputSystem.settings.backgroundBehavior = InputSettings.BackgroundBehavior.IgnoreFocus; InputSystem.settings.editorInputBehaviorInPlayMode = InputSettings.EditorInputBehaviorInPlayMode.AllDeviceInputAlwaysGoesToGameView; Application.runInBackground = true; virtualKeyboard = InputSystem.AddDevice(); virtualKeyboard.MakeCurrent(); return virtualKeyboard; } private static void DisableCombatActors() { SpawnDirector director = Object.FindAnyObjectByType(); if (director != null) { director.enabled = false; } foreach (EnemyController enemy in Object.FindObjectsByType( FindObjectsSortMode.None)) { enemy.gameObject.SetActive(false); } } private static IEnumerator LoadCombatPrototype() { AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype"); while (!load.isDone) { yield return null; } yield return null; yield return null; } } }