Files
Project-M/Assets/_Project/Scripts/Server/World/RunDirectorSystem.cs
T
kronic b34945c2d2 LANTERN purge B3+B5: delete the cycle/core/win-lose spine + onboarding; save epoch v7
Deletes CyclePhaseSystem, GoalReachedSystem, CoreDamage/CoreRestore, ThreatDirector,
CoreIntegrity/GoalProgress/RunPhase/RunOutcome/ThreatState components,
CoreVisualFeedbackSystem, and the whole Client/Onboarding slice (+6 test files).

Keepers reworked: RunDirectorSystem (UpdateBefore attr + launch guard + goal/threat
bank removed; sole SaveRequest raiser now), CycleDirectorSpawnSystem (ledger/meta
host only), WaveSystem UNGATED (waves run wherever a WaveDirector is baked),
EnemyAISystem core-fallback stripped, AmbientAudioSystem reworked (bed + run cues;
no CycleState gate), MusicSystem RunInfo-only, HudSystem big trim (goal meter, core
bar, siege banner, terminal banner, outcome flash, onboarding hook all gone),
MetaShop/ClassPrep/AimReticle siege gates dropped, DebugOverlay/ops re-meant
(SpawnWave=force next wave, EndSiege=quiet arena; SetCalm/AdvanceGoal/SetHeat
retired, bytes reserved), TuningConfig Core knobs retired (ids 20-23 reserved),
StorageMath.DrainFraction deleted, HowToPlay copy rewritten.

Save epoch v7 (fresh epoch, operator-approved): SaveData drops goal/core/outcome +
conveyor/machine-IO fields; MinLoadableVersion=7; PendingSave/PendingStructure
trimmed; RollTerminalCampaignForward deleted; SaveStructureScan signature slimmed.

390 tests green; Play world-creation clean (player + waves live, no exceptions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:27:12 -07:00

439 lines
25 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"/>).
///
/// 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 RunsCompleted and requests a
/// save. An abort/wipe banks NOTHING but the depth high-water (D-F3).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
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:
{
if (allReady && run.WasAllReady == 0)
{
// 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)
{
// Conscript the party: the launch roster is EVERYONE connected (N7 co-location — all at
// base, all ready). Room advances teleport ONLY RunParticipants, so a mid-run late joiner
// is never yanked into the fight; the tag is released on the Returning edge.
var conscript = new EntityCommandBuffer(Allocator.Temp);
foreach (var (_, playerE) in
SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>().WithEntityAccess())
conscript.AddComponent<RunParticipant>(playerE);
conscript.Playback(state.EntityManager);
conscript.Dispose();
// 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)
{
// DR-046: teardown MOVED to the RoomExplore exit — the room + resource nodes persist through
// RoomReward + the loot window so the party can mine after clearing.
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).
// Only EXPEDITION players hold the gate (BoonOfferSystem's documented contract): a player who
// died and respawned to base must not stall the party (post-impl review, confirmed major).
bool anyPending = false;
foreach (var (offer, pregion) in
SystemAPI.Query<RefRO<BoonOffer>, RefRO<RegionTag>>().WithAll<PlayerTag>())
if (pregion.ValueRO.Region == RegionId.Expedition && 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;
// NO offer survives this gate: zero every straggler (a dead-respawned base player the
// auto-pick deliberately skips, a grace-expired AFK) so a stale Pending can never wedge the
// HUD modal open or stall a later reward gate (post-impl review, confirmed major).
foreach (var offer in SystemAPI.Query<RefRW<BoonOffer>>().WithAll<PlayerTag>())
offer.ValueRW = default;
// DR-046: don't advance yet — open the LOOT WINDOW. The cleared room + its resource nodes persist
// (teardown moved to the RoomExplore exit); a portal is up. Leave via the portal or a soft timeout.
run.ExploreGraceTick = TickUtil.NonZero(now + Tuning.ExploreGraceTicks);
if (SystemAPI.HasComponent<PortalCommand>(dirEntity))
SystemAPI.SetComponent(dirEntity, default(PortalCommand)); // fresh portal latch for this window
info.Lifecycle = RunLifecycle.RoomExplore;
break;
}
case RunLifecycle.RoomExplore:
{
// DR-046 LOOT WINDOW: the cleared room + its resource nodes persist; a portal is up. Advance when a
// participant interacts the portal (PortalCommand, set by PortalInteractReceiveSystem) OR the soft
// timeout elapses (never a softlock). Abort if the expedition emptied (unless the boss already fell).
if (expeditionPlayers == 0) // DR-046 fix: an empty expedition advances NOW (boss -> Returning banks the win
{ // immediately; non-boss -> abort no-credit) — no ~30s ExploreGrace dead-time on the win moment.
run.ExploreGraceTick = 0u;
info.Lifecycle = RunLifecycle.Returning;
break;
}
bool portalUsed = SystemAPI.HasComponent<PortalCommand>(dirEntity)
&& SystemAPI.GetComponent<PortalCommand>(dirEntity).HasInteract != 0;
bool exploreTimedOut = run.ExploreGraceTick == 0u
|| !new NetworkTick(run.ExploreGraceTick).IsNewerThan(serverTick);
if (!portalUsed && !exploreTimedOut)
break; // still looting
run.ExploreGraceTick = 0u;
if (SystemAPI.HasComponent<PortalCommand>(dirEntity))
SystemAPI.SetComponent(dirEntity, default(PortalCommand));
// The MOVED teardown: NOW destroy the cleared room (nodes + clutter), then advance.
var exploreEcb = new EntityCommandBuffer(Allocator.Temp);
RoomTeardown.DestroyRoom(m_RoomTagged, exploreEcb, (byte)(info.CurrentRoom & 0xFF));
exploreEcb.Playback(state.EntityManager);
exploreEcb.Dispose();
if (run.LastTerminalCleared != 0)
{
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
}
else
{
// Open the branching ROUTE GATE (relocated from RoomReward): publish authoritative reachable
// options; RouteSelect is the teardown gap (the room is gone now).
var map = RunMapMath.Generate(run.RunSeed);
int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol, out var cols);
if (optionCount == 0)
{
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);
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:
{
// PARTICIPANT teleport home + region flip + roster release. Only the launch roster comes
// home (a mid-run joiner already at base keeps its position); the tag removal re-opens the
// next run's conscription cleanly (post-impl review, confirmed medium).
int idx = 0;
var homebound = new EntityCommandBuffer(Allocator.Temp);
foreach (var (region, xform, playerE) in
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>()
.WithAll<PlayerTag, RunParticipant>().WithEntityAccess())
{
region.ValueRW.Region = RegionId.Base;
var p = baseCenter;
p.x += 1.5f * idx;
p.y = xform.ValueRO.Position.y;
xform.ValueRW.Position = p;
homebound.RemoveComponent<RunParticipant>(playerE);
idx++;
}
homebound.Playback(state.EntityManager);
homebound.Dispose();
// 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: a save checkpoint (the win-meter/retaliation credits are retired — LANTERN purge).
if (run.LastTerminalCleared != 0 && 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, timed, fx, offer) in
SystemAPI.Query<DynamicBuffer<StatModifier>, DynamicBuffer<TimedModifier>, RefRW<BoonEffects>, RefRW<BoonOffer>>().WithAll<PlayerTag>())
{
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan);
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.PrepSourceIdBase,
Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan); // DR-046: strip the run-scoped prep loadout too
// Phase 1.7: zero the mechanic-changer boons + strip the stale Frenzy timed row (its paired
// StatModifier is already cleared by the boon-band range-strip above).
fx.ValueRW = default;
TimedModifierUtil.RemoveBySourceId(timed, Tuning.FrenzySourceId);
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, RunParticipant>())
{
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;
}
}
}