Files
Project-M/Assets/_Project/Scripts/Server/Combat/BoonOfferSystem.cs
T
2026-07-13 18:30:41 -07:00

79 lines
3.8 KiB
C#

using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only choice-of-3 boon dealer: once per <see cref="RunRuntime.RoomEpoch"/> (int-equality latch on
/// <see cref="BoonOfferState"/>, attached beside the catalog singleton), when the run FSM enters RoomReward it
/// draws each EXPEDITION player's 3 distinct, rarity-weighted, class-filtered options via
/// <see cref="BoonMath.PickBoons"/> — deterministically seeded from Hash(RunSeed, room, NetworkId) — and writes
/// the player's owner-only replicated <see cref="BoonOffer"/> (Pending=1). A base-region player (dead-respawned,
/// late joiner) gets NO offer and never holds the gate (RunDirector counts only Pending!=0). BoonApplySystem
/// (Step 10) consumes picks; the Returning-edge strip zeroes stragglers.
///
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> — on the RoomReward ENTRY tick this runs after the
/// transition, so offers exist BEFORE RunDirector's exit gate first evaluates (next tick). No CyclePhase edge.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(RunDirectorSystem))]
public partial struct BoonOfferSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<BoonCatalog>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var catalogEntity = SystemAPI.GetSingletonEntity<BoonCatalog>();
// One-shot: attach this system's latch beside the catalog singleton (the RoomFieldState idiom).
if (!SystemAPI.HasComponent<BoonOfferState>(catalogEntity))
{
state.EntityManager.AddComponentData(catalogEntity, new BoonOfferState());
return; // structural change — clean re-read next tick
}
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
if (info.Lifecycle != RunLifecycle.RoomReward)
return;
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
var offered = SystemAPI.GetComponent<BoonOfferState>(catalogEntity);
if (offered.OfferedRoomEpoch == run.RoomEpoch)
return; // this room's offers are already dealt
var catalog = SystemAPI.GetComponent<BoonCatalog>(catalogEntity);
if (!catalog.Value.IsCreated)
return;
ref var pool = ref catalog.Value.Value;
foreach (var (offer, owner, region, cls, fx) in
SystemAPI.Query<RefRW<BoonOffer>, RefRO<GhostOwner>, RefRO<RegionTag>, RefRO<PlayerClass>, RefRO<BoonEffects>>()
.WithAll<PlayerTag>())
{
if (region.ValueRO.Region != RegionId.Expedition)
continue; // home-bound players (dead-respawned, joiners) are dealt nothing
// Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room, player, owned-effects-at-draw).
uint offerSeed = RunMapMath.Hash(run.RunSeed, (uint)info.CurrentRoom, (uint)owner.ValueRO.NetworkId) | 1u;
BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, fx.ValueRO, ref pool, out byte o0, out byte o1, out byte o2);
offer.ValueRW = new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 };
}
offered.OfferedRoomEpoch = run.RoomEpoch;
SystemAPI.SetComponent(catalogEntity, offered);
}
}
}