Files
Project-M/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs
T
kronic 4f0b4e8087 END-2: final siege + latching win/lose (SL-3)
At GoalProgress.Charge>=Target a new server-only GoalReachedSystem arms a larger final siege (x live FinalSiegeMultiplier) and flips RunPhase=FinalDefense; CyclePhaseSystem latches a REPLICATED RunOutcome (Victory on clear / Loss on Core breach) and halts the director. RunOutcome is a [GhostField] byte on the global CycleDirector ghost (the client banner observes it); RunPhase stays server-only. ThreatDirector/CoreRestore/CoreDamage halt once decided; SiegeTimeout is off during the final siege. SaveData v5 persists the outcome so a won/lost run loads finished. GoalProgress.Target 10->4. Completes Path A's spine. See DR-036.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:38:21 -07:00

143 lines
7.8 KiB
C#

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);
// END-2: a decided run (Victory/Loss) or one already in the FINAL siege arms NO further sieges. The
// SiegeTimeout cull is also disabled during the final siege (a cull -> false Victory). Guarded with
// HasComponent so EditMode worlds without RunPhase/RunOutcome keep the pre-END-2 behaviour.
byte runPhase = SystemAPI.HasComponent<RunPhase>(cycleEntity)
? SystemAPI.GetComponent<RunPhase>(cycleEntity).Value : RunPhaseId.Normal;
byte runOutcome = SystemAPI.HasComponent<RunOutcome>(cycleEntity)
? SystemAPI.GetComponent<RunOutcome>(cycleEntity).Value : RunOutcomeId.InProgress;
bool canArm = runPhase == RunPhaseId.Normal && runOutcome == RunOutcomeId.InProgress;
// ---- 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 && canArm)
{
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
}
// ---- SOURCE: scheduled base sieges. A timed cadence arms a siege even with NO expedition trip, so
// the base-defense loop has stakes on its own. The first fire is one full interval out (a mine/build
// grace window); size escalates by the live wave number. All ticks wrap-safe (TickUtil.NonZero). ----
if (config.ScheduleEnabled != 0 && config.ScheduleIntervalTicks > 0)
{
if (threat.NextScheduledTick == 0 || cycle.Phase != CyclePhase.Calm)
{
// Seed, and DEFER while a siege runs, so the next scheduled siege is always one full interval
// AFTER the current one resolves -> a guaranteed calm/build window even if a siege runs long.
threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
}
else if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0 && canArm
&& !new NetworkTick(threat.NextScheduledTick).IsNewerThan(serverTick))
{
int wave = SystemAPI.TryGetSingleton<WaveState>(out var ws) ? ws.WaveNumber : 0;
threat.PendingSiegeSize = math.max(1, config.SizeBase + config.ScheduleSizePerWave * wave);
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
}
}
// ---- 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 && runPhase != RunPhaseId.FinalDefense)
{
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);
}
}
}