Run Re-Do

This commit is contained in:
2026-07-02 20:41:43 -07:00
parent 86575dd5bc
commit 16e396841e
188 changed files with 8291 additions and 2429 deletions
@@ -0,0 +1,133 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="BoonPickRequest"/> + the reward-grace AUTO-PICK backstop. A valid pick
/// (sender resolved, <c>RunInfo.Lifecycle == RoomReward</c> — the D-F4 gate — <c>Pending == 1</c>, index in
/// range, option id known to the catalog) appends ONE <see cref="StatModifier"/> in the run-scoped BOON band
/// (<c>Tuning.BoonSourceIdBase + BoonPickCounter++</c> — distinct rows, one range-strip clears the run) and
/// clears <c>Pending</c>; the buffer mutation is non-structural and folds through the unchanged
/// StatRecomputeSystem on both worlds (rollback-correct). When the reward grace elapses, every still-pending
/// EXPEDITION player is auto-dealt <c>Option0</c> (the operator's default un-picked policy — a player always
/// gets something) so the run never stalls on an AFK picker.
///
/// Ordering: <c>[UpdateBefore(RunDirectorSystem)]</c> — ALL RPC receivers sit before the director (the
/// ReadyToggle/RouteSelect symmetry). This closes the D-F4 straggler race STRUCTURALLY: on the tick the
/// director strips (Returning), a straggler pick is rejected here FIRST (lifecycle is already past RoomReward),
/// so nothing can append after the strip; and the auto-pick lands before the director's exit gate reads
/// Pending. Requests are ALWAYS destroyed. No CyclePhase edge (the room-chain hard rule).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct BoonApplySystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<BoonCatalog>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
state.RequireForUpdate<NetworkTime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
bool rewarding = info.Lifecycle == RunLifecycle.RoomReward;
var catalog = SystemAPI.GetComponent<BoonCatalog>(SystemAPI.GetSingletonEntity<BoonCatalog>());
if (!catalog.Value.IsCreated)
return;
ref var pool = ref catalog.Value.Value;
bool runDirty = false;
// ---- explicit picks (drained every tick so stale requests die even outside RoomReward) ----
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, entity) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, BoonOffer, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = entity;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, requestEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<BoonPickRequest>>().WithEntityAccess())
{
var conn = receive.ValueRO.SourceConnection;
if (rewarding
&& req.ValueRO.Index < 3
&& SystemAPI.HasComponent<NetworkId>(conn)
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
{
var offer = SystemAPI.GetComponent<BoonOffer>(player);
if (offer.Pending == 1)
{
byte id = req.ValueRO.Index == 2 ? offer.Option2
: req.ValueRO.Index == 1 ? offer.Option1 : offer.Option0;
if (Apply(ref state, player, id, ref pool, ref run))
{
offer.Pending = 0;
SystemAPI.SetComponent(player, offer);
runDirty = true;
}
}
}
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
playerByConn.Dispose();
// ---- reward-grace auto-pick backstop (Option0 — the player always gets something) ----
if (rewarding && run.RewardGraceTick != 0u)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (serverTick.IsValid && !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick))
{
foreach (var (offer, region, entity) in
SystemAPI.Query<RefRW<BoonOffer>, RefRO<RegionTag>>()
.WithAll<PlayerTag, StatModifier>().WithEntityAccess())
{
if (offer.ValueRO.Pending != 1 || region.ValueRO.Region != RegionId.Expedition)
continue;
if (Apply(ref state, entity, offer.ValueRO.Option0, ref pool, ref run))
runDirty = true;
offer.ValueRW.Pending = 0; // cleared even if the id was unknown — never wedge the gate
}
}
}
if (runDirty)
SystemAPI.SetComponent(dirEntity, run); // the documented BoonPickCounter co-write (band provenance)
}
/// <summary>Append the boon's StatModifier in the run-scoped band. False iff the id is unknown/zero.</summary>
static bool Apply(ref SystemState state, Entity player, byte boonId, ref BoonCatalogBlob pool, ref RunRuntime run)
{
if (boonId == 0)
return false;
int idx = BoonMath.FindDef(ref pool, boonId);
if (idx < 0)
return false; // unknown id (catalog drift) — preserve-and-skip, never throw
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
mods.Add(new StatModifier
{
Target = pool.Defs[idx].Target,
Op = pool.Defs[idx].Op,
Value = pool.Defs[idx].Value,
SourceId = Tuning.BoonSourceIdBase + (run.BoonPickCounter % Tuning.BoonSourceIdSpan),
});
run.BoonPickCounter += 1;
return true;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5749745bedc86ca4396b9a3911ef8773
@@ -0,0 +1,78 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only choice-of-3 boon dealer: once per <see cref="RunRuntime.RoomEpoch"/> (int-equality latch on
/// <see cref="BoonOfferState"/>, attached beside the catalog singleton), when the run FSM enters RoomReward it
/// draws each EXPEDITION player's 3 distinct, rarity-weighted, class-filtered options via
/// <see cref="BoonMath.PickBoons"/> — deterministically seeded from Hash(RunSeed, room, NetworkId) — and writes
/// the player's owner-only replicated <see cref="BoonOffer"/> (Pending=1). A base-region player (dead-respawned,
/// late joiner) gets NO offer and never holds the gate (RunDirector counts only Pending!=0). BoonApplySystem
/// (Step 10) consumes picks; the Returning-edge strip zeroes stragglers.
///
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> — on the RoomReward ENTRY tick this runs after the
/// transition, so offers exist BEFORE RunDirector's exit gate first evaluates (next tick). No CyclePhase edge.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(RunDirectorSystem))]
public partial struct BoonOfferSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<BoonCatalog>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var catalogEntity = SystemAPI.GetSingletonEntity<BoonCatalog>();
// One-shot: attach this system's latch beside the catalog singleton (the RoomFieldState idiom).
if (!SystemAPI.HasComponent<BoonOfferState>(catalogEntity))
{
state.EntityManager.AddComponentData(catalogEntity, new BoonOfferState());
return; // structural change — clean re-read next tick
}
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
if (info.Lifecycle != RunLifecycle.RoomReward)
return;
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
var offered = SystemAPI.GetComponent<BoonOfferState>(catalogEntity);
if (offered.OfferedRoomEpoch == run.RoomEpoch)
return; // this room's offers are already dealt
var catalog = SystemAPI.GetComponent<BoonCatalog>(catalogEntity);
if (!catalog.Value.IsCreated)
return;
ref var pool = ref catalog.Value.Value;
foreach (var (offer, owner, region, cls) in
SystemAPI.Query<RefRW<BoonOffer>, RefRO<GhostOwner>, RefRO<RegionTag>, RefRO<PlayerClass>>()
.WithAll<PlayerTag>())
{
if (region.ValueRO.Region != RegionId.Expedition)
continue; // home-bound players (dead-respawned, joiners) are dealt nothing
// Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room).
uint offerSeed = RunMapMath.Hash(run.RunSeed, (uint)info.CurrentRoom, (uint)owner.ValueRO.NetworkId) | 1u;
BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, ref pool, out byte o0, out byte o1, out byte o2);
offer.ValueRW = new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 };
}
offered.OfferedRoomEpoch = run.RoomEpoch;
SystemAPI.SetComponent(catalogEntity, offered);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2d3715c60d2cc2348ac4ff7600006d23
@@ -0,0 +1,192 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only per-ROOM enemy director — the Step-6 successor of the presence-keyed <c>ZoneEnemyDirectorSystem</c>.
/// While the run FSM has a room active (<see cref="RunInfo.Lifecycle"/> == InRoom) it seeds ONE wave per
/// <see cref="RunRuntime.RoomEpoch"/> (int-equality reseed) sized by <see cref="ZoneEnemyMath.WaveSlots"/> indexed
/// on the room's <see cref="RoomPlan.DifficultyEpoch"/> (deeper rooms + Elite/Boss types skew heavier — the
/// grounded MC-2 mix bands are reused verbatim), drip-spawned one SLOT per cadence at the deterministic ring
/// around <see cref="RegionMath.ExpeditionRoomOrigin"/>(base, ActiveSubSlot), under the same
/// <see cref="ZoneEnemyDirector.MaxAlive"/> "spawn-the-pack-only-if-it-fits-else-wait" relevancy guard. A
/// <see cref="RoomTypeId.Boss"/> room spawns ONE beefed boss instead (health × <see cref="Tuning.BossHealthMultiplier"/>,
/// scale × <see cref="Tuning.BossScaleMultiplier"/> — v1's boss is a scaled Charger). Every spawn keeps the full
/// stack — EnemyTag + RegionTag{Expedition} + <see cref="ZoneEnemyTag"/> — PLUS <see cref="RoomTag"/>{room} (the
/// teardown contract). Scale preserved via <c>baked.WithPosition</c>.
///
/// The room CLEAR edge surfaces ONLY through the replicated <see cref="ExpeditionObjective"/>.State == Cleared
/// (wave fully spawned AND zero alive, latched per seeded epoch) — written FIRST, ABOVE every early-return
/// (snapshot-above-early-return) so the HUD never freezes; RunDirectorSystem consumes it one-tick-late (Step 7).
/// The old CycleRuntime.ClearedThisEpoch write is gone (the C4 collapse), and the old base-siege Calm gate is
/// deliberately DROPPED — a home retaliation siege no longer freezes a live sortie (the DR-042 latent gap).
///
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> ONLY — reads the freshly-advanced room state same-tick.
/// NO CyclePhase edge may ever return to the room chain (Play-only sort-cycle, invisible to EditMode).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(RunDirectorSystem))]
public partial struct RoomEnemyDirectorSystem : ISystem
{
EntityQuery m_ZoneEnemies;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
state.RequireForUpdate<ZoneEnemyDirector>();
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var runEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(runEntity);
var run = SystemAPI.GetComponent<RunRuntime>(runEntity);
bool roomActive = info.Lifecycle == RunLifecycle.InRoom;
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
// REPLICATED objective summary FIRST, above every early-return (snapshot-above-early-return): the HUD
// readout must never freeze stale. Cleared latches only for a wave seeded FOR THIS RoomEpoch.
if (SystemAPI.HasComponent<ExpeditionObjective>(runEntity))
{
byte objState;
short objRemaining;
if (roomActive && (aliveZone > 0 || zs.RemainingToSpawn > 0))
{
objState = ExpeditionObjectiveState.Active;
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
}
else if (roomActive && zs.SeededEpoch == run.RoomEpoch && zs.RemainingToSpawn == 0 && aliveZone == 0)
{
objState = ExpeditionObjectiveState.Cleared; // fully spawned + fully dead -> advance-ready
objRemaining = 0;
}
else
{
objState = ExpeditionObjectiveState.Idle;
objRemaining = 0;
}
SystemAPI.SetComponent(runEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
}
if (!roomActive)
return;
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
if (prefabs.Length == 0)
return;
// Single plan authority: the node RunDirector published — never re-derived here.
var map = RunMapMath.Generate(run.RunSeed);
var node = map.NodeAt(run.CurrentNodeId);
var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount);
byte room = (byte)(info.CurrentRoom & 0xFF);
bool bossRoom = plan.RoomType == RoomTypeId.Boss;
var bands = new MixBands
{
GruntBase = dir.GruntsPerWave,
ChargerBase = dir.ChargersPerWave,
SpitterBase = dir.SpitterBase,
SwarmerSlotBase = dir.SwarmerSlotBase,
ChargerPerEpoch = dir.ChargerPerEpoch,
SpitterPerEpoch = dir.SpitterPerEpoch,
SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch,
SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch,
};
// (Re)seed once per ROOM (its OWN counter, in SLOTS; a swarmer slot is one pack; a boss room is 1 slot).
if (zs.SeededEpoch != run.RoomEpoch)
{
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
}
if (zs.RemainingToSpawn > 0)
{
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
if (dueNow)
{
int slot = (int)zs.SpawnCounter;
byte kind = bossRoom ? ZoneEnemyMath.KindCharger
: ZoneEnemyMath.KindForSlot(plan.DifficultyEpoch, slot, bands);
int packSize = !bossRoom && kind == ZoneEnemyMath.KindSwarmer
? ZoneEnemyMath.PackSizeForSlot(plan.DifficultyEpoch, slot, bands, dir.SwarmerPackSize) : 1;
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — keep the slot).
if (aliveZone + packSize <= math.max(1, dir.MaxAlive))
{
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
float3 center = bossRoom
? origin // the boss anchors the room center
: EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
center.y = origin.y;
int prefabIdx = kind;
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
var prefab = prefabs[prefabIdx].Prefab;
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int k = 0; k < packSize; k++)
{
float3 pos = packSize > 1
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
pos.y = origin.y;
var enemy = ecb.Instantiate(prefab);
var xform = baked.WithPosition(pos); // preserve the baked [GhostField] Scale
if (bossRoom)
xform.Scale = baked.Scale * Tuning.BossScaleMultiplier;
ecb.SetComponent(enemy, xform);
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
ecb.AddComponent<ZoneEnemyTag>(enemy);
ecb.AddComponent(enemy, new RoomTag { Room = room });
if (bossRoom && SystemAPI.HasComponent<Health>(prefab))
{
var hp = SystemAPI.GetComponent<Health>(prefab);
hp.Current *= Tuning.BossHealthMultiplier;
hp.Max *= Tuning.BossHealthMultiplier;
ecb.SetComponent(enemy, hp);
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
zs.SpawnCounter += 1; // ONE slot consumed even for a pack
zs.RemainingToSpawn -= 1;
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
}
}
}
SystemAPI.SetComponent(directorEntity, zs);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3204b510b450f384a93bd49902c65721
@@ -1,189 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only expedition zone-enemy director: while a player is OUT in the expedition region and the base is in
/// <see cref="CyclePhase.Calm"/>, it seeds and drip-spawns one epoch-seeded combat wave around the expedition
/// origin. The wave size + grunt/charger composition is pure <see cref="ZoneEnemyMath"/> of the
/// <see cref="CycleRuntime.ExpeditionEpoch"/> (grunt-heavy -&gt; charger-heavy as the epoch climbs), spawned one
/// every <see cref="ZoneEnemyDirector.SpawnIntervalTicks"/> at a deterministic ring, capped at
/// <see cref="ZoneEnemyDirector.MaxAlive"/> concurrent (the v1 ghost-relevancy budget). Each enemy is the
/// existing Husk ghost prefab + <c>RegionTag{Expedition}</c> + <see cref="ZoneEnemyTag"/>, so it reuses the whole
/// combat/readability/AI stack (the per-region AI filter keeps it seeking the expedition player only). When the
/// wave is fully spawned and every zone enemy is dead, it marks <see cref="CycleRuntime.ClearedThisEpoch"/> once —
/// the gate's once-per-epoch Ore reward reads that on the player's return.
///
/// DR-042 C7b: it ALSO writes the replicated <see cref="ExpeditionObjective"/> summary every tick, ABOVE the
/// presence early-return (snapshot-above-early-return), so the client HUD's "enemies remaining / cleared" readout
/// never freezes stale even when nobody is out.
///
/// Ordering: <c>[UpdateAfter(ExpeditionFieldSystem)]</c> ONLY. ExpeditionFieldSystem is itself
/// <c>[UpdateAfter(CyclePhaseSystem)]</c>, so ALSO declaring <c>[UpdateBefore(CyclePhaseSystem)]</c> here (as the
/// v1 plan first sketched) would close a CyclePhase-&gt;Field-&gt;Zone-&gt;CyclePhase sort cycle that throws at Play
/// world creation and is invisible to EditMode. Running after the field manager also reads the freshly-bumped
/// epoch + current phase. Zone enemies are interpolated ghosts, moved server-only — no prediction.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(ExpeditionFieldSystem))]
public partial struct ZoneEnemyDirectorSystem : ISystem
{
EntityQuery m_ZoneEnemies;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<CycleState>();
state.RequireForUpdate<ZoneEnemyDirector>();
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
// Per-player presence: the SPAWNER only runs while someone is OUT in the expedition (mirrors
// ExpeditionFieldSystem). The objective readout below is written FIRST, every tick, even when nobody's out.
int expeditionPlayers = 0;
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
if (region.ValueRO.Region == RegionId.Expedition)
expeditionPlayers++;
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
int epoch = runtime.ExpeditionEpoch;
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
// DR-042 C7b: write the REPLICATED objective summary FIRST, above the early-returns (snapshot-above-
// early-return) so the HUD never freezes stale. Rides the untagged CycleDirector ghost (cross-region safe).
if (SystemAPI.HasComponent<ExpeditionObjective>(cycleEntity))
{
byte objState;
short objRemaining;
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
{
objState = ExpeditionObjectiveState.Cleared; // cleared but not yet claimed -> "return to claim"
objRemaining = 0;
}
else if (expeditionPlayers > 0 && (aliveZone > 0 || zs.RemainingToSpawn > 0))
{
objState = ExpeditionObjectiveState.Active;
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
}
else
{
objState = ExpeditionObjectiveState.Idle;
objRemaining = 0;
}
SystemAPI.SetComponent(cycleEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
}
if (expeditionPlayers == 0)
return; // nobody out there: the field manager owns teardown, the spawner does nothing
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
if (prefabs.Length == 0)
return;
// MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base
// siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts.
var bands = new MixBands
{
GruntBase = dir.GruntsPerWave,
ChargerBase = dir.ChargersPerWave,
SpitterBase = dir.SpitterBase,
SwarmerSlotBase = dir.SwarmerSlotBase,
ChargerPerEpoch = dir.ChargerPerEpoch,
SpitterPerEpoch = dir.SpitterPerEpoch,
SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch,
SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch,
};
// (Re)seed this epoch's wave once — its OWN counter (in SLOTS; a swarmer slot is one pack).
if (zs.SeededEpoch != epoch)
{
zs.SeededEpoch = epoch;
zs.SpawnCounter = 0;
zs.RemainingToSpawn = ZoneEnemyMath.WaveSlots(epoch, bands);
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
}
if (zs.RemainingToSpawn > 0)
{
// Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap.
bool calm = cycle.Phase == CyclePhase.Calm;
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
if (calm && dueNow)
{
int slot = (int)zs.SpawnCounter;
byte kind = ZoneEnemyMath.KindForSlot(epoch, slot, bands);
int packSize = kind == ZoneEnemyMath.KindSwarmer
? ZoneEnemyMath.PackSizeForSlot(epoch, slot, bands, dir.SwarmerPackSize) : 1;
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
if (aliveZone + packSize <= math.max(1, dir.MaxAlive))
{
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
float3 center = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
center.y = origin.y;
int prefabIdx = kind;
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
var prefab = prefabs[prefabIdx].Prefab;
// Preserve the prefab's baked Scale ([GhostField]) — FromPosition would reset Scale->1.
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int k = 0; k < packSize; k++)
{
float3 pos = packSize > 1
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
pos.y = origin.y;
var enemy = ecb.Instantiate(prefab);
ecb.SetComponent(enemy, baked.WithPosition(pos));
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
ecb.AddComponent<ZoneEnemyTag>(enemy);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
zs.SpawnCounter += 1; // ONE slot consumed even for a pack
zs.RemainingToSpawn -= 1;
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
}
}
}
else if (aliveZone == 0 && runtime.ClearedThisEpoch == 0)
{
// Wave fully spawned AND every zone enemy dead -> a REAL clear. Mark once; the gate pays the
// once-per-epoch Ore reward on the player's return to base.
runtime.ClearedThisEpoch = 1;
SystemAPI.SetComponent(cycleEntity, runtime);
}
SystemAPI.SetComponent(directorEntity, zs);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4dc121e99d0e53640b5e6815640a0bc6