Further Tests & Progress
This commit is contained in:
@@ -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 > 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
|
||||
Reference in New Issue
Block a user