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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user