Files
Project-M/Assets/_Project/Scripts/Server/Combat/BoonApplySystem.cs
T
2026-07-02 20:41:43 -07:00

134 lines
6.6 KiB
C#

using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="BoonPickRequest"/> + the reward-grace AUTO-PICK backstop. A valid pick
/// (sender resolved, <c>RunInfo.Lifecycle == RoomReward</c> — the D-F4 gate — <c>Pending == 1</c>, index in
/// range, option id known to the catalog) appends ONE <see cref="StatModifier"/> in the run-scoped BOON band
/// (<c>Tuning.BoonSourceIdBase + BoonPickCounter++</c> — distinct rows, one range-strip clears the run) and
/// clears <c>Pending</c>; the buffer mutation is non-structural and folds through the unchanged
/// StatRecomputeSystem on both worlds (rollback-correct). When the reward grace elapses, every still-pending
/// EXPEDITION player is auto-dealt <c>Option0</c> (the operator's default un-picked policy — a player always
/// gets something) so the run never stalls on an AFK picker.
///
/// Ordering: <c>[UpdateBefore(RunDirectorSystem)]</c> — ALL RPC receivers sit before the director (the
/// ReadyToggle/RouteSelect symmetry). This closes the D-F4 straggler race STRUCTURALLY: on the tick the
/// director strips (Returning), a straggler pick is rejected here FIRST (lifecycle is already past RoomReward),
/// so nothing can append after the strip; and the auto-pick lands before the director's exit gate reads
/// Pending. Requests are ALWAYS destroyed. No CyclePhase edge (the room-chain hard rule).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct BoonApplySystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<BoonCatalog>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
state.RequireForUpdate<NetworkTime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
bool rewarding = info.Lifecycle == RunLifecycle.RoomReward;
var catalog = SystemAPI.GetComponent<BoonCatalog>(SystemAPI.GetSingletonEntity<BoonCatalog>());
if (!catalog.Value.IsCreated)
return;
ref var pool = ref catalog.Value.Value;
bool runDirty = false;
// ---- explicit picks (drained every tick so stale requests die even outside RoomReward) ----
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, entity) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, BoonOffer, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = entity;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, requestEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<BoonPickRequest>>().WithEntityAccess())
{
var conn = receive.ValueRO.SourceConnection;
if (rewarding
&& req.ValueRO.Index < 3
&& SystemAPI.HasComponent<NetworkId>(conn)
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
{
var offer = SystemAPI.GetComponent<BoonOffer>(player);
if (offer.Pending == 1)
{
byte id = req.ValueRO.Index == 2 ? offer.Option2
: req.ValueRO.Index == 1 ? offer.Option1 : offer.Option0;
if (Apply(ref state, player, id, ref pool, ref run))
{
offer.Pending = 0;
SystemAPI.SetComponent(player, offer);
runDirty = true;
}
}
}
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
playerByConn.Dispose();
// ---- reward-grace auto-pick backstop (Option0 — the player always gets something) ----
if (rewarding && run.RewardGraceTick != 0u)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (serverTick.IsValid && !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick))
{
foreach (var (offer, region, entity) in
SystemAPI.Query<RefRW<BoonOffer>, RefRO<RegionTag>>()
.WithAll<PlayerTag, StatModifier>().WithEntityAccess())
{
if (offer.ValueRO.Pending != 1 || region.ValueRO.Region != RegionId.Expedition)
continue;
if (Apply(ref state, entity, offer.ValueRO.Option0, ref pool, ref run))
runDirty = true;
offer.ValueRW.Pending = 0; // cleared even if the id was unknown — never wedge the gate
}
}
}
if (runDirty)
SystemAPI.SetComponent(dirEntity, run); // the documented BoonPickCounter co-write (band provenance)
}
/// <summary>Append the boon's StatModifier in the run-scoped band. False iff the id is unknown/zero.</summary>
static bool Apply(ref SystemState state, Entity player, byte boonId, ref BoonCatalogBlob pool, ref RunRuntime run)
{
if (boonId == 0)
return false;
int idx = BoonMath.FindDef(ref pool, boonId);
if (idx < 0)
return false; // unknown id (catalog drift) — preserve-and-skip, never throw
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
mods.Add(new StatModifier
{
Target = pool.Defs[idx].Target,
Op = pool.Defs[idx].Op,
Value = pool.Defs[idx].Value,
SourceId = Tuning.BoonSourceIdBase + (run.BoonPickCounter % Tuning.BoonSourceIdSpan),
});
run.BoonPickCounter += 1;
return true;
}
}
}