using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; namespace ProjectM.Server { /// /// Server-authoritative damage application. Drains each damageable entity's /// buffer (appended by earlier /// this tick), subtracts the summed amount from , then clears the buffer so /// each hit is applied exactly once. Entities that carry character stats (players) clamp to their /// data-driven ceiling; others (training dummies) /// clamp at zero. A dead is destroyed; player death is deferred. /// Health.Current is a [GhostField], so the new value replicates to clients for display. /// /// Runs server-only () inside the prediction /// group so it shares tick timing with movement/damage, where it executes once per tick. The /// single structural change (destroying a dead dummy) is batched through a frame-allocator /// that is played back immediately to the entity manager — so a /// plain-world EditMode test needs no separate ECB system. /// [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] [UpdateAfter(typeof(ProjectileDamageSystem))] // Pin the drain AFTER DashSystem: a same-tick player-sourced projectile (ProjectileDamageSystem stamps // SourceTick = now and this system drains the SAME tick) must see a dash window STARTED this tick — // without the edge the negation at src == StartTick is an unconstrained sorter tiebreak. The Dash chain // (StatRecompute→PlayerControl→Dash) and the projectile chain (PlayerAim→AbilityFire→ProjectileMove→ // ProjectileDamage→here) are otherwise disjoint, so this edge cannot form a cycle (Play-validated). [UpdateAfter(typeof(DashSystem))] [BurstCompile] public partial struct HealthApplyDamageSystem : ISystem { [BurstCompile] public void OnUpdate(ref SystemState state) { var ecb = new EntityCommandBuffer(Allocator.Temp); bool haveTick = SystemAPI.TryGetSingleton(out var netTime); uint negatedThisTick = 0; uint punishesThisTick = 0; foreach (var (health, dmg, entity) in SystemAPI.Query, DynamicBuffer>() .WithEntityAccess()) { if (dmg.Length == 0) continue; // Dev god-mode: while enabled, this entity ignores ALL damage (server-authoritative, once per tick). if (SystemAPI.HasComponent(entity) && SystemAPI.IsComponentEnabled(entity)) { dmg.Clear(); continue; } // Respawn invulnerability: a freshly-recovered player ignores damage for a window. if (haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent(entity)) { uint until = SystemAPI.GetComponent(entity).UntilTick; if (until != 0) { var untilTick = new NetworkTick(until); if (untilTick.IsValid && untilTick.IsNewerThan(netTime.ServerTick)) { dmg.Clear(); continue; } } } bool hasDash = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent(entity); DashState ds = hasDash ? SystemAPI.GetComponent(entity) : default; bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent(entity); uint negatedForThisEntity = 0u; float total = 0f; for (int i = 0; i < dmg.Length; i++) { uint src = dmg[i].SourceTick; if (hasDash && src != 0u && ds.IFrameUntilTick != 0u) { var srcTick = new NetworkTick(src); var startTick = new NetworkTick(ds.StartTick); var untilTick = new NetworkTick(ds.IFrameUntilTick); // Dash i-frames cover the HALF-OPEN window [StartTick, IFrameUntilTick): negate iff src >= start AND src < until. bool atOrAfterStart = srcTick.IsValid && startTick.IsValid && !startTick.IsNewerThan(srcTick); bool beforeUntil = untilTick.IsValid && untilTick.IsNewerThan(srcTick); if (atOrAfterStart && beforeUntil) { negatedThisTick++; negatedForThisEntity++; continue; // dash i-frame negates this hit (per-element, not a whole-buffer clear) } } total += dmg[i].Amount; // MC-1 punish scoring: a player-sourced hit (SourceNetworkId >= 0) landing inside a Charger's // whiff-stagger window counts ONCE — zeroing StaggerUntilTick keeps punishes:windows <= 1. if (isCharger && dmg[i].SourceNetworkId >= 0) { var lunge = SystemAPI.GetComponent(entity); if (lunge.StaggerUntilTick != 0u) { var stag = new NetworkTick(lunge.StaggerUntilTick); if (stag.IsValid && stag.IsNewerThan(netTime.ServerTick)) { punishesThisTick++; lunge.StaggerUntilTick = 0u; SystemAPI.SetComponent(entity, lunge); } } } } dmg.Clear(); if (negatedForThisEntity != 0u) { ds.NegatedCount += negatedForThisEntity; // server-side spam signal; DashSystem reads it at window-close SystemAPI.SetComponent(entity, ds); } float newHp = health.ValueRO.Current - total; // Effective max health (base + modifiers) is the runtime ceiling for entities that carry // character stats (players); others just clamp at zero. No auto-heal on a max increase. if (SystemAPI.HasComponent(entity)) newHp = math.clamp(newHp, 0f, SystemAPI.GetComponent(entity).MaxHealth); else newHp = math.max(0f, newHp); health.ValueRW.Current = newHp; // Server-authoritative death: training dummies + enemies + EB-1 Destructible structures despawn; // player death is deferred (clamp only). A structure carries NO EffectiveCharacterStats, so it took // the math.max(0,..) branch above and CAN reach 0 — never give a structure stats (it would clamp to // a non-zero floor and become immortal). if (health.ValueRO.Current <= 0f && (SystemAPI.HasComponent(entity) || SystemAPI.HasComponent(entity) || SystemAPI.HasComponent(entity))) ecb.DestroyEntity(entity); } if ((negatedThisTick != 0u || punishesThisTick != 0u) && SystemAPI.HasSingleton()) { var telem = SystemAPI.GetSingletonRW(); telem.ValueRW.DashIFrameNegatedHits += negatedThisTick; telem.ValueRW.ChargerWhiffPunishesLanded += punishesThisTick; } ecb.Playback(state.EntityManager); ecb.Dispose(); } } }