Files
Project-M/Assets/_Project/Scripts/Server/Combat/PrepPurchaseSystem.cs
T
kronic 813c829420 Hygiene B4d: PlayerResolve dedup + explicit Temp-ECB Dispose tail
- New Server/PlayerResolve.TryResolve single-sources the RPC
  SourceConnection -> NetworkId -> conn->player map resolve (3 sites:
  ClassSelectReceive, PrepPurchase, DebugCommandReceive); EntityManager
  reads keep it source-gen-safe from Bursted receivers.
- ecb.Dispose() after Playback in 9 Temp-ECB systems (explicit-lifetime
  hygiene).
- The TuningConfig.GetOrDefault(ref state) variant of this tail was
  REVERTED: state.GetEntityQuery in OnUpdate trips the Entities
  "creates a query during OnUpdate" diagnostic per system per world
  (caught in Play smoke) - the SystemAPI.TryGetSingleton idiom is
  already source-gen-optimal, confirming the original B4 deferral.

Verified: 466/466 EditMode green on the final tree, console clean,
Play smoke 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:39:55 -07:00

79 lines
3.9 KiB
C#

using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="PrepPurchaseRequest"/> — 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 — <see cref="StorageMath.TotalOf"/> pre-check BEFORE <see cref="StorageMath.Withdraw"/>, since Withdraw
/// CLAMPS and never rejects). A purchase appends ONE run-scoped <see cref="StatModifier"/> in the prep band
/// (<see cref="Tuning.PrepSourceIdBase"/> + 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).
/// </summary>
[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<PrepPurchaseRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(b));
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<ResourceLedger>();
}
public void OnUpdate(ref SystemState state)
{
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
var director = SystemAPI.GetSingletonEntity<ResourceLedger>();
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, e) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = e;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, reqEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<PrepPurchaseRequest>>().WithEntityAccess())
{
ecb.DestroyEntity(reqEntity); // ALWAYS consumed
if (!accept) continue;
var conn = receive.ValueRO.SourceConnection;
if (!PlayerResolve.TryResolve(ref state, playerByConn, conn, 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<StatModifier>(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<StorageEntry>(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();
}
}
}