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
@@ -56,12 +56,15 @@ namespace ProjectM.Authoring
// EnemyTelegraph even on a Charger (the prefab composes both authorings on one entity); reading the // EnemyTelegraph even on a Charger (the prefab composes both authorings on one entity); reading the
// sibling ChargerAuthoring here avoids a double-AddComponent. WindupTicks = the client danger-ramp // sibling ChargerAuthoring here avoids a double-AddComponent. WindupTicks = the client danger-ramp
// denominator per variant; IsCharger lets the client pick the Charger look (LungeState is server-only). // denominator per variant; IsCharger lets the client pick the Charger look (LungeState is server-only).
bool isCharger = GetComponent<ChargerAuthoring>() != null; // Kind byte (client telegraph look) — derived from the sibling variant authoring (EnemyBaker is the
AddComponent(entity, new EnemyTelegraph // SOLE EnemyTelegraph writer). Grunt=0 / Charger=1 / Spitter=2 / Swarmer=3 (ZoneEnemyMath.Kind*).
{ byte kind = ZoneEnemyMath.KindGrunt;
WindupTicks = (byte)(isCharger ? 30 : Tuning.AttackWindupTicks), byte windup = (byte)Tuning.AttackWindupTicks;
IsCharger = (byte)(isCharger ? 1 : 0), var spitter = GetComponent<SpitterAuthoring>();
}); if (GetComponent<ChargerAuthoring>() != null) { kind = ZoneEnemyMath.KindCharger; windup = 30; }
else if (spitter != null) { kind = ZoneEnemyMath.KindSpitter; windup = (byte)Mathf.Clamp(spitter.WindupTicks, 1, 255); }
else if (GetComponent<SwarmerAuthoring>() != null) { kind = ZoneEnemyMath.KindSwarmer; windup = 6; }
AddComponent(entity, new EnemyTelegraph { WindupTicks = windup, Kind = kind });
} }
} }
} }
@@ -0,0 +1,44 @@
using ProjectM.Simulation;
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — authoring for the hostile Spitter projectile prefab (an ownerless INTERPOLATED ghost, duplicated from an
/// existing interpolated ghost so the GhostAuthoringComponent comes free). Bakes <see cref="EnemyProjectile"/> with
/// the spit's default Speed/Damage/Range; the firing Spitter OVERRIDES Direction + Speed + Damage + Region at spawn
/// and ADDS the <c>RegionTag</c> (so this prefab MUST NOT bake RegionTag — AddComponent would throw on a baked one).
/// NO Health (so it is invisible to every player hit-test) and NO <c>[GhostField]</c> beyond the stock LocalTransform.
/// </summary>
public class EnemyProjectileAuthoring : MonoBehaviour
{
[Min(0f), Tooltip("Default muzzle speed (the firing Spitter overrides this per-variant).")]
public float Speed = 11f;
[Min(0f), Tooltip("Default damage (the firing Spitter overrides this from its AttackDamage).")]
public float Damage = 8f;
[Min(0f), Tooltip("Max travel distance before the spit expires (world units).")]
public float Range = 16f;
private class EnemyProjectileBaker : Baker<EnemyProjectileAuthoring>
{
public override void Bake(EnemyProjectileAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new EnemyProjectile
{
Speed = authoring.Speed,
Damage = authoring.Damage,
Range = authoring.Range,
Direction = new float2(0f, 1f),
DistanceTravelled = 0f,
LastStep = 0f,
Region = 0,
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ff79c8fbcacb8c34faad37d59836b5ac
@@ -0,0 +1,49 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — marks a Husk prefab as a SPITTER variant (the ranged "reposition" question). Compose WITH
/// <see cref="EnemyAuthoring"/> on the prefab root: EnemyAuthoring bakes the common Husk components + the spit's
/// damage/cooldown (EnemyStats.AttackDamage / AttackCooldownTicks), this bakes the server-only
/// <see cref="SpitterState"/> (zeroed NextShotTick = ready). Component-PRESENCE is the discriminator EnemyAISystem
/// branches on (no enum); the Grunt + Charger passes exclude it via <c>.WithNone&lt;SpitterState&gt;()</c>. The
/// actual spit projectile is a SEPARATE ghost configured by the SpitterProjectilePrefab subscene singleton.
/// </summary>
public class SpitterAuthoring : MonoBehaviour
{
[Min(0f), Tooltip("Distance the Spitter tries to hold from its target (band centre).")]
public float PreferredRange = 9f;
[Min(0f), Tooltip("Half-width dead-zone around PreferredRange where it holds and fires.")]
public float RangeTolerance = 1.5f;
[Min(0f), Tooltip("Muzzle speed of the spit (world units/second). Slow enough to be dodgeable at range.")]
public float ProjectileSpeed = 11f;
[Min(0f), Tooltip("If the target closes within this AND the Spitter can't retreat, it fires point-blank.")]
public float CorneredRange = 3f;
[Min(1), Tooltip("Telegraph wind-up before the spit fires (ticks). Keep >= ~24 (> interp delay) to stay dodgeable.")]
public int WindupTicks = 26;
private class SpitterBaker : Baker<SpitterAuthoring>
{
public override void Bake(SpitterAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent(entity, new SpitterState
{
PreferredRange = authoring.PreferredRange,
RangeTolerance = authoring.RangeTolerance,
ProjectileSpeed = authoring.ProjectileSpeed,
CorneredRange = authoring.CorneredRange,
WindupTicks = authoring.WindupTicks,
NextShotTick = 0,
});
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 55fe00810b31aa54abd577b6a07192e2
@@ -0,0 +1,25 @@
using ProjectM.Simulation;
using Unity.Entities;
using UnityEngine;
namespace ProjectM.Authoring
{
/// <summary>
/// MC-2 — marks a Husk prefab as a SWARMER variant (the "surround" question). Compose WITH
/// <see cref="EnemyAuthoring"/> on the prefab root, tuned fast + low-HP + fast frequent low-chip bites (via the
/// EnemyAuthoring fields). This bakes only the <see cref="SwarmerTag"/> marker: a Swarmer has NO AI branch (it
/// falls through the Grunt seek+strike pass); the tag drives the director's CLUSTER spawn (a pack per slot) + a
/// client tint. Keeps EnemyTag + RegionTag like every Husk.
/// </summary>
public class SwarmerAuthoring : MonoBehaviour
{
private class SwarmerBaker : Baker<SwarmerAuthoring>
{
public override void Bake(SwarmerAuthoring authoring)
{
var entity = GetEntity(authoring, TransformUsageFlags.Dynamic);
AddComponent<SwarmerTag>(entity);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b6a84b442d0535642abc303c01546a15
@@ -22,6 +22,17 @@ namespace ProjectM.Authoring
[Min(0), Tooltip("Additional Husks per subsequent wave.")] public int CountPerWave = 2; [Min(0), Tooltip("Additional Husks per subsequent wave.")] public int CountPerWave = 2;
[Min(1), Tooltip("Ticks between individual spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 24; [Min(1), Tooltip("Ticks between individual spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 24;
[Min(1), Tooltip("Ticks of calm between waves (~60/sec).")] public int LullTicks = 240; [Min(1), Tooltip("Ticks of calm between waves (~60/sec).")] public int LullTicks = 240;
[Min(1), Tooltip("Max concurrent live BASE husks (mandatory fork-4a cap; bounds spits + swarmer packs at the climax).")] public int MaxAlive = 14;
[Min(0), Tooltip("Charger base count.")] public int ChargerBase = 0;
[Min(0), Tooltip("Spitter base count.")] public int SpitterBase = 0;
[Min(0), Tooltip("Swarmer SLOT base count (each slot = one pack).")] public int SwarmerSlotBase = 0;
[Min(0), Tooltip("Extra chargers per wave.")] public int ChargerPerEpoch = 1;
[Min(0), Tooltip("Extra spitters per wave (the base gains the ranged question as it escalates).")] public int SpitterPerEpoch = 1;
[Min(0), Tooltip("Extra swarmer slots per wave (0 = no base swarms in v1; bounded by MaxAlive when enabled).")] public int SwarmerSlotPerEpoch = 0;
[Min(1), Tooltip("Swarmers per swarmer-slot cluster.")] public int SwarmerPackSize = 3;
[Min(0), Tooltip("Swarmer pack-size ramp per wave (0 = fixed).")] public int SwarmerPackPerEpoch = 0;
[Min(0f), Tooltip("Tight ring radius for a swarmer pack.")] public float ClusterTightRadius = 2.5f;
private class WaveDirectorBaker : Baker<WaveDirectorAuthoring> private class WaveDirectorBaker : Baker<WaveDirectorAuthoring>
{ {
@@ -37,6 +48,16 @@ namespace ProjectM.Authoring
CountPerWave = authoring.CountPerWave, CountPerWave = authoring.CountPerWave,
SpawnIntervalTicks = authoring.SpawnIntervalTicks, SpawnIntervalTicks = authoring.SpawnIntervalTicks,
LullTicks = authoring.LullTicks, LullTicks = authoring.LullTicks,
MaxAlive = authoring.MaxAlive,
ChargerBase = authoring.ChargerBase,
SpitterBase = authoring.SpitterBase,
SwarmerSlotBase = authoring.SwarmerSlotBase,
ChargerPerEpoch = authoring.ChargerPerEpoch,
SpitterPerEpoch = authoring.SpitterPerEpoch,
SwarmerSlotPerEpoch = authoring.SwarmerSlotPerEpoch,
SwarmerPackSize = authoring.SwarmerPackSize,
SwarmerPackPerEpoch = authoring.SwarmerPackPerEpoch,
ClusterTightRadius = authoring.ClusterTightRadius,
}); });
var buffer = AddBuffer<WaveEnemyPrefab>(entity); var buffer = AddBuffer<WaveEnemyPrefab>(entity);
@@ -14,15 +14,23 @@ namespace ProjectM.Authoring
/// </summary> /// </summary>
public class ZoneEnemyDirectorAuthoring : MonoBehaviour public class ZoneEnemyDirectorAuthoring : MonoBehaviour
{ {
[Tooltip("Zone-enemy variant prefabs. Index 0 = Grunt, index 1 = Charger. Each must carry EnemyAuthoring + an interpolated GhostAuthoringComponent.")] [Tooltip("Zone-enemy variant prefabs by Kind: [0]=Grunt, [1]=Charger, [2]=Spitter, [3]=Swarmer. Each must carry EnemyAuthoring (+ its variant authoring) + an interpolated GhostAuthoringComponent.")]
public GameObject[] EnemyPrefabs; public GameObject[] EnemyPrefabs;
[Min(1), Tooltip("Max concurrent live zone enemies (the v1 ghost-relevancy budget).")] public int MaxAlive = 12; [Min(1), Tooltip("Max concurrent live zone enemies (the ghost-relevancy budget; also caps swarmer packs).")] public int MaxAlive = 12;
[Min(0f)] public float RingRadius = 14f; [Min(0f)] public float RingRadius = 14f;
[Min(1)] public int RingSlots = 10; [Min(1)] public int RingSlots = 10;
[Min(1), Tooltip("Ticks between individual spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 30; [Min(1), Tooltip("Ticks between individual slot 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("Grunt base count (the fixed floor = remainder of the slots).")] 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("Charger base count.")] public int ChargersPerWave = 1;
[Min(0), Tooltip("Spitter base count (0 = introduced via the per-epoch ramp).")] public int SpitterBase = 0;
[Min(0), Tooltip("Swarmer SLOT base count (each slot = one pack).")] public int SwarmerSlotBase = 0;
[Min(0), Tooltip("Extra chargers per epoch.")] public int ChargerPerEpoch = 1;
[Min(0), Tooltip("Extra spitters per epoch (1 -> Spitter first appears at epoch 2).")] public int SpitterPerEpoch = 1;
[Min(0), Tooltip("Extra swarmer slots per epoch.")] public int SwarmerSlotPerEpoch = 1;
[Min(1), Tooltip("Swarmers per swarmer-slot cluster.")] public int SwarmerPackSize = 4;
[Min(0), Tooltip("Swarmer pack-size ramp per epoch (0 = fixed).")] public int SwarmerPackPerEpoch = 0;
[Min(0f), Tooltip("Tight ring radius for a swarmer pack.")] public float ClusterTightRadius = 2.5f;
[Min(0), Tooltip("Flat Ore banked to the shared ledger on a real clear, once per sortie.")] public int RewardOre = 25; [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> private class ZoneEnemyDirectorBaker : Baker<ZoneEnemyDirectorAuthoring>
@@ -40,6 +48,14 @@ namespace ProjectM.Authoring
GruntsPerWave = authoring.GruntsPerWave, GruntsPerWave = authoring.GruntsPerWave,
ChargersPerWave = authoring.ChargersPerWave, ChargersPerWave = authoring.ChargersPerWave,
RewardOre = authoring.RewardOre, RewardOre = authoring.RewardOre,
SpitterBase = authoring.SpitterBase,
SwarmerSlotBase = authoring.SwarmerSlotBase,
ChargerPerEpoch = authoring.ChargerPerEpoch,
SpitterPerEpoch = authoring.SpitterPerEpoch,
SwarmerSlotPerEpoch = authoring.SwarmerSlotPerEpoch,
SwarmerPackSize = authoring.SwarmerPackSize,
SwarmerPackPerEpoch = authoring.SwarmerPackPerEpoch,
ClusterTightRadius = authoring.ClusterTightRadius,
}); });
var buffer = AddBuffer<ZoneEnemyPrefab>(entity); var buffer = AddBuffer<ZoneEnemyPrefab>(entity);
@@ -27,11 +27,14 @@ namespace ProjectM.Server
[UpdateAfter(typeof(PredictedSimulationSystemGroup))] [UpdateAfter(typeof(PredictedSimulationSystemGroup))]
public partial struct EnemyAISystem : ISystem public partial struct EnemyAISystem : ISystem
{ {
EntityQuery m_EnemyProjectiles;
[BurstCompile] [BurstCompile]
public void OnCreate(ref SystemState state) public void OnCreate(ref SystemState state)
{ {
state.RequireForUpdate<NetworkTime>(); state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>())); state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>()));
m_EnemyProjectiles = state.GetEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
} }
[BurstCompile] [BurstCompile]
@@ -104,7 +107,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup, region) in foreach (var (xform, stats, cooldown, knockback, windup, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>, SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>() RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<LungeState>()) .WithAll<EnemyTag>().WithNone<LungeState, SpitterState>())
{ {
float3 pos = xform.ValueRO.Position; float3 pos = xform.ValueRO.Position;
byte huskRegion = region.ValueRO.Region; byte huskRegion = region.ValueRO.Region;
@@ -212,7 +215,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>, SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>() RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
.WithAll<EnemyTag>()) .WithAll<EnemyTag>().WithNone<SpitterState>())
{ {
float3 pos = xform.ValueRO.Position; float3 pos = xform.ValueRO.Position;
byte cHuskRegion = region.ValueRO.Region; byte cHuskRegion = region.ValueRO.Region;
@@ -331,6 +334,112 @@ namespace ProjectM.Server
} }
} }
} }
// --- Spitter pass: a Husk variant baked with SpitterState holds a RANGED range-band and fires a
// telegraphed, dodgeable spit. Partitioned .WithAll<SpitterState>().WithNone<LungeState>() (and the Grunt
// pass excludes SpitterState) so a Spitter is moved by EXACTLY this pass — the sole-Position-writer rule.
bool haveSpit = SystemAPI.TryGetSingleton<SpitterProjectilePrefab>(out var spitCfg) && spitCfg.Prefab != Entity.Null;
int liveSpits = m_EnemyProjectiles.CalculateEntityCount();
LocalTransform spitBakedLt = default;
EnemyProjectile spitBakedProj = default;
if (haveSpit)
{
spitBakedLt = state.EntityManager.GetComponentData<LocalTransform>(spitCfg.Prefab);
spitBakedProj = state.EntityManager.GetComponentData<EnemyProjectile>(spitCfg.Prefab);
}
foreach (var (xform, stats, knockback, windup, spitter, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<KnockbackState>,
RefRW<AttackWindup>, RefRW<SpitterState>, RefRO<RegionTag>>()
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState>())
{
float3 pos = xform.ValueRO.Position;
byte sRegion = region.ValueRO.Region;
bool sCoreAlive = coreAlive && sRegion == RegionId.Base;
// 1. Knockback overrides everything (sole Position writer preserved).
var kb = knockback.ValueRO;
if (kb.UntilTick != 0)
{
var kbTick = new NetworkTick(kb.UntilTick);
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
{
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
kpos.y = pos.y;
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
xform.ValueRW.Position = kpos;
windup.ValueRW.WindUpUntilTick = 0;
continue;
}
knockback.ValueRW.UntilTick = 0;
}
// 2. Target (region-scoped shared helper); Core fallback like the Grunt/Charger passes.
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, sRegion, structAggro, out bool sIsStruct, out int sIdx);
if (sIdx < 0 && !sCoreAlive)
continue;
Entity sTargetEntity = sIdx < 0 ? Entity.Null
: (sIsStruct ? structureEntities[sIdx] : playerEntities[sIdx]);
float3 sTargetPos = sIdx < 0 ? corePos
: (sIsStruct ? structurePositions[sIdx] : playerPositions[sIdx]);
// 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target.
var sp = spitter.ValueRO;
float3 bandVel = EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance);
float3 sNewPos = pos + bandVel * dt; sNewPos.y = pos.y;
if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter);
xform.ValueRW.Position = sNewPos;
float3 sToTarget = sTargetPos - pos; sToTarget.y = 0f;
if (math.lengthsq(sToTarget) > 1e-6f)
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(sToTarget), math.up());
// 4. Telegraphed shot: commit a wind-up (the dodge window) when the shot gate is ready; on elapse,
// spawn a spit toward the target. A cornered Spitter still fires (point-blank) — no safe corner.
uint sWindRaw = windup.ValueRO.WindUpUntilTick;
if (sWindRaw != 0)
{
var sWindTick = new NetworkTick(sWindRaw);
if (!(sWindTick.IsValid && sWindTick.IsNewerThan(serverTick)))
{
float2 dir2 = math.lengthsq(sToTarget) > 1e-6f ? math.normalize(sToTarget.xz) : new float2(0f, 1f);
if (haveSpit && liveSpits < math.max(1, spitCfg.MaxLiveProjectiles))
{
float3 spawnPos = pos + new float3(dir2.x, 0f, dir2.y) * 0.8f;
spawnPos.y = pos.y;
var spit = ecb.Instantiate(spitCfg.Prefab);
ecb.SetComponent(spit, spitBakedLt.WithPosition(spawnPos)); // preserve baked [GhostField] Scale
ecb.SetComponent(spit, new EnemyProjectile
{
Direction = dir2,
Speed = sp.ProjectileSpeed,
Damage = stats.ValueRO.AttackDamage,
Range = spitBakedProj.Range,
DistanceTravelled = 0f,
LastStep = 0f,
Region = sRegion,
});
ecb.AddComponent(spit, new RegionTag { Region = sRegion }); // relevancy (the spit prefab bakes none)
liveSpits++;
uint shotCd = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + shotCd);
}
else
{
// Over the concurrent cap (or no prefab wired): soft-fail — short retry, no full cooldown burn.
spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + 8u);
}
windup.ValueRW.WindUpUntilTick = 0;
}
}
else
{
bool sReady = sp.NextShotTick == 0 || !new NetworkTick(sp.NextShotTick).IsNewerThan(serverTick);
if (sReady && (sTargetEntity != Entity.Null || sCoreAlive))
{
uint wTicks = (uint)math.max(1, sp.WindupTicks);
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks);
}
}
}
// Slice 1 (Feature D): derive the replicated IsLunging cue ONCE per tick from the end-of-tick LungeState // Slice 1 (Feature D): derive the replicated IsLunging cue ONCE per tick from the end-of-tick LungeState
// (single point, idempotent — mirrors PlayerDeathStateSystem deriving Dead from Health). .WithPresent so a // (single point, idempotent — mirrors PlayerDeathStateSystem deriving Dead from Health). .WithPresent so a
@@ -0,0 +1,135 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// MC-2 — resolves hostile Spitter projectiles against PLAYERS + STRUCTURES (never other enemies — only
/// PlayerTag / PlacedStructure are snapshotted, so a spit can't friendly-fire the Husks), server-only in the
/// plain <see cref="SimulationSystemGroup"/> after <see cref="EnemyProjectileMoveSystem"/> (post-move position).
/// SWEPT planar hit-test (the DR-018 anti-tunnelling discipline): the travel segment is rebuilt from the STORED
/// <see cref="EnemyProjectile.LastStep"/> (cur - Direction*LastStep), NEVER a fresh delta. REGION-FILTERED: a
/// target whose <see cref="RegionTag"/>.Region != the spit's Region is skipped — relevancy hides cross-region
/// ghosts from CLIENTS, but the server world holds base + expedition players 1000u apart, so server damage needs
/// its own guard (the missing-filter blocker the design review caught). On a hit it appends
/// DamageEvent{SourceNetworkId=-1, SourceTick=now} (drained the FOLLOWING tick by the predicted
/// <c>HealthApplyDamageSystem</c> — appending from the predicted loop would double-apply on rollback; SourceTick
/// makes the dash i-frame negation correct across the 1-tick gap, so dash-through-spit works for free) and
/// destroys the spit at-most-once; a spit past its Range expires.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(EnemyProjectileMoveSystem))]
public partial struct EnemyProjectileDamageSystem : ISystem
{
/// <summary>Extra forgiveness for the spit's own size, added to a target's hit radius.</summary>
const float k_ProjectileRadius = 0.2f;
/// <summary>Hit radius used for structures, which (by design) bake no HitRadius (so player shots never hit them).</summary>
const float k_StructureRadius = 1.0f;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<EnemyProjectile>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
var ecb = new EntityCommandBuffer(Allocator.Temp);
// Snapshot valid targets once (stable query order). PLAYERS carry HitRadius (PlayerAuthoring);
// STRUCTURES deliberately do NOT (so player projectiles never friendly-fire the base) -> a constant.
var targetEntities = new NativeList<Entity>(Allocator.Temp);
var targetPositions = new NativeList<float3>(Allocator.Temp);
var targetRadii = new NativeList<float>(Allocator.Temp);
var targetRegions = new NativeList<byte>(Allocator.Temp);
foreach (var (xform, hitRadius, health, region, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<PlayerTag>().WithEntityAccess())
{
if (health.ValueRO.Current <= 0f) continue; // don't hit a corpse
targetEntities.Add(e);
targetPositions.Add(xform.ValueRO.Position);
targetRadii.Add(hitRadius.ValueRO.Value);
targetRegions.Add(region.ValueRO.Region);
}
foreach (var (xform, health, region, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<PlacedStructure>().WithEntityAccess())
{
if (health.ValueRO.Current <= 0f) continue; // skip a structure pending destroy this tick
targetEntities.Add(e);
targetPositions.Add(xform.ValueRO.Position);
targetRadii.Add(k_StructureRadius);
targetRegions.Add(region.ValueRO.Region);
}
var destroyed = new NativeHashSet<Entity>(16, Allocator.Temp);
foreach (var (xform, proj, projEntity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyProjectile>>().WithEntityAccess())
{
float3 cur = xform.ValueRO.Position;
float2 segEnd = new float2(cur.x, cur.z);
float2 dir = proj.ValueRO.Direction;
float2 segStart = segEnd - dir * proj.ValueRO.LastStep; // stored move-step, never a fresh dt
float2 seg = segEnd - segStart;
float segLenSq = math.lengthsq(seg);
byte projRegion = proj.ValueRO.Region;
int bestIdx = -1;
float bestT = float.MaxValue;
for (int i = 0; i < targetEntities.Length; i++)
{
if (targetRegions[i] != projRegion) continue; // server-side damage region guard
float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
float t = segLenSq > 1e-8f
? math.saturate(math.dot(tp - segStart, seg) / segLenSq)
: 0f;
float2 closest = segStart + t * seg;
float hitDist = targetRadii[i] + k_ProjectileRadius;
if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT)
{
bestT = t;
bestIdx = i;
}
}
if (bestIdx >= 0)
{
ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent
{
Amount = proj.ValueRO.Damage,
SourceNetworkId = -1, // hostile environment, not a player
SourceTick = TickUtil.NonZero(now),
});
if (destroyed.Add(projEntity))
ecb.DestroyEntity(projEntity);
continue;
}
if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range && destroyed.Add(projEntity))
ecb.DestroyEntity(projEntity);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
destroyed.Dispose();
targetEntities.Dispose();
targetPositions.Dispose();
targetRadii.Dispose();
targetRegions.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f6dbd4ab9a2b154e8d7cb1796904ab6
@@ -0,0 +1,49 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// MC-2 — integrates hostile Spitter projectiles (<see cref="EnemyProjectile"/>) server-only in the plain
/// <see cref="SimulationSystemGroup"/> (the spits are ownerless INTERPOLATED ghosts, not predicted — like the
/// Husks that fire them). Advances each spit along its locked Direction at Speed*dt, accumulates
/// DistanceTravelled, and STORES <see cref="EnemyProjectile.LastStep"/> = Speed*dt so
/// <see cref="EnemyProjectileDamageSystem"/> can rebuild the exact swept segment it traversed this tick
/// (cur - Direction*LastStep) WITHOUT re-reading a delta in that separate system (the DR-018 swept-tunnelling
/// discipline — a fresh delta in the damage pass is the trap). Ordered <c>[UpdateAfter(EnemyAISystem)]</c> (the
/// spawner) so a spit moves the same tick it is born. Writes LocalTransform (replicated via the stock variant);
/// structural-free. dt is the server fixed step here, exactly as <see cref="EnemyAISystem"/> reads it.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(EnemyAISystem))]
public partial struct EnemyProjectileMoveSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<EnemyProjectile>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float dt = SystemAPI.Time.DeltaTime; // server fixed step in the plain group, same as EnemyAISystem
foreach (var (xform, proj) in SystemAPI.Query<RefRW<LocalTransform>, RefRW<EnemyProjectile>>())
{
float step = proj.ValueRO.Speed * dt;
float3 dir = new float3(proj.ValueRO.Direction.x, 0f, proj.ValueRO.Direction.y);
float3 from = xform.ValueRO.Position;
float3 pos = from + dir * step;
pos.y = from.y; // hold the movement plane
xform.ValueRW.Position = pos;
proj.ValueRW.LastStep = step;
proj.ValueRW.DistanceTravelled = proj.ValueRO.DistanceTravelled + step;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9acb4c22874b1fa489433644b90334db
@@ -51,6 +51,21 @@ namespace ProjectM.Server
var wave = SystemAPI.GetComponent<WaveState>(directorEntity); var wave = SystemAPI.GetComponent<WaveState>(directorEntity);
// MC-2 fork-4a: the base siege adopts the 4-type weighted mix (BaseCount = the Grunt base). The size
// curve becomes WaveSlots(wave, bands) — a deliberate, operator-approved redefinition; MaxAlive is the
// mandatory cap so spitter spits + swarmer packs can't spike the relevancy loop during the END-game climax.
var bands = new MixBands
{
GruntBase = director.BaseCount,
ChargerBase = director.ChargerBase,
SpitterBase = director.SpitterBase,
SwarmerSlotBase = director.SwarmerSlotBase,
ChargerPerEpoch = director.ChargerPerEpoch,
SpitterPerEpoch = director.SpitterPerEpoch,
SwarmerSlotPerEpoch = director.SwarmerSlotPerEpoch,
SwarmerPackPerEpoch = director.SwarmerPackPerEpoch,
};
// Ring centre on the base plot when present. // Ring centre on the base plot when present.
float3 center = new float3(0f, 1f, 0f); float3 center = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor)) if (SystemAPI.TryGetSingleton<BaseAnchor>(out var baseAnchor))
@@ -65,8 +80,7 @@ namespace ProjectM.Server
{ {
// Start the next (bigger) wave. // Start the next (bigger) wave.
wave.WaveNumber += 1; wave.WaveNumber += 1;
wave.RemainingToSpawn = wave.RemainingToSpawn = ZoneEnemyMath.WaveSlots(wave.WaveNumber, bands);
math.max(1, director.BaseCount + (wave.WaveNumber - 1) * director.CountPerWave);
wave.Phase = WavePhase.Spawning; wave.Phase = WavePhase.Spawning;
wave.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick wave.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick
} }
@@ -78,26 +92,43 @@ namespace ProjectM.Server
if (dueNow) if (dueNow)
{ {
int slots = math.max(1, director.RingSlots); int slots = math.max(1, director.RingSlots);
int prefabIdx = wave.SpawnCounter % prefabs.Length; byte kind = ZoneEnemyMath.KindForSlot(wave.WaveNumber, wave.SpawnCounter, bands);
float3 pos = EnemyAIMath.RingPosition(center, wave.SpawnCounter, slots, director.RingRadius); int packSize = kind == ZoneEnemyMath.KindSwarmer
pos.y = center.y; ? ZoneEnemyMath.PackSizeForSlot(wave.WaveNumber, wave.SpawnCounter, bands, director.SwarmerPackSize) : 1;
// Live BASE husks for the entity cap (expedition zone enemies are EnemyTag too -> excluded).
int aliveBase = 0;
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
if (hr.ValueRO.Region == RegionId.Base) aliveBase++;
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
if (aliveBase + packSize <= math.max(1, director.MaxAlive))
{
int prefabIdx = kind;
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
float3 packCenter = EnemyAIMath.RingPosition(center, wave.SpawnCounter, slots, director.RingRadius);
packCenter.y = center.y;
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefabs[prefabIdx].Prefab);
var ecb = new EntityCommandBuffer(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int k = 0; k < packSize; k++)
{
float3 pos = packSize > 1
? EnemyAIMath.ClusterOffset(packCenter, k, packSize, director.ClusterTightRadius) : packCenter;
pos.y = center.y;
var husk = ecb.Instantiate(prefabs[prefabIdx].Prefab); var husk = ecb.Instantiate(prefabs[prefabIdx].Prefab);
// Preserve the prefab's baked variant Scale (a replicated [GhostField]) + rotation; ecb.SetComponent(husk, baked.WithPosition(pos)); // preserve baked [GhostField] Scale
// LocalTransform.FromPosition() would reset Scale->1, shrinking/growing animated variants.
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefabs[prefabIdx].Prefab);
ecb.SetComponent(husk, baked.WithPosition(pos));
// Husks belong to the base region (hidden from expedition players by relevancy).
ecb.AddComponent(husk, new RegionTag { Region = RegionId.Base }); ecb.AddComponent(husk, new RegionTag { Region = RegionId.Base });
}
ecb.Playback(state.EntityManager); ecb.Playback(state.EntityManager);
ecb.Dispose(); ecb.Dispose();
wave.SpawnCounter += 1; wave.SpawnCounter += 1; // ONE slot consumed even for a pack
wave.RemainingToSpawn -= 1; wave.RemainingToSpawn -= 1;
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks)); wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks));
} }
} }
}
else else
{ {
// Wave fully spawned: cleared only when no BASE husk remains. Expedition zone enemies are also // Wave fully spawned: cleared only when no BASE husk remains. Expedition zone enemies are also
@@ -71,58 +71,82 @@ namespace ProjectM.Server
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity); var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
int epoch = runtime.ExpeditionEpoch; int epoch = runtime.ExpeditionEpoch;
// (Re)seed this epoch's wave once — its OWN counter, never WaveState's. // MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base
// siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts.
var bands = new MixBands
{
GruntBase = dir.GruntsPerWave,
ChargerBase = dir.ChargersPerWave,
SpitterBase = dir.SpitterBase,
SwarmerSlotBase = dir.SwarmerSlotBase,
ChargerPerEpoch = dir.ChargerPerEpoch,
SpitterPerEpoch = dir.SpitterPerEpoch,
SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch,
SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch,
};
// (Re)seed this epoch's wave once — its OWN counter (in SLOTS; a swarmer slot is one pack).
if (zs.SeededEpoch != epoch) if (zs.SeededEpoch != epoch)
{ {
zs.SeededEpoch = epoch; zs.SeededEpoch = epoch;
zs.SpawnCounter = 0; zs.SpawnCounter = 0;
zs.RemainingToSpawn = ZoneEnemyMath.WaveSize(epoch, dir.GruntsPerWave, dir.ChargersPerWave); zs.RemainingToSpawn = ZoneEnemyMath.WaveSlots(epoch, bands);
zs.NextSpawnTick = TickUtil.NonZero(now); // first enemy this tick zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
} }
int aliveZone = m_ZoneEnemies.CalculateEntityCount(); int aliveZone = m_ZoneEnemies.CalculateEntityCount();
if (zs.RemainingToSpawn > 0) if (zs.RemainingToSpawn > 0)
{ {
// Spawn only in Calm (a base Siege pauses the expedition wave; it resumes when the base is safe), one // Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap.
// per cadence, and only while under the concurrent cap.
bool calm = cycle.Phase == CyclePhase.Calm; bool calm = cycle.Phase == CyclePhase.Calm;
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick); bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
if (calm && dueNow && aliveZone < math.max(1, dir.MaxAlive)) if (calm && dueNow)
{
int slot = (int)zs.SpawnCounter;
byte kind = ZoneEnemyMath.KindForSlot(epoch, slot, bands);
int packSize = kind == ZoneEnemyMath.KindSwarmer
? ZoneEnemyMath.PackSizeForSlot(epoch, slot, bands, dir.SwarmerPackSize) : 1;
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
if (aliveZone + packSize <= math.max(1, dir.MaxAlive))
{ {
float3 baseCenter = new float3(0f, 1f, 0f); float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor)) if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor); baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter); float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
float3 center = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
center.y = origin.y;
int slot = (int)zs.SpawnCounter; int prefabIdx = kind;
bool charger = ZoneEnemyMath.IsChargerSlot(epoch, slot, dir.GruntsPerWave, dir.ChargersPerWave); if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
int prefabIdx = charger ? 1 : 0;
if (prefabIdx >= prefabs.Length) prefabIdx = prefabs.Length - 1;
var prefab = prefabs[prefabIdx].Prefab; var prefab = prefabs[prefabIdx].Prefab;
// Preserve the prefab's baked Scale ([GhostField]) — FromPosition would reset Scale->1.
float3 pos = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius); var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
pos.y = origin.y;
var ecb = new EntityCommandBuffer(Allocator.Temp); var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int k = 0; k < packSize; k++)
{
float3 pos = packSize > 1
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
pos.y = origin.y;
var enemy = ecb.Instantiate(prefab); var enemy = ecb.Instantiate(prefab);
// Preserve the prefab's baked Scale ([GhostField]) + rotation — FromPosition would reset Scale->1.
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
ecb.SetComponent(enemy, baked.WithPosition(pos)); ecb.SetComponent(enemy, baked.WithPosition(pos));
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition }); ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
ecb.AddComponent<ZoneEnemyTag>(enemy); ecb.AddComponent<ZoneEnemyTag>(enemy);
}
ecb.Playback(state.EntityManager); ecb.Playback(state.EntityManager);
ecb.Dispose(); ecb.Dispose();
zs.SpawnCounter += 1; zs.SpawnCounter += 1; // ONE slot consumed even for a pack
zs.RemainingToSpawn -= 1; zs.RemainingToSpawn -= 1;
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks)); zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
} }
} }
}
else if (aliveZone == 0 && runtime.ClearedThisEpoch == 0) else if (aliveZone == 0 && runtime.ClearedThisEpoch == 0)
{ {
// Wave fully spawned AND every zone enemy dead -> a REAL clear (the player killed them; the empty-edge // Wave fully spawned AND every zone enemy dead -> a REAL clear. Mark once; the gate pays the
// teardown can't reach here because we early-return when no one is out). Mark once; the gate pays the
// once-per-epoch Ore reward on the player's return to base. // once-per-epoch Ore reward on the player's return to base.
runtime.ClearedThisEpoch = 1; runtime.ClearedThisEpoch = 1;
SystemAPI.SetComponent(cycleEntity, runtime); SystemAPI.SetComponent(cycleEntity, runtime);
@@ -29,7 +29,8 @@ namespace ProjectM.Simulation
/// <summary>Per-variant wind-up DURATION in ticks (the client danger-ramp denominator).</summary> /// <summary>Per-variant wind-up DURATION in ticks (the client danger-ramp denominator).</summary>
public byte WindupTicks; public byte WindupTicks;
/// <summary>0 = Grunt-style; 1 = Charger (committed-lunge tell).</summary> /// <summary>Enemy kind for the client telegraph look: 0=Grunt, 1=Charger, 2=Spitter, 3=Swarmer (the
public byte IsCharger; /// ZoneEnemyMath.Kind* bytes). Baked per variant by EnemyBaker (the SOLE writer); never a [GhostField].</summary>
public byte Kind;
} }
} }
@@ -126,5 +126,42 @@ namespace ProjectM.Simulation
if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; } if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; }
} }
} }
/// <summary>
/// MC-2 Spitter range-band velocity (planar XZ): ADVANCE toward <paramref name="to"/> at
/// <paramref name="speed"/> when farther than <paramref name="preferred"/> + <paramref name="tolerance"/>,
/// RETREAT directly away when closer than <paramref name="preferred"/> - <paramref name="tolerance"/>, and
/// HOLD (zero) inside the dead-zone band. Y forced to 0. Returns zero when the points coincide. Pure /
/// Burst-safe / EditMode-testable; keeps the Spitter at its firing distance instead of closing to melee.
/// </summary>
public static float3 BandVelocity(float3 from, float3 to, float speed, float preferred, float tolerance)
{
float3 d = to - from;
d.y = 0f;
float distSq = math.lengthsq(d);
if (distSq < 1e-8f)
return float3.zero;
float dist = math.sqrt(distSq);
float3 dir = d / dist;
float tol = math.max(0f, tolerance);
if (dist > preferred + tol)
return dir * speed; // too far -> close in
if (dist < preferred - tol)
return -dir * speed; // too close -> back off
return float3.zero; // in band -> hold and fire
}
/// <summary>
/// Deterministic tight-cluster offset for swarmer <paramref name="index"/> of a pack of
/// <paramref name="packSize"/> around <paramref name="center"/> at <paramref name="tightRadius"/> (reuses the
/// <see cref="RingPosition"/> even-angle math at a small radius). A single swarmer (packSize&lt;=1) spawns at
/// the centre. Stable per index so a replayed pack lands identically. Pure.
/// </summary>
public static float3 ClusterOffset(float3 center, int index, int packSize, float tightRadius)
{
if (packSize <= 1)
return center;
return RingPosition(center, index, packSize, tightRadius);
}
} }
} }
@@ -0,0 +1,53 @@
using Unity.Entities;
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-2 — a hostile Spitter projectile: a server-spawned, OWNERLESS INTERPOLATED ghost moved server-only in the
/// plain SimulationSystemGroup (NOT predicted — like the Husks that fire it). It replicates ONLY the stock
/// LocalTransform (no hand-written [GhostField]); this component is server-only state. It deliberately carries NO
/// Health, so it is invisible to every WithAll&lt;Health&gt; target loop (player melee/projectile hit-tests can
/// never see it — fork 2a: spits are pure dodge/dash checks, NOT shootable). Integrated by
/// EnemyProjectileMoveSystem and swept-hit-tested against players + structures by EnemyProjectileDamageSystem.
/// </summary>
public struct EnemyProjectile : IComponentData
{
/// <summary>Planar heading (world XZ -> float2 x,y), unit length, locked at spawn.</summary>
public float2 Direction;
/// <summary>Travel speed (world units/second).</summary>
public float Speed;
/// <summary>Damage applied to the first valid same-region target hit.</summary>
public float Damage;
/// <summary>Max travel distance before it expires (world units).</summary>
public float Range;
/// <summary>Accumulated travelled distance (server-only; drives range-expiry).</summary>
public float DistanceTravelled;
/// <summary>Distance moved on the LAST tick (= Speed * the server fixed step). The damage system rebuilds the
/// swept segment as cur - Direction*LastStep — NEVER a fresh SystemAPI.Time.DeltaTime (this system runs in the
/// PLAIN group where that dt is the wall-frame delta, not the fixed step). Prevents high-speed tunnelling.</summary>
public float LastStep;
/// <summary>Region byte (RegionId.Base/Expedition), copied from the firing Spitter. The damage system skips any
/// target whose RegionTag.Region != this — relevancy hides cross-region ghosts from CLIENTS, but the SERVER
/// world holds base + expedition players 1000u apart, so server damage needs its OWN region guard.</summary>
public byte Region;
}
/// <summary>
/// Baked subscene singleton: the Spitter projectile ghost prefab + the concurrent soft-cap. The server reads it
/// via GetSingleton (the prefab Entity lives HERE, never per-Spitter — mirrors AbilityDatabase / WaveEnemyPrefab).
/// MaxLiveProjectiles bounds the RegionRelevancySystem O(ghosts x conn)/tick loop: a Spitter at/over the cap
/// soft-fails its shot (no cooldown burn — the EB-2 turret soft-fail pattern).
/// </summary>
public struct SpitterProjectilePrefab : IComponentData
{
public Entity Prefab;
public int MaxLiveProjectiles;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8a31a7b0c834ae24db480005ffdb6a15
@@ -0,0 +1,30 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-2 — baked weighted-composition table shared by BOTH enemy directors (the expedition
/// ZoneEnemyDirectorSystem and the base-siege WaveSystem). Pure integer weights consumed by the deterministic
/// <see cref="ZoneEnemyMath"/>.{WaveSlots, KindForSlot, PackSizeForSlot} functions (no enum, no RNG -&gt;
/// replay/save-stable). Per kind: a base count + a per-epoch ramp; the Grunt count is the REMAINDER (slots minus
/// the others) so it stays a fixed floor while chargers / spitters / swarmer-slots grow as the epoch (expedition)
/// or wave (base siege) climbs. A "swarmer slot" expands to a PackSize cluster at spawn (PackSizeForSlot), so one
/// slot = one pack. The LEGACY band {GruntBase=g, ChargerBase=c, ChargerPerEpoch=1, rest 0} reproduces the old
/// 2-type <see cref="ZoneEnemyMath.WaveSize"/> / <see cref="ZoneEnemyMath.IsChargerSlot"/> exactly (a parity test
/// pins this, so the base-siege size curve is provably unchanged where it must be).
/// </summary>
public struct MixBands : IComponentData
{
public int GruntBase;
public int ChargerBase;
public int SpitterBase;
public int SwarmerSlotBase;
public int ChargerPerEpoch;
public int SpitterPerEpoch;
public int SwarmerSlotPerEpoch;
/// <summary>Exposed-but-default-0 epoch ramp for the swarmer PACK size (PackSizeForSlot adds
/// SwarmerPackPerEpoch*(epoch-1) to the director's base pack size). v1 keeps it 0 = fixed pack size.</summary>
public int SwarmerPackPerEpoch;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 850f904d96b1c7d41959dddbdbf0b4b5
@@ -0,0 +1,45 @@
using Unity.Entities;
namespace ProjectM.Simulation
{
/// <summary>
/// MC-2 — server-only Spitter "reposition" brain state. Component PRESENCE is the Spitter discriminator (no
/// enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted): a Husk variant
/// baked with SpitterState is driven by the ranged range-band branch, mutually exclusive with the Charger
/// branch (the AI partitions Spitter = .WithAll&lt;EnemyTag,SpitterState&gt;().WithNone&lt;LungeState&gt;() so no
/// enemy is ever double-moved). The Spitter holds a PREFERRED RANGE band from its target — retreating if too
/// close, advancing if too far — and fires a TELEGRAPHED, dodgeable projectile on its OWN fire gate. If
/// cornered (no retreat room) within CorneredRange it falls back to the Grunt seek+strike. NOT a [GhostField]
/// (only server systems read it). All ticks via TickUtil.NonZero; compared with NetworkTick only.
/// </summary>
public struct SpitterState : IComponentData
{
/// <summary>Band centre: the distance the Spitter tries to hold from its target (world units).</summary>
public float PreferredRange;
/// <summary>Half-width dead-zone around PreferredRange; inside [pref-tol, pref+tol] the Spitter holds.</summary>
public float RangeTolerance;
/// <summary>Muzzle speed baked onto the spit projectile (world units/second).</summary>
public float ProjectileSpeed;
/// <summary>If the target closes within this distance AND the Spitter can't retreat, it melee-falls-back.</summary>
public float CorneredRange;
/// <summary>Telegraph wind-up lead in ticks before the spit fires (the dodge window). Baked (v1 not
/// live-tunable); keep >= ~24 (> interp delay) so a player reacting to the aim-line can clear the shot.</summary>
public int WindupTicks;
/// <summary>Server-only fire gate: raw tick of the earliest tick it may spit again (NonZero; 0 = ready). Its
/// OWN gate, never EnemyAttackCooldown. Compared via NetworkTick.IsNewerThan.</summary>
public uint NextShotTick;
}
/// <summary>
/// MC-2 — pure marker for a Swarmer "surround" enemy: mechanically a Grunt (NO AI branch — it falls through the
/// Grunt seek+strike pass) with swarm-tuned baked EnemyStats (fast, low-HP, fast frequent low-chip bites). The
/// tag drives only (a) the director's CLUSTER spawn (PackSize swarmers in one tick) and (b) a client tint. Keeps
/// EnemyTag + RegionTag like every Husk, so readability / health-bars / damage / region-AI all work unchanged.
/// </summary>
public struct SwarmerTag : IComponentData { }
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: be9404154fd4f964099918079d2da6b8
@@ -16,6 +16,17 @@ namespace ProjectM.Simulation
public int CountPerWave; public int CountPerWave;
public int SpawnIntervalTicks; public int SpawnIntervalTicks;
public int LullTicks; public int LullTicks;
public int MaxAlive; // MC-2 fork-4a: mandatory cap for the 4-type base siege (uncapped packs/spits spike relevancy).
// MC-2 base-siege mix bands (the director builds a ZoneEnemyMath.MixBands): BaseCount above = the Grunt base count.
public int ChargerBase;
public int SpitterBase;
public int SwarmerSlotBase;
public int ChargerPerEpoch;
public int SpitterPerEpoch;
public int SwarmerSlotPerEpoch;
public int SwarmerPackSize;
public int SwarmerPackPerEpoch;
public float ClusterTightRadius;
} }
/// <summary>Baked pool of Husk prefab variants the director draws from round-robin (Grunt / Swarmer / Brute / ...).</summary> /// <summary>Baked pool of Husk prefab variants the director draws from round-robin (Grunt / Swarmer / Brute / ...).</summary>
@@ -28,6 +28,16 @@ namespace ProjectM.Simulation
public int SpawnIntervalTicks; public int SpawnIntervalTicks;
public int GruntsPerWave; public int GruntsPerWave;
public int ChargersPerWave; public int ChargersPerWave;
// MC-2 mix bands (the director builds a ZoneEnemyMath.MixBands from these): GruntsPerWave/ChargersPerWave
// above are the Grunt/Charger BASE counts; these add the Spitter/Swarmer bases + per-epoch ramps + the pack.
public int SpitterBase;
public int SwarmerSlotBase;
public int ChargerPerEpoch;
public int SpitterPerEpoch;
public int SwarmerSlotPerEpoch;
public int SwarmerPackSize; // swarmers per swarmer-slot cluster (>=1)
public int SwarmerPackPerEpoch; // exposed pack ramp (v1 default 0 = fixed)
public float ClusterTightRadius; // tight ring radius for a swarmer pack
public int RewardOre; public int RewardOre;
} }
@@ -38,5 +38,66 @@ namespace ProjectM.Simulation
int s = ((slot % size) + size) % size; int s = ((slot % size) + size) % size;
return s >= size - chargers; return s >= size - chargers;
} }
// ---- MC-2: 4-type weighted composition (Grunt/Charger/Spitter/Swarmer), shared by both directors ----
// Kind bytes (NO C# enum — directors index a per-Kind prefab buffer by these; EnemyAISystem is Bursted).
public const byte KindGrunt = 0;
public const byte KindCharger = 1;
public const byte KindSpitter = 2;
public const byte KindSwarmer = 3;
/// <summary>
/// Total SLOTS in this epoch/wave under <paramref name="bands"/>: GruntBase + the per-kind ramped counts
/// (charger/spitter/swarmer-slot = base + perEpoch*(epoch-1)). Lower-bounded at 1 so there is always a fight.
/// A swarmer SLOT expands to a pack at spawn (<see cref="PackSizeForSlot"/>), so this counts packs, not
/// individual swarmers. For the LEGACY band it equals <see cref="WaveSize"/> (parity-tested). Pure integer.
/// </summary>
public static int WaveSlots(int epoch, in MixBands bands)
{
int e = math.max(1, epoch);
int grunts = math.max(0, bands.GruntBase);
int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1));
int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1));
int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1));
return math.max(1, grunts + chargers + spitters + swarmers);
}
/// <summary>
/// Deterministic Kind byte for spawn <paramref name="slot"/> of this epoch/wave. Slots are partitioned in a
/// FIXED order — Grunts, then Spitters, then Chargers, then Swarmer-slots last — so the wave skews threat-heavy
/// as the ramped counts climb (Grunts are the remainder = a fixed floor). Any leftover slot (when the kinds
/// under-fill the max(1,..) floor) defaults to Grunt. Stable per (epoch, slot). For the LEGACY band this
/// returns KindCharger on exactly the slots the old <see cref="IsChargerSlot"/> did (parity-tested). Pure.
/// </summary>
public static byte KindForSlot(int epoch, int slot, in MixBands bands)
{
int e = math.max(1, epoch);
int size = WaveSlots(epoch, bands);
int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1));
int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1));
int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1));
int grunts = math.max(0, size - chargers - spitters - swarmers); // remainder = fixed grunt floor
int s = ((slot % size) + size) % size;
if (s < grunts) return KindGrunt;
s -= grunts;
if (s < spitters) return KindSpitter;
s -= spitters;
if (s < chargers) return KindCharger;
s -= chargers;
if (s < swarmers) return KindSwarmer;
return KindGrunt; // defensive: unreachable while counts sum to size
}
/// <summary>
/// Swarmer cluster size for a swarmer slot: <paramref name="basePackSize"/> plus the (default-0)
/// <see cref="MixBands.SwarmerPackPerEpoch"/> ramp. Lower-bounded at 1. v1 bakes the ramp 0 -> a fixed pack;
/// the field is exposed for later tuning.
/// </summary>
public static int PackSizeForSlot(int epoch, int slot, in MixBands bands, int basePackSize)
{
int e = math.max(1, epoch);
return math.max(1, basePackSize + math.max(0, bands.SwarmerPackPerEpoch) * (e - 1));
}
} }
} }
@@ -0,0 +1,49 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Mathematics;
namespace ProjectM.Tests
{
/// <summary>
/// MC-2 pure-math tests for the new EnemyAIMath helpers: BandVelocity (Spitter range-band keep-distance) and
/// ClusterOffset (swarmer pack placement). No ECS world.
/// </summary>
public class EnemyAIMathMC2Tests
{
[Test]
public void BandVelocity_AdvancesWhenTooFar()
{
var v = EnemyAIMath.BandVelocity(new float3(0, 1, 0), new float3(20, 1, 0), 5f, 9f, 1.5f);
Assert.Greater(v.x, 0.1f, "too far -> moves toward the target");
Assert.AreEqual(0f, v.y, 1e-5f, "planar");
}
[Test]
public void BandVelocity_RetreatsWhenTooClose()
{
var v = EnemyAIMath.BandVelocity(new float3(0, 1, 0), new float3(3, 1, 0), 5f, 9f, 1.5f);
Assert.Less(v.x, -0.1f, "too close -> backs away from the target");
}
[Test]
public void BandVelocity_HoldsInBand()
{
var v = EnemyAIMath.BandVelocity(new float3(0, 1, 0), new float3(9, 1, 0), 5f, 9f, 1.5f);
Assert.AreEqual(0f, math.length(v), 1e-4f, "inside the dead-zone band -> hold and fire");
}
[Test]
public void ClusterOffset_SingleAtCentre_PackSpread()
{
var c = new float3(100, 1, 5);
var single = EnemyAIMath.ClusterOffset(c, 0, 1, 2.5f);
Assert.AreEqual(c.x, single.x, 1e-5f, "a lone swarmer spawns at the pack centre");
Assert.AreEqual(c.z, single.z, 1e-5f);
var a = EnemyAIMath.ClusterOffset(c, 0, 4, 2.5f);
var b = EnemyAIMath.ClusterOffset(c, 1, 4, 2.5f);
Assert.Greater(math.distance(a, b), 0.01f, "pack members get distinct offsets");
var again = EnemyAIMath.ClusterOffset(c, 2, 4, 2.5f);
Assert.AreEqual(0f, math.distance(again, EnemyAIMath.ClusterOffset(c, 2, 4, 2.5f)), 1e-5f, "deterministic");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9242a20ea43527243a4e93c343f0cb49
@@ -0,0 +1,132 @@
using NUnit.Framework;
using ProjectM.Server;
using ProjectM.Simulation;
using Unity.Core;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Tests
{
/// <summary>
/// MC-2 tests for the hostile Spitter projectile systems (server-only, plain SimulationSystemGroup):
/// EnemyProjectileMoveSystem integrates + writes LastStep; EnemyProjectileDamageSystem swept-hit-tests players +
/// structures, REGION-FILTERED, appending a DamageEvent + destroying the spit at-most-once. Covers the two
/// review-mandated regressions: swept anti-TUNNELLING (a per-tick step bigger than the target radius still
/// registers) and the cross-region damage guard (an Expedition spit must not damage a Base target on its path).
/// </summary>
public class EnemyProjectileTests
{
static void SetTick(World w, uint tick)
{
var em = w.EntityManager;
using var q = em.CreateEntityQuery(typeof(NetworkTime));
Entity e = q.IsEmpty ? em.CreateEntity(typeof(NetworkTime)) : q.GetSingletonEntity();
em.SetComponentData(e, new NetworkTime { ServerTick = new NetworkTick(tick) });
}
static (World, SimulationSystemGroup) MoveWorld()
{
var w = new World("EnemyProjMove");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyProjectileMoveSystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f));
return (w, g);
}
static (World, SimulationSystemGroup) DamageWorld()
{
var w = new World("EnemyProjDmg");
var g = w.GetOrCreateSystemManaged<SimulationSystemGroup>();
g.AddSystemToUpdateList(w.GetOrCreateSystem<EnemyProjectileDamageSystem>());
g.SortSystems();
w.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 0.1f));
SetTick(w, 200);
return (w, g);
}
static Entity MakeSpit(EntityManager em, float3 pos, float2 dir, float speed, float range, byte region, float lastStep = 0f, float damage = 10f)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new EnemyProjectile { Direction = dir, Speed = speed, Damage = damage, Range = range, DistanceTravelled = 0f, LastStep = lastStep, Region = region });
return e;
}
static Entity MakePlayerTarget(EntityManager em, float3 pos, byte region, float radius = 0.6f)
{
var e = em.CreateEntity();
em.AddComponentData(e, LocalTransform.FromPosition(pos));
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
em.AddComponentData(e, new HitRadius { Value = radius });
em.AddComponentData(e, new RegionTag { Region = region });
em.AddBuffer<DamageEvent>(e);
em.AddComponent<PlayerTag>(e);
return e;
}
[Test]
public void Move_IntegratesAndStoresLastStep()
{
var (w, g) = MoveWorld();
using (w)
{
var em = w.EntityManager;
var spit = MakeSpit(em, new float3(0, 1, 0), new float2(1, 0), 10f, 5f, RegionId.Base);
g.Update(); // dt 0.1 * speed 10 = step 1
var p = em.GetComponentData<EnemyProjectile>(spit);
Assert.AreEqual(1f, p.LastStep, 1e-4f, "LastStep = Speed*dt (for the swept segment)");
Assert.AreEqual(1f, p.DistanceTravelled, 1e-4f);
Assert.AreEqual(1f, em.GetComponentData<LocalTransform>(spit).Position.x, 1e-4f, "moved along +X");
}
}
[Test]
public void Damage_HitsSameRegionPlayer_DestroysAtMostOnce()
{
var (w, g) = DamageWorld();
using (w)
{
var em = w.EntityManager;
var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base);
var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Base, lastStep: 1f);
g.Update();
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(player).Length, "same-region player takes the hit");
Assert.IsFalse(em.Exists(spit), "the spit is consumed on hit");
}
}
[Test]
public void Damage_RegionFilter_ExpeditionSpitSparesBasePlayer()
{
var (w, g) = DamageWorld();
using (w)
{
var em = w.EntityManager;
var basePlayer = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base);
var spit = MakeSpit(em, new float3(5, 1, 0), new float2(1, 0), 10f, 20f, RegionId.Expedition, lastStep: 1f);
g.Update();
Assert.AreEqual(0, em.GetBuffer<DamageEvent>(basePlayer).Length, "cross-region spit must NOT damage an off-region player");
Assert.IsTrue(em.Exists(spit), "and it is not consumed by an off-region target");
}
}
[Test]
public void Damage_SweptSegment_NoTunnelThroughSmallTarget()
{
var (w, g) = DamageWorld();
using (w)
{
var em = w.EntityManager;
// target radius 0.5 at x=5; spit now at x=10 but stepped 8 this tick (start x=2) -> segment [2..10] crosses x=5.
var player = MakePlayerTarget(em, new float3(5, 1, 0), RegionId.Base, radius: 0.5f);
var spit = MakeSpit(em, new float3(10, 1, 0), new float2(1, 0), 80f, 50f, RegionId.Base, lastStep: 8f);
g.Update();
Assert.AreEqual(1, em.GetBuffer<DamageEvent>(player).Length,
"swept segment hits even when the per-tick step exceeds the target radius (no tunnelling)");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e63d6c6d98027f248be3fc163961ca95
@@ -0,0 +1,91 @@
using NUnit.Framework;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// MC-2 pure-math tests for the 4-type weighted composition (ZoneEnemyMath.WaveSlots / KindForSlot /
/// PackSizeForSlot) shared by both enemy directors. Deterministic integer math (no ECS world). The PARITY test
/// pins that the legacy band reproduces the old 2-type WaveSize/IsChargerSlot EXACTLY, so the base-siege size +
/// composition is provably controlled where it must be (the fork-4a safety net).
/// </summary>
public class ZoneEnemyMixTests
{
static MixBands Bands(int g, int c, int sp, int sw, int cPer, int spPer, int swPer, int packPer = 0) => new MixBands
{
GruntBase = g, ChargerBase = c, SpitterBase = sp, SwarmerSlotBase = sw,
ChargerPerEpoch = cPer, SpitterPerEpoch = spPer, SwarmerSlotPerEpoch = swPer, SwarmerPackPerEpoch = packPer,
};
[Test]
public void WaveSlots_LowerBoundedAtOne_AndSumsTheBands()
{
Assert.AreEqual(1, ZoneEnemyMath.WaveSlots(1, Bands(0, 0, 0, 0, 0, 0, 0)), "empty band still yields a fight");
Assert.AreEqual(5, ZoneEnemyMath.WaveSlots(1, Bands(4, 1, 0, 0, 1, 0, 0)), "4 grunts + 1 charger at epoch 1");
Assert.AreEqual(7, ZoneEnemyMath.WaveSlots(3, Bands(4, 1, 0, 0, 1, 0, 0)), "epoch 3: +1 charger/epoch -> 4+(1+2)");
Assert.AreEqual(4 + 2 + 1 + 1, ZoneEnemyMath.WaveSlots(2, Bands(4, 1, 0, 0, 1, 1, 1)), "epoch 2: 4 grunts + 2 chargers + 1 spitter + 1 swarmer-slot");
}
[Test]
public void KindForSlot_Deterministic()
{
var b = Bands(4, 1, 1, 1, 1, 1, 1);
for (int slot = 0; slot < 30; slot++)
Assert.AreEqual(ZoneEnemyMath.KindForSlot(5, slot, b), ZoneEnemyMath.KindForSlot(5, slot, b), "stable per (epoch,slot)");
}
[Test]
public void KindForSlot_GruntFloorFixed_ThreatsGrowWithEpoch()
{
var b = Bands(4, 1, 0, 0, 1, 0, 0); // grunts fixed at 4, chargers grow
CountKinds(b, 1, out int g1, out int c1, out int _, out int _);
Assert.AreEqual(4, g1); Assert.AreEqual(1, c1);
CountKinds(b, 5, out int g5, out int c5, out int _, out int _);
Assert.AreEqual(4, g5, "grunt count is a fixed floor"); Assert.AreEqual(5, c5, "chargers = base + (epoch-1)");
}
[Test]
public void KindForSlot_ParityWithLegacyIsChargerSlot()
{
for (int g = 0; g <= 6; g++)
for (int c = 0; c <= 4; c++)
for (int e = 1; e <= 6; e++)
{
var b = Bands(g, c, 0, 0, 1, 0, 0); // legacy band: charger ramps +1/epoch, no spitter/swarmer
int size = ZoneEnemyMath.WaveSlots(e, b);
Assert.AreEqual(ZoneEnemyMath.WaveSize(e, g, c), size, $"WaveSlots vs WaveSize g{g} c{c} e{e}");
for (int slot = 0; slot < size + 3; slot++)
{
bool legacy = ZoneEnemyMath.IsChargerSlot(e, slot, g, c);
bool now = ZoneEnemyMath.KindForSlot(e, slot, b) == ZoneEnemyMath.KindCharger;
Assert.AreEqual(legacy, now, $"parity g{g} c{c} e{e} slot{slot}");
}
}
}
[Test]
public void PackSizeForSlot_FixedByDefault_RampsWhenSet()
{
var fixedBand = Bands(0, 0, 0, 1, 0, 0, 1);
Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 4), "base pack");
Assert.AreEqual(4, ZoneEnemyMath.PackSizeForSlot(5, 0, fixedBand, 4), "no ramp -> fixed across epochs");
var rampBand = Bands(0, 0, 0, 1, 0, 0, 1, packPer: 2);
Assert.AreEqual(4 + 2 * 2, ZoneEnemyMath.PackSizeForSlot(3, 0, rampBand, 4), "epoch 3 ramp +2*(3-1)");
Assert.GreaterOrEqual(ZoneEnemyMath.PackSizeForSlot(1, 0, fixedBand, 0), 1, "lower-bounded at 1");
}
static void CountKinds(MixBands b, int epoch, out int g, out int c, out int sp, out int sw)
{
g = c = sp = sw = 0;
int size = ZoneEnemyMath.WaveSlots(epoch, b);
for (int slot = 0; slot < size; slot++)
{
byte k = ZoneEnemyMath.KindForSlot(epoch, slot, b);
if (k == ZoneEnemyMath.KindGrunt) g++;
else if (k == ZoneEnemyMath.KindCharger) c++;
else if (k == ZoneEnemyMath.KindSpitter) sp++;
else sw++;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6853067864d6bc342bac525cdee324f8
@@ -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.**