77de740b63
Enum renamed in StatIds.cs (byte values unchanged - serialized definitions/saves never re-mean); all call sites + doc mentions swept (ClassTraits, PlayerAuthoring, CharacterStatsDefinition field type, menu/UI, tests). CharacterStatsDefinition SO CLASS name kept (asset-binding risk; deferred per plan). 390 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
152 lines
7.4 KiB
C#
152 lines
7.4 KiB
C#
using NUnit.Framework;
|
|
using ProjectM.Simulation;
|
|
using Unity.Collections;
|
|
using Unity.Entities;
|
|
|
|
namespace ProjectM.Tests
|
|
{
|
|
/// <summary>
|
|
/// Plain-Entities test for <see cref="StatRecomputeSystem"/>: builds an AbilityDatabase blob singleton
|
|
/// + a player-like entity (CharacterStatsRef / socket loadout / modifier buffer / effective components),
|
|
/// ticks the SimulationSystemGroup, and asserts the effective stats equal the folded (base + modifiers)
|
|
/// values and stay stable across repeated ticks (the every-tick recompute is idempotent). The legacy
|
|
/// AbilityRef -> EffectiveAbilityStats fold is deleted (LANTERN purge); the socket fold is the ability path.
|
|
/// </summary>
|
|
public class StatRecomputeSystemTests
|
|
{
|
|
const byte AbilityPrimary = (byte)AbilityId.Primary;
|
|
const byte CharDefault = (byte)FrameKind.Default;
|
|
|
|
static BlobAssetReference<AbilityDatabaseBlob> BuildDb()
|
|
{
|
|
using var b = new BlobBuilder(Allocator.Temp);
|
|
ref var root = ref b.ConstructRoot<AbilityDatabaseBlob>();
|
|
var a = b.Allocate(ref root.Abilities, 1);
|
|
a[0] = new AbilityDefBlob
|
|
{
|
|
Id = AbilityPrimary, Damage = 20f, ProjectileSpeed = 25f, Range = 20f,
|
|
AutoTargetRange = 12f, AutoTargetConeRadians = 0.6f, CooldownTicks = 12, Name = "Primary"
|
|
};
|
|
var c = b.Allocate(ref root.Characters, 1);
|
|
c[0] = new CharacterStatsBlob
|
|
{
|
|
Id = CharDefault, MoveSpeed = 6f, TurnRateRadiansPerSec = 12.5f, MaxHealth = 100f, Name = "Default"
|
|
};
|
|
return b.CreateBlobAssetReference<AbilityDatabaseBlob>(Allocator.Persistent);
|
|
}
|
|
|
|
static (World world, Entity player) MakeWorld(out BlobAssetReference<AbilityDatabaseBlob> blob)
|
|
{
|
|
var world = new World("StatRecomputeTestWorld");
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<StatRecomputeSystem>());
|
|
group.SortSystems();
|
|
|
|
var em = world.EntityManager;
|
|
blob = BuildDb();
|
|
var dbEntity = em.CreateEntity(typeof(AbilityDatabase));
|
|
em.SetComponentData(dbEntity, new AbilityDatabase { Value = blob });
|
|
|
|
var player = em.CreateEntity(
|
|
typeof(CharacterStatsRef), typeof(StatModifier),
|
|
typeof(EffectiveCharacterStats), typeof(Simulate));
|
|
em.SetComponentData(player, new CharacterStatsRef { Id = CharDefault });
|
|
return (world, player);
|
|
}
|
|
|
|
static void AddMod(World world, Entity player, StatTarget target, ModOp op, float value)
|
|
{
|
|
var buf = world.EntityManager.GetBuffer<StatModifier>(player);
|
|
buf.Add(new StatModifier { Target = (byte)target, Op = (byte)op, Value = value });
|
|
}
|
|
|
|
[Test]
|
|
public void NoModifiers_Effective_Equals_Base()
|
|
{
|
|
var (world, player) = MakeWorld(out var blob);
|
|
try
|
|
{
|
|
world.GetExistingSystemManaged<SimulationSystemGroup>().Update();
|
|
var ec = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player);
|
|
Assert.AreEqual(6f, ec.MoveSpeed, 1e-3f);
|
|
Assert.AreEqual(12.5f, ec.TurnRateRadiansPerSec, 1e-3f);
|
|
Assert.AreEqual(100f, ec.MaxHealth, 1e-3f);
|
|
}
|
|
finally { world.Dispose(); blob.Dispose(); }
|
|
}
|
|
|
|
[Test]
|
|
public void Modifiers_Fold_Into_Effective()
|
|
{
|
|
var (world, player) = MakeWorld(out var blob);
|
|
try
|
|
{
|
|
AddMod(world, player, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.5f); // 6*1.5 = 9
|
|
AddMod(world, player, StatTarget.MaxHealth, ModOp.Flat, 30f); // 100+30 = 130
|
|
world.GetExistingSystemManaged<SimulationSystemGroup>().Update();
|
|
var ec = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player);
|
|
Assert.AreEqual(9f, ec.MoveSpeed, 1e-3f);
|
|
Assert.AreEqual(130f, ec.MaxHealth, 1e-3f);
|
|
}
|
|
finally { world.Dispose(); blob.Dispose(); }
|
|
}
|
|
|
|
[Test]
|
|
public void Recompute_Is_Idempotent_Across_Ticks()
|
|
{
|
|
var (world, player) = MakeWorld(out var blob);
|
|
try
|
|
{
|
|
AddMod(world, player, StatTarget.MaxHealth, ModOp.Flat, 10f); // 100+10 = 110
|
|
var group = world.GetExistingSystemManaged<SimulationSystemGroup>();
|
|
group.Update();
|
|
var first = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player).MaxHealth;
|
|
for (int i = 0; i < 5; i++) group.Update();
|
|
var last = world.EntityManager.GetComponentData<EffectiveCharacterStats>(player).MaxHealth;
|
|
Assert.AreEqual(110f, first, 1e-3f);
|
|
Assert.AreEqual(first, last, 1e-4f);
|
|
}
|
|
finally { world.Dispose(); blob.Dispose(); }
|
|
}
|
|
|
|
[Test]
|
|
public void SocketFold_Folds_Per_Socket_With_Uniform_Band()
|
|
{
|
|
var world = new World("SocketFoldTestWorld");
|
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<StatRecomputeSystem>());
|
|
group.SortSystems();
|
|
var em = world.EntityManager;
|
|
var blob = BuildDb();
|
|
try
|
|
{
|
|
var dbEntity = em.CreateEntity(typeof(AbilityDatabase));
|
|
em.SetComponentData(dbEntity, new AbilityDatabase { Value = blob });
|
|
|
|
// player carrying the socket loadout + per-socket effective buffer.
|
|
var player = em.CreateEntity(
|
|
typeof(CharacterStatsRef), typeof(StatModifier), typeof(EffectiveCharacterStats),
|
|
typeof(AbilitySocket), typeof(EffectiveSocketStats), typeof(Simulate));
|
|
em.SetComponentData(player, new CharacterStatsRef { Id = CharDefault });
|
|
var socketBuf = em.GetBuffer<AbilitySocket>(player);
|
|
var effBuf = em.GetBuffer<EffectiveSocketStats>(player);
|
|
for (int i = 0; i < SocketId.Count; i++) { socketBuf.Add(new AbilitySocket { SparkId = 0 }); effBuf.Add(new EffectiveSocketStats()); }
|
|
socketBuf[1] = new AbilitySocket { SparkId = AbilityPrimary }; // socket 1 holds the Spark; 0/2/3 empty
|
|
// uniform band: +5 flat Damage applies to every socket's fold (Phase-1 band semantics).
|
|
em.GetBuffer<StatModifier>(player).Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 5f });
|
|
|
|
group.Update();
|
|
|
|
var result = em.GetBuffer<EffectiveSocketStats>(player);
|
|
Assert.AreEqual(SocketId.Count, result.Length);
|
|
Assert.AreEqual(25f, result[1].Damage, 1e-3f, "socket 1: Primary base 20 + band 5");
|
|
Assert.AreEqual(25f, result[1].ProjectileSpeed, 1e-3f);
|
|
Assert.AreEqual(12, result[1].CooldownTicks);
|
|
Assert.AreEqual(0f, result[0].Damage, 1e-3f, "empty socket 0 -> zeroed default");
|
|
Assert.AreEqual(0f, result[2].Damage, 1e-3f, "empty socket 2 -> zeroed default");
|
|
}
|
|
finally { world.Dispose(); blob.Dispose(); }
|
|
}
|
|
}
|
|
}
|