using BumpCombat.Core; using System.Reflection; using BumpCombat.Combat; using BumpCombat.Enemies; using BumpCombat.Player; using NUnit.Framework; using UnityEngine; namespace BumpCombat.Tests { public sealed class ArtifactBalanceStatusRegressionTests { private static readonly BindingFlags NonPublicInstance = BindingFlags.Instance | BindingFlags.NonPublic; private GameObject owner; [SetUp] public void SetUp() { owner = new GameObject("Artifact Balance Status Tests"); } [TearDown] public void TearDown() { Object.DestroyImmediate(owner); } [Test] public void Ignite_UsesHalfSecondFirstTickAndIncludesThreeSecondBoundary() { EnemyController enemy = CreateEnemy("Ignite Boundary Target"); SetAutoProperty(enemy, "CurrentHealth", 100f); Assert.That(enemy.ApplyIgnite(3f, 0.5f, 3f), Is.True); MethodInfo updateTimedEffects = GetUpdateTimedEffects(); for (int tick = 1; tick <= 6; tick++) { updateTimedEffects.Invoke(enemy, new object[] { 0.5f }); Assert.That( enemy.CurrentHealth, Is.EqualTo(100f - tick * 3f).Within(0.001f), $"Expected ignite tick {tick} at the {tick * 0.5f:0.0}s boundary."); } Assert.That(enemy.IsIgnited, Is.False); Assert.That(enemy.IgniteRemaining, Is.Zero); } [Test] public void Ignite_ReapplicationKeepsStrongValueAndDoesNotDelayNextTick() { EnemyController enemy = CreateEnemy("Ignite Reapply Target"); SetAutoProperty(enemy, "CurrentHealth", 100f); MethodInfo updateTimedEffects = GetUpdateTimedEffects(); Assert.That(enemy.ApplyIgnite(3f, 0.5f, 5f), Is.True); updateTimedEffects.Invoke(enemy, new object[] { 0.5f }); Assert.That(enemy.CurrentHealth, Is.EqualTo(95f).Within(0.001f)); Assert.That(enemy.ApplyIgnite(1f, 0.5f, 2f), Is.True); Assert.That(enemy.IgniteRemaining, Is.EqualTo(2.5f).Within(0.001f)); updateTimedEffects.Invoke(enemy, new object[] { 0.5f }); Assert.That( enemy.CurrentHealth, Is.EqualTo(90f).Within(0.001f), "Reapplying ignite must not move the next scheduled tick."); } [Test] public void Ignite_ShockAmplificationFloorsTheFinalTickDamage() { EnemyController enemy = CreateEnemy("Shocked Ignite Target"); SetAutoProperty(enemy, "CurrentHealth", 100f); Assert.That(enemy.ApplyShock(1f, 0.2f), Is.True); Assert.That(enemy.ApplyIgnite(0.5f, 0.5f, 11f), Is.True); GetUpdateTimedEffects().Invoke(enemy, new object[] { 0.5f }); Assert.That( enemy.CurrentHealth, Is.EqualTo(87f), "An 11-damage tick amplified by shock is floored from 13.2 to 13 at application."); } [Test] public void DamageTakenEffects_StackByMultiplicationAndOnlyNormalBumpsUseVulnerability() { EnemyController enemy = CreateEnemy("Damage Taken Effects Target"); Assert.That(enemy.ApplyShock(4f, 0.2f), Is.True); Assert.That(enemy.ApplyBumpVulnerability(3f, 0.25f), Is.True); SetAutoProperty(enemy, "CurrentHealth", 100f); Assert.That( enemy.TryTakeDamage(10f, Vector2.zero, 0f, false, false), Is.True); Assert.That(enemy.CurrentHealth, Is.EqualTo(88f).Within(0.001f)); SetAutoProperty(enemy, "CurrentHealth", 100f); Assert.That( enemy.TryTakeDamage(10f, Vector2.zero, 0f, false, true), Is.True); Assert.That(enemy.CurrentHealth, Is.EqualTo(85f).Within(0.001f)); } [Test] public void DamageTakenEffects_FloorAfterShockAndVulnerability() { EnemyController enemy = CreateEnemy("Truncated Status Target"); Assert.That(enemy.ApplyShock(4f, 0.2f), Is.True); Assert.That(enemy.ApplyBumpVulnerability(3f, 0.25f), Is.True); SetAutoProperty(enemy, "CurrentHealth", 100f); Assert.That( enemy.TryTakeDamage(13f, Vector2.zero, 0f, false, true), Is.True); Assert.That( enemy.LastReportedDamage, Is.EqualTo(19f), "13 × 1.2 × 1.25 = 19.5, with a single final floor to 19."); Assert.That(enemy.CurrentHealth, Is.EqualTo(81f)); } [Test] public void ChainLightningDirectHit_UsesZeroAndOneShockProbabilityPaths() { GameObject playerObject = new("Chain Lightning Direct Hit Source"); playerObject.transform.SetParent(owner.transform); playerObject.AddComponent(); playerObject.AddComponent(); ActiveArtifactController artifacts = playerObject.AddComponent(); typeof(ActiveArtifactController) .GetMethod("Awake", NonPublicInstance) ?.Invoke(artifacts, null); MethodInfo applyArtifactHit = typeof(ActiveArtifactController).GetMethod( "ApplyArtifactHit", NonPublicInstance); Assert.That(applyArtifactHit, Is.Not.Null); ActiveArtifactDefinition noShock = CreateChainDefinition( "chain-no-shock", 0f); EnemyController noShockTarget = CreateEnemy("No Shock Target"); SetAutoProperty(noShockTarget, "CurrentHealth", 100f); Assert.That( applyArtifactHit.Invoke( artifacts, new object[] { noShockTarget, noShock, 1f, Vector2.right, 0f, false, false, 0f, 1, }), Is.True); Assert.That(noShockTarget.IsShocked, Is.False); ActiveArtifactDefinition guaranteedShock = CreateChainDefinition( "chain-guaranteed-shock", 1f); EnemyController guaranteedTarget = CreateEnemy("Guaranteed Shock Target"); SetAutoProperty(guaranteedTarget, "CurrentHealth", 100f); Assert.That( applyArtifactHit.Invoke( artifacts, new object[] { guaranteedTarget, guaranteedShock, 11f, Vector2.right, 0f, false, false, 0f, 2, }), Is.True); Assert.That(guaranteedTarget.IsShocked, Is.True); Assert.That(guaranteedTarget.CurrentHealth, Is.EqualTo(89f).Within(0.001f)); Assert.That( applyArtifactHit.Invoke( artifacts, new object[] { guaranteedTarget, guaranteedShock, 11f, Vector2.right, 0f, false, false, 0f, 3, }), Is.True); Assert.That( guaranteedTarget.CurrentHealth, Is.EqualTo(76f).Within(0.001f), "A shock applied by a direct hit amplifies the next hit from 11 to 13 damage."); Object.DestroyImmediate(noShock); Object.DestroyImmediate(guaranteedShock); } [Test] public void TimedStatusReapplication_KeepsStrongestValuesAndCoexistsWithoutDuplicates() { EnemyController enemy = CreateEnemy("Status Reapplication Target"); Assert.That(enemy.ApplyShock(4f, 0.3f), Is.True); Assert.That(enemy.ApplyShock(1f, 0.1f), Is.True); Assert.That(enemy.ShockRemaining, Is.EqualTo(4f).Within(0.001f)); Assert.That(enemy.ShockIncrease, Is.EqualTo(0.3f).Within(0.001f)); Assert.That(enemy.ApplyBumpVulnerability(3f, 0.4f), Is.True); Assert.That(enemy.ApplyBumpVulnerability(1f, 0.25f), Is.True); Assert.That( enemy.BumpVulnerabilityRemaining, Is.EqualTo(3f).Within(0.001f)); Assert.That( enemy.BumpVulnerabilityIncrease, Is.EqualTo(0.4f).Within(0.001f)); Assert.That(enemy.IsShocked, Is.True); Assert.That(enemy.IsBumpVulnerable, Is.True); SetAutoProperty(enemy, "CurrentHealth", 100f); Assert.That( enemy.TryTakeDamage(10f, Vector2.zero, 0f, false, true), Is.True); Assert.That(enemy.LastReportedDamage, Is.EqualTo(18f)); Assert.That(enemy.CurrentHealth, Is.EqualTo(82f)); } [Test] public void Ignite_UsesFractionalDamageSnapshotAndFloorsAfterShockAtTick() { EnemyController enemy = CreateEnemy("Fractional Ignite Snapshot Target"); SetAutoProperty(enemy, "CurrentHealth", 100f); GameObject playerObject = new("Fractional Ignite Snapshot Source"); playerObject.transform.SetParent(owner.transform); PlayerStats stats = playerObject.AddComponent(); stats.AddModifier(new StatModifier( "collision-increased", CharacterStat.CollisionDamage, ModifierOperation.Increased, 0.5f)); float snapshotDamage = stats.CalculateDamage(3f, DamageTag.Collision); Assert.That(snapshotDamage, Is.EqualTo(4.5f).Within(0.001f)); Assert.That(enemy.ApplyShock(2f, 0.2f), Is.True); Assert.That(enemy.ApplyIgnite(0.5f, 0.5f, snapshotDamage), Is.True); GetUpdateTimedEffects().Invoke(enemy, new object[] { 0.5f }); Assert.That(enemy.LastReportedDamage, Is.EqualTo(5f)); Assert.That(enemy.CurrentHealth, Is.EqualTo(95f)); } [Test] public void Ignite_ConsumesZeroDamageTickWithoutReplayingItAfterShock() { EnemyController enemy = CreateEnemy("Zero Ignite Tick Target"); SetAutoProperty(enemy, "CurrentHealth", 100f); MethodInfo updateTimedEffects = GetUpdateTimedEffects(); Assert.That(enemy.ApplyIgnite(2f, 1f, 0.9f), Is.True); updateTimedEffects.Invoke(enemy, new object[] { 1f }); Assert.That(enemy.CurrentHealth, Is.EqualTo(100f)); Assert.That(enemy.ApplyShock(2f, 1f), Is.True); updateTimedEffects.Invoke(enemy, new object[] { 0.5f }); Assert.That(enemy.CurrentHealth, Is.EqualTo(100f), "The first zero-damage tick must not be replayed."); updateTimedEffects.Invoke(enemy, new object[] { 0.5f }); Assert.That(enemy.CurrentHealth, Is.EqualTo(99f), "Only the next scheduled tick is amplified and applied."); } private EnemyController CreateEnemy(string name) { GameObject enemyObject = new(name); enemyObject.transform.SetParent(owner.transform); enemyObject.AddComponent(); enemyObject.AddComponent(); enemyObject.AddComponent(); EnemyController enemy = enemyObject.AddComponent(); EnemyAttack enemyAttack = enemyObject.GetComponent(); typeof(EnemyAttack) .GetMethod("Awake", NonPublicInstance) ?.Invoke(enemyAttack, null); typeof(EnemyController) .GetMethod("Awake", NonPublicInstance) ?.Invoke(enemy, null); return enemy; } private static MethodInfo GetUpdateTimedEffects() { return typeof(EnemyController).GetMethod( "UpdateTimedEffects", NonPublicInstance); } private static ActiveArtifactDefinition CreateChainDefinition( string id, float shockChance) { ActiveArtifactDefinition definition = ScriptableObject.CreateInstance(); definition.Configure( id, id, "AR", ActiveArtifactEffect.ChainLightning, DamageTag.Collision, Color.white, Color.white, 30f, 30f, 0.8f, 1f, 1f, 2f, 2f, 0f, 0f, normalElectrocuteChance: shockChance, normalElectrocuteIncrease: 0.2f, normalElectrocuteDuration: 3f); return definition; } private static void SetAutoProperty( object target, string propertyName, object value) { target.GetType() .GetField( $"<{propertyName}>k__BackingField", NonPublicInstance) ?.SetValue(target, value); } } }