Tests: base mining field, expedition teardown, schedule siege, melee harvest
8 new EditMode tests (302 total, all green): BaseFieldSpawnSystem (target count + Base/Ore + cadence + top-up), ExpeditionFieldSystem teardown preserves the base field, ThreatDirector schedule arming, and melee harvest routing (base->ledger, expedition->personal inventory) which guards the cross-region leak the post-impl review caught. See DR-031. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Server;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Collections;
|
||||||
|
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="BaseFieldSpawnSystem"/> — the home-base mining
|
||||||
|
/// field. A bare world is seeded with a NetworkTime singleton, a BaseAnchor (plot centred on origin), a
|
||||||
|
/// ResourceNode prefab (Prefab-tagged so it is excluded from the live count) and a BaseFieldSpawner +
|
||||||
|
/// BaseFieldRuntime. Pins: the first pass seeds the field to TargetCount with every node RegionTag{Base} +
|
||||||
|
/// ResourceId.Ore inside the [Inner,Outer] annulus; the cadence gate suppresses a respawn before the interval
|
||||||
|
/// elapses; and a depleted field tops back up to TargetCount once the interval passes (no economy soft-lock).
|
||||||
|
/// </summary>
|
||||||
|
public class BaseFieldSpawnSystemTests
|
||||||
|
{
|
||||||
|
static (World world, SimulationSystemGroup group, Entity spawner, Entity prefab) MakeWorld(string name, uint serverTick, int target, float inner, float outer, int interval)
|
||||||
|
{
|
||||||
|
var world = new World(name);
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<BaseFieldSpawnSystem>());
|
||||||
|
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) });
|
||||||
|
|
||||||
|
// Plot centred on origin: GridOrigin -16, dims 16, cell 2 => PlotCenter = (0,0,0).
|
||||||
|
var anchor = em.CreateEntity(typeof(BaseAnchor));
|
||||||
|
em.SetComponentData(anchor, new BaseAnchor
|
||||||
|
{
|
||||||
|
AnchorPos = float3.zero,
|
||||||
|
GridOrigin = new float3(-16f, 0f, -16f),
|
||||||
|
CellSize = 2f,
|
||||||
|
GridDims = new int2(16, 16),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The node prefab: Prefab-tagged so the live-count query (and the system's) skip it.
|
||||||
|
var prefab = em.CreateEntity(typeof(Prefab), typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag), typeof(HitRadius));
|
||||||
|
em.SetComponentData(prefab, LocalTransform.FromPosition(float3.zero));
|
||||||
|
em.SetComponentData(prefab, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f });
|
||||||
|
em.SetComponentData(prefab, new RegionTag { Region = RegionId.Expedition });
|
||||||
|
em.SetComponentData(prefab, new HitRadius { Value = 1.2f });
|
||||||
|
|
||||||
|
var spawner = em.CreateEntity(typeof(BaseFieldSpawner), typeof(BaseFieldRuntime));
|
||||||
|
em.SetComponentData(spawner, new BaseFieldSpawner
|
||||||
|
{
|
||||||
|
Prefab = prefab,
|
||||||
|
TargetCount = target,
|
||||||
|
InnerRadius = inner,
|
||||||
|
OuterRadius = outer,
|
||||||
|
RespawnIntervalTicks = interval,
|
||||||
|
});
|
||||||
|
em.SetComponentData(spawner, new BaseFieldRuntime { Epoch = 0, NextSpawnTick = 0u });
|
||||||
|
return (world, group, spawner, prefab);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Entity[] LiveNodes(EntityManager em)
|
||||||
|
{
|
||||||
|
// Default query options exclude Prefab + Disabled, so the prefab is not counted.
|
||||||
|
using var q = em.CreateEntityQuery(ComponentType.ReadOnly<ResourceNode>(), ComponentType.ReadOnly<RegionTag>());
|
||||||
|
return q.ToEntityArray(Allocator.Temp).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void SetServerTick(EntityManager em, uint tick)
|
||||||
|
{
|
||||||
|
using var q = em.CreateEntityQuery(ComponentType.ReadWrite<NetworkTime>());
|
||||||
|
var nt = q.GetSingletonEntity();
|
||||||
|
em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(tick) });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void First_Pass_Seeds_Target_Count_Base_Ore_Nodes_In_Annulus()
|
||||||
|
{
|
||||||
|
var (world, group, _, _) = MakeWorld("BaseFieldSeed", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
var nodes = LiveNodes(em);
|
||||||
|
Assert.AreEqual(8, nodes.Length, "The first pass seeds exactly TargetCount base nodes.");
|
||||||
|
foreach (var n in nodes)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(RegionId.Base, em.GetComponentData<RegionTag>(n).Region, "Base nodes are RegionTag.Base so RegionRelevancy keeps them for base players.");
|
||||||
|
Assert.AreEqual(ResourceId.Ore, em.GetComponentData<ResourceNode>(n).ResourceId, "Base nodes are Ore-only (the build currency).");
|
||||||
|
float2 xz = em.GetComponentData<LocalTransform>(n).Position.xz;
|
||||||
|
float r = math.length(xz);
|
||||||
|
Assert.GreaterOrEqual(r, 10f - 0.01f, "A node is no nearer than InnerRadius (clears the build plot).");
|
||||||
|
Assert.LessOrEqual(r, 20f + 0.01f, "A node is no farther than OuterRadius (stays reachable).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Does_Not_Respawn_Before_The_Interval_Elapses()
|
||||||
|
{
|
||||||
|
var (world, group, _, _) = MakeWorld("BaseFieldCadence", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
group.Update(); // seeds 8, NextSpawnTick = 700
|
||||||
|
|
||||||
|
// Deplete 3 nodes.
|
||||||
|
var nodes = LiveNodes(em);
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
em.DestroyEntity(nodes[i]);
|
||||||
|
Assert.AreEqual(5, LiveNodes(em).Length);
|
||||||
|
|
||||||
|
// Same tick (100 < 700): the cadence gate suppresses a refill.
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(5, LiveNodes(em).Length, "No top-up before RespawnIntervalTicks elapses.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Tops_Up_To_Target_After_Depletion_Once_Interval_Passes()
|
||||||
|
{
|
||||||
|
var (world, group, _, _) = MakeWorld("BaseFieldTopUp", serverTick: 100, target: 8, inner: 10f, outer: 20f, interval: 600);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
group.Update(); // seeds 8, NextSpawnTick = 700
|
||||||
|
|
||||||
|
var nodes = LiveNodes(em);
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
em.DestroyEntity(nodes[i]);
|
||||||
|
Assert.AreEqual(5, LiveNodes(em).Length);
|
||||||
|
|
||||||
|
// Advance past the interval (800 > 700): refill the 3-node deficit back to TargetCount.
|
||||||
|
SetServerTick(em, 800);
|
||||||
|
group.Update();
|
||||||
|
Assert.AreEqual(8, LiveNodes(em).Length, "A depleted field tops back up to TargetCount (no economy soft-lock).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a7a8c39aa67d45d4586a269f136999c5
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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>
|
||||||
|
/// Regression test for the <see cref="ExpeditionFieldSystem"/> teardown region-filter. When the last player
|
||||||
|
/// leaves the expedition the field is cleared — but that teardown must destroy ONLY RegionTag{Expedition}
|
||||||
|
/// nodes, never the permanent RegionTag{Base} home-base mining field. Before the fix the unfiltered teardown
|
||||||
|
/// wiped every ResourceNode on the empty edge (a despawn storm beside base players that broke the core loop).
|
||||||
|
/// </summary>
|
||||||
|
public class ExpeditionFieldTeardownTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Expedition_Empty_Edge_Destroys_Only_Expedition_Nodes_Base_Field_Survives()
|
||||||
|
{
|
||||||
|
var world = new World("ExpeditionTeardown");
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var group = world.GetOrCreateSystemManaged<SimulationSystemGroup>();
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<ExpeditionFieldSystem>());
|
||||||
|
group.SortSystems();
|
||||||
|
world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f));
|
||||||
|
var em = world.EntityManager;
|
||||||
|
|
||||||
|
// Cycle director: was occupied last tick, nobody out there now => the occupied->empty edge fires.
|
||||||
|
var cycle = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime));
|
||||||
|
em.SetComponentData(cycle, new CycleState { Phase = CyclePhase.Calm, CycleNumber = 1 });
|
||||||
|
em.SetComponentData(cycle, new CycleRuntime { PrevExpeditionOccupied = 1 });
|
||||||
|
|
||||||
|
// Spawner singleton (required); null prefab so the spawn branch is inert.
|
||||||
|
var spawnerE = em.CreateEntity(typeof(ResourceFieldSpawner));
|
||||||
|
em.SetComponentData(spawnerE, new ResourceFieldSpawner { Prefab = Entity.Null, Count = 5, Radius = 10f });
|
||||||
|
|
||||||
|
var baseNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag));
|
||||||
|
em.SetComponentData(baseNode, LocalTransform.FromPosition(new float3(20, 0, 0)));
|
||||||
|
em.SetComponentData(baseNode, new ResourceNode { ResourceId = ResourceId.Ore, Remaining = 30, HarvestPerHit = 5f });
|
||||||
|
em.SetComponentData(baseNode, new RegionTag { Region = RegionId.Base });
|
||||||
|
|
||||||
|
var expNode = em.CreateEntity(typeof(LocalTransform), typeof(ResourceNode), typeof(RegionTag));
|
||||||
|
em.SetComponentData(expNode, LocalTransform.FromPosition(new float3(1020, 0, 0)));
|
||||||
|
em.SetComponentData(expNode, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f });
|
||||||
|
em.SetComponentData(expNode, new RegionTag { Region = RegionId.Expedition });
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.IsTrue(em.Exists(baseNode), "The permanent base mining field survives the expedition teardown.");
|
||||||
|
Assert.IsFalse(em.Exists(expNode), "Only the expedition node is cleared when the last player leaves.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 269ed0f5b1c5cb6418682ccf05db45dd
|
||||||
@@ -429,5 +429,64 @@ namespace ProjectM.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- any-attack harvest (MC-4 melee mines too) ----
|
||||||
|
|
||||||
|
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 Cleave_Harvests_A_Base_Node_To_The_Shared_Ledger()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeHarvestBase", 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var ledger = em.CreateEntity(typeof(ResourceLedger));
|
||||||
|
em.AddBuffer<StorageEntry>(ledger);
|
||||||
|
var p = MakePlayer(em, new float2(0, 1)); // at origin, facing +Z
|
||||||
|
var node = em.CreateEntity();
|
||||||
|
em.AddComponentData(node, LocalTransform.FromPosition(new float3(0, 0, 2))); // in the light cone
|
||||||
|
em.AddComponentData(node, new ResourceNode { ResourceId = ResourceId.Ore, Remaining = 30, HarvestPerHit = 5f });
|
||||||
|
em.AddComponentData(node, new RegionTag { Region = RegionId.Base });
|
||||||
|
Press(em, p);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
Assert.AreEqual(5, LedgerCount(em, ledger, ResourceId.Ore), "a base-node melee hit credits the shared ledger (the build pool).");
|
||||||
|
Assert.AreEqual(25, em.GetComponentData<ResourceNode>(node).Remaining, "the node is depleted by HarvestPerHit (written back for the chip VFX).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Cleave_Harvests_An_Expedition_Node_To_Personal_Inventory_Not_The_Ledger()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("MeleeHarvestExp", 100, server: true);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var ledger = em.CreateEntity(typeof(ResourceLedger));
|
||||||
|
em.AddBuffer<StorageEntry>(ledger);
|
||||||
|
var p = MakePlayer(em, new float2(0, 1)); // GhostOwner NetworkId 7
|
||||||
|
em.AddComponent<PlayerTag>(p);
|
||||||
|
em.AddBuffer<InventorySlot>(p);
|
||||||
|
var node = em.CreateEntity();
|
||||||
|
em.AddComponentData(node, LocalTransform.FromPosition(new float3(0, 0, 2)));
|
||||||
|
em.AddComponentData(node, new ResourceNode { ResourceId = ResourceId.Aether, Remaining = 30, HarvestPerHit = 5f });
|
||||||
|
em.AddComponentData(node, new RegionTag { Region = RegionId.Expedition });
|
||||||
|
Press(em, p);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
var inv = em.GetBuffer<InventorySlot>(p);
|
||||||
|
Assert.AreEqual(5, InventoryMath.CountOf(inv, ResourceId.Aether), "an expedition-node melee hit lands in the swinging player's PERSONAL inventory.");
|
||||||
|
Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Aether), "an expedition harvest does NOT credit the shared base ledger (DR-026 personal haul).");
|
||||||
|
Assert.AreEqual(25, em.GetComponentData<ResourceNode>(node).Remaining, "the node is still depleted.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,5 +151,52 @@ namespace ProjectM.Tests
|
|||||||
"The siege clock resets after collapse.");
|
"The siege clock resets after collapse.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Schedule_First_Pass_Seeds_NextTick_Without_Firing()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("ThreatScheduleSeed", serverTick: 200);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var config = DefaultConfig();
|
||||||
|
config.PostExpeditionEnabled = 0;
|
||||||
|
config.ScheduleEnabled = 1;
|
||||||
|
config.ScheduleIntervalTicks = 100;
|
||||||
|
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 0 }, config);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
var ts = em.GetComponentData<ThreatState>(dir);
|
||||||
|
Assert.AreEqual(300u, ts.NextScheduledTick, "The first pass seeds the next scheduled tick one interval out (200 + 100).");
|
||||||
|
Assert.AreEqual(0, ts.PendingSiegeSize, "The first pass only seeds — it does not arm a siege immediately.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Schedule_Arms_Siege_On_Cadence_Without_An_Expedition()
|
||||||
|
{
|
||||||
|
var (world, group) = MakeWorld("ThreatScheduleFire", serverTick: 400);
|
||||||
|
using (world)
|
||||||
|
{
|
||||||
|
var em = world.EntityManager;
|
||||||
|
var config = DefaultConfig();
|
||||||
|
config.PostExpeditionEnabled = 0; // isolate the schedule source
|
||||||
|
config.ScheduleEnabled = 1;
|
||||||
|
config.ScheduleIntervalTicks = 100;
|
||||||
|
config.ScheduleSizePerWave = 0;
|
||||||
|
config.SizeBase = 5;
|
||||||
|
config.PostExpeditionDelayTicks = 10;
|
||||||
|
// NextScheduledTick 300 <= now 400 => the scheduled siege is due.
|
||||||
|
var dir = MakeDirector(em, CyclePhase.Calm, new ThreatState { NextScheduledTick = 300 }, config);
|
||||||
|
|
||||||
|
group.Update();
|
||||||
|
|
||||||
|
var ts = em.GetComponentData<ThreatState>(dir);
|
||||||
|
Assert.AreEqual(5, ts.PendingSiegeSize, "A due scheduled tick arms a SizeBase siege with NO expedition trip.");
|
||||||
|
Assert.AreEqual(410u, ts.ArmTick, "The scheduled siege telegraphs at now + delay (400 + 10).");
|
||||||
|
Assert.AreEqual(500u, ts.NextScheduledTick, "The next scheduled siege is one interval out (400 + 100).");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user