Run Re-Do
This commit is contained in:
@@ -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 -> 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->Field->Zone->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
|
||||
Reference in New Issue
Block a user