LANTERN purge: delete the superseded base/expedition shell (audit H1/H3/M5)
The 2026-08-06 audit found the shipping scene was still the abandoned co-op-Hades game with LANTERN combat bolted on, and that a third of the codebase was live code for a direction abandoned on 2026-07-13. Operator chose deletion over freezing: "everything is saved in source control if needed. I want the project to be clean." DELETED (~140 source files, Scripts 335->231, Tests 77->43): - Enemy variants + boss (H3). ChargerAuthoring / SpitterAuthoring / SwarmerAuthoring were attached to ZERO prefabs, so LungeState / SpitterState / SwarmerTag were never baked: ~272 lines of Bursted AI passes, BossAISystem (261 lines) and the whole MixBands escalation curve could not match a single chunk at runtime, while 734 lines of green tests certified them. Both shipping enemy prefabs were already byte-identical in stats. - Run/room lifecycle: RunDirector FSM, RunInfo/RunMap/RoomPlan/RoomTag, route select, portal interact, ready-check, room field/teardown. - Meta shop, prep loadout, boons (incl. KillRewardSystem and DashTrailDamageSystem, which existed only to serve boon flags). - Build palette + structures, shared storage, inventory/equipment (already recorded PAUSED in CLAUDE.md). - The HUD panels driving all of the above (HudSystem 1168 -> 610). KEPT deliberately: BaseGridMath + BaseAnchor (8 systems use PlotCenter for spawn rings, respawn and dynamic light), the resource ledger + StorageMath, the save system, region/relevancy. Three of these were in the delete set until I checked their consumers — worth remembering that the file-level manifest was wrong about them. Also folds in audit finding M5: PlayerClass was a second, server-only copy of the byte FrameId already replicates. It existed for the meta shop; with that gone, FrameId is the single frame identity. Harvest is now single-sink (ledger). HarvestMath keeps its shape so LANTERN's carried-vs-banked cargo split lands in one place, not two. 295/295 EditMode green, zero compile errors. Subscene re-bake and Play validation follow in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,20 +49,13 @@ namespace ProjectM.Server
|
||||
xform.Position = BaseGridMath.PlotCenter(anchor);
|
||||
ecb.SetComponent(director, xform);
|
||||
|
||||
// Expedition redesign: run-FSM working state + the co-op route first-commit latch + the persisted
|
||||
// meta counters — ALL added UNCONDITIONALLY at spawn (D-F2: a New-Game boot must have the components
|
||||
// the bank block reads; Continue restores VALUES only, inside the HasData block — Step 12b).
|
||||
// HostSalt starts a fixed non-tick seed lineage (bumped per launch); the save folds persisted
|
||||
// RunsCompleted in at restore so cross-session runs diverge.
|
||||
ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u });
|
||||
ecb.AddComponent(director, default(RouteCommand));
|
||||
ecb.AddComponent(director, default(PortalCommand)); // DR-046 room-exit portal interact latch
|
||||
|
||||
ecb.AddComponent(director, default(MetaCounters));
|
||||
|
||||
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
||||
// ghost never serializes an empty ledger to clients (no replication flicker).
|
||||
// DR-042 C6c: a NEW game seeds starting Ore below; a restored save (Continue) keeps its ledger.
|
||||
// Born-correct load: if the menu staged a save (Continue), apply the ledger AT SPAWN so the
|
||||
// director ghost never serializes an empty ledger to clients (no replication flicker).
|
||||
//
|
||||
// 2026-08-07 audit purge: this block also seeded RunRuntime (HostSalt), RouteCommand,
|
||||
// PortalCommand, MetaCounters, MetaTierState and a born-correct RunInfo mirror. All of that was
|
||||
// the superseded base/expedition run-FSM and meta shop; the director now hosts exactly one thing
|
||||
// — the global resource ledger.
|
||||
bool restoredLedger = false;
|
||||
if (SystemAPI.TryGetSingletonEntity<PendingSave>(out var pendingEntity))
|
||||
{
|
||||
@@ -72,35 +65,7 @@ namespace ProjectM.Server
|
||||
var srcLedger = SystemAPI.GetBuffer<PendingSaveLedgerRow>(pendingEntity);
|
||||
var destLedger = ecb.SetBuffer<StorageEntry>(director);
|
||||
SaveApply.WriteLedger(srcLedger, destLedger);
|
||||
restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore (C6c)
|
||||
|
||||
// v6: restore the permanent meta — counters (VALUES only; the component was added
|
||||
// unconditionally above, D-F2), the tier record (SetBuffer replaces the baked-empty
|
||||
// [GhostField] buffer pre-Playback — the StorageEntry idiom — rows VERBATIM incl. unknown
|
||||
// ids), the born-correct RunInfo HUD mirror (the spawn-time exception to RunDirector's
|
||||
// sole-writer rule), and the HostSalt fold (cross-session first-run maps diverge once
|
||||
// you've banked clears — the promise at the RunRuntime add).
|
||||
ecb.SetComponent(director, new MetaCounters
|
||||
{
|
||||
RunsCompleted = pending.RunsCompleted,
|
||||
MaxDepthReached = pending.MaxDepthReached,
|
||||
});
|
||||
var metaSrc = SystemAPI.GetBuffer<PendingMetaRow>(pendingEntity);
|
||||
var metaDst = ecb.SetBuffer<MetaTierState>(director);
|
||||
for (int mi = 0; mi < metaSrc.Length; mi++)
|
||||
metaDst.Add(new MetaTierState { ClassId = metaSrc[mi].ClassId, UpgradeId = metaSrc[mi].UpgradeId, Tier = metaSrc[mi].Tier });
|
||||
if (SystemAPI.HasComponent<RunInfo>(spawner.Prefab))
|
||||
{
|
||||
var runInfo = SystemAPI.GetComponent<RunInfo>(spawner.Prefab); // baked Lifecycle=Staging
|
||||
runInfo.RunsCompleted = pending.RunsCompleted;
|
||||
runInfo.MaxDepthReached = pending.MaxDepthReached;
|
||||
ecb.SetComponent(director, runInfo);
|
||||
}
|
||||
ecb.SetComponent(director, new RunRuntime
|
||||
{
|
||||
HostSalt = RunMapMath.Hash(0x5EED0001u, (uint)pending.RunsCompleted + 1u),
|
||||
});
|
||||
|
||||
restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore
|
||||
}
|
||||
ecb.DestroyEntity(pendingEntity);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server receiver for <see cref="PortalInteractRequest"/> — a participant interacting the room-exit portal during
|
||||
/// RoomExplore. Honored ONLY when <c>RunInfo.Lifecycle==RoomExplore</c> and the sender is an EXPEDITION player
|
||||
/// (region gate, the RouteSelect idiom). Sets the server-only <see cref="PortalCommand"/> latch IN-PLACE; it does
|
||||
/// NOT write RunInfo or tear the room down — RunDirectorSystem (the sole FSM/teardown owner) consumes the latch and
|
||||
/// advances. Plain server group, before RunDirectorSystem; requests ALWAYS destroyed; NO CyclePhase edge.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||
public partial struct PortalInteractReceiveSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<PortalInteractRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(b));
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<PortalCommand>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
||||
bool gateOpen = SystemAPI.GetComponent<RunInfo>(dirEntity).Lifecycle == RunLifecycle.RoomExplore;
|
||||
|
||||
// Sender region lookup (N3 idiom): a base-bound joiner cannot pull the party out of the room.
|
||||
var regionByConn = new NativeHashMap<int, byte>(8, Allocator.Temp);
|
||||
foreach (var (owner, region) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||||
regionByConn[owner.ValueRO.NetworkId] = region.ValueRO.Region;
|
||||
|
||||
bool interacted = SystemAPI.GetComponent<PortalCommand>(dirEntity).HasInteract != 0;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (receive, requestEntity) in
|
||||
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>>().WithAll<PortalInteractRequest>().WithEntityAccess())
|
||||
{
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
bool valid = gateOpen && !interacted
|
||||
&& SystemAPI.HasComponent<NetworkId>(conn)
|
||||
&& regionByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out byte senderRegion)
|
||||
&& senderRegion == RegionId.Expedition;
|
||||
if (valid)
|
||||
{
|
||||
SystemAPI.SetComponent(dirEntity, new PortalCommand { HasInteract = 1 });
|
||||
interacted = true;
|
||||
}
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
regionByConn.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cfb4147e08bf1b244bef1fa5d71d8b9e
|
||||
@@ -1,61 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server receiver for <see cref="ReadyToggleRequest"/>: resolves the sender (SourceConnection → NetworkId →
|
||||
/// GhostOwner → player entity, the AbilityUpgradeSystem idiom) and SETS <see cref="PlayerReady.Value"/>.
|
||||
/// Honored ONLY while the run FSM is in Staging or Launching — an un-ready during the Launching countdown is the
|
||||
/// launch-abort escape hatch (RunDirectorSystem reverts to Staging); toggles arriving mid-run are dropped (the
|
||||
/// Returning edge clears every flag anyway). Ordered BEFORE RunDirectorSystem so a toggle lands the same tick the
|
||||
/// ready-count is derived. Plain server group (one-off RPC effects never run in the predicted loop); the request
|
||||
/// entity is ALWAYS destroyed.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||
public partial struct ReadyToggleSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAll<ReadyToggleRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
byte lifecycle = SystemAPI.GetSingleton<RunInfo>().Lifecycle;
|
||||
bool accept = lifecycle == RunLifecycle.Staging || lifecycle == RunLifecycle.Launching;
|
||||
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, entity) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, PlayerReady>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = entity;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (receive, req, requestEntity) in
|
||||
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<ReadyToggleRequest>>().WithEntityAccess())
|
||||
{
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
if (accept
|
||||
&& SystemAPI.HasComponent<NetworkId>(conn)
|
||||
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
|
||||
{
|
||||
SystemAPI.SetComponent(player, new PlayerReady { Value = (byte)(req.ValueRO.Ready != 0 ? 1 : 0) });
|
||||
}
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
playerByConn.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7392579cf8b92e64f9686b58da99f7c2
|
||||
@@ -1,97 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server receiver for <see cref="RouteSelectRequest"/> — the co-op route choice (any-player-first-commits, the
|
||||
/// operator's locked authority model). Validates each pick server-authoritatively:
|
||||
/// <c>Lifecycle == RouteSelect</c> · run identity <c>(uint)ForRunEpoch == RunRuntime.RunSeed</c> (the Step-8
|
||||
/// review re-mean: the replicated seed IS the run token; the server-only RunEpoch is not client-knowable) ·
|
||||
/// <c>ForLayer == RunInfo.CurrentRoom</c> (the gate's un-incremented cleared layer) · <c>OptionIndex</c> within
|
||||
/// the replicated <c>RouteOptionCount</c> · the SENDER's <see cref="RegionTag"/> is Expedition (N3 — a
|
||||
/// base-bound joiner cannot commit the party's route) · nothing accepted yet this gate.
|
||||
///
|
||||
/// FIRST-COMMIT LATCH: the accepted pick is written to the server-only <see cref="RouteCommand"/> via an
|
||||
/// IMMEDIATE in-place <c>SystemAPI.SetComponent</c> INSIDE the drain loop plus a local accepted flag (the DR-014
|
||||
/// atomicity idiom) — two same-tick picks can never both observe an open gate; a hoisted read would re-create
|
||||
/// the exact N1 race the design review killed. <see cref="RouteCommand.ForRunEpoch"/> is stamped from the TRUE
|
||||
/// server-only epoch (never the client-echoed value). Requests are ALWAYS destroyed. This system writes ONLY
|
||||
/// RouteCommand — RunDirectorSystem stays the sole RunInfo/RunRuntime writer and consumes the latch
|
||||
/// (abort → pick → grace, in that order). Ordered before it so a pick can land the same tick it is consumed;
|
||||
/// NO CyclePhase edge (the room-chain hard rule).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||
public partial struct RouteSelectSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAll<RouteSelectRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<RunRuntime>();
|
||||
state.RequireForUpdate<RouteCommand>();
|
||||
}
|
||||
|
||||
[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 gateOpen = info.Lifecycle == RunLifecycle.RouteSelect;
|
||||
|
||||
// Sender-region lookup (N3): connection NetworkId -> the player's CURRENT region. A player entity
|
||||
// without RegionTag simply never enters the map -> its pick is a clean reject, never a throw.
|
||||
var regionByConn = new NativeHashMap<int, byte>(8, Allocator.Temp);
|
||||
foreach (var (owner, region) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||||
regionByConn[owner.ValueRO.NetworkId] = region.ValueRO.Region;
|
||||
|
||||
// Local accepted flag beside the in-place write = the first-commit latch (nothing else writes
|
||||
// RouteCommand mid-loop; RunDirector's gate-entry clear ran on a previous tick by construction).
|
||||
bool accepted = SystemAPI.GetComponent<RouteCommand>(dirEntity).HasPick != 0;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (receive, req, requestEntity) in
|
||||
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<RouteSelectRequest>>().WithEntityAccess())
|
||||
{
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
bool valid = gateOpen
|
||||
&& !accepted
|
||||
&& (uint)req.ValueRO.ForRunEpoch == run.RunSeed
|
||||
&& req.ValueRO.ForLayer == info.CurrentRoom
|
||||
&& req.ValueRO.OptionIndex < info.RouteOptionCount
|
||||
&& SystemAPI.HasComponent<NetworkId>(conn)
|
||||
&& regionByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out byte senderRegion)
|
||||
&& senderRegion == RegionId.Expedition;
|
||||
|
||||
if (valid)
|
||||
{
|
||||
// IMMEDIATE in-place commit (never an ECB-deferred write) + the local flag: first pick wins.
|
||||
SystemAPI.SetComponent(dirEntity, new RouteCommand
|
||||
{
|
||||
HasPick = 1,
|
||||
OptionIndex = req.ValueRO.OptionIndex,
|
||||
ForRunEpoch = run.RunEpoch, // the TRUE server epoch — never echo the client value
|
||||
ForLayer = req.ValueRO.ForLayer,
|
||||
});
|
||||
accepted = true;
|
||||
}
|
||||
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
regionByConn.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f56898976ef03af499c200e0d6f43b0d
|
||||
@@ -1,438 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7c36de338378264e9aa0ad2c2512e7f
|
||||
Reference in New Issue
Block a user