using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.NetCode; namespace ProjectM.Server { /// /// Server receiver for — the PERMANENT meta-upgrade purchase (Aether → tier). /// Honored ONLY in Staging (N4: the base shop is a between-runs surface; mid-run Aether belongs to the run). /// Per request, IN-LOOP against the live director buffers (the DR-014 placement idiom — two same-tick purchases /// on barely-enough Aether cannot both pass): resolve sender → , validate catalog id / /// class mask (, never raw 1<<ClassId) / MaxTier / prereq, price the NEXT tier /// ( — tier is server-computed, never on the wire), then /// pre-check BEFORE (Withdraw CLAMPS, it /// never rejects), bump-or-append the row, and upsert the ABSOLUTE-value meta /// StatModifier (R-F1: Value = ValuePerTier * newTier, keyed Tuning.MetaSourceIdBase + id) on every /// pre-collected live player of that class (R-F2 — offline classmates get theirs born-correct at next spawn via /// GoInGameServerSystem). Success raises so the tier is on disk before a crash. /// Plain server group, before RunDirectorSystem (the receiver convention); requests are ALWAYS destroyed. /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateBefore(typeof(RunDirectorSystem))] public partial struct MetaSpendSystem : ISystem { [BurstCompile] public void OnCreate(ref SystemState state) { var builder = new EntityQueryBuilder(Allocator.Temp) .WithAll(); state.RequireForUpdate(state.GetEntityQuery(builder)); state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); } [BurstCompile] public void OnUpdate(ref SystemState state) { // N4 phase gate — hoisted (per-tick-uniform, like the ReadyToggle accept flag). bool accept = SystemAPI.GetSingleton().Lifecycle == RunLifecycle.Staging; var catalog = SystemAPI.GetSingleton(); var director = SystemAPI.GetSingletonEntity(); if (!catalog.Value.IsCreated || !SystemAPI.HasBuffer(director)) accept = false; // authoring hole: drop the requests below (no withdraw happened; nothing to roll back) // Sender resolution (SourceConnection → NetworkId → GhostOwner → player, the ReadyToggle idiom). var playerByConn = new NativeHashMap(8, Allocator.Temp); // R-F2: pre-collect the live (player, class) pairs ONCE — a successful purchase upserts the modifier on // every live member of the class, not just the buyer (shared per-class pool, operator default). var classMembers = new NativeList(8, Allocator.Temp); var classIds = new NativeList(8, Allocator.Temp); foreach (var (owner, playerClass, entity) in SystemAPI.Query, RefRO>() .WithAll().WithEntityAccess()) { playerByConn[owner.ValueRO.NetworkId] = entity; classMembers.Add(entity); classIds.Add(playerClass.ValueRO.ClassId); } var ecb = new EntityCommandBuffer(Allocator.Temp); foreach (var (receive, req, requestEntity) in SystemAPI.Query, RefRO>().WithEntityAccess()) { ecb.DestroyEntity(requestEntity); // ALWAYS consumed, accepted or not if (!accept) continue; var conn = receive.ValueRO.SourceConnection; if (!SystemAPI.HasComponent(conn) || !playerByConn.TryGetValue(SystemAPI.GetComponent(conn).Value, out var buyer)) continue; byte classId = SystemAPI.GetComponent(buyer).ClassId; ref var pool = ref catalog.Value.Value; int defIdx = MetaMath.FindDef(ref pool, req.ValueRO.UpgradeId); if (defIdx < 0) continue; // unknown id — dropped (a forged/stale request, not a crash) ref var def = ref pool.Defs[defIdx]; if ((def.ClassMask & BoonMath.MaskFor(classId)) == 0) continue; // LIVE in-loop reads (no hoist — the previous request this tick may have bumped the tier or // drained the ledger; hoisted copies would let both pass). var record = SystemAPI.GetBuffer(director); byte owned = MetaMath.TierOf(record, classId, req.ValueRO.UpgradeId); if (owned >= def.MaxTier) continue; if (def.PrereqId != 0xFF && MetaMath.TierOf(record, classId, def.PrereqId) < def.PrereqTier) continue; int cost = MetaMath.CostForTier(in def, owned); var ledger = SystemAPI.GetBuffer(director); if (StorageMath.TotalOf(ledger, ResourceId.Aether) < cost) continue; // pre-check: Withdraw CLAMPS StorageMath.Withdraw(ledger, ResourceId.Aether, cost); // atomic commit (DR-014) byte newTier = (byte)(owned + 1); bool bumped = false; for (int i = 0; i < record.Length; i++) if (record[i].ClassId == classId && record[i].UpgradeId == req.ValueRO.UpgradeId) { record[i] = new MetaTierState { ClassId = classId, UpgradeId = req.ValueRO.UpgradeId, Tier = newTier }; bumped = true; break; } if (!bumped) record.Add(new MetaTierState { ClassId = classId, UpgradeId = req.ValueRO.UpgradeId, Tier = newTier }); // R-F1: ABSOLUTE-value upsert (Value = ValuePerTier * newTier) — never an incremental append; a // second append would double-count in StatRecomputeSystem's sum. uint sourceId = Tuning.MetaSourceIdBase + req.ValueRO.UpgradeId; for (int p = 0; p < classMembers.Length; p++) { if (classIds[p] != classId) continue; var mods = SystemAPI.GetBuffer(classMembers[p]); bool upserted = false; for (int m = 0; m < mods.Length; m++) if (mods[m].SourceId == sourceId) { var row = mods[m]; row.Value = def.ValuePerTier * newTier; mods[m] = row; upserted = true; break; } if (!upserted) mods.Add(new StatModifier { Target = def.Target, Op = def.Op, Value = def.ValuePerTier * newTier, SourceId = sourceId, }); } // Persist immediately — the tier is real money (Aether); a crash must not eat it. if (SystemAPI.HasComponent(director)) SystemAPI.SetComponent(director, new SaveRequest { Pending = 1 }); } ecb.Playback(state.EntityManager); playerByConn.Dispose(); classMembers.Dispose(); classIds.Dispose(); } } }