using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; namespace ProjectM.Server { /// /// Phase 1.7 on-kill boons. When HealthApplyDamageSystem stamps an enemy it records the /// crediting player's NetworkId; this system grants that killer their on-kill boons ONCE per corpse: /// heals the killer (clamped to ) and /// refreshes a short cooldown-reduction buff ( — /// re-stamped, never stacked). Idempotent via the latch (a value write, no edge-detect). /// /// A SEPARATE system (not folded into HealthApplyDamageSystem) because healing the killer needs RW /// access, which would alias that system's RefRW<Health> victim query. Here the /// only query is RefRW<Dying> over enemies, and all killer writes go through ComponentLookup/BufferLookup /// on player entities — no aliasing. Server-only (no rollback) inside the predicted group, after damage application. /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(PredictedSimulationSystemGroup))] [UpdateAfter(typeof(HealthApplyDamageSystem))] public partial struct KillRewardSystem : ISystem { ComponentLookup m_Fx; ComponentLookup m_Health; ComponentLookup m_EffChar; BufferLookup m_Mods; BufferLookup m_Timed; const float k_SiphonHeal = 8f; // HP restored per kill (tunable) [BurstCompile] public void OnCreate(ref SystemState state) { m_Fx = state.GetComponentLookup(isReadOnly: true); m_Health = state.GetComponentLookup(isReadOnly: false); m_EffChar = state.GetComponentLookup(isReadOnly: true); m_Mods = state.GetBufferLookup(isReadOnly: false); m_Timed = state.GetBufferLookup(isReadOnly: false); state.RequireForUpdate(); state.RequireForUpdate(); // only run while a fresh corpse exists } [BurstCompile] public void OnUpdate(ref SystemState state) { var serverTick = SystemAPI.GetSingleton().ServerTick; if (!serverTick.IsValid) return; m_Fx.Update(ref state); m_Health.Update(ref state); m_EffChar.Update(ref state); m_Mods.Update(ref state); m_Timed.Update(ref state); // Resolve killers by NetworkId (players only). var playerByNet = new NativeHashMap(8, Allocator.Temp); foreach (var (owner, e) in SystemAPI.Query>().WithAll().WithEntityAccess()) playerByNet[owner.ValueRO.NetworkId] = e; uint until = TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.FrenzyDurationTicks)); foreach (var (dying, corpse) in SystemAPI.Query>().WithAll().WithEntityAccess()) { if (dying.ValueRO.Rewarded != 0) continue; dying.ValueRW.Rewarded = 1; // mark ONCE — idempotent even when the killer can't be resolved int killerNet = dying.ValueRO.KillerNetId; if (killerNet < 0 || !playerByNet.TryGetValue(killerNet, out var killer)) continue; if (!m_Fx.HasComponent(killer)) continue; byte flags = m_Fx[killer].Flags; // Siphon: heal the killer, clamped to their effective max (no over-heal; skip a corpse killer). if ((flags & BoonFlag.Siphon) != 0 && m_Health.HasComponent(killer)) { var h = m_Health[killer]; if (h.Current > 0f) { float max = m_EffChar.HasComponent(killer) ? m_EffChar[killer].MaxHealth : h.Max; h.Current = math.min(h.Current + k_SiphonHeal, max); m_Health[killer] = h; } } // Frenzy: refresh (never stack) a short cooldown-reduction buff on the killer. if ((flags & BoonFlag.Frenzy) != 0 && m_Mods.HasBuffer(killer) && m_Timed.HasBuffer(killer)) { TimedModifierUtil.Upsert(m_Mods[killer], m_Timed[killer], Tuning.FrenzySourceId, (byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, Tuning.FrenzyCooldownMult, until); } } playerByNet.Dispose(); } } }