356 lines
14 KiB
C#
356 lines
14 KiB
C#
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<PlayerStats>();
|
||
playerObject.AddComponent<Rigidbody2D>();
|
||
ActiveArtifactController artifacts =
|
||
playerObject.AddComponent<ActiveArtifactController>();
|
||
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<PlayerStats>();
|
||
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<Rigidbody2D>();
|
||
enemyObject.AddComponent<CircleCollider2D>();
|
||
enemyObject.AddComponent<SpriteRenderer>();
|
||
EnemyController enemy = enemyObject.AddComponent<EnemyController>();
|
||
EnemyAttack enemyAttack = enemyObject.GetComponent<EnemyAttack>();
|
||
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<ActiveArtifactDefinition>();
|
||
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);
|
||
}
|
||
}
|
||
}
|