Further Tests & Progress

This commit is contained in:
2026-06-04 11:35:57 -07:00
parent 5c11ff4fad
commit 51401d2c2b
65 changed files with 2784 additions and 45 deletions
@@ -0,0 +1,151 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="BuildPlaceSystem"/> — the RPC structure-placement
/// handler. A bare world is seeded with StructureCatalog (+ a Turret entry referencing a Prefab-tagged prefab),
/// BaseAnchor, ResourceLedger (+ Ore), NetworkTime, and synthetic BuildPlaceRequest + ReceiveRpcCommandRequest
/// entities. The headline case is co-op atomicity: two same-tick requests for one cell must place EXACTLY one
/// structure and withdraw the cost ONCE (the in-place commit). Also pins cost/plot validation and request cleanup.
/// </summary>
public class BuildPlaceSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, int oreCount)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<BuildPlaceSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var nt = em.CreateEntity(typeof(NetworkTime));
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(300) });
var anchor = em.CreateEntity(typeof(BaseAnchor));
em.SetComponentData(anchor, new BaseAnchor
{
AnchorPos = new float3(5, 0, 5),
GridOrigin = new float3(0, 0, 0),
CellSize = 2f,
GridDims = new int2(5, 5),
});
// Turret prefab: LocalTransform (looked up for placement) + PlacedStructure (SetComponent target on the
// clone) + a real Prefab tag so it is excluded from the live-structure occupancy scan.
var prefab = em.CreateEntity(typeof(LocalTransform), typeof(PlacedStructure));
em.AddComponent<Prefab>(prefab);
var catalogE = em.CreateEntity(typeof(StructureCatalog));
var catalog = em.AddBuffer<StructureCatalogEntry>(catalogE);
catalog.Add(new StructureCatalogEntry
{
Type = StructureType.Turret, Prefab = prefab, CostResourceId = ResourceId.Ore, CostAmount = 10,
});
var ledgerE = em.CreateEntity(typeof(ResourceLedger));
var ledger = em.AddBuffer<StorageEntry>(ledgerE);
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = oreCount });
return (world, group);
}
static void MakeBuildRequest(EntityManager em, byte type, int cellX, int cellZ)
{
var e = em.CreateEntity();
em.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ });
em.AddComponentData(e, default(ReceiveRpcCommandRequest));
}
static int StructureCount(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(PlacedStructure));
return q.CalculateEntityCount();
}
static int OreCount(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(ResourceLedger));
var ledger = em.GetBuffer<StorageEntry>(q.GetSingletonEntity());
for (int i = 0; i < ledger.Length; i++)
if (ledger[i].ItemId == ResourceId.Ore) return ledger[i].Count;
return 0;
}
[Test]
public void Valid_Request_Places_Structure_Withdraws_Cost_And_Destroys_Request()
{
var (world, group) = MakeWorld("BuildValid", oreCount: 50);
using (world)
{
var em = world.EntityManager;
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
group.Update();
Assert.AreEqual(1, StructureCount(em), "A valid request places exactly one structure.");
Assert.AreEqual(40, OreCount(em), "The build cost (10) is withdrawn from the ledger.");
using var reqQ = em.CreateEntityQuery(typeof(BuildPlaceRequest));
Assert.AreEqual(0, reqQ.CalculateEntityCount(), "The handled request is destroyed.");
}
}
[Test]
public void Two_Same_Cell_Requests_Place_Only_One_And_Withdraw_Once()
{
var (world, group) = MakeWorld("BuildAtomic", oreCount: 50);
using (world)
{
var em = world.EntityManager;
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
group.Update();
Assert.AreEqual(1, StructureCount(em),
"Two same-tick requests for one cell place exactly one structure (co-op atomicity).");
Assert.AreEqual(40, OreCount(em), "The cost is withdrawn exactly once, not twice.");
}
}
[Test]
public void Insufficient_Resources_Places_Nothing()
{
var (world, group) = MakeWorld("BuildPoor", oreCount: 5);
using (world)
{
var em = world.EntityManager;
MakeBuildRequest(em, StructureType.Turret, cellX: 1, cellZ: 1);
group.Update();
Assert.AreEqual(0, StructureCount(em), "A request that can't afford the cost places nothing.");
Assert.AreEqual(5, OreCount(em), "The ledger is untouched on an unaffordable request.");
}
}
[Test]
public void Out_Of_Plot_Cell_Places_Nothing()
{
var (world, group) = MakeWorld("BuildOOB", oreCount: 50);
using (world)
{
var em = world.EntityManager;
MakeBuildRequest(em, StructureType.Turret, cellX: 99, cellZ: 99);
group.Update();
Assert.AreEqual(0, StructureCount(em), "An out-of-plot cell places nothing.");
Assert.AreEqual(50, OreCount(em), "No cost is withdrawn for an illegal placement.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 69248c6e19368b246a8aa8b151a8f7b0
@@ -0,0 +1,138 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="CyclePhaseSystem"/> — the macro-loop director
/// (Expedition → Defend → Build → next cycle). A bare world is seeded with a NetworkTime singleton and a cycle
/// entity carrying CycleState + CycleRuntime (and optionally WaveState / GoalProgress). All timing is wrap-safe
/// NetworkTick math; these tests pin each phase transition and the per-cycle goal-charge increment.
/// </summary>
public class CyclePhaseSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<CyclePhaseSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var nt = em.CreateEntity(typeof(NetworkTime));
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
return (world, group);
}
static Entity MakeCycle(EntityManager em, byte phase, uint phaseEndTick, int cycleNumber, int defendStartWave)
{
var e = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime));
em.SetComponentData(e, new CycleState { Phase = phase, PhaseEndTick = phaseEndTick, CycleNumber = cycleNumber });
em.SetComponentData(e, new CycleRuntime { DefendStartWave = defendStartWave });
return e;
}
static void MakeWaveState(EntityManager em, int waveNumber, int remainingToSpawn)
{
var e = em.CreateEntity(typeof(WaveState));
em.SetComponentData(e, new WaveState { WaveNumber = waveNumber, RemainingToSpawn = remainingToSpawn });
}
[Test]
public void Expedition_Enters_Defend_When_Timer_Due_Capturing_StartWave()
{
var (world, group) = MakeWorld("CycleExpToDefend", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Expedition, phaseEndTick: 100, cycleNumber: 1, defendStartWave: 0);
MakeWaveState(em, waveNumber: 5, remainingToSpawn: 0);
group.Update();
var cs = em.GetComponentData<CycleState>(cycle);
Assert.AreEqual(CyclePhase.Defend, cs.Phase, "An expired Expedition timer enters Defend.");
Assert.AreEqual(0u, cs.PhaseEndTick, "Defend is wave-driven, so PhaseEndTick is cleared.");
Assert.AreEqual(5, em.GetComponentData<CycleRuntime>(cycle).DefendStartWave,
"DefendStartWave captures the current WaveState.WaveNumber.");
}
}
[Test]
public void Expedition_Holds_While_Timer_Pending()
{
var (world, group) = MakeWorld("CycleExpHold", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Expedition, phaseEndTick: 5000, cycleNumber: 1, defendStartWave: 0);
group.Update();
Assert.AreEqual(CyclePhase.Expedition, em.GetComponentData<CycleState>(cycle).Phase,
"Expedition holds until its timer is due.");
}
}
[Test]
public void Defend_Enters_Build_When_Wave_Cleared()
{
var (world, group) = MakeWorld("CycleDefendToBuild", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Defend, phaseEndTick: 0, cycleNumber: 1, defendStartWave: 1);
// Wave advanced past the captured start, fully spawned, and no Husks alive (none created).
MakeWaveState(em, waveNumber: 2, remainingToSpawn: 0);
group.Update();
var cs = em.GetComponentData<CycleState>(cycle);
Assert.AreEqual(CyclePhase.Build, cs.Phase, "A cleared Defend wave enters Build.");
Assert.AreNotEqual(0u, cs.PhaseEndTick, "Build is timed, so a PhaseEndTick is stamped.");
}
}
[Test]
public void Build_Enters_Expedition_Incrementing_Cycle_And_Goal()
{
var (world, group) = MakeWorld("CycleBuildToExp", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Build, phaseEndTick: 100, cycleNumber: 1, defendStartWave: 0);
em.AddComponentData(cycle, new GoalProgress { Charge = 0, Target = 10 });
group.Update();
var cs = em.GetComponentData<CycleState>(cycle);
Assert.AreEqual(CyclePhase.Expedition, cs.Phase, "An expired Build timer starts the next Expedition.");
Assert.AreEqual(2, cs.CycleNumber, "CycleNumber increments on the new cycle.");
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(cycle).Charge,
"One goal charge accrues per completed cycle (single writer).");
}
}
[Test]
public void WaveNumber_Is_Synced_From_WaveState_For_The_Hud()
{
var (world, group) = MakeWorld("CycleWaveSync", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var cycle = MakeCycle(em, CyclePhase.Defend, phaseEndTick: 0, cycleNumber: 1, defendStartWave: 1);
MakeWaveState(em, waveNumber: 4, remainingToSpawn: 2);
group.Update();
Assert.AreEqual(4, em.GetComponentData<CycleState>(cycle).WaveNumber,
"CycleState.WaveNumber mirrors the server-only WaveState.WaveNumber so the replicated-state-only HUD can show it.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: def6f8080b5a28d4eb9ee4781b283752
@@ -0,0 +1,123 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="ExpeditionGateSystem"/> (walk-in region
/// transit). A bare world is seeded with an <c>ExpeditionGate</c> (+ LocalTransform) and a player
/// (RegionTag + LocalTransform + PlayerTag). A player whose region matches the gate's FromRegion and who is
/// within the gate radius is transited (RegionTag flipped + LocalTransform teleported to ArrivalPos).
/// Returning to base during the Expedition phase caps the cycle phase timer. Pins the proximity gate, the
/// region/radius guards, and the early-return phase cap.
/// </summary>
public class ExpeditionGateSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ExpeditionGateSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
return (world, group);
}
static void MakeGate(EntityManager em, float3 pos, byte from, byte to, float radius, float3 arrival)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new ExpeditionGate { FromRegion = from, ToRegion = to, Radius = radius, ArrivalPos = arrival });
}
static Entity MakePlayer(EntityManager em, float3 pos, byte region)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new RegionTag { Region = region });
em.AddComponent<PlayerTag>(e);
return e;
}
[Test]
public void Player_In_Gate_Radius_Is_Transited_And_Teleported()
{
var (world, group) = MakeWorld("GateTransitWorld");
using (world)
{
var em = world.EntityManager;
var arrival = new float3(1000, 1, 0);
MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: arrival);
var player = MakePlayer(em, new float3(5, 1, 0), RegionId.Base);
group.Update();
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(player).Region,
"Region flips to the gate's ToRegion.");
var p = em.GetComponentData<LocalTransform>(player).Position;
Assert.AreEqual(1000f, p.x, 1e-3f, "Player is teleported to the gate's ArrivalPos (x).");
Assert.AreEqual(0f, p.z, 1e-3f, "Player is teleported to the gate's ArrivalPos (z).");
}
}
[Test]
public void Player_Outside_Radius_Is_Not_Transited()
{
var (world, group) = MakeWorld("GateNoTransitWorld");
using (world)
{
var em = world.EntityManager;
MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: new float3(1000, 1, 0));
var player = MakePlayer(em, new float3(50, 1, 0), RegionId.Base);
group.Update();
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region,
"A player beyond the gate radius stays in its region.");
}
}
[Test]
public void Player_Wrong_Region_Is_Not_Transited()
{
var (world, group) = MakeWorld("GateWrongRegionWorld");
using (world)
{
var em = world.EntityManager;
// Gate only acts on players currently in the Base region.
MakeGate(em, new float3(0, 1, 0), RegionId.Base, RegionId.Expedition, radius: 15f, arrival: new float3(1000, 1, 0));
var player = MakePlayer(em, new float3(1, 1, 0), RegionId.Expedition);
group.Update();
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(player).Region,
"A player whose region does not match FromRegion is ignored even inside the radius.");
}
}
[Test]
public void Return_To_Base_During_Expedition_Caps_The_Phase_Timer()
{
var (world, group) = MakeWorld("GateReturnCapWorld");
using (world)
{
var em = world.EntityManager;
MakeGate(em, new float3(0, 1, 0), RegionId.Expedition, RegionId.Base, radius: 15f, arrival: new float3(0, 1, 0));
MakePlayer(em, new float3(3, 1, 0), RegionId.Expedition);
var cycle = em.CreateEntity(typeof(CycleState));
em.SetComponentData(cycle, new CycleState { Phase = CyclePhase.Expedition, PhaseEndTick = 5000, CycleNumber = 1 });
group.Update();
Assert.AreEqual(1u, em.GetComponentData<CycleState>(cycle).PhaseEndTick,
"Returning to base mid-Expedition caps PhaseEndTick to 1 so Defend starts next tick.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dfddde749d3109843901804073127701
@@ -0,0 +1,143 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for enemy KNOCKBACK (server-only, no re-bake). Two halves:
/// ProjectileDamageSystem STAMPS a <see cref="KnockbackState"/> on a hit Husk (Dir = the projectile heading,
/// UntilTick = now + Tuning.KnockbackDurationTicks); EnemyAISystem APPLIES it — moving the Husk along the
/// knockback heading (overriding seek) and suppressing its strike for the window, then resuming seek. Both
/// systems are server-only Burst ISystems; a NetworkTime singleton is seeded (TurretFireSystems pattern).
/// Knockback is gated by Tuning.KnockbackSpeed (0 disables) — so ProjectileDamageSystem only stamps when &gt; 0.
/// </summary>
public class KnockbackTests
{
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
[Test]
public void ProjectileDamage_Stamps_Knockback_On_Hit_Husk()
{
var world = new World("KnockbackStampWorld");
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ProjectileDamageSystem>());
group.SortSystems();
using (world)
{
var em = world.EntityManager;
SetServerTick(world, 200);
var husk = em.CreateEntity();
em.AddComponentData(husk, LocalTransform.FromPosition(new float3(0, 0, 5)));
em.AddComponentData(husk, new HitRadius { Value = 0.8f });
em.AddComponentData(husk, new Health { Current = 50f, Max = 50f });
em.AddBuffer<DamageEvent>(husk);
em.AddComponentData(husk, new KnockbackState()); // baked on real Husks
var proj = em.CreateEntity();
em.AddComponentData(proj, LocalTransform.FromPosition(new float3(0, 0, 5)));
em.AddComponentData(proj, new Projectile { Direction = new float2(0, 1), Speed = 10f, Damage = 20f, Range = 20f, DistanceTravelled = 5f });
em.AddComponentData(proj, new GhostOwner { NetworkId = 1 });
world.SetTime(new TimeData(elapsedTime: 0.1f, deltaTime: 0.1f));
group.Update();
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(husk).Length, "The hit still deals damage.");
Assert.IsFalse(em.Exists(proj), "The projectile is consumed on hit.");
var kb = em.GetComponentData<KnockbackState>(husk);
Assert.AreEqual(TickUtil.NonZero(200 + (uint)Tuning.KnockbackDurationTicks), kb.UntilTick,
"Knockback is scheduled until now + KnockbackDurationTicks.");
Assert.AreEqual(0f, kb.Dir.x, 1e-4f);
Assert.AreEqual(1f, kb.Dir.y, 1e-4f, "Knockback heading matches the projectile direction.");
Assert.AreEqual(Tuning.KnockbackSpeed, kb.Speed, 1e-4f);
}
}
static (World world, SimulationSystemGroup group) MakeAiWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<EnemyAISystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
static Entity MakePlayer(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponent<PlayerTag>(e);
em.AddBuffer<DamageEvent>(e);
return e;
}
static Entity MakeHusk(EntityManager em, float3 pos, KnockbackState kb)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 8f, AttackCooldownTicks = 36 });
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 });
em.AddComponentData(e, kb);
em.AddComponentData(e, new AttackWindup());
em.AddComponent<EnemyTag>(e);
return e;
}
[Test]
public void Knockback_Overrides_Seek_Then_Resumes()
{
var (world, group) = MakeAiWorld("KnockbackAiWorld", 200);
using (world)
{
var em = world.EntityManager;
MakePlayer(em, new float3(10, 1, 0)); // player at +X
var husk = MakeHusk(em, new float3(5, 1, 0),
new KnockbackState { Dir = new float2(-1, 0), Speed = Tuning.KnockbackSpeed, UntilTick = TickUtil.NonZero(208) });
group.Update(); // tick 200: knocked -> moves -X (against the seek toward +X)
float xKnocked = em.GetComponentData<LocalTransform>(husk).Position.x;
Assert.Less(xKnocked, 5f, "While knocked the Husk moves along the knockback heading (-X), not toward the player (+X).");
SetServerTick(world, 208);
group.Update(); // window elapsed -> seek resumes toward the player at +X
float xResumed = em.GetComponentData<LocalTransform>(husk).Position.x;
Assert.Greater(xResumed, xKnocked, "Once the knockback window elapses the Husk seeks back toward the player.");
Assert.AreEqual(0u, em.GetComponentData<KnockbackState>(husk).UntilTick, "Knockback state is cleared after the window.");
}
}
[Test]
public void Knocked_Husk_Does_Not_Strike()
{
var (world, group) = MakeAiWorld("KnockbackNoStrikeWorld", 200);
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, new float3(10, 1, 0));
// Husk inside AttackRange of the player, but knocked.
MakeHusk(em, new float3(9, 1, 0),
new KnockbackState { Dir = new float2(-1, 0), Speed = Tuning.KnockbackSpeed, UntilTick = TickUtil.NonZero(208) });
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length,
"A recoiling Husk does not strike even when inside AttackRange.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 85b62d9117f60de448d7a515c0709a89
@@ -0,0 +1,107 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="PlayerRespawnSystem"/> — the authoritative
/// death→respawn timer. A bare world is seeded with NetworkTime + PlayerSpawner singletons and a player
/// (Health, RespawnState, RespawnInvuln, LocalTransform, GhostOwner, EffectiveCharacterStats, PlayerTag).
/// Pins: a newly-dead player schedules a respawn tick; a due tick refills health + repositions + grants
/// invuln + clears the schedule; an alive player clears any stale pending schedule.
/// </summary>
public class PlayerRespawnSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<PlayerRespawnSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var nt = em.CreateEntity(typeof(NetworkTime));
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
var sp = em.CreateEntity(typeof(PlayerSpawner));
em.SetComponentData(sp, new PlayerSpawner { SpawnPoint = new float3(0, 1, 0), SpawnRingRadius = 10f, RingSlots = 4 });
return (world, group);
}
static Entity MakePlayer(EntityManager em, float health, float maxHealth, uint respawnTick,
int delayTicks, int invulnTicks, float3 pos, int networkId)
{
var e = em.CreateEntity();
em.AddComponentData(e, new Health { Current = health, Max = maxHealth });
em.AddComponentData(e, new RespawnState { RespawnTick = respawnTick, DelayTicks = delayTicks, InvulnTicks = invulnTicks });
em.AddComponentData(e, new RespawnInvuln { UntilTick = 0 });
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
em.AddComponentData(e, new EffectiveCharacterStats { MaxHealth = maxHealth });
em.AddComponent<PlayerTag>(e);
return e;
}
[Test]
public void Newly_Dead_Player_Schedules_Respawn_Tick()
{
var (world, group) = MakeWorld("RespawnSchedule", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, health: 0f, maxHealth: 100f, respawnTick: 0,
delayTicks: 60, invulnTicks: 120, pos: new float3(5, 1, 5), networkId: 1);
group.Update();
Assert.AreEqual(RespawnMath.RespawnTick(200, 60), em.GetComponentData<RespawnState>(player).RespawnTick,
"A newly-dead player schedules its respawn tick (now + delay).");
Assert.AreEqual(0f, em.GetComponentData<Health>(player).Current, 1e-4f, "Still down until the tick is due.");
}
}
[Test]
public void Due_Respawn_Restores_Health_Repositions_And_Grants_Invuln()
{
var (world, group) = MakeWorld("RespawnRecover", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, health: 0f, maxHealth: 100f, respawnTick: 160,
delayTicks: 60, invulnTicks: 120, pos: new float3(999, 1, 999), networkId: 1);
group.Update();
Assert.AreEqual(100f, em.GetComponentData<Health>(player).Current, 1e-4f, "Health refills to the effective max.");
Assert.AreEqual(320u, em.GetComponentData<RespawnInvuln>(player).UntilTick,
"Post-respawn invulnerability is granted until now + InvulnTicks (200 + 120).");
Assert.AreEqual(0u, em.GetComponentData<RespawnState>(player).RespawnTick, "The respawn schedule is cleared on recovery.");
Assert.Less(em.GetComponentData<LocalTransform>(player).Position.x, 100f,
"The player is teleported from its death spot back to the base spawn ring.");
}
}
[Test]
public void Alive_Player_Clears_Stale_Pending_Respawn()
{
var (world, group) = MakeWorld("RespawnAliveClear", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, health: 50f, maxHealth: 100f, respawnTick: 999,
delayTicks: 60, invulnTicks: 120, pos: new float3(5, 1, 5), networkId: 1);
group.Update();
Assert.AreEqual(0u, em.GetComponentData<RespawnState>(player).RespawnTick,
"An alive player clears any stale pending respawn schedule.");
Assert.AreEqual(50f, em.GetComponentData<Health>(player).Current, 1e-4f, "Alive health is untouched.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5be985c7449132943ad509f0426bd2cb
@@ -0,0 +1,106 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="RegionTransitSystem"/> — the RPC region-transit
/// handler. A bare world is seeded with a BaseAnchor, a mock connection entity (NetworkId), a player
/// (GhostOwner + RegionTag + LocalTransform + PlayerTag) and a RegionTransitRequest + ReceiveRpcCommandRequest
/// whose SourceConnection points at the connection. Pins: a request from a resolvable connection flips the
/// player's region + teleports it to the region origin; an unresolvable connection transits nobody; the request
/// is consumed either way.
/// </summary>
public class RegionTransitSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<RegionTransitSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var anchor = em.CreateEntity(typeof(BaseAnchor));
em.SetComponentData(anchor, new BaseAnchor
{
AnchorPos = new float3(5, 0, 5),
GridOrigin = new float3(0, 0, 0),
CellSize = 2f,
GridDims = new int2(5, 5),
});
return (world, group);
}
static Entity MakeConnection(EntityManager em, int networkId)
{
var e = em.CreateEntity();
em.AddComponentData(e, new NetworkId { Value = networkId });
return e;
}
static Entity MakePlayer(EntityManager em, int networkId, byte region, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, new GhostOwner { NetworkId = networkId });
em.AddComponentData(e, new RegionTag { Region = region });
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponent<PlayerTag>(e);
return e;
}
static void MakeTransitRequest(EntityManager em, byte targetRegion, Entity sourceConnection)
{
var e = em.CreateEntity();
em.AddComponentData(e, new RegionTransitRequest { TargetRegion = targetRegion });
em.AddComponentData(e, new ReceiveRpcCommandRequest { SourceConnection = sourceConnection });
}
[Test]
public void Request_From_Known_Connection_Transits_Its_Player()
{
var (world, group) = MakeWorld("RegionTransitOk");
using (world)
{
var em = world.EntityManager;
var conn = MakeConnection(em, networkId: 1);
var player = MakePlayer(em, networkId: 1, region: RegionId.Base, pos: new float3(5, 1, 5));
MakeTransitRequest(em, RegionId.Expedition, conn);
group.Update();
Assert.AreEqual(RegionId.Expedition, em.GetComponentData<RegionTag>(player).Region,
"The sender's player flips to the requested region.");
Assert.AreEqual(1005f, em.GetComponentData<LocalTransform>(player).Position.x, 1e-2f,
"The player teleports to the expedition region origin (base center + 1000 on X).");
using var reqQ = em.CreateEntityQuery(typeof(RegionTransitRequest));
Assert.AreEqual(0, reqQ.CalculateEntityCount(), "The handled request is destroyed.");
}
}
[Test]
public void Request_From_Unresolvable_Connection_Transits_Nobody()
{
var (world, group) = MakeWorld("RegionTransitUnknown");
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, networkId: 1, region: RegionId.Base, pos: new float3(5, 1, 5));
MakeTransitRequest(em, RegionId.Expedition, Entity.Null); // no NetworkId on Entity.Null
group.Update();
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(player).Region,
"A request whose connection can't be resolved transits nobody.");
using var reqQ = em.CreateEntityQuery(typeof(RegionTransitRequest));
Assert.AreEqual(0, reqQ.CalculateEntityCount(), "The request is still consumed.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 727cdc6da6f5b8842ba5b311702e5224
@@ -0,0 +1,117 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="ResourceHarvestSystem"/> — the swept-segment
/// harvest sweep that deposits a hit node's yield into the GLOBAL resource ledger. A bare world is seeded with
/// a ResourceLedger singleton (+ StorageEntry buffer), resource nodes (LocalTransform + HitRadius +
/// ResourceNode) and projectiles (LocalTransform + Projectile with a baked LastStep, since ProjectileMoveSystem
/// is not in this world). Pins: a hit deposits + decrements Remaining + consumes the projectile; two same-tick
/// hits over-harvest but destroy the node at most once (a double DestroyEntity would throw at playback); a miss
/// leaves everything untouched and the projectile alive.
/// </summary>
public class ResourceHarvestSystemTests
{
static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<ResourceHarvestSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var ledger = em.CreateEntity(typeof(ResourceLedger));
em.AddBuffer<StorageEntry>(ledger);
return (world, group, ledger);
}
static Entity MakeNode(EntityManager em, float3 pos, float hitRadius, byte resourceId, int remaining, float perHit)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new HitRadius { Value = hitRadius });
em.AddComponentData(e, new ResourceNode { ResourceId = resourceId, Remaining = remaining, HarvestPerHit = perHit });
return e;
}
static Entity MakeProjectile(EntityManager em, float3 pos, float2 dir, float lastStep)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Projectile { Direction = dir, LastStep = lastStep });
return e;
}
static int LedgerCount(EntityManager em, Entity ledger, ushort itemId)
{
var buf = em.GetBuffer<StorageEntry>(ledger);
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == itemId) return buf[i].Count;
return 0;
}
[Test]
public void Hit_Deposits_To_Ledger_Decrements_Node_And_Consumes_Projectile()
{
var (world, group, ledger) = MakeWorld("HarvestHit");
using (world)
{
var em = world.EntityManager;
var node = MakeNode(em, new float3(10, 1, 10), hitRadius: 1f, resourceId: ResourceId.Aether, remaining: 100, perHit: 25f);
var proj = MakeProjectile(em, new float3(10, 1, 10), new float2(1, 0), lastStep: 5f);
group.Update();
Assert.AreEqual(25, LedgerCount(em, ledger, ResourceId.Aether), "One hit deposits HarvestPerHit into the ledger.");
Assert.AreEqual(75, em.GetComponentData<ResourceNode>(node).Remaining, "Node Remaining decrements by the harvested amount.");
Assert.IsTrue(em.Exists(node), "A node with resource left survives.");
Assert.IsFalse(em.Exists(proj), "The harvesting projectile is consumed.");
}
}
[Test]
public void Two_Projectiles_Deplete_Node_But_Destroy_It_At_Most_Once()
{
var (world, group, ledger) = MakeWorld("HarvestDeplete");
using (world)
{
var em = world.EntityManager;
var node = MakeNode(em, new float3(10, 1, 10), hitRadius: 1f, resourceId: ResourceId.Aether, remaining: 40, perHit: 25f);
var p1 = MakeProjectile(em, new float3(10, 1, 10), new float2(1, 0), lastStep: 5f);
var p2 = MakeProjectile(em, new float3(10, 1, 10), new float2(0, 1), lastStep: 5f);
group.Update();
Assert.AreEqual(50, LedgerCount(em, ledger, ResourceId.Aether), "Both hits deposit, even though the second over-harvests.");
Assert.IsFalse(em.Exists(node), "A depleted node is destroyed exactly once (a double destroy would throw at playback).");
Assert.IsFalse(em.Exists(p1), "Both projectiles are consumed.");
Assert.IsFalse(em.Exists(p2));
}
}
[Test]
public void Missing_Projectile_Leaves_Node_And_Ledger_Untouched()
{
var (world, group, ledger) = MakeWorld("HarvestMiss");
using (world)
{
var em = world.EntityManager;
var node = MakeNode(em, new float3(10, 1, 10), hitRadius: 1f, resourceId: ResourceId.Aether, remaining: 100, perHit: 25f);
var proj = MakeProjectile(em, new float3(50, 1, 50), new float2(1, 0), lastStep: 5f);
group.Update();
Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "A miss deposits nothing.");
Assert.AreEqual(100, em.GetComponentData<ResourceNode>(node).Remaining, "A miss leaves Remaining untouched.");
Assert.IsTrue(em.Exists(proj), "A projectile that hits no node survives (no destroy-on-miss).");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 375130bf205b09744a1fc73efb304bfe
@@ -0,0 +1,107 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="StorageOpReceiveSystem"/> — the RPC handler
/// that applies deposit/withdraw ops to the shared storage container's replicated <c>StorageEntry</c> buffer.
/// A bare world with a <c>SharedStorageContainer</c> singleton (carrying the buffer) plus synthetic
/// <c>StorageOpRequest</c> + <c>ReceiveRpcCommandRequest</c> entities exercises the handler. The system plays
/// its ECB back immediately (Temp allocator), so the handled request entity is destroyed within the single
/// group update. Mirrors HealthApplyDamageSystemTests. Locks the deposit/withdraw/drop-row behaviour before
/// the Stage-C const refactor and any later storage-model changes.
/// </summary>
public class StorageOpReceiveSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<StorageOpReceiveSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
return (world, group);
}
static Entity MakeContainer(EntityManager em, ushort itemId, int count)
{
var e = em.CreateEntity(typeof(SharedStorageContainer));
var buf = em.AddBuffer<StorageEntry>(e);
if (itemId != 0)
buf.Add(new StorageEntry { ItemId = itemId, Count = count });
return e;
}
static void MakeRequest(EntityManager em, byte op, ushort itemId, int count)
{
var e = em.CreateEntity();
em.AddComponentData(e, new StorageOpRequest { Op = op, ItemId = itemId, Count = count });
em.AddComponentData(e, default(ReceiveRpcCommandRequest));
}
[Test]
public void Withdraw_Decrements_Existing_Row_And_Destroys_Request()
{
var (world, group) = MakeWorld("StorageWithdrawWorld");
using (world)
{
var em = world.EntityManager;
var container = MakeContainer(em, itemId: 1, count: 100);
MakeRequest(em, StorageOp.Withdraw, itemId: 1, count: 30);
group.Update();
var buf = em.GetBuffer<StorageEntry>(container);
Assert.AreEqual(1, buf.Length, "A partial withdraw keeps the row.");
Assert.AreEqual(70, buf[0].Count, "100 - 30 = 70 must remain.");
using var reqQuery = em.CreateEntityQuery(typeof(StorageOpRequest));
Assert.AreEqual(0, reqQuery.CalculateEntityCount(),
"The handled request entity must be destroyed by the system's ECB.");
}
}
[Test]
public void Deposit_Of_New_Item_Appends_A_Row()
{
var (world, group) = MakeWorld("StorageDepositWorld");
using (world)
{
var em = world.EntityManager;
var container = MakeContainer(em, itemId: 1, count: 100);
MakeRequest(em, StorageOp.Deposit, itemId: 2, count: 20);
group.Update();
var buf = em.GetBuffer<StorageEntry>(container);
Assert.AreEqual(2, buf.Length, "Depositing a previously-absent item appends a second row.");
int item2 = -1;
for (int i = 0; i < buf.Length; i++)
if (buf[i].ItemId == 2) item2 = buf[i].Count;
Assert.AreEqual(20, item2, "The appended row carries the deposited count.");
}
}
[Test]
public void Withdraw_Of_Full_Stack_Drops_The_Row()
{
var (world, group) = MakeWorld("StorageWithdrawZeroWorld");
using (world)
{
var em = world.EntityManager;
var container = MakeContainer(em, itemId: 1, count: 30);
MakeRequest(em, StorageOp.Withdraw, itemId: 1, count: 30);
group.Update();
var buf = em.GetBuffer<StorageEntry>(container);
Assert.AreEqual(0, buf.Length, "Withdrawing the whole stack drops the row entirely.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 62eb6ac6dd96837468c04d1d89b39499
@@ -0,0 +1,110 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the Husk attack TELEGRAPH (the 2-phase strike in EnemyAISystem). The
/// strike no longer fires instantly: when a Husk is first in-range + cooldown-ready it commits a wind-up
/// (<see cref="AttackWindup.WindUpUntilTick"/> = now + Tuning.AttackWindupTicks, replicated so the client can
/// cue it) and damages NOTHING; the strike lands only when the wind-up tick elapses, and leaving range
/// mid-wind-up cancels it. Server timing is fully headless (the replication + client cue are the Play check).
/// </summary>
public class TelegraphTests
{
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<EnemyAISystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
static Entity MakePlayer(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponent<PlayerTag>(e);
em.AddBuffer<DamageEvent>(e);
return e;
}
static Entity MakeHusk(EntityManager em, float3 pos)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new EnemyStats { MoveSpeed = 3f, AttackRange = 1.6f, AttackDamage = 8f, AttackCooldownTicks = 36 });
em.AddComponentData(e, new EnemyAttackCooldown { NextAttackTick = 0 });
em.AddComponentData(e, new KnockbackState());
em.AddComponentData(e, new AttackWindup());
em.AddComponent<EnemyTag>(e);
return e;
}
[Test]
public void Husk_Winds_Up_First_Then_Strikes_At_Expiry()
{
var (world, group) = MakeWorld("TelegraphStrike", 200);
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, new float3(10, 1, 0));
var husk = MakeHusk(em, new float3(9, 1, 0)); // distance 1 < AttackRange 1.6 -> in range
group.Update(); // tick 200: begins the wind-up, deals NO damage yet
uint expected = TickUtil.NonZero(200 + (uint)Tuning.AttackWindupTicks);
Assert.AreEqual(expected, em.GetComponentData<AttackWindup>(husk).WindUpUntilTick,
"An in-range, ready Husk commits a wind-up until now + AttackWindupTicks.");
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "No damage lands during the wind-up.");
SetServerTick(world, expected);
group.Update(); // wind-up elapsed -> strike lands
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(player).Length, "The strike lands exactly when the wind-up elapses.");
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(husk).WindUpUntilTick, "The wind-up resets after the strike.");
Assert.AreNotEqual(0u, em.GetComponentData<EnemyAttackCooldown>(husk).NextAttackTick, "The strike cooldown is stamped.");
}
}
[Test]
public void Leaving_Range_Mid_WindUp_Cancels_The_Strike()
{
var (world, group) = MakeWorld("TelegraphCancel", 200);
using (world)
{
var em = world.EntityManager;
var player = MakePlayer(em, new float3(10, 1, 0));
var husk = MakeHusk(em, new float3(9, 1, 0));
group.Update(); // begins the wind-up
uint windTick = em.GetComponentData<AttackWindup>(husk).WindUpUntilTick;
Assert.AreNotEqual(0u, windTick);
// Player flees far out of range before the wind-up completes.
em.SetComponentData(player, LocalTransform.FromPosition(new float3(60, 1, 0)));
SetServerTick(world, windTick);
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(player).Length, "Leaving range mid-wind-up cancels the strike.");
Assert.AreEqual(0u, em.GetComponentData<AttackWindup>(husk).WindUpUntilTick, "The cancelled wind-up is cleared.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4eceb2e9d3917444281e0513e22e34d0
@@ -0,0 +1,134 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="TimedModifierExpirySystem"/> — timed/removable
/// StatModifiers. A bare world is seeded with a NetworkTime singleton and a player carrying paired StatModifier
/// + TimedModifier buffers (same SourceId). Pins: a modifier persists until its tick then is removed by SourceId
/// (along with its tracker row); independent expiry of multiple timed mods; the clear-by-SourceId helper; and
/// the TickUtil.NonZero guard so a wrap-tick expiry never collides with the 0 = "inert" sentinel. The
/// replicated StatModifier layout is untouched (separate tracker buffer) so there is no ghost re-bake.
/// </summary>
public class TimedModifierExpirySystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<TimedModifierExpirySystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static Entity MakePlayer(EntityManager em)
{
var e = em.CreateEntity();
em.AddBuffer<StatModifier>(e);
em.AddBuffer<TimedModifier>(e);
return e;
}
static void AddTimedMod(EntityManager em, Entity e, byte target, float value, uint sourceId, uint untilTick)
{
em.GetBuffer<StatModifier>(e).Add(new StatModifier { Target = target, Op = (byte)ModOp.Flat, Value = value, SourceId = sourceId });
em.GetBuffer<TimedModifier>(e).Add(new TimedModifier { SourceId = sourceId, UntilTick = untilTick });
}
[Test]
public void Modifier_Persists_Before_Expiry_And_Is_Removed_After()
{
var (world, group) = MakeWorld("TimedExpire", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var p = MakePlayer(em);
AddTimedMod(em, p, (byte)StatTarget.Damage, 10f, sourceId: 0xABCDu, untilTick: 300);
group.Update(); // serverTick 200 < 300 -> not due
Assert.AreEqual(1, em.GetBuffer<StatModifier>(p).Length, "Modifier persists before its expiry tick.");
Assert.AreEqual(1, em.GetBuffer<TimedModifier>(p).Length);
SetServerTick(world, 300);
group.Update(); // due (300 not newer than 300)
Assert.AreEqual(0, em.GetBuffer<StatModifier>(p).Length, "Expired modifier is removed by SourceId.");
Assert.AreEqual(0, em.GetBuffer<TimedModifier>(p).Length, "The timed-tracker row is removed too.");
}
}
[Test]
public void Timed_Modifiers_Expire_Independently()
{
var (world, group) = MakeWorld("TimedIndependent", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var p = MakePlayer(em);
AddTimedMod(em, p, (byte)StatTarget.Damage, 10f, sourceId: 1u, untilTick: 250);
AddTimedMod(em, p, (byte)StatTarget.MoveSpeed, 0.2f, sourceId: 2u, untilTick: 350);
SetServerTick(world, 250);
group.Update();
var mods = em.GetBuffer<StatModifier>(p);
Assert.AreEqual(1, mods.Length, "Only the first timed modifier expires at tick 250.");
Assert.AreEqual(2u, mods[0].SourceId, "The longer-lived modifier (SourceId 2) survives.");
SetServerTick(world, 350);
group.Update();
Assert.AreEqual(0, em.GetBuffer<StatModifier>(p).Length, "The second expires at its own tick.");
}
}
[Test]
public void RemoveBySourceId_Clears_On_Demand()
{
var (world, group) = MakeWorld("TimedClearByType", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var p = MakePlayer(em);
var b = em.GetBuffer<StatModifier>(p);
b.Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 5f, SourceId = 7u });
b.Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 9f, SourceId = 8u });
int removed = TimedModifierUtil.RemoveBySourceId(em.GetBuffer<StatModifier>(p), 7u);
Assert.AreEqual(1, removed, "Clear-by-SourceId removes exactly the matching row.");
var mods = em.GetBuffer<StatModifier>(p);
Assert.AreEqual(1, mods.Length);
Assert.AreEqual(8u, mods[0].SourceId, "The non-matching modifier is untouched.");
}
}
[Test]
public void NonZero_UntilTick_Never_Collides_With_The_Zero_Sentinel()
{
var (world, group) = MakeWorld("TimedWrap", serverTick: 1);
using (world)
{
var em = world.EntityManager;
var p = MakePlayer(em);
uint until = TickUtil.NonZero(0u); // a grant/death exactly at tick 0 must not read as 'inert'
Assert.AreNotEqual(0u, until, "TickUtil.NonZero coerces 0 -> 1 so a scheduled expiry is never the inert sentinel.");
AddTimedMod(em, p, (byte)StatTarget.Damage, 3f, sourceId: 9u, untilTick: until);
group.Update(); // serverTick 1 >= until(1) -> due
Assert.AreEqual(0, em.GetBuffer<StatModifier>(p).Length, "A modifier scheduled at the wrap sentinel still expires.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3714a9107b437a0419aec060006cec76
@@ -0,0 +1,139 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="TurretFireSystem"/> (hitscan defense turret).
/// A bare world is seeded with a <c>NetworkTime</c> singleton, a turret (PlacedStructure + Turret + RegionTag
/// + LocalTransform) and Husks (Health + EnemyTag + RegionTag + LocalTransform + a DamageEvent buffer). The
/// system snapshots living Husks, fires at the nearest in-range one in its OWN region on the
/// <c>PlacedStructure.NextTick</c> cooldown, and appends a <c>DamageEvent{SourceNetworkId=-1}</c>. These tests
/// pin range filtering, region gating, and the wrap-safe cooldown.
/// </summary>
public class TurretFireSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<TurretFireSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
SetServerTick(world, serverTick);
return (world, group);
}
static void SetServerTick(World world, uint tick)
{
var em = world.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static Entity MakeTurret(EntityManager em, float3 pos, byte region, float range, int cooldown, float damage)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new RegionTag { Region = region });
em.AddComponentData(e, new PlacedStructure { Type = StructureType.Turret, NextTick = 0 });
em.AddComponentData(e, new Turret { Range = range, CooldownTicks = cooldown, Damage = damage });
return e;
}
static Entity MakeHusk(EntityManager em, float3 pos, byte region, float health)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new RegionTag { Region = region });
em.AddComponentData(e, new Health { Current = health, Max = health });
em.AddComponent<EnemyTag>(e);
em.AddBuffer<DamageEvent>(e);
return e;
}
[Test]
public void Turret_Fires_At_InRange_Husk_In_Its_Region()
{
var (world, group) = MakeWorld("TurretFireWorld", serverTick: 200);
using (world)
{
var em = world.EntityManager;
var turret = MakeTurret(em, new float3(5, 1, 5), RegionId.Base, range: 20f, cooldown: 30, damage: 10f);
var husk = MakeHusk(em, new float3(10, 1, 10), RegionId.Base, health: 50f);
group.Update();
var dmg = em.GetBuffer<DamageEvent>(husk);
Assert.AreEqual(1, dmg.Length, "An in-range, same-region Husk takes exactly one turret hit.");
Assert.AreEqual(10f, dmg[0].Amount, 1e-4f, "DamageEvent carries the turret's damage.");
Assert.AreEqual(-1, dmg[0].SourceNetworkId, "Turret damage uses the -1 (world) source id.");
Assert.AreNotEqual(0u, em.GetComponentData<PlacedStructure>(turret).NextTick,
"The cooldown tick is stamped after firing.");
}
}
[Test]
public void Turret_Ignores_Husk_Out_Of_Range()
{
var (world, group) = MakeWorld("TurretRangeWorld", serverTick: 200);
using (world)
{
var em = world.EntityManager;
MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 5f, cooldown: 30, damage: 10f);
var husk = MakeHusk(em, new float3(100, 1, 0), RegionId.Base, health: 50f);
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(husk).Length, "A Husk beyond Range takes no hit.");
}
}
[Test]
public void Turret_Ignores_Husk_In_A_Different_Region()
{
var (world, group) = MakeWorld("TurretRegionWorld", serverTick: 200);
using (world)
{
var em = world.EntityManager;
MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f);
var husk = MakeHusk(em, new float3(2, 1, 2), RegionId.Expedition, health: 50f);
group.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(husk).Length,
"A Husk in a different region is never targeted (region gating).");
}
}
[Test]
public void Turret_Respects_Cooldown_Then_Fires_Again()
{
var (world, group) = MakeWorld("TurretCooldownWorld", serverTick: 200);
using (world)
{
var em = world.EntityManager;
MakeTurret(em, new float3(0, 1, 0), RegionId.Base, range: 50f, cooldown: 30, damage: 10f);
var husk = MakeHusk(em, new float3(3, 1, 0), RegionId.Base, health: 5000f);
group.Update(); // fires at tick 200, NextTick -> 230
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(husk).Length, "Fires on the first ready tick.");
SetServerTick(world, 210);
group.Update(); // 210 < 230 -> still cooling down
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(husk).Length, "No second shot while cooling down.");
SetServerTick(world, 240);
group.Update(); // 240 >= 230 -> ready again
Assert.AreEqual(2, em.GetBuffer<DamageEvent>(husk).Length, "Fires again once the cooldown elapses.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a86a8f9715eff4c45817718443589cdb
@@ -0,0 +1,142 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// Plain-Entities EditMode tests for the server-only <see cref="WaveSystem"/> (Husk wave/threat director).
/// A bare world is seeded with NetworkTime + CycleState singletons and a director entity carrying
/// WaveDirector + WaveState + a WaveEnemyPrefab buffer (whose prefab is a real <c>Prefab</c>-tagged entity so
/// it is excluded from the alive-Husk query and Instantiate yields plain Husk instances). Pins: a due Lull
/// starts the next (escalating) wave; Spawning emits one Husk per interval; the director is gated off outside
/// Defend; a fully-spawned, cleared wave returns to Lull.
/// </summary>
public class WaveSystemTests
{
static (World world, SimulationSystemGroup group) MakeWorld(string name, uint serverTick, byte cyclePhase)
{
var world = new World(name);
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
group.AddSystemToUpdateList(world.GetOrCreateSystem<WaveSystem>());
group.SortSystems();
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
var em = world.EntityManager;
var nt = em.CreateEntity(typeof(NetworkTime));
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) });
var cyc = em.CreateEntity(typeof(CycleState));
em.SetComponentData(cyc, new CycleState { Phase = cyclePhase });
return (world, group);
}
static Entity MakeHuskPrefab(EntityManager em)
{
var e = em.CreateEntity(typeof(LocalTransform), typeof(EnemyTag));
em.AddComponent<Prefab>(e); // real prefab: excluded from EnemyTag queries; Instantiate strips the tag
return e;
}
static Entity MakeDirector(EntityManager em, Entity huskPrefab, byte phase, int waveNumber,
uint nextActionTick, int remainingToSpawn, int spawnCounter)
{
var e = em.CreateEntity(typeof(WaveDirector), typeof(WaveState));
em.SetComponentData(e, new WaveDirector
{
RingRadius = 10f, RingSlots = 12, BaseCount = 3, CountPerWave = 2,
SpawnIntervalTicks = 10, LullTicks = 5,
});
em.SetComponentData(e, new WaveState
{
Phase = phase, WaveNumber = waveNumber, NextActionTick = nextActionTick,
RemainingToSpawn = remainingToSpawn, SpawnCounter = spawnCounter,
});
var buf = em.AddBuffer<WaveEnemyPrefab>(e);
buf.Add(new WaveEnemyPrefab { Prefab = huskPrefab });
return e;
}
static int HuskCount(EntityManager em)
{
using var q = em.CreateEntityQuery(typeof(EnemyTag));
return q.CalculateEntityCount();
}
[Test]
public void Due_Lull_Starts_Wave_With_Escalating_Count()
{
var (world, group) = MakeWorld("WaveLullStart", serverTick: 100, cyclePhase: CyclePhase.Defend);
using (world)
{
var em = world.EntityManager;
var prefab = MakeHuskPrefab(em);
var dir = MakeDirector(em, prefab, WavePhase.Lull, waveNumber: 0, nextActionTick: 100, remainingToSpawn: 0, spawnCounter: 0);
group.Update();
var w = em.GetComponentData<WaveState>(dir);
Assert.AreEqual(WavePhase.Spawning, w.Phase, "A due lull starts spawning.");
Assert.AreEqual(1, w.WaveNumber, "Wave number advances.");
Assert.AreEqual(3, w.RemainingToSpawn, "Wave 1 count = BaseCount + (1-1)*CountPerWave = 3.");
Assert.AreEqual(0, HuskCount(em), "No Husk is spawned on the lull->spawning transition tick itself.");
}
}
[Test]
public void Spawning_Emits_One_Husk_And_Decrements_Remaining()
{
var (world, group) = MakeWorld("WaveSpawnOne", serverTick: 100, cyclePhase: CyclePhase.Defend);
using (world)
{
var em = world.EntityManager;
var prefab = MakeHuskPrefab(em);
var dir = MakeDirector(em, prefab, WavePhase.Spawning, waveNumber: 1, nextActionTick: 100, remainingToSpawn: 3, spawnCounter: 0);
group.Update();
Assert.AreEqual(1, HuskCount(em), "One Husk spawns this interval.");
var w = em.GetComponentData<WaveState>(dir);
Assert.AreEqual(2, w.RemainingToSpawn, "RemainingToSpawn decrements.");
Assert.AreEqual(1, w.SpawnCounter, "SpawnCounter advances (drives ring slot + round-robin).");
}
}
[Test]
public void Director_Is_Gated_Off_Outside_Defend()
{
var (world, group) = MakeWorld("WaveGated", serverTick: 100, cyclePhase: CyclePhase.Expedition);
using (world)
{
var em = world.EntityManager;
var prefab = MakeHuskPrefab(em);
var dir = MakeDirector(em, prefab, WavePhase.Lull, waveNumber: 0, nextActionTick: 100, remainingToSpawn: 0, spawnCounter: 0);
group.Update();
var w = em.GetComponentData<WaveState>(dir);
Assert.AreEqual(WavePhase.Lull, w.Phase, "Outside Defend the director does not run.");
Assert.AreEqual(0, w.WaveNumber, "Wave number stays put outside Defend.");
}
}
[Test]
public void Fully_Spawned_Cleared_Wave_Returns_To_Lull()
{
var (world, group) = MakeWorld("WaveCleared", serverTick: 100, cyclePhase: CyclePhase.Defend);
using (world)
{
var em = world.EntityManager;
var prefab = MakeHuskPrefab(em);
var dir = MakeDirector(em, prefab, WavePhase.Spawning, waveNumber: 1, nextActionTick: 100, remainingToSpawn: 0, spawnCounter: 3);
group.Update();
Assert.AreEqual(WavePhase.Lull, em.GetComponentData<WaveState>(dir).Phase,
"A fully-spawned wave with no live Husks returns to Lull.");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b4f0f87ab30c466459b587c756994096