diff --git a/Nightborn.L0.sln b/Nightborn.L0.sln index a1faa7c..11b2d22 100644 --- a/Nightborn.L0.sln +++ b/Nightborn.L0.sln @@ -7,6 +7,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C3AD2FCF-F06 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nightborn.Core", "src\Nightborn.Core\Nightborn.Core.csproj", "{A1AFC50B-D6E5-4542-874A-2C911D6D3FE9}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{494AACD4-6553-4847-973E-DA41F0DC390B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nightborn.Core.Tests", "tests\Nightborn.Core.Tests\Nightborn.Core.Tests.csproj", "{B008EA0C-2A5E-456F-969C-1F9947084745}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -20,8 +24,13 @@ Global {A1AFC50B-D6E5-4542-874A-2C911D6D3FE9}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1AFC50B-D6E5-4542-874A-2C911D6D3FE9}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1AFC50B-D6E5-4542-874A-2C911D6D3FE9}.Release|Any CPU.Build.0 = Release|Any CPU + {B008EA0C-2A5E-456F-969C-1F9947084745}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B008EA0C-2A5E-456F-969C-1F9947084745}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B008EA0C-2A5E-456F-969C-1F9947084745}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B008EA0C-2A5E-456F-969C-1F9947084745}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {A1AFC50B-D6E5-4542-874A-2C911D6D3FE9} = {C3AD2FCF-F06D-48D2-8A84-2D8E73352FAD} + {B008EA0C-2A5E-456F-969C-1F9947084745} = {494AACD4-6553-4847-973E-DA41F0DC390B} EndGlobalSection EndGlobal diff --git a/src/Nightborn.Core/Combat/BloodEnergyConfig.cs b/src/Nightborn.Core/Combat/BloodEnergyConfig.cs new file mode 100644 index 0000000..d59cc77 --- /dev/null +++ b/src/Nightborn.Core/Combat/BloodEnergyConfig.cs @@ -0,0 +1,16 @@ +namespace Nightborn.Core.Combat +{ + /// + /// Tunable parameters for blood energy formulas. + /// Values are based on GDD1 (Blood Energy Economy). + /// + public sealed class BloodEnergyConfig + { + public float BaseAttackGain { get; set; } = 5f; + public float BaseKillGain { get; set; } = 20f; + public float BaseHurtGain { get; set; } = 12f; + public float BaseSwitchCost { get; set; } = 40f; + public float BaseMaxEnergy { get; set; } = 100f; + public float FixedWaveBonus { get; set; } = 30f; + } +} diff --git a/src/Nightborn.Core/Combat/BloodEnergyEconomy.cs b/src/Nightborn.Core/Combat/BloodEnergyEconomy.cs new file mode 100644 index 0000000..7d2805a --- /dev/null +++ b/src/Nightborn.Core/Combat/BloodEnergyEconomy.cs @@ -0,0 +1,188 @@ +using System; + +namespace Nightborn.Core.Combat +{ + /// + /// L0 blood energy resource system. + /// - Pure C# (no Unity dependency) + /// - Handles gain/spend/reset/clamp rules + /// - Exposes formula helpers defined in GDD1 + /// + public sealed class BloodEnergyEconomy + { + private const float MinimumSwitchCost = 1f; + private const float MaxSkillDiscount = 1f; + + private readonly BloodEnergyConfig _config; + private float _current; + private float _max; + private bool _isDead; + + public BloodEnergyEconomy(BloodEnergyConfig? config = null) + { + _config = config ?? new BloodEnergyConfig(); + _max = _config.BaseMaxEnergy; + _current = 0f; + _isDead = false; + } + + public float Current => _current; + + public float Max => _max; + + public bool IsDead => _isDead; + + public event Action? OnEnergyChanged; + public event Action? OnEnergyEmpty; + public event Action? OnEnergyFull; + + public void SetDead(bool isDead) + { + _isDead = isDead; + } + + public void Reset() + { + var previous = _current; + _current = 0f; + EmitChanged(previous, _current); + } + + public void Add(float amount) + { + if (_isDead) + { + return; + } + + if (amount <= 0f) + { + return; + } + + var previous = _current; + _current = Clamp(previous + amount, 0f, _max); + EmitChanged(previous, _current); + } + + public bool CanSpend(float amount) + { + return amount > 0f && _current >= amount; + } + + public bool Spend(float amount) + { + if (!CanSpend(amount)) + { + return false; + } + + var previous = _current; + _current = Clamp(previous - amount, 0f, _max); + EmitChanged(previous, _current); + return true; + } + + /// + /// Applies same-frame gain/spend with GDD order: gains first, then spend. + /// Returns true when spend succeeds. + /// + public bool ProcessFrame(float gainAmount, float spendAmount) + { + Add(gainAmount); + return Spend(spendAmount); + } + + /// + /// Recomputes max energy by skill upgrade and clamps current if needed. + /// + public void SetUpgradeBonus(float upgradeBonus) + { + var previous = _current; + var safeUpgrade = Math.Max(0f, upgradeBonus); + _max = _config.BaseMaxEnergy * (1f + safeUpgrade); + _current = Clamp(_current, 0f, _max); + EmitChanged(previous, _current); + } + + public float CalculateAttackGain(float enemyTypeMultiplier) + { + return Math.Max(0f, _config.BaseAttackGain * enemyTypeMultiplier); + } + + public float CalculateKillGain(float enemyTypeMultiplier) + { + return Math.Max(0f, _config.BaseKillGain * enemyTypeMultiplier); + } + + public float CalculateHurtGain(float damageTaken, float maxHealth) + { + if (maxHealth <= 0f || damageTaken <= 0f) + { + return 0f; + } + + var ratio = Clamp(damageTaken / maxHealth, 0f, 1f); + return _config.BaseHurtGain * ratio; + } + + public float CalculateSwitchCost(float skillDiscount) + { + var clampedDiscount = Clamp(skillDiscount, 0f, MaxSkillDiscount); + var cost = _config.BaseSwitchCost * (1f - clampedDiscount); + return Math.Max(cost, MinimumSwitchCost); + } + + public float CalculateWaveClearBonus(int waveNumber) + { + if (waveNumber <= 0) + { + return 0f; + } + + var raw = _config.FixedWaveBonus * waveNumber; + var cap = _config.FixedWaveBonus * 2f; + return Math.Min(raw, cap); + } + + private void EmitChanged(float previous, float current) + { + if (Approximately(previous, current)) + { + return; + } + + OnEnergyChanged?.Invoke(_current, _max); + + if (_current <= 0f) + { + OnEnergyEmpty?.Invoke(); + } + + if (Approximately(_current, _max)) + { + OnEnergyFull?.Invoke(); + } + } + + private static float Clamp(float value, float min, float max) + { + if (value < min) + { + return min; + } + + if (value > max) + { + return max; + } + + return value; + } + + private static bool Approximately(float a, float b) + { + return Math.Abs(a - b) < 0.0001f; + } + } +} diff --git a/tests/Nightborn.Core.Tests/BloodEnergyEconomyTests.cs b/tests/Nightborn.Core.Tests/BloodEnergyEconomyTests.cs new file mode 100644 index 0000000..f6efb32 --- /dev/null +++ b/tests/Nightborn.Core.Tests/BloodEnergyEconomyTests.cs @@ -0,0 +1,188 @@ +using Nightborn.Core.Combat; +using NUnit.Framework; + +namespace Nightborn.Core.Tests; + +public class BloodEnergyEconomyTests +{ + [Test] + public void AttackGain_ShouldIncreaseEnergy() + { + var sut = new BloodEnergyEconomy(); + sut.Add(sut.CalculateAttackGain(1f)); + + Assert.That(sut.Current, Is.EqualTo(5f).Within(0.0001f)); + } + + [Test] + public void Spend_WhenEnoughEnergy_ShouldSucceed() + { + var sut = new BloodEnergyEconomy(); + sut.Add(50f); + + var ok = sut.Spend(40f); + + Assert.That(ok, Is.True); + Assert.That(sut.Current, Is.EqualTo(10f).Within(0.0001f)); + } + + [Test] + public void Spend_WhenInsufficientEnergy_ShouldFailWithoutSideEffects() + { + var sut = new BloodEnergyEconomy(); + sut.Add(10f); + + var ok = sut.Spend(40f); + + Assert.That(ok, Is.False); + Assert.That(sut.Current, Is.EqualTo(10f).Within(0.0001f)); + } + + [Test] + public void Add_ShouldClampToMaxAndFireFullEvent() + { + var sut = new BloodEnergyEconomy(); + var fullFired = false; + sut.OnEnergyFull += () => fullFired = true; + + sut.Add(95f); + sut.Add(10f); + + Assert.That(sut.Current, Is.EqualTo(100f).Within(0.0001f)); + Assert.That(fullFired, Is.True); + } + + [Test] + public void Reset_ShouldSetEnergyToZero() + { + var sut = new BloodEnergyEconomy(); + sut.Add(60f); + + sut.Reset(); + + Assert.That(sut.Current, Is.EqualTo(0f).Within(0.0001f)); + } + + [Test] + public void EnergyEmpty_ShouldFireWhenReachesZero() + { + var sut = new BloodEnergyEconomy(); + var emptyFired = false; + sut.OnEnergyEmpty += () => emptyFired = true; + + sut.Add(24f); + sut.Spend(24f); + + Assert.That(emptyFired, Is.True); + } + + [Test] + public void SwitchCost_ShouldRespectDiscountAndMinimumCost() + { + var sut = new BloodEnergyEconomy(); + + var discounted = sut.CalculateSwitchCost(0.4f); + var minimum = sut.CalculateSwitchCost(1.0f); + + Assert.That(discounted, Is.EqualTo(24f).Within(0.0001f)); + Assert.That(minimum, Is.EqualTo(1f).Within(0.0001f)); + } + + [Test] + public void UpgradeBonusReduction_ShouldClampCurrentToNewMax() + { + var cfg = new BloodEnergyConfig { BaseMaxEnergy = 100f }; + var sut = new BloodEnergyEconomy(cfg); + sut.Add(80f); + + cfg.BaseMaxEnergy = 50f; + sut.SetUpgradeBonus(0f); + + Assert.That(sut.Max, Is.EqualTo(50f).Within(0.0001f)); + Assert.That(sut.Current, Is.EqualTo(50f).Within(0.0001f)); + } + + [Test] + public void Add_WhenDead_ShouldBeIgnored() + { + var sut = new BloodEnergyEconomy(); + sut.SetDead(true); + + sut.Add(10f); + + Assert.That(sut.Current, Is.EqualTo(0f).Within(0.0001f)); + } + + [Test] + public void ProcessFrame_ShouldApplyGainBeforeSpend() + { + var sut = new BloodEnergyEconomy(); + sut.Add(35f); + + var spendOk = sut.ProcessFrame(gainAmount: 10f, spendAmount: 40f); + + Assert.That(spendOk, Is.True); + Assert.That(sut.Current, Is.EqualTo(5f).Within(0.0001f)); + } + + [Test] + public void ProcessFrame_WhenGainStillInsufficient_ShouldFailSpend() + { + var sut = new BloodEnergyEconomy(); + sut.Add(20f); + + var spendOk = sut.ProcessFrame(gainAmount: 5f, spendAmount: 40f); + + Assert.That(spendOk, Is.False); + Assert.That(sut.Current, Is.EqualTo(25f).Within(0.0001f)); + } + + [Test] + public void WaveClearBonus_ShouldCapAtTwoTimesFixedBonus() + { + var cfg = new BloodEnergyConfig { FixedWaveBonus = 30f }; + var sut = new BloodEnergyEconomy(cfg); + + Assert.That(sut.CalculateWaveClearBonus(1), Is.EqualTo(30f).Within(0.0001f)); + Assert.That(sut.CalculateWaveClearBonus(2), Is.EqualTo(60f).Within(0.0001f)); + Assert.That(sut.CalculateWaveClearBonus(10), Is.EqualTo(60f).Within(0.0001f)); + } + + [Test] + public void HurtGain_ShouldClampDamageRatioToOne() + { + var sut = new BloodEnergyEconomy(new BloodEnergyConfig { BaseHurtGain = 12f }); + + var gain = sut.CalculateHurtGain(damageTaken: 200f, maxHealth: 100f); + + Assert.That(gain, Is.EqualTo(12f).Within(0.0001f)); + } + + [Test] + public void Add_NonPositiveAmount_ShouldNotChangeEnergyOrFireEvent() + { + var sut = new BloodEnergyEconomy(); + var changedCount = 0; + sut.OnEnergyChanged += (_, _) => changedCount++; + + sut.Add(0f); + sut.Add(-5f); + + Assert.That(sut.Current, Is.EqualTo(0f).Within(0.0001f)); + Assert.That(changedCount, Is.EqualTo(0)); + } + + [Test] + public void Spend_NonPositiveAmount_ShouldFailAndNotChangeEnergy() + { + var sut = new BloodEnergyEconomy(); + sut.Add(10f); + + var zero = sut.Spend(0f); + var negative = sut.Spend(-1f); + + Assert.That(zero, Is.False); + Assert.That(negative, Is.False); + Assert.That(sut.Current, Is.EqualTo(10f).Within(0.0001f)); + } +} diff --git a/tests/Nightborn.Core.Tests/GlobalUsings.cs b/tests/Nightborn.Core.Tests/GlobalUsings.cs new file mode 100644 index 0000000..cefced4 --- /dev/null +++ b/tests/Nightborn.Core.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/tests/Nightborn.Core.Tests/Nightborn.Core.Tests.csproj b/tests/Nightborn.Core.Tests/Nightborn.Core.Tests.csproj new file mode 100644 index 0000000..c599966 --- /dev/null +++ b/tests/Nightborn.Core.Tests/Nightborn.Core.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + +