Base<->Expedition ties: portal rooms, class-at-base, prep spend + Health.Max, C3/C4/Spitter (DR-046)
Portal-gated rooms: new RunLifecycle.RoomExplore loot window; room+nodes persist past the last kill; PortalInteractRequest -> server-only PortalCommand advances (client derives the portal pos). RunDirectorSystem moves teardown off the InRoom edge, relocates the boss-branch/route-gate into the RoomExplore exit, and advances an empty expedition immediately (incl. a boss clear). Class-at-base via a shared ClassSwapUtil (meta-band strip + per-class MetaTierState replay, so the base RPC and the dev SetClass can't drift); ClassSelectRequest Staging-gated. Per-run PREP buffs (PrepPurchaseRequest, PrepCatalog): once-per-run == the row's prep StatModifier band is present, TotalOf-before-Withdraw atomic, stripped on Returning beside the boon band. Health.Max promoted to [GhostField] (the one ghost-hash re-bake) so the boss + floating enemy HP bars read it directly. Feel: melee cone connect-thunk (C3), hit-stop throttle so a horde wipe can't stutter (C4), Spitter cornered-hold. 456/456 EditMode; two adversarial reviews (pre-code 13-confirmed folded; post-impl clean bar the RoomExplore boss-clear dead-time, fixed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
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 (!SystemAPI.HasComponent<NetworkId>(conn)
|
||||
|| !playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(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<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user