410 lines
23 KiB
C#
410 lines
23 KiB
C#
using ProjectM.Simulation;
|
|
using Unity.Burst;
|
|
using Unity.Collections;
|
|
using Unity.Entities;
|
|
using Unity.Mathematics;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
|
|
namespace ProjectM.Server
|
|
{
|
|
/// <summary>
|
|
/// SOLE writer of the replicated run-lifecycle FSM (<see cref="RunInfo"/>) and its server-only working state
|
|
/// (<see cref="RunRuntime"/>) — the expedition redesign's counterpart of CyclePhaseSystem's single-writer
|
|
/// discipline (that system stays the sole writer of the BASE Calm↔Siege posture; the two FSMs are distinct).
|
|
///
|
|
/// Step-7 = the REAL LINEAR traversal: Staging (ready-check) → Launching (3-2-1 telegraph, un-ready aborts) →
|
|
/// InRoom (fight; the clear edge arrives as the replicated <see cref="ExpeditionObjective"/>.State == Cleared,
|
|
/// consumed ONE-TICK-LATE by construction — RoomEnemyDirectorSystem writes it after this system each tick, so no
|
|
/// system-ordering back-edge exists) → RoomReward (cleared room TORN DOWN at entry via <see cref="RoomTeardown"/>;
|
|
/// boon picks gate the exit from Step 10, all-Pending==0 today) → advance (bump room/epoch, flip the ping-pong
|
|
/// sub-slot, teleport — teardown-at-entry + spawn-on-advance guarantees ≥1 empty tick and exactly ONE room alive)
|
|
/// → … → Boss clear → Returning (teleport home + the CLEAR-GATED terminal bank) → Staging. Branching route
|
|
/// choice (RouteSelect) replaces the fixed col-0 advance at Step 8.
|
|
///
|
|
/// The terminal bank (once per RunEpoch, equality-latched): ALWAYS records the honest depth
|
|
/// (max(MaxDepthReached, RoomsClearedThisRun) — never the planned RoomCount) and re-stages; ONLY a genuine
|
|
/// boss-clear terminal (<see cref="RunRuntime.LastTerminalCleared"/>) credits the win meter
|
|
/// (<see cref="GoalProgress"/>.Charge, clamped), RunsCompleted, the retaliation inputs
|
|
/// (<see cref="ThreatState"/>.PendingReturns/ExpeditionsCompleted — carried from the retired gate, C7) and
|
|
/// requests a save. An abort/wipe banks NOTHING but the depth high-water (D-F3).
|
|
///
|
|
/// Ordering: <c>[UpdateBefore(CyclePhaseSystem)]</c> ONLY (GoalReachedSystem is [UpdateAfter(CyclePhaseSystem)] —
|
|
/// transitively after this system, so the Charge credit lands before it reads the edge). Per the hard rule,
|
|
/// NOTHING in the room chain adds another CyclePhase edge (a sort cycle is invisible to EditMode and throws only
|
|
/// at Play world creation).
|
|
/// </summary>
|
|
[BurstCompile]
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
[UpdateBefore(typeof(CyclePhaseSystem))]
|
|
public partial struct RunDirectorSystem : ISystem
|
|
{
|
|
/// <summary>"All ready → 3-2-1 → go" telegraph (~3 s @ 60). An un-ready during the countdown aborts.</summary>
|
|
const uint LaunchCountdownTicks = 180;
|
|
|
|
/// <summary>Boon-pick grace (~30 s @ 60): RoomReward advances when every survivor picked OR this elapses
|
|
/// (the AFK/disconnect backstop; the un-picked-offer policy lands with the boons at Step 10).</summary>
|
|
const uint RewardGraceTicks = 1800;
|
|
|
|
/// <summary>Route-choice grace (~30 s @ 60): the gate auto-picks the LOWEST-INDEX reachable option when it
|
|
/// elapses (the AFK backstop; an accepted pick always beats a same-tick expiry — review F2).</summary>
|
|
const uint RouteGraceTicks = 1800;
|
|
|
|
EntityQuery m_RoomTagged;
|
|
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<NetworkTime>();
|
|
state.RequireForUpdate<RunInfo>();
|
|
state.RequireForUpdate<RunRuntime>();
|
|
m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly<RoomTag>());
|
|
}
|
|
|
|
[BurstCompile]
|
|
public void OnUpdate(ref SystemState state)
|
|
{
|
|
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
|
if (!serverTick.IsValid)
|
|
return;
|
|
uint now = serverTick.TickIndexForValidTick;
|
|
|
|
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
|
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
|
|
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
|
|
|
|
float3 baseCenter = new float3(0f, 1f, 0f);
|
|
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
|
baseCenter = BaseGridMath.PlotCenter(anchor);
|
|
|
|
// Ready-check + party-presence derivation, shared across the states below. The party is co-located at
|
|
// base while Staging (the N7 co-location invariant), so live PlayerTag ghosts ARE the roster; a
|
|
// disconnect drops the counts (LinkedEntityGroup despawn) and the checks re-derive clean.
|
|
int totalPlayers = 0, readyPlayers = 0, expeditionPlayers = 0;
|
|
foreach (var (ready, region) in
|
|
SystemAPI.Query<RefRO<PlayerReady>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
|
{
|
|
totalPlayers++;
|
|
if (ready.ValueRO.Value != 0) readyPlayers++;
|
|
if (region.ValueRO.Region == RegionId.Expedition) expeditionPlayers++;
|
|
}
|
|
bool allReady = totalPlayers > 0 && readyPlayers == totalPlayers;
|
|
|
|
switch (info.Lifecycle)
|
|
{
|
|
case RunLifecycle.Staging:
|
|
{
|
|
// F2 cross-FSM launch guard: no new run while a final siege arms/runs or the outcome latched.
|
|
// Guards default OPEN when the server-only markers are absent (EditMode worlds).
|
|
bool launchAllowed =
|
|
(!SystemAPI.HasComponent<RunPhase>(dirEntity)
|
|
|| SystemAPI.GetComponent<RunPhase>(dirEntity).Value == RunPhaseId.Normal)
|
|
&& (!SystemAPI.HasComponent<RunOutcome>(dirEntity)
|
|
|| SystemAPI.GetComponent<RunOutcome>(dirEntity).Value == RunOutcomeId.InProgress);
|
|
|
|
if (allReady && run.WasAllReady == 0 && launchAllowed)
|
|
{
|
|
// Rising edge → Launching. Seed the run: monotonic epoch + per-playthrough salt lineage,
|
|
// never a tick, never 0, equality-compared downstream.
|
|
run.RunEpoch += 1;
|
|
run.HostSalt = RunMapMath.Hash(run.HostSalt, (uint)run.RunEpoch);
|
|
run.RunSeed = math.max(1u, RunMapMath.Hash((uint)run.RunEpoch, run.HostSalt));
|
|
run.NodeBudgetRemaining = Tuning.ExpeditionNodeBudget;
|
|
run.RoomsClearedThisRun = 0;
|
|
run.BoonPickCounter = 0; // fresh boon-band provenance per run
|
|
run.LastTerminalCleared = 0;
|
|
|
|
var map = RunMapMath.Generate(run.RunSeed);
|
|
info.RunSeed = run.RunSeed;
|
|
info.RoomCount = map.LayerCount;
|
|
info.LaunchTick = TickUtil.NonZero(now + LaunchCountdownTicks);
|
|
info.Lifecycle = RunLifecycle.Launching;
|
|
}
|
|
run.WasAllReady = (byte)(allReady ? 1 : 0);
|
|
break;
|
|
}
|
|
|
|
case RunLifecycle.Launching:
|
|
{
|
|
// Un-ready during the countdown aborts back to Staging (the telegraph's escape hatch).
|
|
if (!allReady)
|
|
{
|
|
info.LaunchTick = 0u;
|
|
info.Lifecycle = RunLifecycle.Staging;
|
|
run.WasAllReady = 0;
|
|
break;
|
|
}
|
|
|
|
bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick);
|
|
if (due)
|
|
{
|
|
// Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0).
|
|
var map = RunMapMath.Generate(run.RunSeed);
|
|
EnterRoom(ref state, ref info, ref run, in map, layer: 0, col: 0, baseCenter, bumpEpoch: true);
|
|
info.LaunchTick = 0u;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case RunLifecycle.InRoom:
|
|
{
|
|
// All expedition players gone (disconnect/death-warp edge) → clean abort, no credit.
|
|
if (expeditionPlayers == 0)
|
|
{
|
|
run.LastTerminalCleared = 0;
|
|
info.Lifecycle = RunLifecycle.Returning;
|
|
break;
|
|
}
|
|
|
|
// The room clear edge — the replicated objective RoomEnemyDirectorSystem computed LAST tick
|
|
// (one-tick-late by construction; no ordering back-edge). Teardown happens AT THIS ENTRY, the
|
|
// next room spawns on the advance tick → ≥1 empty tick, exactly one room alive.
|
|
if (SystemAPI.HasComponent<ExpeditionObjective>(dirEntity)
|
|
&& SystemAPI.GetComponent<ExpeditionObjective>(dirEntity).State == ExpeditionObjectiveState.Cleared)
|
|
{
|
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
RoomTeardown.DestroyRoom(m_RoomTagged, ecb, (byte)(info.CurrentRoom & 0xFF));
|
|
ecb.Playback(state.EntityManager);
|
|
ecb.Dispose();
|
|
|
|
run.RoomsClearedThisRun += 1;
|
|
if (info.CurrentRoom >= info.RoomCount - 1)
|
|
run.LastTerminalCleared = 1; // the Boss fell — a genuine terminal clear
|
|
run.RewardGraceTick = TickUtil.NonZero(now + RewardGraceTicks);
|
|
info.Lifecycle = RunLifecycle.RoomReward;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case RunLifecycle.RoomReward:
|
|
{
|
|
if (expeditionPlayers == 0 && run.LastTerminalCleared == 0)
|
|
{
|
|
info.Lifecycle = RunLifecycle.Returning;
|
|
break;
|
|
}
|
|
|
|
// Exit gate: every SURVIVING player has picked (BoonOffer.Pending==0 — inert until Step 10)
|
|
// OR the grace elapsed (wrap-safe IsNewerThan, never raw uint — F4).
|
|
bool anyPending = false;
|
|
foreach (var offer in SystemAPI.Query<RefRO<BoonOffer>>().WithAll<PlayerTag>())
|
|
if (offer.ValueRO.Pending != 0) { anyPending = true; break; }
|
|
bool graceElapsed = run.RewardGraceTick == 0u
|
|
|| !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick);
|
|
if (anyPending && !graceElapsed)
|
|
break;
|
|
|
|
run.RewardGraceTick = 0u;
|
|
if (run.LastTerminalCleared != 0)
|
|
{
|
|
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
|
|
}
|
|
else
|
|
{
|
|
// Open the ROUTE GATE (Step 8 — the branching choice): publish the AUTHORITATIVE reachable
|
|
// options (the client map panel is regen-for-display; the clickable buttons bind to these
|
|
// bytes). The cleared room is already gone — RouteSelect IS the teardown gap; the next room
|
|
// materializes only when the choice commits.
|
|
var map = RunMapMath.Generate(run.RunSeed);
|
|
int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol,
|
|
out var cols);
|
|
if (optionCount == 0)
|
|
{
|
|
// Unreachable by construction (every non-terminal node has an out-edge) — a future
|
|
// generator regression must abort CLEANLY, never wedge on stale options (review F4).
|
|
info.RouteOptionCount = 0;
|
|
info.Lifecycle = RunLifecycle.Returning;
|
|
}
|
|
else
|
|
{
|
|
int nextLayer = info.CurrentRoom + 1;
|
|
info.RouteOptionCount = (byte)math.min(optionCount, 3);
|
|
info.RouteOpt0Col = cols.Length > 0 ? cols[0] : (byte)0;
|
|
info.RouteOpt1Col = cols.Length > 1 ? cols[1] : (byte)0;
|
|
info.RouteOpt2Col = cols.Length > 2 ? cols[2] : (byte)0;
|
|
info.RouteOpt0Type = cols.Length > 0 ? map.Node(nextLayer, cols[0]).RoomType : (byte)0;
|
|
info.RouteOpt1Type = cols.Length > 1 ? map.Node(nextLayer, cols[1]).RoomType : (byte)0;
|
|
info.RouteOpt2Type = cols.Length > 2 ? map.Node(nextLayer, cols[2]).RoomType : (byte)0;
|
|
run.RouteGraceTick = TickUtil.NonZero(now + RouteGraceTicks);
|
|
// Entry-clear: any accepted pick provably belongs to THIS gate (RouteSelectSystem runs
|
|
// BEFORE this system, so it cannot accept on the entry tick).
|
|
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
|
|
SystemAPI.SetComponent(dirEntity, default(RouteCommand));
|
|
info.Lifecycle = RunLifecycle.RouteSelect;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case RunLifecycle.RouteSelect:
|
|
{
|
|
// Predicate order is LOAD-BEARING (review F2): abort → pick-consume → grace. A same-tick pick
|
|
// from a vanishing party must never resurrect the run (EnterRoom would conscript base players);
|
|
// an accepted pick must beat a same-tick grace expiry (the player was told "committed").
|
|
if (expeditionPlayers == 0)
|
|
{
|
|
info.RouteOptionCount = 0; // close the gate ON the abort edge itself (review F3)
|
|
run.LastTerminalCleared = 0;
|
|
info.Lifecycle = RunLifecycle.Returning;
|
|
break;
|
|
}
|
|
|
|
var cmd = SystemAPI.HasComponent<RouteCommand>(dirEntity)
|
|
? SystemAPI.GetComponent<RouteCommand>(dirEntity)
|
|
: default;
|
|
bool routeGraceElapsed = run.RouteGraceTick == 0u
|
|
|| !new NetworkTick(run.RouteGraceTick).IsNewerThan(serverTick);
|
|
|
|
if (cmd.HasPick != 0)
|
|
{
|
|
// The party's committed choice (first-accepted-wins latch; any-player-first-commits).
|
|
byte chosenCol = cmd.OptionIndex == 2 ? info.RouteOpt2Col
|
|
: cmd.OptionIndex == 1 ? info.RouteOpt1Col : info.RouteOpt0Col;
|
|
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
|
|
SystemAPI.SetComponent(dirEntity, default(RouteCommand));
|
|
run.RouteGraceTick = 0u;
|
|
var map = RunMapMath.Generate(run.RunSeed);
|
|
EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, chosenCol, baseCenter, bumpEpoch: true);
|
|
}
|
|
else if (routeGraceElapsed)
|
|
{
|
|
// AFK backstop: deterministic LOWEST-INDEX reachable option (RouteOpt0 is ascending-first).
|
|
run.RouteGraceTick = 0u;
|
|
var map = RunMapMath.Generate(run.RunSeed);
|
|
EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, info.RouteOpt0Col, baseCenter, bumpEpoch: true);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case RunLifecycle.Returning:
|
|
{
|
|
// Party teleport HOME + region flip back to Base.
|
|
int idx = 0;
|
|
foreach (var (region, xform) in
|
|
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
|
|
{
|
|
region.ValueRW.Region = RegionId.Base;
|
|
var p = baseCenter;
|
|
p.x += 1.5f * idx;
|
|
p.y = xform.ValueRO.Position.y;
|
|
xform.ValueRW.Position = p;
|
|
idx++;
|
|
}
|
|
|
|
// THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3).
|
|
if (run.LastBankedRunEpoch != run.RunEpoch)
|
|
{
|
|
run.LastBankedRunEpoch = run.RunEpoch;
|
|
|
|
// Always: the honest depth high-water (actual rooms cleared, never the planned count).
|
|
if (SystemAPI.HasComponent<MetaCounters>(dirEntity))
|
|
{
|
|
var meta = SystemAPI.GetComponent<MetaCounters>(dirEntity);
|
|
meta.MaxDepthReached = math.max(meta.MaxDepthReached, run.RoomsClearedThisRun);
|
|
if (run.LastTerminalCleared != 0)
|
|
meta.RunsCompleted += 1;
|
|
SystemAPI.SetComponent(dirEntity, meta);
|
|
info.RunsCompleted = meta.RunsCompleted; // HUD mirror
|
|
info.MaxDepthReached = meta.MaxDepthReached; // HUD mirror
|
|
}
|
|
|
|
// Boss-clear only: the win meter, the retaliation inputs (C7), and a save checkpoint.
|
|
if (run.LastTerminalCleared != 0)
|
|
{
|
|
if (SystemAPI.HasComponent<GoalProgress>(dirEntity))
|
|
{
|
|
var goal = SystemAPI.GetComponent<GoalProgress>(dirEntity);
|
|
goal.Charge = math.min(goal.Charge + 1, goal.Target);
|
|
SystemAPI.SetComponent(dirEntity, goal);
|
|
}
|
|
if (SystemAPI.HasComponent<ThreatState>(dirEntity))
|
|
{
|
|
var threat = SystemAPI.GetComponent<ThreatState>(dirEntity);
|
|
threat.PendingReturns += 1;
|
|
threat.ExpeditionsCompleted += 1;
|
|
SystemAPI.SetComponent(dirEntity, threat);
|
|
}
|
|
if (SystemAPI.HasComponent<SaveRequest>(dirEntity))
|
|
SystemAPI.SetComponent(dirEntity, new SaveRequest { Pending = 1 });
|
|
}
|
|
}
|
|
|
|
// TWO-CHANNEL strip (DR-037): run boons EXPIRE at home — one range-strip clears every
|
|
// boon-band StatModifier (replicates via the [GhostField] buffer; StatRecompute reverts the
|
|
// effective stats on both worlds) and zeroes any straggler offer. Class/meta/equip bands are
|
|
// disjoint and survive. Idempotent — safe on every Returning tick.
|
|
foreach (var (mods, offer) in
|
|
SystemAPI.Query<DynamicBuffer<StatModifier>, RefRW<BoonOffer>>().WithAll<PlayerTag>())
|
|
{
|
|
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
|
|
Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan);
|
|
offer.ValueRW = default;
|
|
}
|
|
|
|
// Clear EVERY ready flag — the next run needs a fresh, deliberate ready-check from everyone.
|
|
foreach (var ready in SystemAPI.Query<RefRW<PlayerReady>>().WithAll<PlayerTag>())
|
|
ready.ValueRW.Value = 0;
|
|
|
|
run.RewardGraceTick = 0u;
|
|
run.RouteGraceTick = 0u; // gate hygiene (review F5): no stale grace into the next run
|
|
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
|
|
SystemAPI.SetComponent(dirEntity, default(RouteCommand)); // no leftover latch either
|
|
run.WasAllReady = 0;
|
|
run.LastTerminalCleared = 0;
|
|
|
|
info.CurrentRoom = 0;
|
|
info.RouteOptionCount = 0;
|
|
info.LaunchTick = 0u;
|
|
info.Lifecycle = RunLifecycle.Staging;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Single write-back point — RunInfo/RunRuntime are ALWAYS published (F12: the HUD readout can never
|
|
// freeze stale behind a branch's early-break).
|
|
SystemAPI.SetComponent(dirEntity, info);
|
|
SystemAPI.SetComponent(dirEntity, run);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enter room (<paramref name="layer"/>, <paramref name="col"/>): publish the node as the single plan
|
|
/// authority (<see cref="RunRuntime.CurrentNodeId"/>/<c>CurrentRoomType</c> — the field/enemy directors NEVER
|
|
/// re-derive it), flip the ping-pong sub-slot, bump <see cref="RunRuntime.RoomEpoch"/> so the room systems
|
|
/// reseed, and teleport the party onto the new origin (Position write in place — never FromPosition).
|
|
/// </summary>
|
|
void EnterRoom(ref SystemState state, ref RunInfo info, ref RunRuntime run, in RunMap map,
|
|
int layer, int col, float3 baseCenter, bool bumpEpoch)
|
|
{
|
|
var node = map.Node(layer, col);
|
|
run.ActiveSubSlot = (byte)(layer & 1);
|
|
run.CurrentNodeId = RunMap.NodeId(layer, col);
|
|
run.CurrentCol = (byte)col;
|
|
run.CurrentRoomType = node.RoomType;
|
|
if (bumpEpoch)
|
|
run.RoomEpoch += 1;
|
|
|
|
info.CurrentRoom = layer;
|
|
info.CurrentCol = (byte)col;
|
|
info.CurrentRoomType = node.RoomType;
|
|
info.CurrentBiome = node.Biome;
|
|
info.RouteOptionCount = 0;
|
|
|
|
float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
|
|
int idx = 0;
|
|
foreach (var (region, xform) in
|
|
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
|
|
{
|
|
region.ValueRW.Region = RegionId.Expedition;
|
|
var p = roomOrigin;
|
|
p.x += 1.5f * idx; // small spread so kinematic capsules don't stack
|
|
p.y = xform.ValueRO.Position.y;
|
|
xform.ValueRW.Position = p;
|
|
idx++;
|
|
}
|
|
|
|
info.Lifecycle = RunLifecycle.InRoom;
|
|
}
|
|
}
|
|
}
|