using System.Collections; using BumpCombat.Enemies; using BumpCombat.Player; using BumpCombat.Progression; using BumpCombat.Spawning; using BumpCombat.Core; using NUnit.Framework; using UnityEngine; using UnityEngine.SceneManagement; using UnityEngine.TestTools; namespace BumpCombat.PlayModeTests { public sealed class ArtifactBalanceBuffRegressionTests { private GameObject isolationOwner; [SetUp] public void SetUp() { RunManager.ForceProductionModeForTests = false; ArtifactRewardController.GrantCatalogForTests = true; Time.timeScale = 1f; } [TearDown] public void TearDown() { if (isolationOwner != null) { Object.Destroy(isolationOwner); } RunManager.ForceProductionModeForTests = false; ArtifactRewardController.GrantCatalogForTests = false; Time.timeScale = 1f; } [UnityTest] public IEnumerator EnemyCharacterModel_ModifiersDriveRuntimeAndRemainInstanceLocal() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); if (director != null) { director.enabled = false; } EnemyController enemy = FindOrdinaryEnemy(); if (enemy == null && director != null) { director.DebugSpawnImmediate(1); yield return null; enemy = FindOrdinaryEnemy(); } Assert.That(enemy, Is.Not.Null); Assert.That(enemy.IsDead, Is.False); Assert.That(enemy.IsEventEnemy, Is.False); Assert.That(enemy.IsGroggyTierGated, Is.False); PlayerController player = Object.FindAnyObjectByType(); Assert.That(player, Is.Not.Null); PlayerStats playerStats = player.GetComponent(); EnemyModel enemyModel = enemy.GetComponent(); Assert.That(enemyModel, Is.Not.Null); float initialHealth = enemy.CurrentHealth; float initialMaximumHealth = enemy.MaximumHealth; float initialMoveSpeed = enemy.MoveSpeed; float initialAttackDamage = enemy.AttackDamage; Assert.That(initialMaximumHealth, Is.GreaterThan(0f)); Assert.That(initialHealth, Is.GreaterThan(2f)); Assert.That(initialMoveSpeed, Is.GreaterThan(0f)); Assert.That(initialAttackDamage, Is.GreaterThan(0f)); Assert.That(enemyModel.AddModifier(new StatModifier( "test.enemy.max-health", CharacterStat.MaxHealth, ModifierOperation.Increased, 0.5f)), Is.True); Assert.That(enemy.MaximumHealth, Is.EqualTo(initialMaximumHealth * 1.5f).Within(0.001f)); Assert.That(enemy.CurrentHealth, Is.EqualTo(initialHealth).Within(0.001f)); Assert.That(enemyModel.AddModifier(new StatModifier( "test.enemy.move-speed", CharacterStat.MoveSpeed, ModifierOperation.Increased, 0.25f)), Is.True); Assert.That(enemy.MoveSpeed, Is.EqualTo(initialMoveSpeed * 1.25f).Within(0.001f)); Assert.That(enemyModel.AddModifier(new StatModifier( "test.enemy.attack-damage", CharacterStat.AttackDamage, ModifierOperation.Increased, 0.25f)), Is.True); Assert.That(enemy.AttackDamage, Is.EqualTo(initialAttackDamage * 1.25f).Within(0.001f)); Assert.That(enemyModel.AddModifier(new StatModifier( "test.enemy.incoming-damage", CharacterStat.IncomingDamage, ModifierOperation.Increased, 0.25f)), Is.True); Assert.That(enemy.ApplyShock(3f, 0.2f), Is.True); Assert.That(enemy.TryTakeDamage(1.4f, Vector2.zero, 0f, false), Is.True); Assert.That(enemy.LastAppliedDamage, Is.EqualTo(2f).Within(0.001f)); Assert.That(enemy.CurrentHealth, Is.EqualTo(initialHealth - 2f).Within(0.001f)); isolationOwner = new GameObject("Second Enemy Model"); EnemyModel secondEnemyModel = isolationOwner.AddComponent(); secondEnemyModel.ConfigureDefinition(enemy.Definition); Assert.That( secondEnemyModel.GetStackCount( "test.enemy.move-speed", CharacterStat.MoveSpeed), Is.Zero); Assert.That( playerStats.GetStackCount( "test.enemy.move-speed", CharacterStat.MoveSpeed), Is.Zero); typeof(EnemyController) .GetProperty(nameof(enemy.CurrentHealth)) .GetSetMethod(true) .Invoke(enemy, new object[] { enemy.MaximumHealth }); enemy.enabled = false; Assert.That( enemyModel.RemoveModifiersFromSource("test.enemy.max-health"), Is.EqualTo(1)); Assert.That(enemy.MaximumHealth, Is.EqualTo(initialMaximumHealth).Within(0.001f)); Assert.That(enemy.CurrentHealth, Is.GreaterThan(enemy.MaximumHealth)); enemy.enabled = true; Assert.That(enemy.CurrentHealth, Is.EqualTo(initialMaximumHealth).Within(0.001f)); } [UnityTest] public IEnumerator IgniteLethalTick_UsesSceneDeathLifecycleOnce() { yield return LoadCombatPrototype(); SpawnDirector director = Object.FindAnyObjectByType(); Assert.That(director, Is.Not.Null); director.enabled = false; EnemyController enemy = Object.FindAnyObjectByType(); if (enemy == null) { director.DebugSpawnImmediate(1); yield return null; enemy = Object.FindAnyObjectByType(); } Assert.That(enemy, Is.Not.Null); int deathCount = 0; bool effectsClearedAtDeath = false; enemy.OnDied += deadEnemy => { deathCount++; effectsClearedAtDeath = deadEnemy.IsDead && !deadEnemy.IsIgnited && !deadEnemy.IsShocked && !deadEnemy.IsBumpVulnerable; }; Assert.That(enemy.ApplyShock(3f, 0.2f), Is.True); Assert.That(enemy.ApplyBumpVulnerability(3f, 0.25f), Is.True); Assert.That( enemy.ApplyIgnite(3f, 0.5f, enemy.CurrentHealth), Is.True); float timeout = 1.5f; while (deathCount == 0 && timeout > 0f) { timeout -= Time.deltaTime; yield return null; } Assert.That(deathCount, Is.EqualTo(1)); Assert.That(effectsClearedAtDeath, Is.True); float despawnDeadline = Time.realtimeSinceStartup + 3f; while (enemy != null && Time.realtimeSinceStartup < despawnDeadline) { yield return null; } Assert.That(enemy == null, Is.True); Assert.That(deathCount, Is.EqualTo(1)); } [UnityTest] public IEnumerator PulseAndCycloneBuffs_SurviveSelectionChangesWithExactValues() { yield return LoadCombatPrototype(); PlayerController player = Object.FindAnyObjectByType(); ActiveArtifactController artifacts = player.GetComponent(); PlayerStats stats = player.GetComponent(); SelectArtifact(artifacts, ActiveArtifactEffect.Pulse); Assert.That(artifacts.TryUseCurrent(false), Is.True); Assert.That( stats.Evaluate(CharacterStat.IncomingDamage, 20f), Is.EqualTo(16f).Within(0.001f)); Assert.That( stats.GetStackCount( "pulse.damage-reduction", CharacterStat.IncomingDamage), Is.EqualTo(1)); Assert.That(artifacts.SelectNext(), Is.True); Assert.That( stats.Evaluate(CharacterStat.IncomingDamage, 20f), Is.EqualTo(16f).Within(0.001f)); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); Assert.That(artifacts.TryUseCurrent(false), Is.True); Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.True); Assert.That( stats.MoveSpeed, Is.EqualTo(3.45f).Within(0.001f)); yield return new WaitForSecondsRealtime(0.45f); Assert.That(artifacts.SelectNext(), Is.True); Assert.That(artifacts.HasCycloneMoveSpeedBuff, Is.True); Assert.That( stats.MoveSpeed, Is.EqualTo(3.45f).Within(0.001f)); } [UnityTest] public IEnumerator CycloneBuff_AddsToGrowthAndPreservesGrowthAfterExpiry() { yield return LoadCombatPrototype(); PlayerController player = Object.FindAnyObjectByType(); ActiveArtifactController artifacts = player.GetComponent(); PlayerStats stats = player.GetComponent(); Assert.That( stats.AddModifier(new StatModifier( "growth.move-speed", CharacterStat.MoveSpeed, ModifierOperation.Increased, 0.1f)), Is.True); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); Assert.That(artifacts.TryUseCurrent(false), Is.True); Assert.That(stats.MoveSpeed, Is.EqualTo(3.75f).Within(0.001f)); yield return new WaitForSecondsRealtime(3.15f); Assert.That(stats.MoveSpeed, Is.EqualTo(3.3f).Within(0.001f)); Assert.That( stats.GetStackCount("growth.move-speed", CharacterStat.MoveSpeed), Is.EqualTo(1)); Assert.That( stats.GetStackCount("cyclone.move-speed", CharacterStat.MoveSpeed), Is.Zero); } [UnityTest] public IEnumerator CycloneBuff_StrongReapplicationWinsOverWeakReapplication() { yield return LoadCombatPrototype(); PlayerController player = Object.FindAnyObjectByType(); ActiveArtifactController artifacts = player.GetComponent(); PlayerStats stats = player.GetComponent(); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); Assert.That(artifacts.TryUseCurrent(true), Is.True); yield return new WaitForSecondsRealtime(0.75f); Assert.That(artifacts.IsExecutingArtifact, Is.False); Assert.That(artifacts.TryUseCurrent(false), Is.True); Assert.That(stats.MoveSpeed, Is.EqualTo(3.75f).Within(0.001f)); yield return new WaitForSecondsRealtime(3.15f); Assert.That( stats.MoveSpeed, Is.EqualTo(3.75f).Within(0.001f), "A weak reapplication must not shorten the strong buff."); yield return new WaitForSecondsRealtime(1.05f); Assert.That(stats.MoveSpeed, Is.EqualTo(3f).Within(0.001f)); } [UnityTest] public IEnumerator TemporaryBuffs_PauseTimersAndClearOnControllerDisable() { yield return LoadCombatPrototype(); PlayerController player = Object.FindAnyObjectByType(); ActiveArtifactController artifacts = player.GetComponent(); PlayerStats stats = player.GetComponent(); SelectArtifact(artifacts, ActiveArtifactEffect.Pulse); Assert.That(artifacts.TryUseCurrent(false), Is.True); Assert.That( stats.GetStackCount( "pulse.damage-reduction", CharacterStat.IncomingDamage), Is.EqualTo(1)); Time.timeScale = 0f; yield return new WaitForSecondsRealtime(0.35f); Assert.That( stats.GetStackCount( "pulse.damage-reduction", CharacterStat.IncomingDamage), Is.EqualTo(1)); Assert.That( stats.Evaluate(CharacterStat.IncomingDamage, 20f), Is.EqualTo(16f).Within(0.001f)); artifacts.enabled = false; Assert.That( stats.GetStackCount( "pulse.damage-reduction", CharacterStat.IncomingDamage), Is.Zero); Time.timeScale = 1f; } [UnityTest] public IEnumerator CycloneBuff_CancelledByDamageStillExpiresAndPreservesGrowth() { yield return LoadCombatPrototype(); PlayerController player = Object.FindAnyObjectByType(); PlayerHealth health = player.GetComponent(); ActiveArtifactController artifacts = player.GetComponent(); PlayerStats stats = player.GetComponent(); Assert.That( stats.AddModifier(new StatModifier( "growth.move-speed.cancel-test", CharacterStat.MoveSpeed, ModifierOperation.Increased, 0.1f)), Is.True); SelectArtifact(artifacts, ActiveArtifactEffect.Cyclone); Assert.That(artifacts.TryUseCurrent(false), Is.True); Assert.That(artifacts.IsExecutingArtifact, Is.True); Assert.That(health.TryTakeDamage(1f, Vector2.zero, 0f), Is.True); for (int frame = 0; frame < 30 && artifacts.IsExecutingArtifact; frame++) { yield return null; } Assert.That(artifacts.IsExecutingArtifact, Is.False); Time.timeScale = 1f; yield return new WaitForSecondsRealtime(3.2f); Assert.That( stats.MoveSpeed, Is.EqualTo(3.3f).Within(0.001f), "Cancelling CY must not orphan its three-second expiration."); Assert.That( stats.GetStackCount( "growth.move-speed.cancel-test", CharacterStat.MoveSpeed), Is.EqualTo(1)); Assert.That( stats.GetStackCount( "cyclone.move-speed", CharacterStat.MoveSpeed), Is.Zero); } private static IEnumerator LoadCombatPrototype() { AsyncOperation load = SceneManager.LoadSceneAsync("CombatPrototype"); while (!load.isDone) { yield return null; } yield return null; yield return null; yield return null; } private static EnemyController FindOrdinaryEnemy() { EnemyController[] enemies = Object.FindObjectsByType(FindObjectsSortMode.None); foreach (EnemyController enemy in enemies) { if (enemy != null && !enemy.IsDead && !enemy.IsEventEnemy && !enemy.IsGroggyTierGated) { return enemy; } } return null; } private static void SelectArtifact( ActiveArtifactController artifacts, ActiveArtifactEffect effect) { Assert.That(artifacts.OwnedArtifactCount, Is.GreaterThanOrEqualTo(2)); 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."); } } }