Initial commit: Tiny Tackle Heroes Unity project
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user