From 227c3878ae36e869ae0fa5e00e3a41802a84a898 Mon Sep 17 00:00:00 2001
From: SepComet <2428390463@qq.com>
Date: Mon, 27 Apr 2026 17:37:11 +0800
Subject: [PATCH] feat(l0): implement blood energy economy and edge-case tests
---
Nightborn.L0.sln | 9 +
.../Combat/BloodEnergyConfig.cs | 16 ++
.../Combat/BloodEnergyEconomy.cs | 188 ++++++++++++++++++
.../BloodEnergyEconomyTests.cs | 188 ++++++++++++++++++
tests/Nightborn.Core.Tests/GlobalUsings.cs | 1 +
.../Nightborn.Core.Tests.csproj | 24 +++
6 files changed, 426 insertions(+)
create mode 100644 src/Nightborn.Core/Combat/BloodEnergyConfig.cs
create mode 100644 src/Nightborn.Core/Combat/BloodEnergyEconomy.cs
create mode 100644 tests/Nightborn.Core.Tests/BloodEnergyEconomyTests.cs
create mode 100644 tests/Nightborn.Core.Tests/GlobalUsings.cs
create mode 100644 tests/Nightborn.Core.Tests/Nightborn.Core.Tests.csproj
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+