feat(l0): implement blood energy economy and edge-case tests

This commit is contained in:
SepComet 2026-04-27 17:37:11 +08:00
parent 5c24481b88
commit 227c3878ae
6 changed files with 426 additions and 0 deletions

View File

@ -7,6 +7,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C3AD2FCF-F06
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nightborn.Core", "src\Nightborn.Core\Nightborn.Core.csproj", "{A1AFC50B-D6E5-4542-874A-2C911D6D3FE9}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nightborn.Core", "src\Nightborn.Core\Nightborn.Core.csproj", "{A1AFC50B-D6E5-4542-874A-2C911D6D3FE9}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{A1AFC50B-D6E5-4542-874A-2C911D6D3FE9}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{A1AFC50B-D6E5-4542-874A-2C911D6D3FE9} = {C3AD2FCF-F06D-48D2-8A84-2D8E73352FAD} {A1AFC50B-D6E5-4542-874A-2C911D6D3FE9} = {C3AD2FCF-F06D-48D2-8A84-2D8E73352FAD}
{B008EA0C-2A5E-456F-969C-1F9947084745} = {494AACD4-6553-4847-973E-DA41F0DC390B}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@ -0,0 +1,16 @@
namespace Nightborn.Core.Combat
{
/// <summary>
/// Tunable parameters for blood energy formulas.
/// Values are based on GDD1 (Blood Energy Economy).
/// </summary>
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;
}
}

View File

@ -0,0 +1,188 @@
using System;
namespace Nightborn.Core.Combat
{
/// <summary>
/// L0 blood energy resource system.
/// - Pure C# (no Unity dependency)
/// - Handles gain/spend/reset/clamp rules
/// - Exposes formula helpers defined in GDD1
/// </summary>
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<float, float>? 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;
}
/// <summary>
/// Applies same-frame gain/spend with GDD order: gains first, then spend.
/// Returns true when spend succeeds.
/// </summary>
public bool ProcessFrame(float gainAmount, float spendAmount)
{
Add(gainAmount);
return Spend(spendAmount);
}
/// <summary>
/// Recomputes max energy by skill upgrade and clamps current if needed.
/// </summary>
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;
}
}
}

View File

@ -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));
}
}

View File

@ -0,0 +1 @@
global using NUnit.Framework;

View File

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Nightborn.Core\Nightborn.Core.csproj" />
</ItemGroup>
</Project>