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 turret fire (hitscan) — EnemyAISystem reversed. Snapshots living Husks once (entity, planar /// pos, region); each turret picks the nearest Husk in ITS region within Range and, on a NetworkTick /// cooldown stored in , appends a direct DamageEvent /// (SourceNetworkId=-1) to it. Reuses HealthApplyDamageSystem (already destroys EnemyTag at HP<=0) — no /// projectile, no tunnelling, no friendly-fire. Plain server SimulationSystemGroup /// [UpdateAfter(PredictedSimulationSystemGroup)] (the predicted group is OrderFirst → UpdateBefore is /// ignored); the appended DamageEvent drains next tick (~16ms), consistent with EnemyAISystem. Self-gates: /// Husks only exist during the Defend wave. /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateAfter(typeof(PredictedSimulationSystemGroup))] public partial struct TurretFireSystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly())); } [BurstCompile] public void OnUpdate(ref SystemState state) { var serverTick = SystemAPI.GetSingleton().ServerTick; if (!serverTick.IsValid) return; uint now = serverTick.TickIndexForValidTick; var huskEntities = new NativeList(Allocator.Temp); var huskPos = new NativeList(Allocator.Temp); var huskRegion = new NativeList(Allocator.Temp); foreach (var (xform, health, region, e) in SystemAPI.Query, RefRO, RefRO>() .WithAll().WithEntityAccess()) { if (health.ValueRO.Current <= 0f) continue; huskEntities.Add(e); huskPos.Add(xform.ValueRO.Position.xz); huskRegion.Add(region.ValueRO.Region); } if (huskEntities.Length == 0) { huskEntities.Dispose(); huskPos.Dispose(); huskRegion.Dispose(); return; } // EB-2: resolve the shared ledger ONCE (NEVER GetSingleton — a 2nd StorageEntry buffer // exists on the base container). Turrets withdraw Charge from it sequentially (a finite pool split in // query order; later turrets soft-fail when it empties). var ledgerEntity = SystemAPI.GetSingletonEntity(); var ledger = SystemAPI.GetBuffer(ledgerEntity); var ecb = new EntityCommandBuffer(Allocator.Temp); foreach (var (ps, turret, xform, region) in SystemAPI.Query, RefRO, RefRO, RefRO>()) { uint nextRaw = ps.ValueRO.NextTick; if (nextRaw != 0) { var nextTick = new NetworkTick(nextRaw); if (nextTick.IsValid && nextTick.IsNewerThan(serverTick)) continue; // still cooling down } float2 tp = xform.ValueRO.Position.xz; byte treg = region.ValueRO.Region; float rangeSq = turret.ValueRO.Range * turret.ValueRO.Range; int best = -1; float bestSq = float.MaxValue; for (int i = 0; i < huskEntities.Length; i++) { if (huskRegion[i] != treg) continue; float sq = math.distancesq(huskPos[i], tp); if (sq <= rangeSq && sq < bestSq) { bestSq = sq; best = i; } } if (best >= 0) { // EB-2 felt spend: a shot costs Charge from the shared ledger. Gate BOTH the damage AND the // cooldown advance on a SUCCESSFUL withdraw — out of Charge = SOFT-FAIL (no shot, no cooldown // burn, so the turret fires the instant Charge returns). Refund a partial (cost>1 underflow). int cost = math.max(1, Tuning.TurretChargeCostPerShot); int got = StorageMath.Withdraw(ledger, ResourceId.Charge, cost); if (got >= cost) { ecb.AppendToBuffer(huskEntities[best], new DamageEvent { Amount = turret.ValueRO.Damage, SourceNetworkId = -1, SourceTick = TickUtil.NonZero(now), }); uint cd = (uint)math.max(1, turret.ValueRO.CooldownTicks); ps.ValueRW.NextTick = TickUtil.NonZero(now + cd); } else if (got > 0) { StorageMath.Deposit(ledger, ResourceId.Charge, got); // never consume Charge without firing } } } ecb.Playback(state.EntityManager); ecb.Dispose(); huskEntities.Dispose(); huskPos.Dispose(); huskRegion.Dispose(); } } }