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
@@ -35,7 +35,7 @@ namespace ProjectM.Tests
map = RunMapMath.Generate(Seed);
var dir = em.CreateEntity(typeof(RunInfo), typeof(RunRuntime), typeof(ExpeditionObjective),
typeof(RouteCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
typeof(RouteCommand), typeof(PortalCommand), typeof(MetaCounters), typeof(GoalProgress), typeof(ThreatState), typeof(SaveRequest));
em.SetComponentData(dir, new RunInfo
{
Lifecycle = RunLifecycle.InRoom,
@@ -73,7 +73,15 @@ namespace ProjectM.Tests
static void MarkCleared(EntityManager em, Entity dir) =>
em.SetComponentData(dir, new ExpeditionObjective { State = ExpeditionObjectiveState.Cleared, Remaining = 0 });
static int RoomEntities(EntityManager em)
// DR-046: drive the RoomExplore loot window past its portal gate (interact the portal, then tick).
static void PortalAdvance(EntityManager em, SimulationSystemGroup group, Entity dir)
{
em.SetComponentData(dir, new PortalCommand { HasInteract = 1 });
group.Update();
}
static int RoomEntities(EntityManager em)
{
var q = em.CreateEntityQuery(typeof(RoomTag));
int n = q.CalculateEntityCount();
@@ -82,48 +90,48 @@ namespace ProjectM.Tests
}
[Test]
public void Cleared_TearsDownAtRewardEntry_ThenAdvancesWithSlotFlipAndEpochBump()
public void Cleared_LootWindowThenPortalAdvances_WithSlotFlipAndEpochBump()
{
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager;
// Room-0 content that must die at the RoomReward entry.
var node0 = em.CreateEntity(typeof(RoomTag));
em.SetComponentData(node0, new RoomTag { Room = 0 });
MarkCleared(em, dir);
group.Update(); // InRoom -> RoomReward + teardown
group.Update(); // InRoom -> RoomReward (DR-046: room PERSISTS now, no teardown here)
Assert.AreEqual(RunLifecycle.RoomReward, em.GetComponentData<RunInfo>(dir).Lifecycle);
Assert.AreEqual(0, RoomEntities(em), "cleared room torn down AT ENTRY (the empty-tick guarantee)");
Assert.AreEqual(1, RoomEntities(em), "DR-046: the cleared room persists into the loot window");
Assert.AreEqual(1, em.GetComponentData<RunRuntime>(dir).RoomsClearedThisRun, "honest depth counter");
group.Update(); // RoomReward -> RouteSelect gate (no boons pending yet)
group.Update(); // RoomReward -> RoomExplore (loot window; portal up)
Assert.AreEqual(RunLifecycle.RoomExplore, em.GetComponentData<RunInfo>(dir).Lifecycle);
Assert.AreEqual(1, RoomEntities(em), "nodes still lootable during RoomExplore");
PortalAdvance(em, group, dir); // interact the portal -> teardown + open the route gate
var gateInfo = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "the branching gate opens (Step 8)");
Assert.AreEqual(RunLifecycle.RouteSelect, gateInfo.Lifecycle, "portal advances to the branching gate");
Assert.AreEqual(0, RoomEntities(em), "room torn down AT the portal exit (the empty-tick guarantee)");
Assert.Greater((int)gateInfo.RouteOptionCount, 0, "authoritative options published");
// Commit the party's pick directly through the server-only latch (the RPC path is pinned in
// RouteSelectSystemTests): choose the LAST option so a non-lowest pick is exercised when count > 1.
byte pickIdx = (byte)(gateInfo.RouteOptionCount - 1);
byte expectedCol = pickIdx == 2 ? gateInfo.RouteOpt2Col
: pickIdx == 1 ? gateInfo.RouteOpt1Col : gateInfo.RouteOpt0Col;
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
group.Update(); // RouteSelect -> consume the pick -> InRoom room 1 at the PICKED column
group.Update(); // RouteSelect -> InRoom room 1 at the PICKED column
var info = em.GetComponentData<RunInfo>(dir);
var run = em.GetComponentData<RunRuntime>(dir);
Assert.AreEqual(RunLifecycle.InRoom, info.Lifecycle);
Assert.AreEqual(1, info.CurrentRoom);
Assert.AreEqual(expectedCol, info.CurrentCol, "entered the PICKED column (non-maskable criterion)");
Assert.AreEqual(expectedCol, info.CurrentCol, "entered the PICKED column");
Assert.AreEqual(1, run.ActiveSubSlot, "ping-pong sub-slot flipped");
Assert.AreEqual(2, run.RoomEpoch, "RoomEpoch bumped so the room systems reseed");
Assert.AreEqual(RunMap.NodeId(1, expectedCol), run.CurrentNodeId, "single plan authority published");
Assert.AreEqual(map.Node(1, expectedCol).RoomType, run.CurrentRoomType);
Assert.AreEqual(0, em.GetComponentData<RouteCommand>(dir).HasPick, "latch consumed");
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed on advance");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(player).Position.x, 1499f,
"party teleported onto the idle sub-slot (+1500)");
Assert.GreaterOrEqual(em.GetComponentData<LocalTransform>(player).Position.x, 1499f, "party teleported (+1500)");
world.Dispose();
}
@@ -134,7 +142,6 @@ namespace ProjectM.Tests
var (world, group, dir, player) = MakeMidRunWorld(0, out map0);
var em = world.EntityManager;
int bossLayer = map0.LayerCount - 1;
// Jump the state to the boss room.
var info0 = em.GetComponentData<RunInfo>(dir);
info0.CurrentRoom = bossLayer;
em.SetComponentData(dir, info0);
@@ -145,8 +152,9 @@ namespace ProjectM.Tests
em.SetComponentData(dir, run0);
MarkCleared(em, dir);
group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1)
group.Update(); // RoomReward -> Returning
group.Update(); // InRoom -> RoomReward (LastTerminalCleared = 1; room persists)
group.Update(); // RoomReward -> RoomExplore
PortalAdvance(em, group, dir); // portal -> Returning (boss cleared)
group.Update(); // Returning: bank + teleport home -> Staging
var info = em.GetComponentData<RunInfo>(dir);
@@ -162,7 +170,7 @@ namespace ProjectM.Tests
Assert.AreEqual(1, em.GetComponentData<SaveRequest>(dir).Pending, "save checkpoint requested");
Assert.AreEqual(1, info.RunsCompleted, "HUD mirror updated");
group.Update(); // extra Staging ticks must not re-bank (once-per-RunEpoch latch)
group.Update();
group.Update();
Assert.AreEqual(1, em.GetComponentData<GoalProgress>(dir).Charge, "no double credit (F7)");
Assert.AreEqual(1, em.GetComponentData<MetaCounters>(dir).RunsCompleted);
@@ -197,17 +205,17 @@ namespace ProjectM.Tests
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update(); // -> RoomReward (teardown)
group.Update(); // -> RouteSelect (gate open, grace armed at T0)
group.Update();
group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> RouteSelect (route grace armed)
var gate = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.RouteSelect, gate.Lifecycle);
byte pickIdx = (byte)(gate.RouteOptionCount - 1);
byte pickedCol = pickIdx == 2 ? gate.RouteOpt2Col : pickIdx == 1 ? gate.RouteOpt1Col : gate.RouteOpt0Col;
// A pick latches AND the grace expires on the SAME tick -> the pick must win (review F2 precedence).
em.SetComponentData(dir, new RouteCommand { HasPick = 1, OptionIndex = pickIdx, ForRunEpoch = 1, ForLayer = 0 });
SetTick(world, T0 + 100000); // way past any grace
SetTick(world, T0 + 100000);
group.Update();
var info = em.GetComponentData<RunInfo>(dir);
@@ -222,12 +230,13 @@ namespace ProjectM.Tests
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update(); // -> RoomReward
group.Update(); // -> RouteSelect
group.Update();
group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> RouteSelect
var gate = em.GetComponentData<RunInfo>(dir);
byte lowestCol = gate.RouteOpt0Col;
SetTick(world, T0 + 100000); // grace elapses, nobody picked
SetTick(world, T0 + 100000);
group.Update();
var info = em.GetComponentData<RunInfo>(dir);
@@ -242,8 +251,9 @@ namespace ProjectM.Tests
var (world, group, dir, player) = MakeMidRunWorld(0, out var map);
var em = world.EntityManager;
MarkCleared(em, dir);
group.Update(); // -> RoomReward
group.Update(); // -> RouteSelect
group.Update();
group.Update(); // -> RoomExplore
PortalAdvance(em, group, dir); // -> RouteSelect
Assert.Greater((int)em.GetComponentData<RunInfo>(dir).RouteOptionCount, 0);
em.SetComponentData(player, new RegionTag { Region = RegionId.Base }); // all left
@@ -251,7 +261,7 @@ namespace ProjectM.Tests
var info = em.GetComponentData<RunInfo>(dir);
Assert.AreEqual(RunLifecycle.Returning, info.Lifecycle);
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed ON the abort edge (review F3 — no 1-tick clickable-panel window)");
Assert.AreEqual(0, (int)info.RouteOptionCount, "gate closed ON the abort edge (review F3)");
world.Dispose();
}
}