158 lines
8.1 KiB
C#
158 lines
8.1 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
|
|
|
|
if (pool.Defs[idx].Kind == 1)
|
|
{
|
|
// Phase 1.7 mechanic-changer: mutate the baked-present BoonEffects (non-structural) instead of
|
|
// appending a StatModifier. Bytes only (Burst-safe switch). No BoonPickCounter bump (no band row).
|
|
if (!state.EntityManager.HasComponent<BoonEffects>(player))
|
|
return false; // real players are baked with it; skip defensively otherwise
|
|
var fx = state.EntityManager.GetComponentData<BoonEffects>(player);
|
|
byte delta = (byte)pool.Defs[idx].Value;
|
|
switch (pool.Defs[idx].EffectKind)
|
|
{
|
|
case BoonEffectKind.Pierce: fx.Pierce = (byte)(fx.Pierce + delta); break;
|
|
case BoonEffectKind.Fork: fx.Fork = (byte)(fx.Fork + delta); break;
|
|
case BoonEffectKind.Chain: fx.Chain = (byte)(fx.Chain + delta); break;
|
|
case BoonEffectKind.DashTrail: fx.Flags |= BoonFlag.DashTrail; break;
|
|
case BoonEffectKind.FinisherDetonate: fx.Flags |= BoonFlag.FinisherDetonate; break;
|
|
case BoonEffectKind.KnockToPull: fx.Flags |= BoonFlag.KnockToPull; break;
|
|
case BoonEffectKind.Siphon: fx.Flags |= BoonFlag.Siphon; break;
|
|
case BoonEffectKind.Frenzy: fx.Flags |= BoonFlag.Frenzy; break;
|
|
default: return false; // unknown effect kind — preserve-and-skip
|
|
}
|
|
state.EntityManager.SetComponentData(player, fx);
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|