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>
This commit is contained in:
2026-06-21 22:58:26 -07:00
parent cf45ec82ae
commit 3109b86d71
33 changed files with 1044 additions and 161 deletions
@@ -95,5 +95,36 @@ namespace ProjectM.Simulation
if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; }
}
}
/// <summary>
/// Region-scoped variant of <see cref="PickWeightedNearest(float3,NativeList{float3},NativeList{float3},float,out bool,out int)"/>:
/// considers ONLY targets whose stored region byte equals <paramref name="region"/>, so a Husk seeks within
/// its own region only (an expedition Husk never paths across the 1000-unit gap to a base player/structure,
/// and a base Husk never to an expedition target). <paramref name="playerRegions"/> /
/// <paramref name="structureRegions"/> are parallel to the position lists; the returned <paramref name="index"/>
/// maps to the FULL list so the caller's by-index entity lookup stays valid. Pure, Burst-safe (byte compares).
/// </summary>
public static void PickWeightedNearest(float3 from, NativeList<float3> playerPositions,
NativeList<byte> playerRegions, NativeList<float3> structurePositions, NativeList<byte> structureRegions,
byte region, float structureWeight, out bool isStructure, out int index)
{
isStructure = false;
index = -1;
float bestSq = float.MaxValue;
for (int i = 0; i < playerPositions.Length; i++)
{
if (playerRegions[i] != region) continue;
float sq = math.lengthsq(playerPositions[i].xz - from.xz);
if (sq < bestSq) { bestSq = sq; index = i; isStructure = false; }
}
float w = math.max(0f, structureWeight);
float wsq = w * w; // applied to SQUARED distance so the weight scales true distance
for (int i = 0; i < structurePositions.Length; i++)
{
if (structureRegions[i] != region) continue;
float sq = math.lengthsq(structurePositions[i].xz - from.xz) * wsq;
if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; }
}
}
}
}