using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Server { /// /// Server-only resource harvest + Blight-clutter clearing: sweeps each surviving projectile's this-tick travel /// segment against a UNIFIED target set of resource-node ghosts AND Blight-clutter ghosts, deposits the hit /// target's yield into the GLOBAL resource ledger (the CycleDirector's buffer, /// resolved via — NEVER GetSingleton<StorageEntry>, which would collide with /// the base storage container) and decrements its Remaining; the target despawns at <= 0. Nodes deposit /// @ HarvestPerHit; clutter deposits /// @ ScrapPerHit (a small "minor scrap" trickle — carving through the frontier). UNIFYING the two into one sweep /// is a CORRECTNESS requirement: two separate sweeps would each DestroyEntity a projectile that overlaps a node /// AND a clutter piece — a double DestroyEntity throws at ECB playback. Runs in the plain server /// SimulationSystemGroup [UpdateAfter(PredictedSimulationSystemGroup)] — after ProjectileDamageSystem has /// consumed Health-target hits and range-expired projectiles, so this only sees true survivors. The swept /// segment is reconstructed from (written by ProjectileMoveSystem in the /// fixed-step group), so it is tunnelling-safe WITHOUT depending on this plain group's variable-frame DeltaTime. /// A target hit by two projectiles in one tick deposits twice but is destroyed exactly once. Relies on the /// asserted ~1000-unit base/expedition coordinate gap so a base projectile can never reach an expedition target. /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateAfter(typeof(PredictedSimulationSystemGroup))] public partial struct ResourceHarvestSystem : ISystem { const float k_ProjectileRadius = Tuning.HarvestProjectileRadius; ComponentLookup m_GhostOwnerLookup; BufferLookup m_InvLookup; [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); m_GhostOwnerLookup = state.GetComponentLookup(true); m_InvLookup = state.GetBufferLookup(false); } [BurstCompile] public void OnUpdate(ref SystemState state) { var ledgerEntity = SystemAPI.GetSingletonEntity(); var ledger = SystemAPI.GetBuffer(ledgerEntity); // Resolve the harvesting player from the projectile's GhostOwner so yield lands in their PERSONAL // inventory. Owner read via a cached lookup (optional); the owner->player map + item catalog are // hoisted out of the per-hit sweep (invariant for the tick). m_GhostOwnerLookup.Update(ref state); m_InvLookup.Update(ref state); bool haveDb = SystemAPI.TryGetSingleton(out var itemDb); var playerByConn = new NativeHashMap(8, Allocator.Temp); foreach (var (owner, playerEntity) in SystemAPI.Query>().WithAll().WithEntityAccess()) playerByConn[owner.ValueRO.NetworkId] = playerEntity; // Snapshot all harvest/clear targets (nodes + clutter) once this tick into a UNIFIED set. var tgtEntity = new NativeList(Allocator.Temp); var tgtPos = new NativeList(Allocator.Temp); var tgtRadius = new NativeList(Allocator.Temp); var tgtRemaining = new NativeList(Allocator.Temp); var tgtYieldId = new NativeList(Allocator.Temp); var tgtYieldPerHit = new NativeList(Allocator.Temp); var tgtVariant = new NativeList(Allocator.Temp); var tgtIsClutter = new NativeList(Allocator.Temp); foreach (var (xform, hr, node, e) in SystemAPI.Query, RefRO, RefRO>().WithEntityAccess()) { tgtEntity.Add(e); tgtPos.Add(xform.ValueRO.Position.xz); tgtRadius.Add(hr.ValueRO.Value); tgtRemaining.Add(node.ValueRO.Remaining); tgtYieldId.Add(node.ValueRO.ResourceId); tgtYieldPerHit.Add(node.ValueRO.HarvestPerHit); tgtVariant.Add(0); tgtIsClutter.Add(false); } foreach (var (xform, hr, clutter, e) in SystemAPI.Query, RefRO, RefRO>().WithEntityAccess()) { tgtEntity.Add(e); tgtPos.Add(xform.ValueRO.Position.xz); tgtRadius.Add(hr.ValueRO.Value); tgtRemaining.Add(clutter.ValueRO.Remaining); tgtYieldId.Add(clutter.ValueRO.ScrapResourceId); tgtYieldPerHit.Add(clutter.ValueRO.ScrapPerHit); tgtVariant.Add(clutter.ValueRO.Variant); tgtIsClutter.Add(true); } var destroyed = new NativeArray(tgtEntity.Length, Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp); foreach (var (xform, proj, projEntity) in SystemAPI.Query, RefRO>().WithEntityAccess()) { float3 cur = xform.ValueRO.Position; float2 segEnd = cur.xz; float2 segStart = segEnd - proj.ValueRO.Direction * proj.ValueRO.LastStep; float2 seg = segEnd - segStart; float segLenSq = math.lengthsq(seg); int bestIdx = -1; float bestT = float.MaxValue; bool overlappedCleared = false; // struck a target a sibling projectile already cleared THIS tick for (int i = 0; i < tgtEntity.Length; i++) { float2 tp = tgtPos[i]; float t = segLenSq > 1e-8f ? math.saturate(math.dot(tp - segStart, seg) / segLenSq) : 0f; float2 closest = segStart + t * seg; float hitDist = tgtRadius[i] + k_ProjectileRadius; if (math.distancesq(tp, closest) > hitDist * hitDist) continue; if (destroyed[i]) { overlappedCleared = true; continue; } if (t < bestT) { bestT = t; bestIdx = i; } } if (bestIdx < 0) { // No LIVE target on the segment. If the shot still overlapped a target a sibling projectile // cleared this same tick, consume it anyway (a hit always spends the shot); else it's a miss. if (overlappedCleared) ecb.DestroyEntity(projEntity); continue; } // A positive baked yield must always make progress: a raw (int) truncation of a sub-1.0 per-hit // value would deposit 0 AND never decrement Remaining -> an immortal target that silently eats shots. int amount = math.max(1, (int)tgtYieldPerHit[bestIdx]); byte yieldId = tgtYieldId[bestIdx]; // Route the yield into the HARVESTING player's PERSONAL inventory. The projectile carries the // firing player's GhostOwner (AbilityFireSystem); the owner is read OPTIONALLY (cached lookup) so // an un-owned projectile (or a test projectile with no GhostOwner) falls through to the ledger. int remainder = amount; if (m_GhostOwnerLookup.HasComponent(projEntity) && playerByConn.TryGetValue(m_GhostOwnerLookup[projEntity].NetworkId, out var player) && m_InvLookup.HasBuffer(player)) { int stackMax = Tuning.DefaultStackMax; if (haveDb && itemDb.Value.IsCreated) { ref var itemBlob = ref itemDb.Value.Value; if (itemBlob.TryGetItem(yieldId, out var def) && def.StackMax > 0) stackMax = def.StackMax; } var inv = m_InvLookup[player]; remainder = InventoryMath.Deposit(inv, yieldId, amount, stackMax, Tuning.InventoryMaxSlots); } // Unresolvable owner or a full bag: the remainder credits the shared ledger (no-loss valve). if (remainder > 0) StorageMath.Deposit(ledger, yieldId, remainder); int rem = tgtRemaining[bestIdx] - amount; tgtRemaining[bestIdx] = rem; ecb.DestroyEntity(projEntity); if (rem <= 0) { if (!destroyed[bestIdx]) { destroyed[bestIdx] = true; ecb.DestroyEntity(tgtEntity[bestIdx]); } } else if (tgtIsClutter[bestIdx]) { // Persist the decremented Remaining (replicated GhostField) so depletion carries across ticks. SystemAPI.SetComponent(tgtEntity[bestIdx], new BlightClutter { Remaining = rem, Variant = tgtVariant[bestIdx], ScrapResourceId = tgtYieldId[bestIdx], ScrapPerHit = tgtYieldPerHit[bestIdx], }); } else { SystemAPI.SetComponent(tgtEntity[bestIdx], new ResourceNode { ResourceId = tgtYieldId[bestIdx], Remaining = rem, HarvestPerHit = tgtYieldPerHit[bestIdx], }); } } ecb.Playback(state.EntityManager); ecb.Dispose(); playerByConn.Dispose(); destroyed.Dispose(); tgtEntity.Dispose(); tgtPos.Dispose(); tgtRadius.Dispose(); tgtRemaining.Dispose(); tgtYieldId.Dispose(); tgtYieldPerHit.Dispose(); tgtVariant.Dispose(); tgtIsClutter.Dispose(); } } }