Fire control 12.4 closing batch: Death fires once; pin the blast loop's multi-kill and untouched-entity cases

Entity.Death, the owner of the stream, now ends after its first cause. A blast that destroyed a
cockpit fired Death, which removed and deactivated the entity, and the blast's own DamageHull then
raised a second Death to every subscriber but Zone's idempotent handler. Every damage path gets the
guard because none of them raise Death themselves.

New pins: one blast killing three ships (a break after the first kill survives the old suite), a
shieldless ship outside the disc receiving nothing, a cockpit-kill blast and a direct hull kill each
raising Death exactly once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 07:36:04 +02:00
commit 4629bc6799
2 changed files with 98 additions and 1 deletions

View file

@ -516,7 +516,10 @@ public abstract class Entity
Death = HullDamage.Where(_ => Hull.Durability < .01f).Select(_ => CauseOfDeath.HullDestroyed)
.Merge(HeatstrokeDeath.Select(_ => CauseOfDeath.Heatstroke))
.Merge(HypothermiaDeath.Select(_ => CauseOfDeath.Hypothermia))
.Merge(ItemDestroyed.Where(i=>i.GetBehavior<Cockpit>()!=null).Select(_ => CauseOfDeath.CockpitDestroyed));
.Merge(ItemDestroyed.Where(i=>i.GetBehavior<Cockpit>()!=null).Select(_ => CauseOfDeath.CockpitDestroyed))
// Death is terminal: the first cause ends the stream, so no damage path (a blast whose cockpit kill is
// followed by its own hull damage, a second item, a heat tick) can raise it twice to any subscriber.
.Take(1);
//CurrentSecurityLevel.Value = SecurityLevel.Open;
}

View file

@ -535,6 +535,100 @@ public sealed class FireControlCut124Tests : IDisposable
Assert.Equal(afterFirstTick, targetDamage, 4);
}
// AOneBlastKillsEveryShipInsideIt: three ships, each dead to one hit, inside one blast. Each takes hull
// damage exactly once and dies exactly once, and the shot leaves the queue once. Kills: the snapshot loop
// breaking after the first kill (the other two would never be touched).
[Fact]
public void AOneBlastKillsEveryShipInsideIt()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 30f, damage: 100f);
var ships = new[]
{
AddShip(e, e.Target.Position + float3(8, 0, 0), 1f, 300),
AddShip(e, e.Target.Position + float3(-8, 0, 0), 1f, 302),
AddShip(e, e.Target.Position + float3(0, 0, 8), 1f, 304),
};
var hullHits = new int[3];
var deaths = new int[3];
var subs = new List<IDisposable>();
for (var n = 0; n < 3; n++)
{
var k = n;
subs.Add(ships[k].HullDamage.Subscribe(_ => hullHits[k]++));
subs.Add(ships[k].Death.Subscribe(_ => deaths[k]++));
}
var resolved = 0;
subs.Add(e.Zone.ShotResolved.Subscribe(_ => resolved++));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
foreach (var s in subs) s.Dispose();
Assert.Equal(new[] { 1, 1, 1 }, hullHits);
Assert.Equal(new[] { 1, 1, 1 }, deaths);
foreach (var s in ships) Assert.DoesNotContain(s, e.Zone.Entities);
Assert.Empty(e.Zone.PendingShots);
Assert.Equal(1, resolved);
}
// AShieldlessShipOutsideTheDiscIsNotTouched: nothing of any kind reaches an entity the disc does not
// overlap. Kills: an uncovered entity falling through to the damage call (`covered ??= new List`).
[Fact]
public void AShieldlessShipOutsideTheDiscIsNotTouched()
{
var e = Build(TestSettings(), SolidShape(5, 4), velocity: 0, fuse: WeaponFuse.Proximity, blastRadius: 3f, damage: 100f);
var outside = AddShip(e, e.Target.Position + float3(60, 0, 0), 1f, 300);
var touched = 0;
using var a = outside.ArmorDamage.Subscribe(_ => touched++);
using var h = outside.HullDamage.Subscribe(_ => touched++);
using var i = outside.ItemDamage.Subscribe(_ => touched++);
var targetHit = 0f;
using var t = e.Target.HullDamage.Subscribe(x => targetHit += x);
var armourHit = 0f;
using var ta = e.Target.ArmorDamage.Subscribe(x => armourHit += x.damage);
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
e.Zone.Update(.01f);
Assert.Equal(0, touched);
Assert.Equal(1f, outside.Hull.Durability);
Assert.Contains(outside, e.Zone.Entities);
Assert.True(targetHit + armourHit > 0f, "fixture: the blast must land on the target");
}
// ADestroyedCockpitRaisesDeathOnce: the blast destroys the cockpit, Death removes and deactivates the
// entity, and the blast's own DamageHull then still runs; no subscriber sees a second Death.
[Fact]
public void ADestroyedCockpitRaisesDeathOnce()
{
var e = Build(TestSettings(), SolidShape(3, 3), cockpitCell: new int2(1, 1), cockpitDurability: 10f);
var deaths = new List<CauseOfDeath>();
using var d = e.Target.Death.Subscribe(deaths.Add);
var hullEvents = 0;
using var h = e.Target.HullDamage.Subscribe(_ => hullEvents++);
FireControl.Detonate(e.Zone, e.Target.Position.xz, .6f, 5000f, DamageType.Kinetic);
Assert.Equal(new[] { CauseOfDeath.CockpitDestroyed }, deaths);
Assert.DoesNotContain(e.Target, e.Zone.Entities);
Assert.True(hullEvents >= 1, "fixture: the blast's hull damage still lands after the cockpit dies");
}
// ADirectHullKillRaisesDeathOnce: the non-blast path (Entity.DamageHull straight, as a projectile hit does)
// raises Death once, and further damage to the corpse does not raise it again.
[Fact]
public void ADirectHullKillRaisesDeathOnce()
{
var e = Build(TestSettings(), SolidShape(3, 3));
var deaths = 0;
using var d = e.Target.Death.Subscribe(_ => deaths++);
e.Target.DamageHull(2000000f);
e.Target.DamageHull(5f);
Assert.Equal(1, deaths);
}
// ==== Absorption order ====
// ABlastIsAbsorbedThroughArmourFirstPerCell (replaces AbsorbSpendsArmourBeforeTheItemOnTheSameCell): a disc