Base<->Expedition ties: portal rooms, class-at-base, prep spend + Health.Max, C3/C4/Spitter (DR-046)

Portal-gated rooms: new RunLifecycle.RoomExplore loot window; room+nodes
persist past the last kill; PortalInteractRequest -> server-only PortalCommand
advances (client derives the portal pos). RunDirectorSystem moves teardown off
the InRoom edge, relocates the boss-branch/route-gate into the RoomExplore exit,
and advances an empty expedition immediately (incl. a boss clear).

Class-at-base via a shared ClassSwapUtil (meta-band strip + per-class
MetaTierState replay, so the base RPC and the dev SetClass can't drift);
ClassSelectRequest Staging-gated. Per-run PREP buffs (PrepPurchaseRequest,
PrepCatalog): once-per-run == the row's prep StatModifier band is present,
TotalOf-before-Withdraw atomic, stripped on Returning beside the boon band.

Health.Max promoted to [GhostField] (the one ghost-hash re-bake) so the boss +
floating enemy HP bars read it directly. Feel: melee cone connect-thunk (C3),
hit-stop throttle so a horde wipe can't stutter (C4), Spitter cornered-hold.

456/456 EditMode; two adversarial reviews (pre-code 13-confirmed folded;
post-impl clean bar the RoomExplore boss-clear dead-time, fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 10:37:24 -07:00
parent 35357faa7a
commit 304ee8c2a7
35 changed files with 860 additions and 110 deletions
@@ -0,0 +1,82 @@
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="ClassSelectRequest"/> — the player picks their class at base. Honored ONLY in
/// Staging (class = a between-runs choice; mid-run it would desync the fight). Resolves sender → player (the
/// MetaSpend/ReadyToggle idiom), then applies the FULL in-place swap via <see cref="ClassSwapUtil"/> (class seeds +
/// permanent-meta re-sync) and writes AbilityRef / PlayerClass / AbilityCooldown + <see cref="ClassSwapUtil.HealClamp"/>.
/// Plain server group, before RunDirectorSystem (the receiver convention); requests are ALWAYS destroyed. NOT
/// Burst-compiled (a cross-assembly blob+buffer helper on a low-frequency RPC — Burst safety over micro-perf).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct ClassSelectReceiveSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<ClassSelectRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(b));
state.RequireForUpdate<RunInfo>();
}
public void OnUpdate(ref SystemState state)
{
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, e) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = e;
// Meta re-sync inputs (on the director/ledger ghost). dir stays Null if the catalog is absent (guarded).
Entity dir = Entity.Null;
bool haveMeta = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat)
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir) && SystemAPI.HasBuffer<MetaTierState>(dir);
bool haveDb = SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb);
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, reqEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<ClassSelectRequest>>().WithEntityAccess())
{
ecb.DestroyEntity(reqEntity); // ALWAYS consumed
if (!accept) continue;
var conn = receive.ValueRO.SourceConnection;
if (!SystemAPI.HasComponent<NetworkId>(conn)
|| !playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
continue;
if (!SystemAPI.HasComponent<AbilityRef>(player)) continue;
var mods = SystemAPI.GetBuffer<StatModifier>(player);
var metaRecord = haveMeta ? SystemAPI.GetBuffer<MetaTierState>(dir) : default;
ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, haveMeta, metaCat, metaRecord,
out byte newClass, out byte newAbilityId);
SystemAPI.SetComponent(player, new AbilityRef { Id = newAbilityId });
if (SystemAPI.HasComponent<PlayerClass>(player))
SystemAPI.SetComponent(player, new PlayerClass { ClassId = newClass });
if (SystemAPI.HasComponent<AbilityCooldown>(player))
SystemAPI.SetComponent(player, new AbilityCooldown { NextFireTick = 0 }); // swapped ability fires now
if (haveDb && SystemAPI.HasComponent<Health>(player) && SystemAPI.HasComponent<CharacterStatsRef>(player))
{
byte charId = SystemAPI.GetComponent<CharacterStatsRef>(player).Id;
if (abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar))
{
var hp = SystemAPI.GetComponent<Health>(player);
ClassSwapUtil.HealClamp(ref hp, baseChar.MaxHealth, mods);
SystemAPI.SetComponent(player, hp);
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerByConn.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 01a67c1a54ce0574b86700e32e0bdb5b
@@ -397,7 +397,11 @@ namespace ProjectM.Server
// 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target.
var sp = spitter.ValueRO;
float3 bandVel = EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance);
// Once the player has closed inside CorneredRange the Spitter STANDS (no flee) + point-blanks — so a
// melee player who commits can actually catch it (fixes the endless-kite complaint; the spit is dash-dodgeable).
bool sCorneredMove = math.distance(pos.xz, sTargetPos.xz) <= sp.CorneredRange;
float3 bandVel = sCorneredMove ? float3.zero
: EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance);
float3 sNewPos = pos + bandVel * dt; sNewPos.y = pos.y;
if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter);
xform.ValueRW.Position = sNewPos;
@@ -0,0 +1,79 @@
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="PrepPurchaseRequest"/> — the base PREP-LOADOUT spend (DR-046). Modeled on
/// MetaSpendSystem: Staging-only, resolve sender → player, in-loop against the LIVE ledger (the DR-014 atomicity
/// idiom — <see cref="StorageMath.TotalOf"/> pre-check BEFORE <see cref="StorageMath.Withdraw"/>, since Withdraw
/// CLAMPS and never rejects). A purchase appends ONE run-scoped <see cref="StatModifier"/> in the prep band
/// (<see cref="Tuning.PrepSourceIdBase"/> + option id) on the BUYER only (prep is personal). "Once per run" needs
/// NO separate latch: the SourceId's PRESENCE is the gate, and RunDirectorSystem strips the band on Returning, so
/// it re-buys next run (finding #7 — latch lifetime == the band). Plain server group, before RunDirectorSystem;
/// requests ALWAYS destroyed. NOT Burst-compiled (managed PrepCatalog table + low frequency).
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct PrepPurchaseSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<PrepPurchaseRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(b));
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<ResourceLedger>();
}
public void OnUpdate(ref SystemState state)
{
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
var director = SystemAPI.GetSingletonEntity<ResourceLedger>();
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, e) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = e;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, reqEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<PrepPurchaseRequest>>().WithEntityAccess())
{
ecb.DestroyEntity(reqEntity); // ALWAYS consumed
if (!accept) continue;
var conn = receive.ValueRO.SourceConnection;
if (!SystemAPI.HasComponent<NetworkId>(conn)
|| !playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var buyer))
continue;
if (!PrepCatalog.TryGet(req.ValueRO.OptionId, out var row)) continue; // unknown id -> drop
uint sourceId = Tuning.PrepSourceIdBase + row.Id;
var mods = SystemAPI.GetBuffer<StatModifier>(buyer);
bool already = false;
for (int m = 0; m < mods.Length; m++)
if (mods[m].SourceId == sourceId) { already = true; break; } // once per run (band stripped on Returning)
if (already) continue;
// LIVE in-loop ledger check + atomic withdraw (a same-tick second buy on barely-enough can't both pass).
var ledger = SystemAPI.GetBuffer<StorageEntry>(director);
if (StorageMath.TotalOf(ledger, row.CostResId) < row.Cost) continue; // pre-check: Withdraw CLAMPS
StorageMath.Withdraw(ledger, row.CostResId, row.Cost);
mods.Add(new StatModifier
{
Target = row.Target,
Op = row.Op,
Value = row.Value,
SourceId = sourceId,
});
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerByConn.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f052eb701594a3a42ba83e524dd2d28b
@@ -182,60 +182,29 @@ namespace ProjectM.Server
if (sender != Entity.Null && SystemAPI.HasComponent<AbilityRef>(sender)
&& SystemAPI.HasBuffer<StatModifier>(sender))
{
byte newClass = ClassTraits.Normalize((byte)cmd.ArgA);
var classMods = SystemAPI.GetBuffer<StatModifier>(sender);
ClassTraits.Reapply(newClass, classMods);
SystemAPI.SetComponent(sender, new AbilityRef { Id = ClassTraits.AbilityFor(newClass) });
// Expedition redesign (dev fork, operator-approved): keep the PERMANENT meta channel in
// sync with the swap. Reapply only strips the CLASS-seed band, so the OLD class's meta
// rows would survive — strip the meta band, replay the NEW class's persisted tiers (the
// GoInGame skip/clamp rules), and repoint the server-only PlayerClass anchor so a later
// MetaSpendRequest buys against the right class record. Runs BEFORE the heal below so the
// refill folds the new class's meta MaxHealth too.
TimedModifierUtil.RemoveBySourceIdRange(classMods, Tuning.MetaSourceIdBase,
Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan);
if (SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out var metaRecord, true))
{
ref var metaPool = ref metaCat.Value.Value;
byte metaBit = BoonMath.MaskFor(newClass);
for (int mi = 0; mi < metaRecord.Length; mi++)
{
if (metaRecord[mi].ClassId != newClass || metaRecord[mi].Tier == 0) continue;
int defIdx = MetaMath.FindDef(ref metaPool, metaRecord[mi].UpgradeId);
if (defIdx < 0) continue;
if ((metaPool.Defs[defIdx].ClassMask & metaBit) == 0) continue;
byte metaTier = metaRecord[mi].Tier < metaPool.Defs[defIdx].MaxTier
? metaRecord[mi].Tier : metaPool.Defs[defIdx].MaxTier;
classMods.Add(new StatModifier
{
Target = metaPool.Defs[defIdx].Target,
Op = metaPool.Defs[defIdx].Op,
Value = metaPool.Defs[defIdx].ValuePerTier * metaTier,
SourceId = Tuning.MetaSourceIdBase + metaRecord[mi].UpgradeId,
});
}
}
Entity dir2 = Entity.Null;
bool haveMeta2 = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat2)
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir2) && SystemAPI.HasBuffer<MetaTierState>(dir2);
var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer<MetaTierState>(dir2) : default;
// DR-046: the FULL swap (class seeds + meta re-sync) now lives in the shared ClassSwapUtil,
// used by BOTH this dev path and the base ClassSelectReceiveSystem so they cannot drift.
ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, haveMeta2, metaCat2, metaRec2,
out byte swNewClass, out byte swNewAbility);
SystemAPI.SetComponent(sender, new AbilityRef { Id = swNewAbility });
if (SystemAPI.HasComponent<PlayerClass>(sender))
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = newClass });
// Let the swapped Fire ability fire immediately (both abilities share one cooldown gate).
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass });
if (SystemAPI.HasComponent<AbilityCooldown>(sender))
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 }); // 0 = ready
// Heal a living player to the new class's full max (fold blob base + the just-reseeded
// buffer, like StatRecomputeSystem; Effective* still lags a tick here). Doubles as the
// down-clamp when the new max is lower (nothing else clamps Current off a damage event).
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 });
if (SystemAPI.HasComponent<Health>(sender) && SystemAPI.HasComponent<CharacterStatsRef>(sender)
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb))
&& SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb2))
{
var hp = SystemAPI.GetComponent<Health>(sender);
byte charId = SystemAPI.GetComponent<CharacterStatsRef>(sender).Id;
if (hp.Current > 0f && abilityDb.Value.Value.TryGetCharacter(charId, out var baseChar))
byte charId2 = SystemAPI.GetComponent<CharacterStatsRef>(sender).Id;
if (abilityDb2.Value.Value.TryGetCharacter(charId2, out var baseChar2))
{
hp.Current = StatMath.Apply(baseChar.MaxHealth, StatTarget.MaxHealth, classMods);
SystemAPI.SetComponent(sender, hp);
var hp2 = SystemAPI.GetComponent<Health>(sender);
ClassSwapUtil.HealClamp(ref hp2, baseChar2.MaxHealth, classMods);
SystemAPI.SetComponent(sender, hp2);
}
}
}
@@ -69,6 +69,8 @@ namespace ProjectM.Server
// launch); SaveData v6 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
@@ -0,0 +1,66 @@
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();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cfb4147e08bf1b244bef1fa5d71d8b9e
@@ -173,10 +173,8 @@ namespace ProjectM.Server
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();
// 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)
@@ -215,23 +213,55 @@ namespace ProjectM.Server
// 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 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.
// 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);
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;
}
@@ -246,8 +276,6 @@ namespace ProjectM.Server
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;
@@ -256,7 +284,8 @@ namespace ProjectM.Server
break;
}
case RunLifecycle.RouteSelect:
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);
@@ -365,6 +394,9 @@ namespace ProjectM.Server
{
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;
}