using ProjectM.Simulation; using Unity.Burst; using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; namespace ProjectM.Server { /// /// SOLE writer of the replicated run-lifecycle FSM () and its server-only working state /// () — 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 .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 ; /// 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 () credits the win meter /// (.Charge, clamped), RunsCompleted, the retaliation inputs /// (.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: [UpdateBefore(CyclePhaseSystem)] 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). /// [BurstCompile] [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] [UpdateInGroup(typeof(SimulationSystemGroup))] [UpdateBefore(typeof(CyclePhaseSystem))] public partial struct RunDirectorSystem : ISystem { /// "All ready → 3-2-1 → go" telegraph (~3 s @ 60). An un-ready during the countdown aborts. const uint LaunchCountdownTicks = 180; /// 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). const uint RewardGraceTicks = 1800; /// 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). const uint RouteGraceTicks = 1800; EntityQuery m_RoomTagged; [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly()); } [BurstCompile] public void OnUpdate(ref SystemState state) { var serverTick = SystemAPI.GetSingleton().ServerTick; if (!serverTick.IsValid) return; uint now = serverTick.TickIndexForValidTick; var dirEntity = SystemAPI.GetSingletonEntity(); var info = SystemAPI.GetComponent(dirEntity); var run = SystemAPI.GetComponent(dirEntity); float3 baseCenter = new float3(0f, 1f, 0f); if (SystemAPI.TryGetSingleton(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>().WithAll()) { 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(dirEntity) || SystemAPI.GetComponent(dirEntity).Value == RunPhaseId.Normal) && (!SystemAPI.HasComponent(dirEntity) || SystemAPI.GetComponent(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) { // 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>().WithAll().WithEntityAccess()) conscript.AddComponent(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(dirEntity) && SystemAPI.GetComponent(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>().WithAll()) 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>().WithAll()) 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(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(dirEntity) && SystemAPI.GetComponent(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(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(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(dirEntity) ? SystemAPI.GetComponent(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(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>() .WithAll().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(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(dirEntity)) { var meta = SystemAPI.GetComponent(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(dirEntity)) { var goal = SystemAPI.GetComponent(dirEntity); goal.Charge = math.min(goal.Charge + 1, goal.Target); SystemAPI.SetComponent(dirEntity, goal); } if (SystemAPI.HasComponent(dirEntity)) { var threat = SystemAPI.GetComponent(dirEntity); threat.PendingReturns += 1; threat.ExpeditionsCompleted += 1; SystemAPI.SetComponent(dirEntity, threat); } if (SystemAPI.HasComponent(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, RefRW>().WithAll()) { 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 offer.ValueRW = default; } // Clear EVERY ready flag — the next run needs a fresh, deliberate ready-check from everyone. foreach (var ready in SystemAPI.Query>().WithAll()) ready.ValueRW.Value = 0; run.RewardGraceTick = 0u; run.RouteGraceTick = 0u; // gate hygiene (review F5): no stale grace into the next run if (SystemAPI.HasComponent(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); } /// /// Enter room (, ): publish the node as the single plan /// authority (/CurrentRoomType — the field/enemy directors NEVER /// re-derive it), flip the ping-pong sub-slot, bump so the room systems /// reseed, and teleport the party onto the new origin (Position write in place — never FromPosition). /// 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>().WithAll()) { 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; } } }