This commit is contained in:
2026-07-04 16:57:40 -07:00
parent 16e396841e
commit c6b7890212
47 changed files with 1713 additions and 209 deletions
@@ -56,8 +56,8 @@ namespace ProjectM.Server
playerRegions.Add(region.ValueRO.Region);
}
// EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon carry Health; automation
// machines lack it so the query excludes them). Snapshot ABOVE the early-return so Husks keep razing
// EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon AND the Fabricator all carry
// Health since the DR-032 parity fix; the query keys on it). Snapshot ABOVE the early-return so Husks keep razing
// the base even with every player dead/away (the locked 'push for structures' fork).
var structureEntities = new NativeList<Entity>(Allocator.Temp);
var structurePositions = new NativeList<float3>(Allocator.Temp);
@@ -45,7 +45,6 @@ namespace ProjectM.Server
float3 center = haveAnchor ? BaseGridMath.PlotCenter(baseAnchor) : spawner.SpawnPoint;
float ringRadius = haveSpawner ? spawner.SpawnRingRadius : 2f;
int ringSlots = haveSpawner ? spawner.RingSlots : 4;
center = BaseGridMath.PlotCenter(baseAnchor);
foreach (var (health, respawn, invuln, xform, region, owner, eff) in
SystemAPI.Query<RefRW<Health>, RefRW<RespawnState>, RefRW<RespawnInvuln>, RefRW<LocalTransform>,
@@ -123,7 +123,7 @@ namespace ProjectM.Server
zs.SeededEpoch = run.RoomEpoch;
zs.SpawnCounter = 0;
zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands);
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
zs.NextSpawnTick = TickUtil.NonZero(now + Tuning.RoomEntryGraceTicks); // landing grace — let the party orient
}
if (zs.RemainingToSpawn > 0)
@@ -139,6 +139,16 @@ namespace ProjectM.Server
bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick);
if (due)
{
// Conscript the party: the launch roster is EVERYONE connected (N7 co-location — all at
// base, all ready). Room advances teleport ONLY RunParticipants, so a mid-run late joiner
// is never yanked into the fight; the tag is released on the Returning edge.
var conscript = new EntityCommandBuffer(Allocator.Temp);
foreach (var (_, playerE) in
SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>().WithEntityAccess())
conscript.AddComponent<RunParticipant>(playerE);
conscript.Playback(state.EntityManager);
conscript.Dispose();
// Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0).
var map = RunMapMath.Generate(run.RunSeed);
EnterRoom(ref state, ref info, ref run, in map, layer: 0, col: 0, baseCenter, bumpEpoch: true);
@@ -187,15 +197,24 @@ namespace ProjectM.Server
// Exit gate: every SURVIVING player has picked (BoonOffer.Pending==0 — inert until Step 10)
// OR the grace elapsed (wrap-safe IsNewerThan, never raw uint — F4).
// Only EXPEDITION players hold the gate (BoonOfferSystem's documented contract): a player who
// died and respawned to base must not stall the party (post-impl review, confirmed major).
bool anyPending = false;
foreach (var offer in SystemAPI.Query<RefRO<BoonOffer>>().WithAll<PlayerTag>())
if (offer.ValueRO.Pending != 0) { anyPending = true; break; }
foreach (var (offer, pregion) in
SystemAPI.Query<RefRO<BoonOffer>, RefRO<RegionTag>>().WithAll<PlayerTag>())
if (pregion.ValueRO.Region == RegionId.Expedition && offer.ValueRO.Pending != 0)
{ anyPending = true; break; }
bool graceElapsed = run.RewardGraceTick == 0u
|| !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick);
if (anyPending && !graceElapsed)
break;
run.RewardGraceTick = 0u;
// NO offer survives this gate: zero every straggler (a dead-respawned base player the
// auto-pick deliberately skips, a grace-expired AFK) so a stale Pending can never wedge the
// 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;
if (run.LastTerminalCleared != 0)
{
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
@@ -279,18 +298,25 @@ namespace ProjectM.Server
case RunLifecycle.Returning:
{
// Party teleport HOME + region flip back to Base.
// PARTICIPANT teleport home + region flip + roster release. Only the launch roster comes
// home (a mid-run joiner already at base keeps its position); the tag removal re-opens the
// next run's conscription cleanly (post-impl review, confirmed medium).
int idx = 0;
foreach (var (region, xform) in
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
var homebound = new EntityCommandBuffer(Allocator.Temp);
foreach (var (region, xform, playerE) in
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>()
.WithAll<PlayerTag, RunParticipant>().WithEntityAccess())
{
region.ValueRW.Region = RegionId.Base;
var p = baseCenter;
p.x += 1.5f * idx;
p.y = xform.ValueRO.Position.y;
xform.ValueRW.Position = p;
homebound.RemoveComponent<RunParticipant>(playerE);
idx++;
}
homebound.Playback(state.EntityManager);
homebound.Dispose();
// THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3).
if (run.LastBankedRunEpoch != run.RunEpoch)
@@ -393,7 +419,7 @@ namespace ProjectM.Server
float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
int idx = 0;
foreach (var (region, xform) in
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag, RunParticipant>())
{
region.ValueRW.Region = RegionId.Expedition;
var p = roomOrigin;
@@ -13,7 +13,7 @@ namespace ProjectM.Server
/// hand-off is <see cref="ThreatState.PendingSiegeSize"/> (this system sets it; CyclePhaseSystem consumes it).
/// This slice wires ONE source — POST-EXPEDITION retaliation: a completed RUN (banked on the boss-clear
/// return by <see cref="RunDirectorSystem"/> into <see cref="ThreatState.PendingReturns"/> — the retired
/// walk-in ExpeditionGateSystem's carry, Step 11) arms a siege ofe of
/// walk-in ExpeditionGateSystem's carry, Step 11) arms a siege of
/// <see cref="ThreatConfig.SizeBase"/> Husks after a <see cref="ThreatConfig.PostExpeditionDelayTicks"/>
/// telegraph. The Heat/Schedule sources are reserved (config baked-but-inert) so they drop in additively with
/// no re-bake. It also enforces a BOUNDED siege lifetime (<see cref="ThreatConfig.SiegeTimeoutTicks"/>): an