using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.NetCode; namespace ProjectM.Server { /// /// Terminal drain for RPC requests no receiver consumed. Destroys any entity still carrying an unconsumed /// after it has survived a couple of frames, so a missing receiver /// degrades to a silent drop instead of a permanent entity leak. /// /// WHY (2026-08-06 audit, reproduced live): every RPC receiver in this project gates itself behind /// RequireForUpdate<T> on a scene-baked singleton. In DevSandbox none of those singletons exist, /// so eleven receivers never ran at all — one keypress on E/Q/G/T created a server request entity that /// nothing ever visited. Netcode's own WarnAboutStaleRpcSystem logs "has not been consumed or /// destroyed for 4 (MaxRpcAgeFrames) frames", then calls Consume() — it never destroys the entity, and it is /// compiled out of player builds (#if UNITY_EDITOR && !NETCODE_NDEBUG). So the entities /// accumulated forever, silently, and worst in a shipped build. /// /// ORDERING: OrderLast in the plain server , so every same-frame receiver /// (including those inside the nested predicted group) has already had its chance. The Age gate is belt and /// braces on top of that: Age increments per frame the request survives, so requiring >= 2 means the request /// lived through a full frame unhandled before we reap it. Consumed requests are skipped — their owning /// receiver is responsible for destroying them, and Consume() sets Age to ushort.MaxValue anyway. /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup), OrderLast = true)] public partial struct StaleRpcReaperSystem : ISystem { /// Frames an unconsumed request may survive before it is reaped. Must exceed 1 so a receiver /// that legitimately runs later in the same frame is never robbed of its request. const ushort k_MaxAgeFrames = 2; [BurstCompile] public void OnCreate(ref SystemState state) { var b = new EntityQueryBuilder(Allocator.Temp).WithAll(); state.RequireForUpdate(state.GetEntityQuery(b)); } [BurstCompile] public void OnUpdate(ref SystemState state) { var ecb = new EntityCommandBuffer(Allocator.Temp); foreach (var (recv, entity) in SystemAPI.Query>().WithEntityAccess()) { if (recv.ValueRO.IsConsumed) continue; // a receiver owns it; not ours to destroy if (recv.ValueRO.Age < k_MaxAgeFrames) continue; ecb.DestroyEntity(entity); } ecb.Playback(state.EntityManager); ecb.Dispose(); } } }