using NUnit.Framework; using ProjectM.Server; using ProjectM.Simulation; using Unity.Core; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; namespace ProjectM.Tests { /// /// Plain-Entities EditMode tests for the server-only — 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. /// public class ResourceHarvestSystemTests { static (World world, SimulationSystemGroup group, Entity ledger) MakeWorld(string name) { var world = new World(name); var group = world.GetOrCreateSystemManaged(); group.AddSystemToUpdateList(world.GetOrCreateSystem()); group.SortSystems(); world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); var em = world.EntityManager; var ledger = em.CreateEntity(typeof(ResourceLedger)); em.AddBuffer(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(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(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(node).Remaining, "A miss leaves Remaining untouched."); Assert.IsTrue(em.Exists(proj), "A projectile that hits no node survives (no destroy-on-miss)."); } } static Entity MakeClutter(EntityManager em, float3 pos, float hitRadius, int remaining, float scrapPerHit) { var e = em.CreateEntity(); em.AddComponentData(e, LocalTransform.FromPosition(pos)); em.AddComponentData(e, new HitRadius { Value = hitRadius }); em.AddComponentData(e, new BlightClutter { Remaining = remaining, Variant = 0, ScrapResourceId = ResourceId.Biomass, ScrapPerHit = scrapPerHit }); return e; } [Test] public void Clutter_Hit_Deposits_Scrap_Decrements_And_Consumes_Projectile() { var (world, group, ledger) = MakeWorld("ClutterHit"); using (world) { var em = world.EntityManager; var clutter = MakeClutter(em, new float3(10, 1, 10), hitRadius: 1f, remaining: 8, scrapPerHit: 2f); var proj = MakeProjectile(em, new float3(10, 1, 10), new float2(1, 0), lastStep: 5f); group.Update(); Assert.AreEqual(2, LedgerCount(em, ledger, ResourceId.Biomass), "Smashing clutter deposits ScrapPerHit Biomass."); Assert.AreEqual(6, em.GetComponentData(clutter).Remaining, "Clutter Remaining decrements by the scrap amount."); Assert.IsTrue(em.Exists(clutter), "Clutter with hit-points left survives."); Assert.IsFalse(em.Exists(proj), "The smashing projectile is consumed."); } } [Test] public void Clutter_Two_Projectiles_Shatter_It_At_Most_Once() { var (world, group, ledger) = MakeWorld("ClutterShatter"); using (world) { var em = world.EntityManager; var clutter = MakeClutter(em, new float3(10, 1, 10), hitRadius: 1f, remaining: 3, scrapPerHit: 2f); 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(4, LedgerCount(em, ledger, ResourceId.Biomass), "Both hits deposit scrap, even though the second over-clears."); Assert.IsFalse(em.Exists(clutter), "Shattered clutter is destroyed exactly once (a double destroy would throw at playback)."); Assert.IsFalse(em.Exists(p1)); Assert.IsFalse(em.Exists(p2)); } } [Test] public void Overlapping_Node_And_Clutter_Consume_Projectile_Once_Hitting_Nearest() { var (world, group, ledger) = MakeWorld("OverlapNearest"); using (world) { var em = world.EntityManager; // Segment runs +X from x=0 (segStart = pos - dir*lastStep) to x=10 (pos). Clutter at x=4 is nearer // along the segment (t=0.4) than the node at x=9 (t=0.9), so the unified best-t sweep hits clutter. var clutter = MakeClutter(em, new float3(4, 1, 0), hitRadius: 1f, remaining: 100, scrapPerHit: 2f); var node = MakeNode(em, new float3(9, 1, 0), hitRadius: 1f, resourceId: ResourceId.Ore, remaining: 100, perHit: 25f); var proj = MakeProjectile(em, new float3(10, 1, 0), new float2(1, 0), lastStep: 10f); group.Update(); Assert.AreEqual(2, LedgerCount(em, ledger, ResourceId.Biomass), "The nearest target (clutter) is harvested."); Assert.AreEqual(0, LedgerCount(em, ledger, ResourceId.Ore), "The farther target (node) is NOT hit — one projectile, one target."); Assert.AreEqual(98, em.GetComponentData(clutter).Remaining); Assert.AreEqual(100, em.GetComponentData(node).Remaining, "Node untouched."); Assert.IsFalse(em.Exists(proj), "The projectile is consumed exactly once (no double-destroy across the two target types)."); } } [Test] public void Fractional_ScrapPerHit_Still_Deposits_And_Depletes_Never_Immortal() { var (world, group, ledger) = MakeWorld("FractionalScrap"); using (world) { var em = world.EntityManager; // A sub-1.0 per-hit yield must NOT truncate to 0 (that would make the target immortal + eat shots). var clutter = MakeClutter(em, new float3(10, 1, 10), hitRadius: 1f, remaining: 2, scrapPerHit: 0.5f); var proj = MakeProjectile(em, new float3(10, 1, 10), new float2(1, 0), lastStep: 5f); group.Update(); Assert.AreEqual(1, LedgerCount(em, ledger, ResourceId.Biomass), "A positive sub-1.0 yield deposits at least 1 (math.max(1, (int)...))."); Assert.AreEqual(1, em.GetComponentData(clutter).Remaining, "Remaining decrements by at least 1, so depletion always progresses."); Assert.IsTrue(em.Exists(clutter), "Still alive after one hit (2 -> 1), not immortal."); Assert.IsFalse(em.Exists(proj), "The projectile is consumed."); } } [Test] public void Second_Projectile_On_A_SameTick_Cleared_Target_Is_Consumed_Without_Double_Deposit() { var (world, group, ledger) = MakeWorld("SameTickCleared"); using (world) { var em = world.EntityManager; // Both projectiles overlap one clutter that the FIRST hit shatters (remaining == scrapPerHit). var clutter = MakeClutter(em, new float3(10, 1, 10), hitRadius: 1f, remaining: 2, scrapPerHit: 2f); 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(2, LedgerCount(em, ledger, ResourceId.Biomass), "Only the first hit deposits; the second strikes the already-cleared spot (no double-deposit)."); Assert.IsFalse(em.Exists(clutter), "The clutter is shattered exactly once."); Assert.IsFalse(em.Exists(p1), "First projectile consumed (harvested)."); Assert.IsFalse(em.Exists(p2), "Second projectile is still consumed — a hit always spends the shot, even on a same-tick-cleared target."); } } } }