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
@@ -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;
}