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