Run Re-Do
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server receiver for <see cref="MetaSpendRequest"/> — 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 → <see cref="PlayerClass"/>, validate catalog id /
|
||||
/// class mask (<see cref="BoonMath.MaskFor"/>, never raw 1<<ClassId) / MaxTier / prereq, price the NEXT tier
|
||||
/// (<see cref="MetaMath.CostForTier"/> — tier is server-computed, never on the wire), then
|
||||
/// <see cref="StorageMath.TotalOf"/> pre-check BEFORE <see cref="StorageMath.Withdraw"/> (Withdraw CLAMPS, it
|
||||
/// never rejects), bump-or-append the <see cref="MetaTierState"/> row, and upsert the ABSOLUTE-value meta
|
||||
/// StatModifier (R-F1: Value = ValuePerTier * newTier, keyed <c>Tuning.MetaSourceIdBase + id</c>) on every
|
||||
/// pre-collected live player of that class (R-F2 — offline classmates get theirs born-correct at next spawn via
|
||||
/// GoInGameServerSystem). Success raises <see cref="SaveRequest"/> so the tier is on disk before a crash.
|
||||
/// Plain server group, before RunDirectorSystem (the receiver convention); requests are ALWAYS destroyed.
|
||||
/// </summary>
|
||||
[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<MetaSpendRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<MetaUpgradeCatalog>();
|
||||
state.RequireForUpdate<ResourceLedger>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
// N4 phase gate — hoisted (per-tick-uniform, like the ReadyToggle accept flag).
|
||||
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
|
||||
|
||||
var catalog = SystemAPI.GetSingleton<MetaUpgradeCatalog>();
|
||||
var director = SystemAPI.GetSingletonEntity<ResourceLedger>();
|
||||
if (!catalog.Value.IsCreated || !SystemAPI.HasBuffer<MetaTierState>(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<int, Entity>(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<Entity>(8, Allocator.Temp);
|
||||
var classIds = new NativeList<byte>(8, Allocator.Temp);
|
||||
foreach (var (owner, playerClass, entity) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>, RefRO<PlayerClass>>()
|
||||
.WithAll<PlayerTag, StatModifier>().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<ReceiveRpcCommandRequest>, RefRO<MetaSpendRequest>>().WithEntityAccess())
|
||||
{
|
||||
ecb.DestroyEntity(requestEntity); // ALWAYS consumed, accepted or not
|
||||
if (!accept) continue;
|
||||
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
if (!SystemAPI.HasComponent<NetworkId>(conn)
|
||||
|| !playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var buyer))
|
||||
continue;
|
||||
byte classId = SystemAPI.GetComponent<PlayerClass>(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<MetaTierState>(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<StorageEntry>(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<StatModifier>(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<SaveRequest>(director))
|
||||
SystemAPI.SetComponent(director, new SaveRequest { Pending = 1 });
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
playerByConn.Dispose();
|
||||
classMembers.Dispose();
|
||||
classIds.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user