Phase 1 B3: enemies die with a corpse window instead of popping out of existence

Server: HealthApplyDamageSystem marks EnemyTag Dying{UntilTick} (TickUtil.
NonZero, ~54 ticks) on the lethal 0-crossing instead of instant destroy,
zeroes every live cue (replicated AttackWindup, LungeState + IsLunging bit,
KnockbackState), destroys on expiry; plain-world tests keep instant destroy
(no NetworkTime) so the suite's assertions stay meaningful.

Every consumer now ignores corpses (all confirmed entity-count/unfiltered by
the design review): EnemyAISystem all 4 passes, BossAISystem brain + summon
cap, RoomEnemyDirector room-clear + MaxAlive fit, WaveSystem cap + cleared,
CyclePhaseSystem breach wipe + DefendCleared, ThreatDirector timeout cull,
TurretFire targeting, CoreDamage drain, ProjectileDamage snapshot (corpses
are not shields), AbilityFire auto-aim candidates, debug cull + telemetry.
Wipe passes skip Dying to avoid cross-ECB double-destroys.

Client: EnemyAnimationDriveSystem finally drives the controller's IsDead
param (Health.Current<=0 is the replicated death read; corpse locomotion
zeroed); CombatFeedbackSystem moves the kill CRUNCH to the 0-crossing (the
prune-edge timing would land it ~1 s late) - the prune keeps a small
dissolve puff for corpses and the legacy full read for alive-vanish culls.

456/456 EditMode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 18:34:12 -07:00
parent 358ac14b06
commit 3836e9c842
18 changed files with 125 additions and 33 deletions
@@ -45,7 +45,7 @@ namespace ProjectM.Server
var huskRegion = new NativeList<byte>(Allocator.Temp);
foreach (var (xform, health, region, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithEntityAccess())
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // LIVING targets only (B3)
{
if (health.ValueRO.Current <= 0f)
continue;
@@ -39,9 +39,9 @@ namespace ProjectM.Server
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<BossState>());
m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<BossState>(), ComponentType.Exclude<Dying>());
state.RequireForUpdate(m_Bosses);
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>());
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>(), ComponentType.Exclude<Dying>()); // summon cap counts LIVING only (B3)
}
[BurstCompile]
@@ -97,7 +97,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, health, boss, windup, knockback) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRO<Health>, RefRW<BossState>,
RefRW<AttackWindup>, RefRW<KnockbackState>>()
.WithAll<EnemyTag, BossState>())
.WithAll<EnemyTag, BossState>().WithNone<Dying>())
{
float3 pos = xform.ValueRO.Position;
@@ -108,7 +108,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState>())
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState, Dying>())
{
float3 pos = xform.ValueRO.Position;
byte huskRegion = region.ValueRO.Region;
@@ -223,7 +223,7 @@ namespace ProjectM.Server
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
.WithAll<EnemyTag>().WithNone<SpitterState, BossState>())
.WithAll<EnemyTag>().WithNone<SpitterState, BossState, Dying>())
{
float3 pos = xform.ValueRO.Position;
byte cHuskRegion = region.ValueRO.Region;
@@ -363,7 +363,7 @@ namespace ProjectM.Server
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>())
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState, Dying>())
{
float3 pos = xform.ValueRO.Position;
byte sRegion = region.ValueRO.Region;
@@ -470,7 +470,7 @@ namespace ProjectM.Server
// Charger whose bit is currently DISABLED is still visited (Entities default-excludes disabled enableables).
foreach (var (lunge, isLunging) in
SystemAPI.Query<RefRO<LungeState>, EnabledRefRW<IsLunging>>()
.WithAll<EnemyTag>().WithPresent<IsLunging>())
.WithAll<EnemyTag>().WithPresent<IsLunging>().WithNone<Dying>())
{
isLunging.ValueRW = lunge.ValueRO.UntilTick != 0u; // lunging iff a committed lunge is live this tick
}
@@ -132,13 +132,46 @@ namespace ProjectM.Server
health.ValueRW.Current = newHp;
// Server-authoritative death: training dummies + enemies + EB-1 Destructible structures despawn;
// Server-authoritative death: training dummies + EB-1 Destructible structures despawn instantly;
// player death is deferred (clamp only). A structure carries NO EffectiveCharacterStats, so it took
// the math.max(0,..) branch above and CAN reach 0 — never give a structure stats (it would clamp to
// a non-zero floor and become immortal).
if (health.ValueRO.Current <= 0f && (SystemAPI.HasComponent<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity) || SystemAPI.HasComponent<Destructible>(entity)))
ecb.DestroyEntity(entity);
// Phase 1 (B3): ENEMIES get a corpse window instead — mark Dying ONCE on the lethal crossing and
// zero every live cue (the replicated windup, the lunge state + ghost bit, knockback) so clients
// never see a corpse telegraphing. Plain-world EditMode tests have no NetworkTime → keep the old
// instant destroy there so the existing suite still means what it asserts.
if (health.ValueRO.Current <= 0f)
{
if (SystemAPI.HasComponent<EnemyTag>(entity) && haveTick && netTime.ServerTick.IsValid)
{
if (!SystemAPI.HasComponent<Dying>(entity))
{
ecb.AddComponent(entity, new Dying
{
UntilTick = TickUtil.NonZero(netTime.ServerTick.TickIndexForValidTick + Tuning.EnemyDeathWindowTicks),
});
if (SystemAPI.HasComponent<AttackWindup>(entity)) SystemAPI.SetComponent(entity, default(AttackWindup));
if (SystemAPI.HasComponent<KnockbackState>(entity)) SystemAPI.SetComponent(entity, default(KnockbackState));
if (SystemAPI.HasComponent<LungeState>(entity)) SystemAPI.SetComponent(entity, default(LungeState));
if (SystemAPI.HasComponent<IsLunging>(entity)) SystemAPI.SetComponentEnabled<IsLunging>(entity, false);
}
}
else if (SystemAPI.HasComponent<TrainingDummyTag>(entity) || SystemAPI.HasComponent<EnemyTag>(entity) || SystemAPI.HasComponent<Destructible>(entity))
ecb.DestroyEntity(entity);
}
}
// Phase 1 (B3): corpse expiry — destroy Dying enemies whose window elapsed (at-most-once: the Dying
// mark is the only path here, and destruction removes it). NetworkTick compare, never raw uint math.
if (haveTick && netTime.ServerTick.IsValid)
{
foreach (var (dying, entity) in SystemAPI.Query<RefRO<Dying>>().WithEntityAccess())
{
var until = new NetworkTick(dying.ValueRO.UntilTick);
if (until.IsValid && !until.IsNewerThan(netTime.ServerTick))
ecb.DestroyEntity(entity);
}
}
if ((negatedThisTick != 0u || punishesThisTick != 0u) && SystemAPI.HasSingleton<DevTelemetry>())
{
var telem = SystemAPI.GetSingletonRW<DevTelemetry>();
@@ -83,6 +83,7 @@ namespace ProjectM.Server
foreach (var (xform, hitRadius, targetEntity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>>()
.WithAll<Health>()
.WithNone<Dying>() // B3: corpses are not shields - a shot passes through to living targets
.WithEntityAccess())
{
targetEntities.Add(targetEntity);
@@ -45,7 +45,7 @@ namespace ProjectM.Server
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
state.RequireForUpdate<ZoneEnemyDirector>();
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>());
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>(), ComponentType.Exclude<Dying>()); // room clear + MaxAlive fit count LIVING only (B3) - corpses neither hold the room open nor crowd out spawns
}
[BurstCompile]
@@ -98,7 +98,7 @@ namespace ProjectM.Server
// 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>())
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>())
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).
@@ -134,7 +134,7 @@ namespace ProjectM.Server
// Wave fully spawned: cleared only when no BASE husk remains. Expedition zone enemies are also
// EnemyTag but RegionTag{Expedition}; they must NOT hold the base siege open (DR-040 BLOCKER 3).
int baseHusks = 0;
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>())
if (hr.ValueRO.Region == RegionId.Base) baseHusks++;
if (baseHusks == 0)
{
@@ -26,7 +26,7 @@ namespace ProjectM.Server
public void OnCreate(ref SystemState state)
{
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.Exclude<Dying>()); // corpses expire on their own (B3; avoids a cross-ECB double-destroy)
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<DebugCommandRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
@@ -24,7 +24,7 @@ namespace ProjectM.Server
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.Exclude<Dying>()); // telemetry counts LIVING (B3)
if (state.GetEntityQuery(ComponentType.ReadWrite<DevTelemetry>()).IsEmpty)
state.EntityManager.CreateEntity(typeof(DevTelemetry));
}
@@ -58,7 +58,7 @@ namespace ProjectM.Server
var ecb = new EntityCommandBuffer(Allocator.Temp);
int drained = 0;
foreach (var (xform, entity) in
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<EnemyTag>().WithEntityAccess())
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // corpses don't drain the Core (B3)
{
if (!EnemyAIMath.InAttackRange(xform.ValueRO.Position, corePos, CoreReachRadius))
continue;
@@ -107,7 +107,7 @@ namespace ProjectM.Server
// Slice 3: cull the BASE wave only — an Expedition wave runs in its own region and must
// survive a base Core breach. A region-blind EnemyTag wipe would also spuriously trip the
// zone director's aliveZone==0 clear/reward edge. Mirrors ThreatDirectorSystem + DefendCleared.
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithEntityAccess())
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // skip corpses: the B3 expiry pass owns their destroy (cross-ECB double-destroy)
if (hr.ValueRO.Region == RegionId.Base)
ecb.DestroyEntity(he);
ecb.Playback(state.EntityManager);
@@ -185,7 +185,7 @@ namespace ProjectM.Server
// Cleared only when no BASE husk remains: expedition zone enemies (EnemyTag + RegionTag{Expedition})
// must not hold the base siege open (DR-040 BLOCKER 3 — same global-count soft-lock as WaveSystem).
int baseHusks = 0;
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>()) // LIVING only (B3)
if (hr.ValueRO.Region == RegionId.Base) baseHusks++;
return wave.WaveNumber > defendStartWave
&& wave.RemainingToSpawn == 0
@@ -111,7 +111,7 @@ namespace ProjectM.Server
// Collapse the siege: cull every remaining BASE Husk only (expedition zone enemies are also
// EnemyTag but RegionTag{Expedition}; the timeout must not destroy them — DR-040 BLOCKER 3).
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithEntityAccess())
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // skip corpses: the B3 expiry pass owns their destroy
if (hr.ValueRO.Region == RegionId.Base)
ecb.DestroyEntity(he);
ecb.Playback(state.EntityManager);