feat(l0): implement blood energy economy and edge-case tests
This commit is contained in:
parent
5c24481b88
commit
227c3878ae
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
global using NUnit.Framework;
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue