Files
Project-M/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs
T
kronic 3109b86d71 Slice 3: Expedition Combat Spine — epoch-seeded zone waves (DR-040)
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>
2026-06-21 22:58:26 -07:00

107 lines
5.1 KiB
C#

using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// 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 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))]
[UpdateBefore(typeof(CyclePhaseSystem))]
public partial struct ExpeditionGateSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ExpeditionGate>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Snapshot gates once.
var gateFrom = new NativeList<byte>(Allocator.Temp);
var gateTo = new NativeList<byte>(Allocator.Temp);
var gateRadiusSq = new NativeList<float>(Allocator.Temp);
var gatePos = new NativeList<float2>(Allocator.Temp);
var gateArrival = new NativeList<float3>(Allocator.Temp);
foreach (var (gate, xform) in SystemAPI.Query<RefRO<ExpeditionGate>, RefRO<LocalTransform>>())
{
gateFrom.Add(gate.ValueRO.FromRegion);
gateTo.Add(gate.ValueRO.ToRegion);
gateRadiusSq.Add(gate.ValueRO.Radius * gate.ValueRO.Radius);
gatePos.Add(xform.ValueRO.Position.xz);
gateArrival.Add(gate.ValueRO.ArrivalPos);
}
bool returnedToBase = false;
foreach (var (region, xform) in
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
{
byte r = region.ValueRO.Region;
float2 pp = xform.ValueRO.Position.xz;
for (int i = 0; i < gateFrom.Length; i++)
{
if (gateFrom[i] != r) continue;
if (math.distancesq(pp, gatePos[i]) > gateRadiusSq[i]) continue;
region.ValueRW.Region = gateTo[i];
xform.ValueRW.Position = gateArrival[i];
if (gateTo[i] == RegionId.Base)
returnedToBase = true;
break;
}
}
gateFrom.Dispose();
gateTo.Dispose();
gateRadiusSq.Dispose();
gatePos.Dispose();
gateArrival.Dispose();
// 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)
{
if (SystemAPI.TryGetSingletonEntity<ThreatState>(out var threatEntity))
{
var threat = SystemAPI.GetComponent<ThreatState>(threatEntity);
threat.PendingReturns += 1;
threat.ExpeditionsCompleted += 1;
SystemAPI.SetComponent(threatEntity, threat);
}
// Once-per-epoch zone-clear reward: a returner banks flat Ore IFF this epoch's expedition wave was
// actually cleared and not yet rewarded. Resolved ONCE here (not per-returner) so two same-tick co-op
// returns pay exactly once (DR-040 BLOCKER 4) and gate re-entry before a clear can't farm (MINOR 2).
if (SystemAPI.HasSingleton<CycleState>()
&& SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir)
&& SystemAPI.HasSingleton<ResourceLedger>())
{
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
{
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre);
runtime.LastRewardedEpoch = runtime.ExpeditionEpoch;
SystemAPI.SetComponent(cycleEntity, runtime);
}
}
}
}
}
}