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:
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Marker on a runtime-spawned EXPEDITION combat enemy (a Husk variant the server's ZoneEnemyDirectorSystem
|
||||
/// instantiates in the expedition region). Distinguishes zone enemies from base-siege Husks for the single
|
||||
/// teardown point in ExpeditionFieldSystem and for the per-epoch clear count. Zone enemies ALSO keep
|
||||
/// <c>EnemyTag</c> + <c>RegionTag{Expedition}</c>, so Slice-1 readability/health-bars/telegraphs/damage and the
|
||||
/// per-region AI target filter all work for them unchanged.
|
||||
/// </summary>
|
||||
public struct ZoneEnemyTag : IComponentData { }
|
||||
|
||||
/// <summary>
|
||||
/// Baked config for the expedition zone-enemy director (one singleton in the gameplay subscene; the server-only
|
||||
/// ZoneEnemyDirectorSystem reads it). One epoch-seeded wave per sortie: a <see cref="GruntsPerWave"/> +
|
||||
/// <see cref="ChargersPerWave"/> baseline that shifts grunt->charger as the expedition epoch climbs (the
|
||||
/// highest-leverage variety lever), spawned one every <see cref="SpawnIntervalTicks"/> at a deterministic ring of
|
||||
/// <see cref="RingSlots"/> slots / <see cref="RingRadius"/> around the expedition origin, capped at
|
||||
/// <see cref="MaxAlive"/> concurrent (the v1 ghost-relevancy mitigation). On a real clear the returning player
|
||||
/// banks <see cref="RewardOre"/> to the shared ledger, once per epoch.
|
||||
/// </summary>
|
||||
public struct ZoneEnemyDirector : IComponentData
|
||||
{
|
||||
public int MaxAlive;
|
||||
public float RingRadius;
|
||||
public int RingSlots;
|
||||
public int SpawnIntervalTicks;
|
||||
public int GruntsPerWave;
|
||||
public int ChargersPerWave;
|
||||
public int RewardOre;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baked pool of zone-enemy prefab variants the director draws from by composition. Index 0 = Grunt variant,
|
||||
/// index 1 = Charger variant (see <see cref="ZoneEnemyMath.IsChargerSlot"/>). Aliases the existing Husk ghost
|
||||
/// prefabs so zone enemies reuse the whole combat/readability stack with no new ghost type.
|
||||
/// </summary>
|
||||
public struct ZoneEnemyPrefab : IBufferElementData
|
||||
{
|
||||
public Entity Prefab;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime state of the zone-enemy director (server-only singleton; NOT replicated). Tracks its OWN spawn
|
||||
/// counter (never WaveState.SpawnCounter), how many enemies remain to spawn for the current epoch's wave, the
|
||||
/// next spawn tick, and the epoch the current wave was seeded for (re-syncs when CycleRuntime.ExpeditionEpoch
|
||||
/// bumps). All session-scoped: 0-defaults safely on load (no SaveData field — DR-040 defers SaveData v6).
|
||||
/// </summary>
|
||||
public struct ZoneEnemyState : IComponentData
|
||||
{
|
||||
public uint SpawnCounter;
|
||||
public int RemainingToSpawn;
|
||||
public uint NextSpawnTick;
|
||||
public int SeededEpoch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0aedc87a4719411418ca5f914af613b8
|
||||
@@ -0,0 +1,42 @@
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, deterministic composition math for the expedition zone-enemy wave — no RNG state, no wall-clock — so the
|
||||
/// per-epoch wave is reproducible across restarts/saves and EditMode-unit-testable without an ECS world (mirrors
|
||||
/// <see cref="EnemyAIMath"/> / <c>ProductionMath</c>). The highest-leverage Slice-3 variety lever: the encounter
|
||||
/// COMPOSITION shifts grunt-heavy -> charger-heavy as the expedition <c>epoch</c> climbs (grunt count stays
|
||||
/// fixed; the per-epoch growth is all chargers).
|
||||
/// </summary>
|
||||
public static class ZoneEnemyMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Total enemies in this epoch's wave: the baked <paramref name="gruntsPerWave"/> + <paramref name="chargersPerWave"/>
|
||||
/// baseline plus one extra per epoch beyond the first (a gentle ramp). Lower-bounded at 1 so an occupied
|
||||
/// expedition always has a fight. <paramref name="epoch"/> is the monotonic sortie counter (>=1 in practice).
|
||||
/// </summary>
|
||||
public static int WaveSize(int epoch, int gruntsPerWave, int chargersPerWave)
|
||||
{
|
||||
int e = math.max(1, epoch);
|
||||
int baseCount = math.max(0, gruntsPerWave) + math.max(0, chargersPerWave);
|
||||
return math.max(1, baseCount + (e - 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic grunt/charger pick for spawn <paramref name="slot"/> of this epoch's wave. The charger
|
||||
/// count is <paramref name="chargersPerWave"/> + (epoch - 1), clamped to the wave size, assigned to the LAST
|
||||
/// slots; everything earlier is a Grunt. So the grunt count stays fixed at <paramref name="gruntsPerWave"/>
|
||||
/// and the wave skews charger-heavy as the epoch climbs. Returns true for a Charger slot. Stable per
|
||||
/// (epoch, slot) — a replayed wave is identical. Pure integer math (Burst-safe; no enum, no RNG).
|
||||
/// </summary>
|
||||
public static bool IsChargerSlot(int epoch, int slot, int gruntsPerWave, int chargersPerWave)
|
||||
{
|
||||
int e = math.max(1, epoch);
|
||||
int size = WaveSize(epoch, gruntsPerWave, chargersPerWave);
|
||||
int chargers = math.clamp(math.max(0, chargersPerWave) + (e - 1), 0, size);
|
||||
int s = ((slot % size) + size) % size;
|
||||
return s >= size - chargers;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bff328839d124da48bd8a43b9d77f543
|
||||
Reference in New Issue
Block a user