using ProjectM.Simulation; using Unity.Collections; using Unity.Entities; using Unity.NetCode; namespace ProjectM.Server { /// /// Server receiver for — the base PREP-LOADOUT spend (DR-046). Modeled on /// MetaSpendSystem: Staging-only, resolve sender → player, in-loop against the LIVE ledger (the DR-014 atomicity /// idiom — pre-check BEFORE , since Withdraw /// CLAMPS and never rejects). A purchase appends ONE run-scoped in the prep band /// ( + option id) on the BUYER only (prep is personal). "Once per run" needs /// NO separate latch: the SourceId's PRESENCE is the gate, and RunDirectorSystem strips the band on Returning, so /// it re-buys next run (finding #7 — latch lifetime == the band). Plain server group, before RunDirectorSystem; /// requests ALWAYS destroyed. NOT Burst-compiled (managed PrepCatalog table + low frequency). /// [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateBefore(typeof(RunDirectorSystem))] public partial struct PrepPurchaseSystem : ISystem { public void OnCreate(ref SystemState state) { var b = new EntityQueryBuilder(Allocator.Temp).WithAll(); state.RequireForUpdate(state.GetEntityQuery(b)); state.RequireForUpdate(); state.RequireForUpdate(); } public void OnUpdate(ref SystemState state) { bool accept = SystemAPI.GetSingleton().Lifecycle == RunLifecycle.Staging; var director = SystemAPI.GetSingletonEntity(); var playerByConn = new NativeHashMap(8, Allocator.Temp); foreach (var (owner, e) in SystemAPI.Query>().WithAll().WithEntityAccess()) playerByConn[owner.ValueRO.NetworkId] = e; var ecb = new EntityCommandBuffer(Allocator.Temp); foreach (var (receive, req, reqEntity) in SystemAPI.Query, RefRO>().WithEntityAccess()) { ecb.DestroyEntity(reqEntity); // ALWAYS consumed if (!accept) continue; var conn = receive.ValueRO.SourceConnection; if (!SystemAPI.HasComponent(conn) || !playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var buyer)) continue; if (!PrepCatalog.TryGet(req.ValueRO.OptionId, out var row)) continue; // unknown id -> drop uint sourceId = Tuning.PrepSourceIdBase + row.Id; var mods = SystemAPI.GetBuffer(buyer); bool already = false; for (int m = 0; m < mods.Length; m++) if (mods[m].SourceId == sourceId) { already = true; break; } // once per run (band stripped on Returning) if (already) continue; // LIVE in-loop ledger check + atomic withdraw (a same-tick second buy on barely-enough can't both pass). var ledger = SystemAPI.GetBuffer(director); if (StorageMath.TotalOf(ledger, row.CostResId) < row.Cost) continue; // pre-check: Withdraw CLAMPS StorageMath.Withdraw(ledger, row.CostResId, row.Cost); mods.Add(new StatModifier { Target = row.Target, Op = row.Op, Value = row.Value, SourceId = sourceId, }); } ecb.Playback(state.EntityManager); ecb.Dispose(); playerByConn.Dispose(); } } }