Game Scene Split up

This commit is contained in:
2026-06-04 13:45:46 -07:00
parent dbc4a92a86
commit 16b01bec38
49 changed files with 11976 additions and 188 deletions
@@ -40,6 +40,13 @@ namespace ProjectM.Server
{
if (dmg.Length == 0)
continue;
// Dev god-mode: while enabled, this entity ignores ALL damage (server-authoritative, once per tick).
if (SystemAPI.HasComponent<DebugGodMode>(entity) && SystemAPI.IsComponentEnabled<DebugGodMode>(entity))
{
dmg.Clear();
continue;
}
// Respawn invulnerability: a freshly-recovered player ignores damage for a window.
if (haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<RespawnInvuln>(entity))
@@ -41,8 +41,8 @@ namespace ProjectM.Server
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
// M6 Aether Cycle: the base-defense wave only runs during the Defend phase.
if (SystemAPI.TryGetSingleton<CycleState>(out var cycle) && cycle.Phase != CyclePhase.Defend)
// Player-driven loop: the base-defense wave only spawns during a Siege.
if (SystemAPI.TryGetSingleton<CycleState>(out var cycle) && cycle.Phase != CyclePhase.Siege)
return;
var director = SystemAPI.GetSingleton<WaveDirector>();
@@ -0,0 +1,209 @@
#if UNITY_EDITOR
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// EDITOR-ONLY server receiver for <see cref="DebugCommandRequest"/> dev-tool RPCs (from the DebugOverlay or
/// execute_code). Applies authoritative effects so the dev buttons exercise the REAL server paths and work
/// over a live connection too: force/end sieges, grant resources/upgrades, teleport, god-mode, heal/kill,
/// advance the goal. Sender-targeted ops resolve the player via SourceConnection -> NetworkId -> GhostOwner
/// (the RegionTransitSystem pattern). Plain server SimulationSystemGroup (NOT the predicted loop). Reuses
/// StorageMath / StatModifier / RegionMath + the wave/cycle singletons. The whole system is #if UNITY_EDITOR
/// (stripped from builds); the wire TYPE (<see cref="DebugCommandRequest"/>) is unconditional so the RPC
/// collection hash matches across peers. Non-Burst (managed-simple, editor-only) — perf is irrelevant.
/// </summary>
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct DebugCommandReceiveSystem : ISystem
{
EntityQuery m_Husks;
public void OnCreate(ref SystemState state)
{
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<DebugCommandRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
}
public void OnUpdate(ref SystemState state)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
// Connection NetworkId -> player entity (for sender-targeted ops).
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, e) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = e;
bool haveCycle = SystemAPI.TryGetSingletonEntity<CycleState>(out var cycleEntity);
foreach (var (request, receive, reqEntity) in
SystemAPI.Query<RefRO<DebugCommandRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
{
var cmd = request.ValueRO;
Entity sender = Entity.Null;
var connEntity = receive.ValueRO.SourceConnection;
if (SystemAPI.HasComponent<NetworkId>(connEntity))
playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(connEntity).Value, out sender);
switch (cmd.Op)
{
case DebugOp.SpawnWave:
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
{
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
ts.PendingSiegeSize = math.max(1, cmd.ArgA);
ts.ArmTick = 0; // fire as soon as CyclePhaseSystem sees it
SystemAPI.SetComponent(cycleEntity, ts);
}
break;
case DebugOp.EndSiege:
case DebugOp.SetCalm:
CullHusks(ref ecb);
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var we))
{
var w = SystemAPI.GetComponent<WaveState>(we);
w.Phase = WavePhase.Lull;
w.RemainingToSpawn = 0;
SystemAPI.SetComponent(we, w);
}
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
{
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
ts.PendingSiegeSize = 0;
ts.ArmTick = 0;
ts.SiegeStartTick = 0;
SystemAPI.SetComponent(cycleEntity, ts);
}
if (cmd.Op == DebugOp.SetCalm && haveCycle)
{
var cs = SystemAPI.GetComponent<CycleState>(cycleEntity);
cs.Phase = CyclePhase.Calm;
cs.PhaseEndTick = 0;
SystemAPI.SetComponent(cycleEntity, cs);
}
break;
case DebugOp.ClearEnemies:
CullHusks(ref ecb);
break;
case DebugOp.GrantResource:
if (SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerE))
{
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerE);
StorageMath.Deposit(ledger, (ushort)cmd.ArgA, cmd.ArgB);
}
break;
case DebugOp.GrantUpgrade:
if (sender != Entity.Null && SystemAPI.HasBuffer<StatModifier>(sender))
GrowDamageModifier(SystemAPI.GetBuffer<StatModifier>(sender));
break;
case DebugOp.Teleport:
if (sender != Entity.Null && SystemAPI.HasComponent<RegionTag>(sender)
&& SystemAPI.HasComponent<LocalTransform>(sender))
{
byte region = (byte)cmd.ArgA;
SystemAPI.GetComponentRW<RegionTag>(sender).ValueRW.Region = region;
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
SystemAPI.GetComponentRW<LocalTransform>(sender).ValueRW.Position =
RegionMath.RegionOrigin(region, baseCenter);
}
break;
case DebugOp.ToggleGod:
if (sender != Entity.Null && SystemAPI.HasComponent<DebugGodMode>(sender))
SystemAPI.SetComponentEnabled<DebugGodMode>(sender, !SystemAPI.IsComponentEnabled<DebugGodMode>(sender));
break;
case DebugOp.Heal:
if (sender != Entity.Null && SystemAPI.HasComponent<Health>(sender))
{
var h = SystemAPI.GetComponent<Health>(sender);
h.Current = SystemAPI.HasComponent<EffectiveCharacterStats>(sender)
? SystemAPI.GetComponent<EffectiveCharacterStats>(sender).MaxHealth
: h.Max;
SystemAPI.SetComponent(sender, h);
}
break;
case DebugOp.KillPlayer:
if (sender != Entity.Null && SystemAPI.HasComponent<Health>(sender))
{
var h = SystemAPI.GetComponent<Health>(sender);
h.Current = 0f;
SystemAPI.SetComponent(sender, h);
}
break;
case DebugOp.AdvanceGoal:
if (haveCycle && SystemAPI.HasComponent<GoalProgress>(cycleEntity))
{
var g = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
g.Charge += math.max(1, cmd.ArgA);
SystemAPI.SetComponent(cycleEntity, g);
}
break;
case DebugOp.SetHeat:
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
{
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
ts.Heat = cmd.ArgA;
SystemAPI.SetComponent(cycleEntity, ts);
}
break;
}
ecb.DestroyEntity(reqEntity);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
playerByConn.Dispose();
}
void CullHusks(ref EntityCommandBuffer ecb)
{
var husks = m_Husks.ToEntityArray(Allocator.Temp);
for (int i = 0; i < husks.Length; i++)
ecb.DestroyEntity(husks[i]);
husks.Dispose();
}
static void GrowDamageModifier(DynamicBuffer<StatModifier> mods)
{
const uint debugSourceId = 0x00DEB061u; // distinct debug sentinel (replace-by-SourceId keeps it bounded)
for (int i = 0; i < mods.Length; i++)
{
if (mods[i].SourceId == debugSourceId && mods[i].Target == (byte)StatTarget.Damage)
{
var m = mods[i];
m.Value += 0.25f;
mods[i] = m;
return;
}
}
mods.Add(new StatModifier
{
Target = (byte)StatTarget.Damage,
Op = (byte)ModOp.PercentAdd,
Value = 0.25f,
SourceId = debugSourceId,
});
}
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8c34745ae117284439fa4487f586ad0e
@@ -8,14 +8,14 @@ using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only procedural expedition-field manager. Edge-triggered off the cycle phase (via the server-only
/// <see cref="CycleRuntime.PrevPhase"/>): on ENTERING Expedition for a not-yet-seeded cycle it scatters
/// <see cref="ResourceFieldSpawner.Count"/> resource-node ghosts (seeded by CycleNumber via
/// <see cref="Unity.Mathematics.Random"/>) around the expedition region origin, each
/// <see cref="RegionTag"/>{Expedition}; on LEAVING Expedition it destroys every node. Runs in the plain
/// server SimulationSystemGroup <c>[UpdateAfter(CyclePhaseSystem)]</c> so the phase edge is observed the
/// same tick. Server-authoritative; clients despawn nodes via GhostDespawnSystem. Per-cycle reproducible
/// (the seed is the monotonic int CycleNumber, compared by equality — never tick math; never seed 0).
/// Server-only procedural expedition-field manager. Re-keyed off PER-PLAYER PRESENCE (no global phase): it
/// counts players whose server-only <see cref="RegionTag"/> is the Expedition region, and on the
/// empty-&gt;occupied edge (a new sortie) bumps <see cref="CycleRuntime.ExpeditionEpoch"/> and scatters
/// <see cref="ResourceFieldSpawner.Count"/> resource-node ghosts (seeded by the epoch) around the expedition
/// origin, each RegionTag{Expedition}; on the occupied-&gt;empty edge (the LAST player left) it destroys every
/// node. So the field lives as long as anyone is out there, not on a global timer. Plain server
/// SimulationSystemGroup. Server-authoritative; clients despawn nodes via GhostDespawnSystem. Per-epoch
/// reproducible (the seed is the monotonic int epoch, compared by equality — never tick math, never 0).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
@@ -34,10 +34,21 @@ namespace ProjectM.Server
public void OnUpdate(ref SystemState state)
{
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
var spawner = SystemAPI.GetSingleton<ResourceFieldSpawner>();
// Per-player presence: is anyone currently out in the expedition region?
int expeditionPlayers = 0;
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
if (region.ValueRO.Region == RegionId.Expedition)
expeditionPlayers++;
bool occupied = expeditionPlayers > 0;
bool wasOccupied = runtime.PrevExpeditionOccupied != 0;
// empty -> occupied: a new sortie begins; bump the epoch so the field reseeds fresh.
if (occupied && !wasOccupied)
runtime.ExpeditionEpoch += 1;
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
@@ -45,14 +56,14 @@ namespace ProjectM.Server
var ecb = new EntityCommandBuffer(Allocator.Temp);
// SPAWN edge: entered Expedition for a cycle we have not seeded yet.
if (cycle.Phase == CyclePhase.Expedition
&& runtime.LastSpawnedCycle != cycle.CycleNumber
// SPAWN: a player is out and this epoch has not been seeded yet.
if (occupied
&& runtime.LastSpawnedEpoch != runtime.ExpeditionEpoch
&& spawner.Prefab != Entity.Null)
{
var baseXform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
var prefabNode = SystemAPI.GetComponent<ResourceNode>(spawner.Prefab);
var rng = new Random((uint)math.max(1, cycle.CycleNumber));
var rng = new Random((uint)math.max(1, runtime.ExpeditionEpoch));
int count = math.max(1, spawner.Count);
for (int i = 0; i < count; i++)
{
@@ -69,17 +80,17 @@ namespace ProjectM.Server
rn.ResourceId = (byte)(ResourceId.Aether + (byte)(i % 3));
ecb.SetComponent(node, rn);
}
runtime.LastSpawnedCycle = cycle.CycleNumber;
runtime.LastSpawnedEpoch = runtime.ExpeditionEpoch;
}
// DESTROY edge: left Expedition — clear the whole field.
if (runtime.PrevPhase == CyclePhase.Expedition && cycle.Phase != CyclePhase.Expedition)
// DESTROY: the last player left the expedition — clear the whole field.
if (wasOccupied && !occupied)
{
foreach (var (rn, e) in SystemAPI.Query<RefRO<ResourceNode>>().WithEntityAccess())
ecb.DestroyEntity(e);
}
runtime.PrevPhase = cycle.Phase;
runtime.PrevExpeditionOccupied = (byte)(occupied ? 1 : 0);
SystemAPI.SetComponent(cycleEntity, runtime);
ecb.Playback(state.EntityManager);
@@ -33,7 +33,6 @@ namespace ProjectM.Server
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var spawnerEntity = SystemAPI.GetSingletonEntity<CycleDirectorSpawner>();
var spawner = SystemAPI.GetComponent<CycleDirectorSpawner>(spawnerEntity);
@@ -50,14 +49,15 @@ namespace ProjectM.Server
xform.Position = BaseGridMath.PlotCenter(anchor);
ecb.SetComponent(director, xform);
// Override the baked CycleState with the real start tick; add server-only bookkeeping.
// Boot the run-state in Calm (the persistent default) — no timer; ThreatDirector arms sieges.
ecb.SetComponent(director, new CycleState
{
Phase = CyclePhase.Expedition,
Phase = CyclePhase.Calm,
CycleNumber = 1,
PhaseEndTick = TickUtil.NonZero(now + CyclePhase.ExpeditionTicks),
PhaseEndTick = 0u,
});
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
ecb.AddComponent(director, new ThreatState());
}
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
@@ -1,17 +1,22 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative macro-loop director for "The Aether Cycle": Expedition (timed) -> Defend
/// (wave-driven) -> Build (timed) -> next cycle. Maintains the <see cref="CycleState"/> singleton and gates
/// <see cref="WaveSystem"/> so the base-defense wave only spawns during Defend. Runs in the plain server
/// SimulationSystemGroup (NOT prediction) before <see cref="WaveSystem"/>. All timing is wrap-safe
/// NetworkTick math (<see cref="TickUtil.NonZero"/> + <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/>),
/// never raw uint compares. The CycleState/CycleRuntime live on the runtime-spawned CycleDirector ghost.
/// Server-authoritative macro-loop director for the PLAYER-DRIVEN loop. The base sits in <c>Calm</c>
/// (persistent, unhurried — build/prep at your pace, no countdown) until the <see cref="ThreatState"/> arms a
/// siege, then flips to <c>Siege</c> (the base-defense wave) and back to <c>Calm</c> when the wave is cleared.
/// There is no global "Expedition" phase — being out on an expedition is per-player presence (server-only
/// <see cref="RegionTag"/>), read client-side by the HUD, so one global byte never has to represent
/// "player A out / player B home." Maintains the replicated <see cref="CycleState"/> singleton and gates
/// <see cref="WaveSystem"/> (waves spawn only during Siege). Runs in the plain server SimulationSystemGroup
/// before WaveSystem. All timing is wrap-safe NetworkTick math (<see cref="ProjectM.Simulation.TickUtil.NonZero"/>
/// + <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/>), never raw uint compares. Lives on the
/// runtime-spawned CycleDirector ghost. Supersedes the forced timed Expedition→Defend→Build cycle.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
@@ -38,48 +43,64 @@ namespace ProjectM.Server
uint now = serverTick.TickIndexForValidTick;
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
bool timedPhaseDue =
cycle.PhaseEndTick != 0 && !new NetworkTick(cycle.PhaseEndTick).IsNewerThan(serverTick);
switch (cycle.Phase)
if (cycle.Phase == CyclePhase.Calm)
{
case CyclePhase.Expedition:
if (timedPhaseDue)
{
cycle.Phase = CyclePhase.Defend;
cycle.PhaseEndTick = 0; // Defend is wave-driven, not timed.
runtime.DefendStartWave =
SystemAPI.TryGetSingleton<WaveState>(out var w) ? w.WaveNumber : 0;
}
break;
// Default calm: no pending siege => no countdown.
cycle.PhaseEndTick = 0;
case CyclePhase.Defend:
if (DefendCleared(ref state, runtime.DefendStartWave))
if (SystemAPI.HasComponent<ThreatState>(cycleEntity))
{
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
if (threat.PendingSiegeSize > 0)
{
cycle.Phase = CyclePhase.Build;
cycle.PhaseEndTick = TickUtil.NonZero(now + CyclePhase.BuildTicks);
}
break;
// Telegraph: mirror the arm tick into the replicated PhaseEndTick so the HUD can show an
// "incursion in Ns" countdown (reuses the existing HUD countdown path) while it arms.
cycle.PhaseEndTick = threat.ArmTick;
case CyclePhase.Build:
if (timedPhaseDue)
{
cycle.Phase = CyclePhase.Expedition;
cycle.CycleNumber += 1;
cycle.PhaseEndTick = TickUtil.NonZero(now + CyclePhase.ExpeditionTicks);
// Long-arc goal: one charge per completed cycle (single writer).
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
bool armed = threat.ArmTick == 0
|| !new NetworkTick(threat.ArmTick).IsNewerThan(serverTick);
if (armed && SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
{
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
goal.Charge += 1;
SystemAPI.SetComponent(cycleEntity, goal);
// ---- Calm -> Siege: seed WaveSystem's own Spawning entry atomically. Writing
// Phase=Spawning bypasses its Lull escalation recompute (WaveSystem only recomputes
// RemainingToSpawn while Phase==Lull), so the siege spawns EXACTLY the director-chosen
// size and WaveSystem stays the sole WaveState writer thereafter. ----
var w = SystemAPI.GetComponent<WaveState>(waveEntity);
runtime.DefendStartWave = w.WaveNumber; // capture BEFORE the bump (DefendCleared tests > this)
w.WaveNumber += 1;
w.Phase = WavePhase.Spawning;
w.RemainingToSpawn = math.max(1, threat.PendingSiegeSize);
w.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick
SystemAPI.SetComponent(waveEntity, w);
cycle.Phase = CyclePhase.Siege;
cycle.PhaseEndTick = 0; // Siege is wave-driven, not timed.
threat.PendingSiegeSize = 0; // consume once
threat.ArmTick = 0;
SystemAPI.SetComponent(cycleEntity, threat);
}
}
break;
}
}
else if (cycle.Phase == CyclePhase.Siege)
{
if (DefendCleared(ref state, runtime.DefendStartWave))
{
cycle.Phase = CyclePhase.Calm;
cycle.PhaseEndTick = 0;
// Long-arc goal: +1 per siege survived (single writer; was +1 per completed timed cycle).
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
{
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
goal.Charge += 1;
SystemAPI.SetComponent(cycleEntity, goal);
}
}
}
// Surface the live wave number on the replicated CycleState for the HUD (single writer).
@@ -90,8 +111,8 @@ namespace ProjectM.Server
SystemAPI.SetComponent(cycleEntity, runtime);
}
// The Defend wave has run for this phase (WaveNumber advanced past the captured start), is fully
// spawned, and no Husks remain alive.
// The Siege wave has run for this phase (WaveNumber advanced past the captured start), is fully spawned,
// and no Husks remain alive.
bool DefendCleared(ref SystemState state, int defendStartWave)
{
if (!SystemAPI.TryGetSingleton<WaveState>(out var wave))
@@ -11,15 +11,16 @@ namespace ProjectM.Server
/// Server-only walk-in gate transit: a player who walks within a gate's radius (and whose region matches the
/// gate's <see cref="ExpeditionGate.FromRegion"/>) is transited to the gate's ToRegion at its ArrivalPos
/// (RegionTag flipped + LocalTransform teleported — GhostRelevancy re-scopes their ghosts, as in
/// <c>RegionTransitSystem</c>). Returning to the BASE during the Expedition phase expires the Expedition
/// timer so Defend starts early ("timer cap + early return"). Plain server SimulationSystemGroup
/// <c>[UpdateAfter(CyclePhaseSystem)]</c>. Arrival points are offset from the destination gate so a transited
/// player does not immediately re-trigger.
/// <c>RegionTransitSystem</c>). Returning to BASE signals the ThreatDirector (a completed expedition can draw a
/// retaliation siege) by incrementing <see cref="ProjectM.Simulation.ThreatState.PendingReturns"/>. Plain server
/// SimulationSystemGroup, ordered BEFORE CyclePhaseSystem (Gate -> ThreatDirector -> RunState) so the return is
/// consumed the same tick. Arrival points are offset from the destination gate so a transited player does not
/// immediately re-trigger.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(CyclePhaseSystem))]
[UpdateBefore(typeof(CyclePhaseSystem))]
public partial struct ExpeditionGateSystem : ISystem
{
[BurstCompile]
@@ -70,15 +71,14 @@ namespace ProjectM.Server
gatePos.Dispose();
gateArrival.Dispose();
// Early return: a player came back to base mid-Expedition -> expire the Expedition timer (-> Defend).
if (returnedToBase && SystemAPI.TryGetSingletonEntity<CycleState>(out var cycleEntity))
// A player returned to base from an expedition -> signal the ThreatDirector (it sizes/arms any
// retaliation siege). The gate teleports the returner out of its radius, so this fires once per return.
if (returnedToBase && SystemAPI.TryGetSingletonEntity<ThreatState>(out var threatEntity))
{
var cs = SystemAPI.GetComponent<CycleState>(cycleEntity);
if (cs.Phase == CyclePhase.Expedition)
{
cs.PhaseEndTick = 1; // CyclePhaseSystem sees timedPhaseDue next tick -> Defend
SystemAPI.SetComponent(cycleEntity, cs);
}
var threat = SystemAPI.GetComponent<ThreatState>(threatEntity);
threat.PendingReturns += 1;
threat.ExpeditionsCompleted += 1;
SystemAPI.SetComponent(threatEntity, threat);
}
}
}
@@ -0,0 +1,112 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only composite ThreatDirector — the data-driven base-attack SCHEDULER. It owns the decision of WHEN
/// and HOW BIG a siege is; <see cref="CyclePhaseSystem"/> owns the Calm↔Siege transition. The single documented
/// hand-off is <see cref="ThreatState.PendingSiegeSize"/> (this system sets it; CyclePhaseSystem consumes it).
/// This slice wires ONE source — POST-EXPEDITION retaliation: a player returning to base (counted as
/// <see cref="ThreatState.PendingReturns"/> by <see cref="ExpeditionGateSystem"/>) 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
/// unattended siege (e.g. an empty base) auto-collapses so the loop can never soft-lock. Runs in the plain
/// server SimulationSystemGroup, ordered Gate -> ThreatDirector -> RunState(CyclePhaseSystem) -> Wave so a
/// return is consumed the same tick. All timing is wrap-safe NetworkTick math (TickUtil.NonZero +
/// NetworkTick.IsNewerThan / TicksSince), never raw uint.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(ExpeditionGateSystem))]
[UpdateBefore(typeof(CyclePhaseSystem))]
public partial struct ThreatDirectorSystem : ISystem
{
EntityQuery m_Husks;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<CycleState>();
state.RequireForUpdate<ThreatState>();
state.RequireForUpdate<ThreatConfig>();
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
var config = SystemAPI.GetComponent<ThreatConfig>(cycleEntity);
// ---- SOURCE: post-expedition retaliation. A returning player arms ONE siege (simultaneous returns
// collapse to a single arming — extending the de-dup the gate's one-increment-per-return starts). ----
if (config.PostExpeditionEnabled != 0 && threat.PendingReturns > 0)
{
if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0)
{
int size = config.SizeBase + config.SizePerExpeditionResource * 0; // haul-scaling deferred (field baked)
threat.PendingSiegeSize = math.max(1, size);
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
}
threat.PendingReturns = 0; // consume regardless so returns can't pile up
}
// ---- BOUNDED RESOLUTION: a Siege can't drag forever. Record its start; after SiegeTimeoutTicks cull
// the remaining Husks + stop spawning so CyclePhaseSystem's DefendCleared returns the base to Calm. ----
if (cycle.Phase == CyclePhase.Siege)
{
if (threat.SiegeStartTick == 0)
{
threat.SiegeStartTick = TickUtil.NonZero(now);
}
else if (config.SiegeTimeoutTicks > 0)
{
var start = new NetworkTick(threat.SiegeStartTick);
if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks)
{
// Collapse the siege: cull every remaining Husk (a cached tag query, never RefRO on a tag).
var husks = m_Husks.ToEntityArray(Allocator.Temp);
if (husks.Length > 0)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int i = 0; i < husks.Length; i++)
ecb.DestroyEntity(husks[i]);
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
husks.Dispose();
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
{
var w = SystemAPI.GetComponent<WaveState>(waveEntity);
w.RemainingToSpawn = 0;
SystemAPI.SetComponent(waveEntity, w);
}
threat.SiegeStartTick = 0;
}
}
}
else
{
threat.SiegeStartTick = 0; // not under siege
}
SystemAPI.SetComponent(cycleEntity, threat);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3cd1beb28c2b1f84398722a95d1ee784