Slice Combat Depth (MC-2): enemy-variety server spine — Spitter, Swarmer, 4-type mix (DR-041)

Adds the server-authoritative mechanics for three new enemy archetypes on top of
the Grunt/Charger base, plus the weighted wave-composition that introduces them:

- Spitter: a ranged Husk variant (SpitterState) that holds a preferred range-band
  (advance/retreat/hold via EnemyAIMath.BandVelocity) and fires a telegraphed,
  dodgeable EnemyProjectile. New server EnemyProjectileMoveSystem (integrate +
  store LastStep) + EnemyProjectileDamageSystem (region-filtered swept hit-test
  rebuilt from LastStep — DR-018 anti-tunnelling; players use HitRadius, structures
  a const radius; at-most-once destroy). Concurrent-spit soft cap, soft-fail retry.
- Swarmer: marker tag + deterministic cluster spawn (1 slot = 1 pack;
  EnemyAIMath.ClusterOffset), MaxAlive counts ENTITIES so a pack defers if it
  won't fit.
- 4-type weighted mix: MixBands -> ZoneEnemyMath.WaveSlots/KindForSlot/
  PackSizeForSlot drives both the expedition director and (fork-4a) the base siege,
  with a mandatory MaxAlive cap. Legacy WaveSize/IsChargerSlot kept + parity-tested.
- Discriminator stays component-presence (no enum in Bursted systems): query-
  partition guards keep each enemy moved by exactly one EnemyAISystem pass
  (sole-Position-writer). EnemyTelegraph.IsCharger -> Kind byte for the client cue.

New authoring (Spitter/Swarmer/EnemyProjectile) + expanded director authorings with
tunable mix/cluster defaults. 13 new EditMode tests (mix composition + legacy parity,
band/cluster math, projectile move + cross-region + swept anti-tunnelling regressions);
full suite green before commit.

Dormant until the prefab/subscene wiring lands (next): the new systems guard on
TryGetSingleton/RequireForUpdate, so with no prefabs wired the new types stay inert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 20:06:56 -07:00
parent 3109b86d71
commit 56cf60cce3
34 changed files with 1204 additions and 64 deletions
@@ -0,0 +1,92 @@
---
id: DR-041
title: Combat Depth Slice — Enemy Variety (MC-2) + Impact (MC-3) (reviewed + locked)
status: accepted
date: 2026-06-22
tags:
- decision
- design
- combat
- enemies
- netcode
- juice
- slice
permalink: gamevault/07-sessions/decisions/dr-041-combat-depth-enemy-variety-impact
---
# DR-041 — Combat Depth: Enemy Variety (MC-2) + Impact (MC-3)
> The combat-depth slice the operator chose after Slice 3 ("the combat needs a lot more work"). Today there are only **two enemy brains** (Grunt = walk-up melee, Charger = committed lunge) → effectively ONE question, so fights feel samey. This slice adds two NEW readable questions + makes hits FEEL like hits. Preceded by the mandatory adversarial pre-coding design review (1 ground + 3 lenses — netcode/relevancy/determinism · combat feel & readability · reuse/scope → synth; run `wf_eb115556-8cc`). All three lenses **GO_WITH_CHANGES**; the review **corrected four mis-groundings** (folded in below). Implements MC-2 + MC-3 from [[Path_to_Fun]] PATH B. Reuses the Charger pattern, region split, `ZoneEnemyMath`, `CombatFeedbackSystem`, `PrototypeCameraRig`.
## The hole this closes
Pillar #2 ("depth via dialogue"): enemies ask DISTINCT readable questions; the player answers with skilled, committed tools (the dash + melee combo already exist and are good). With only melee threats there's one answer. This slice adds the **reposition question (Spitter)** and the **surround question (Swarmer)**, a weighted mix that ramps them in, and the **impact feel (MC-3)** that makes each exchange land.
## Operator forks (locked)
- **Base siege gets the FULL 4-type mix too** (fork 4a, chosen over expedition-only). `WaveSystem` adopts the shared composition function **+ a MANDATORY new `WaveDirector.MaxAlive` cap** (none today → uncapped spitter projectiles + swarmer packs would spike the `O(ghosts×conn)` relevancy loop during the END-1/END-2 climax — the review made the cap a hard requirement of this choice). A PARITY test proves `WaveSlots` reproduces the legacy `BaseCount+(wave-1)*CountPerWave` size curve for the legacy band, so the base *pacing* is provably controlled even as contents gain variety. **Directly attacks the original "all combat in the home base feels stale" complaint.**
- **No shoot-down of spits** (fork 2a). `EnemyProjectile` stays OUT of the player-projectile hit loops; the dash-through-spit interaction (i-frame negation) is the committed-tool counter. Cheaper, simpler; trivially addable later.
- **Defaults taken** (operator "defaults then adjust" autonomy; all live-tunable): Spitter **holds position** in-band (strafe is a tuning revisit); hit-stop ships the **camera-punch baseline**, true freeze-frame gated behind `HitStopFreezeEnabled=false`; new enemies **staggered** in (Spitter @ epoch ≥2, Swarmer packs @ epoch ≥3); Swarmer **pack size fixed** for v1 (ramp field exposed, unwired).
## Four review corrections (why the review earned its keep)
1. **dt-trap.** The enemy-projectile systems are NOT mirrors of `ProjectileMove/DamageSystem` — those run in `PredictedSimulationSystemGroup` where `SystemAPI.Time.DeltaTime` IS the fixed step; the enemy ones run in the **plain** server group where that dt is wall-frame. Store `LastStep = Speed*dt` at move time; the damage system rebuilds the swept segment ONLY from `cur - dir*LastStep`, never a fresh `Time.DeltaTime`.
2. **Missing damage-region filter (blocker).** `ProjectileDamageSystem` has NO region check (player projectiles are region-irrelevant so it's fine). A hostile spit hit-tests every `Health` entity in the shared world where base/expedition players coexist 1000u apart → `EnemyProjectile` carries `byte Region`; `EnemyProjectileDamageSystem` snapshots each target's `RegionTag.Region` and skips mismatches (same byte guard as `PickWeightedNearest`).
3. **Enemy hit-flash is NOT free.** Enemies render via Rukhanka GPU deformation (no classic `MeshRenderer`/MPB); `CombatFeedbackSystem` sees only the ghost `Entity`, not child render entities. A real material flash needs a ShaderGraph `_Flash*` + a `[MaterialProperty]` IComponentData on the render entity + a ghost→render-entity mapping → **DEFERRED to its own ShaderGraph slice**. MC-3 v1 ships camera-punch + magnitude scaling + a 2-frame emphasis on existing pooled GameObjects.
4. **Spitter telegraph + IsCharger migration mislabeled.** The existing danger cone is a melee wedge at the enemy's feet (useless for a ranged threat) → net-new **Kind-keyed aim-line** out to projectile range during wind-up (in scope). And `EnemyTelegraph.IsCharger` has ZERO runtime readers → `IsCharger→byte Kind` is a pure baker-side change in `EnemyAuthoring`, NOT a read-site migration.
## The build (LOCKED)
### Enemy discriminator — presence-tags, NO enum in the Bursted AI
`EnemyTelegraph.IsCharger → byte Kind` (0=Grunt,1=Charger,2=Spitter,3=Swarmer; `EnemyTelegraph` is not a `[GhostField]` → no re-bake). Brain discrimination stays presence-tag, with a **mandatory query-partition guard** so no entity is double-moved (sole-Position-writer invariant):
- Spitter pass: `.WithAll<EnemyTag,SpitterState>().WithNone<LungeState>()`
- Charger pass: iterate `LungeState` + `.WithAll<EnemyTag>().WithNone<SpitterState>()`
- Grunt pass: `.WithAll<EnemyTag>().WithNone<LungeState,SpitterState>()`
- Baker-time assert: a prefab carries at most one of {`LungeState`,`SpitterState`}. Swarmer = a Grunt + `SwarmerTag` marker (swarm-tuned `EnemyStats`; tag drives clustering + client tint only).
### Spitter (reposition) — new components, all server-only, none `[GhostField]`
- `SpitterState { float PreferredRange(9), RangeTolerance(1.5), ProjectileSpeed, CorneredRange; uint NextShotTick }` (own fire gate via `TickUtil.NonZero`, NOT `EnemyAttackCooldown`).
- `EnemyProjectile { float2 Direction; float Speed,Damage,Range,DistanceTravelled,LastStep; byte Region }`.
- `SpitterProjectilePrefab { Entity Prefab; int MaxLiveProjectiles(24) }` (subscene singleton, server `GetSingleton` — mirrors `AbilityDatabase`/`WaveEnemyPrefab`).
- **AI pass (3rd foreach in `EnemyAISystem`, same `ecb`):** (1) knockback branch verbatim; (2) region-scoped `PickWeightedNearest`; (3) range-band move via new pure `EnemyAIMath.BandVelocity(from,to,speed,pref,tol)` (advance if too far, retreat if too close, zero in-band, Y flat, `SweptMove`); (4) **cornered fallback** — if `SweptMove` collapsed the retreat (existing wall-stop heuristic) AND target within `CorneredRange`, fall into the Grunt seek+wind-up→strike block verbatim; (5) **telegraphed shot** — in-band & `NextShotTick` ready → commit `AttackWindup` (dodge window); on elapse spawn via `GetSingleton<SpitterProjectilePrefab>()` + **`baked.WithPosition`** + `EnemyProjectile` data + `Region`/`RegionTag` = spitter's region, then `NextShotTick = TickUtil.NonZero(now+cd)`. **Soft-cap:** live `EnemyProjectile` ≥ MaxLiveProjectiles → skip firing, NO cooldown burn (EB-2 soft-fail).
- **The spit ghost:** NEW interpolated ownerless ghost, replicates ONLY stock `LocalTransform` (no `[GhostField]`; no `Health` → invisible to all `WithAll<Health>` loops). New-ghost recipe: duplicate a Husk/UpgradePickup, swap to `EnemyProjectileAuthoring`, no `GhostOwner`, `SourceNetworkId=-1`.
- **`EnemyProjectileMoveSystem`** (server, plain `SimulationSystemGroup`, `[UpdateAfter(EnemyAISystem)]`): integrate position, write `LastStep=Speed*dt`.
- **`EnemyProjectileDamageSystem`** (server, plain group, `[UpdateAfter(EnemyProjectileMoveSystem)]`): swept hit-test (segment = `cur - dir*LastStep`); targets players+structures; **region filter** (skip region mismatch); on hit `AppendToBuffer(DamageEvent{SourceNetworkId=-1, SourceTick=TickUtil.NonZero(now)})` + `DestroyEntity` at-most-once. DamageEvent drains the FOLLOWING tick in predicted `HealthApplyDamageSystem` (~16ms, REQUIRED — predicted append would double-apply on rollback); `SourceTick` makes dash-through-spit i-frame negation correct for free.
### Swarmer (surround)
`SwarmerTag` marker, no AI branch (Grunt pass). Identity = baked `EnemyStats`: high MoveSpeed (~6.5), low MaxHealth (~8), short `AttackCooldownTicks`, low `AttackDamage`, LOW `EnemyTelegraph.WindupTicks` (fast frequent bites, not 6 mini-Grunts queuing a melee telegraph). **Cluster spawn:** when composition says a swarmer slot, the director's single `ecb` instantiates `PackSize` swarmers offset via new pure `EnemyAIMath.ClusterOffset(center,k,packSize,tightRadius)`. **Slot vs entity accounting:** `RemainingToSpawn`/`SpawnCounter` decrement by 1 SLOT per pack; `MaxAlive` counts ENTITIES (gate `aliveZone+packSize<=MaxAlive` else WAIT).
### Mix bands — one shared pure function (extend `ZoneEnemyMath`)
Const bytes `KindGrunt=0,KindCharger=1,KindSpitter=2,KindSwarmer=3` (NO C# enum, no RNG, all integer → replay/save-stable). `MixBands { int GruntBase,ChargerBase,SpitterBase,SwarmerSlotBase, ChargerPerEpoch,SpitterPerEpoch,SwarmerSlotPerEpoch }` (baked weights). `WaveSlots(epoch,bands)` (≥1), `KindForSlot(epoch,slot,bands)` (deterministic), `PackSizeForSlot(epoch,slot,bands,basePack)`. Assign in fixed order (Grunts→Spitters→Chargers→Swarmer-slots last). **`IsChargerSlot` kept as a wrapper** `return KindForSlot(...)==KindCharger` (4 legacy tests stay green) + new `KindForSlot` assertions. Both directors index a **4-entry** per-Kind prefab buffer (bake/Play guard: assert exactly 4 entries, clamp+log).
### WaveSystem (base siege) — fork 4a
Adopt `WaveSlots`/`KindForSlot` + a 4-entry prefab buffer; **add `WaveDirector.MaxAlive` (REQUIRED)** + `MixBands` authoring on `WaveDirector`. Parity test: `WaveSlots` reproduces `BaseCount+(wave-1)*CountPerWave` for the legacy band.
### System ordering (linear, no cycle)
`EnemyAISystem` (unchanged `[UpdateAfter(PredictedSimulationSystemGroup)]`) → `EnemyProjectileMoveSystem` `[UpdateAfter(EnemyAISystem)]``EnemyProjectileDamageSystem` `[UpdateAfter(EnemyProjectileMoveSystem)]`. Strictly linear forward chain; nothing references the two new leaf types → no back-edge. **Play world-creation boot mandatory** (the only place a `ComponentSystemSorter` cycle throws; EditMode can't catch it).
### MC-3 — hit-stop (no `Time.timeScale`) + impact
- **Player-dealt-hit camera punch is NET-NEW** (verified `CombatFeedbackSystem.cs:219` gates `PunchFov` behind `isLocalPlayer` → no punch when YOU hit). Add a magnitude-scaled `PunchFov` + `AddShake` on the enemy-`Health`-decrease edge (line ~213). Magnitude `m=saturate(delta/HitStopRefDamage)`; `kick=lerp(Min,Max,m)`.
- **NEW `FeelConfig` fields** (verified absent; only `HitStopFovKick`/`DurationMs` exist), all stamped in `ResetDefaults` (play-enter-reset): `HitStopFovKickMin/Max`, `HitStopMaxFrames`, `HitStopRefDamage`, `HitFlashColor`, `HitFlashDurationMs`, `HitStopFreezeEnabled(false)`.
- **True freeze deferred behind `HitStopFreezeEnabled=false`** (latch camera's published target + pause local anim-param advance 24 frames — NOT scaling the follow-lerp `k`, which the review proved causes camera rubber-band).
- **Spitter aim-line telegraph:** extend `UpdateEnemyDanger` with a `Kind==2` branch drawing a thin aim line/lane along the spitter facing out to PROJECTILE range during wind-up (spitter face-locks target during wind-up). **Dodgeability budget:** wind-up ≥ ~24 ticks (~400ms > interp delay), `ProjectileSpeed` slow enough that flight time at PreferredRange ≥ ~300ms — live-tunable.
## Reuse ledger
**Reused unchanged:** `EnemyTag/Stats/AttackCooldown`, `KnockbackState`, `AttackWindup`, `Health`, `HitRadius`, `DamageEvent`, `RegionTag`, `IsLunging`; `EnemyAIMath.{SeekVelocity,InAttackRange,SlideVelocity,RingPosition,PickWeightedNearest×2}`; `EnemyAISystem.SweptMove`+knockback+Grunt-strike+IsLunging blocks; `HealthApplyDamageSystem` (drains spit DamageEvents + dash negation free); `RegionRelevancySystem`; `CombatFeedbackSystem` numbers/sparks/bars/death; `PrototypeCameraRig.PunchFov/AddShake`; `ZoneEnemyMath` (extended). Player `Projectile*` used as a swept-hit TEMPLATE only.
**New:** components `SpitterState,SwarmerTag,EnemyProjectile,SpitterProjectilePrefab,MixBands`; systems `EnemyProjectileMoveSystem,EnemyProjectileDamageSystem` + Spitter pass; math `BandVelocity,ClusterOffset,WaveSlots/KindForSlot/PackSizeForSlot`; authoring `SpitterAuthoring,SwarmerAuthoring,EnemyProjectileAuthoring` + `WaveDirector` MixBands/MaxAlive + `EnemyTelegraph.IsCharger→Kind`; client MC-3 bundle + Spitter aim-line + 7 `FeelConfig` fields.
## Test plan
Pure-math: `WaveSlots`≥1, per-epoch ramps, `KindForSlot` determinism + composition counts, swarmer bucketing, `PackSizeForSlot`≥1, `IsChargerSlot` wrapper keeps 4 legacy assertions; **parity** test (`WaveSlots` reproduces legacy curve); `BandVelocity` (retreat/advance/in-band/Y-flat); `ClusterOffset` determinism. System: `EnemyProjectileMove` integrate+LastStep+range-expiry; `EnemyProjectileDamage` append+at-most-once + **tunnelling regression** (LastStep>radius still hits) + **region-filter** (Expedition spit doesn't damage Base target); Spitter brain (advance/retreat/in-band-commit-then-spawn-with-Region, soft-cap no-burn); cornered fallback; dash-through-spit negation; cluster spawn (PackSize spawned, 1 slot consumed, pack-over-MaxAlive defers); discriminator routing (no double-visit); 4-entry buffer guard. **Play-validation:** no sort-cycle at world-creation; no Burst ICE; Spitter end-to-end (holds range, aim-line telegraph, dodgeable+dash-negatable spit); region correctness (no cross-region see/damage); swarmer reads as a swarm + respects MaxAlive; mix ramp visibly shifts; MC-3 magnitude-scaled punch + predicted tick-rate UNAFFECTED (no timeScale); perf (live spits ≤24, stable frame time under base siege + expedition swarm).
## Consequences
- **Deferred to a later slice:** DOTS `[MaterialProperty]` enemy hit-flash (ShaderGraph `_Flash*` + render-entity mapping); true freeze-frame hit-stop (gated off); Spitter in-band strafe (1b); player-shoots-spit (2b); Swarmer pack-size epoch ramp (field exposed, unwired).
- **Open (operator):** the combat fun-gate is a hands-on co-op playtest after build ("play with a friend and not want to stop"); the Slice 3 fun-gate still pending too.
- **Status:** reviewed + locked; build IN FLIGHT (see below). Full review (verdicts/blockers/forks) in run transcript `wf_eb115556-8cc`.
## Build progress (in flight — 2026-06-22)
**Done + compiling clean (368/368 EditMode still green, backward-compatible at epoch 1):**
- Leaf components: `SpitterState`(+baked `WindupTicks`), `SwarmerTag`, `EnemyProjectile`, `SpitterProjectilePrefab`, `MixBands` (`Simulation/Combat/`).
- Math: `EnemyAIMath.{BandVelocity, ClusterOffset}`; `ZoneEnemyMath` Kind consts + `WaveSlots/KindForSlot/PackSizeForSlot` (legacy `WaveSize`/`IsChargerSlot` kept intact for parity).
- Systems: `EnemyProjectileMoveSystem` + `EnemyProjectileDamageSystem` (plain server group, LastStep swept, region filter); `EnemyAISystem` Spitter pass + partition guards (`WithNone<LungeState,SpitterState>` Grunt / `WithNone<SpitterState>` Charger) + `m_EnemyProjectiles` cache.
- Discriminator: `EnemyTelegraph.IsCharger→byte Kind`; `EnemyAuthoring` bakes Kind from sibling authoring.
- Authoring: `SpitterAuthoring`, `SwarmerAuthoring`, `EnemyProjectileAuthoring`; both director components (`ZoneEnemyDirector`, `WaveDirector`) + their authoring gained the mix/cluster fields + (Wave) mandatory `MaxAlive`. Base siege adopts `WaveSlots`/`KindForSlot`/cluster (fork 4a); defaults keep the size curve (≈+1 charger +1 spitter/wave) so the END-game stays bounded.
**Remaining:** MC-3 client juice (FeelConfig fields + `CombatFeedbackSystem` player-hit camera punch + Spitter `Kind==2` aim-line); the additive EditMode tests (per the test plan above); prefab + subscene wiring (Spitter/Swarmer/EnemyProjectile ghosts via the new-ghost recipe, 4-entry director rosters, `SpitterProjectilePrefab` singleton, MixBands/MaxAlive on both directors); then the verify ladder + Play smoke + post-impl review + doc bookend + commit. **Resume from here if compacted.**