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
@@ -0,0 +1,65 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// Authoring for the expedition zone-enemy director (singleton). Place ONE on a GameObject in the gameplay
/// subscene; the server-only <c>ZoneEnemyDirectorSystem</c> reads it. Bakes a <see cref="ZoneEnemyDirector"/>
/// config + a <see cref="ZoneEnemyPrefab"/> pool (index 0 = Grunt variant, index 1 = Charger variant — the
/// epoch-seeded composition picks between them) + the initial <see cref="ZoneEnemyState"/>. The entity carries no
/// transform; only the referenced prefabs need a runtime transform. The prefabs ALIAS the existing Husk ghost
/// prefabs, so zone enemies reuse the whole combat/readability stack with no new ghost type.
/// </summary>
public class ZoneEnemyDirectorAuthoring : MonoBehaviour
{
[Tooltip("Zone-enemy variant prefabs. Index 0 = Grunt, index 1 = Charger. Each must carry EnemyAuthoring + an interpolated GhostAuthoringComponent.")]
public GameObject[] EnemyPrefabs;
[Min(1), Tooltip("Max concurrent live zone enemies (the v1 ghost-relevancy budget).")] public int MaxAlive = 12;
[Min(0f)] public float RingRadius = 14f;
[Min(1)] public int RingSlots = 10;
[Min(1), Tooltip("Ticks between individual spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 30;
[Min(0), Tooltip("Grunts in the epoch-1 wave (held roughly constant as the epoch climbs).")] public int GruntsPerWave = 4;
[Min(0), Tooltip("Chargers in the epoch-1 wave (grows ~1 per epoch -> charger-heavy).")] public int ChargersPerWave = 1;
[Min(0), Tooltip("Flat Ore banked to the shared ledger on a real clear, once per sortie.")] public int RewardOre = 25;
private class ZoneEnemyDirectorBaker : Baker<ZoneEnemyDirectorAuthoring>
{
public override void Bake(ZoneEnemyDirectorAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.None);
AddComponent(entity, new ZoneEnemyDirector
{
MaxAlive = authoring.MaxAlive,
RingRadius = authoring.RingRadius,
RingSlots = authoring.RingSlots,
SpawnIntervalTicks = authoring.SpawnIntervalTicks,
GruntsPerWave = authoring.GruntsPerWave,
ChargersPerWave = authoring.ChargersPerWave,
RewardOre = authoring.RewardOre,
});
var buffer = AddBuffer<ZoneEnemyPrefab>(entity);
if (authoring.EnemyPrefabs != null)
{
foreach (var prefab in authoring.EnemyPrefabs)
{
if (prefab != null)
buffer.Add(new ZoneEnemyPrefab { Prefab = GetEntity(prefab, TransformUsageFlags.Dynamic) });
}
}
AddComponent(entity, new ZoneEnemyState
{
SpawnCounter = 0,
RemainingToSpawn = 0,
NextSpawnTick = 0,
SeededEpoch = 0,
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5918787bb2ebb7747afe473a7937ed53