3109b86d71
Reactivate the dormant Expedition region as a procedural combat venue.
v1 loop: walk the gate -> fight an epoch-seeded enemy wave in the
expedition -> clear -> return -> flat Ore reward (once per epoch) ->
escalated retaliation base siege.
- New sim types: ZoneEnemyTag, ZoneEnemyDirector (+ ZoneEnemyPrefab
buffer), ZoneEnemyState, ZoneEnemyMath (grunt->charger composition
by epoch). ZoneEnemyDirectorSystem (server, Burst): drip-spawns the
wave at a deterministic ring under a MaxAlive cap while a player is
out and the base is Calm; marks ClearedThisEpoch on a real clear.
[UpdateAfter(ExpeditionFieldSystem)] only (avoids a sort cycle).
- BLOCKER 1: EnemyAISystem region-filters target selection (player +
structure snapshots gain parallel region lists; no base structures /
no Core fallback for expedition husks).
- BLOCKER 3: WaveSystem, ThreatDirectorSystem timeout cull, and
CyclePhaseSystem DefendCleared + Core-breach cull all count/cull
RegionTag{Base} husks only (the breach cull was caught region-blind
by the post-impl review: a base breach wiped the live expedition
wave and spuriously paid the reward).
- BLOCKER 4: reward de-duped via CycleRuntime.LastRewardedEpoch +
ClearedThisEpoch; ExpeditionGateSystem deposits RewardOre once/epoch.
- ExpeditionFieldSystem teardown also culls zone enemies + region-
guards the clutter loop. Subscene wired with the director + roster.
368/368 EditMode green + clean netcode Play smoke. Docs: DR-040 ->
built, session log, CLAUDE.md cross-region tag-reaudit rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
7.7 KiB
C#
138 lines
7.7 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
|
|
{
|
|
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<NetworkTime>();
|
|
state.RequireForUpdate<CycleState>();
|
|
state.RequireForUpdate<ThreatState>();
|
|
state.RequireForUpdate<ThreatConfig>();
|
|
}
|
|
|
|
[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 BASE Husk only (expedition zone enemies are also
|
|
// EnemyTag but RegionTag{Expedition}; the timeout must not destroy them — DR-040 BLOCKER 3).
|
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithEntityAccess())
|
|
if (hr.ValueRO.Region == RegionId.Base)
|
|
ecb.DestroyEntity(he);
|
|
ecb.Playback(state.EntityManager);
|
|
ecb.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);
|
|
}
|
|
}
|
|
}
|