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
@@ -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>();