Reveal book: no scratch holds a departed entity, a destroyed lit gun is refused at the gate, one IsDestroyed rule, departure and allocation pins

Not verified yet at this commit (build and tests run next).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 02:20:46 +02:00
commit c212ec0232
6 changed files with 198 additions and 22 deletions

View file

@ -290,7 +290,7 @@ public class EntityInstance : MonoBehaviour
_subscriptions.Add(Entity.HullDamage.Subscribe(_ =>
{
if (Entity.Hull.Durability < .01f)
if (Entity.Hull.IsDestroyed)
{
if (!this) return;
_destroyed = true;

View file

@ -893,16 +893,23 @@ public abstract class Entity
// The reveal book's one way in and its one membership gate (callers: Sense and PublishInbox, both in this file):
// this observer saw `item` of `subject` act on `channel`, now, from this bearing. An item-less act (hull
// radiation, a consumable) reveals nothing, and neither does a subject that is not in the zone: a departed
// subject's entries are never created or refreshed, from any caller. A later act of the same item replaces the
// subject's entries are never created or refreshed, from any caller, and neither does an item this observer
// perceives destroyed (PerceivesDestroyed). A later act of the same item replaces the
// earlier one.
internal void RecordReveal(Entity subject, EquippedItem item, SignatureChannel channel)
{
if (!_active || item == null || !Zone.Entities.Contains(subject)) return;
if (!_active || item == null || !Zone.Entities.Contains(subject) || PerceivesDestroyed(subject, item)) return;
if (!_reveals.TryGetValue(subject, out var book))
_reveals[subject] = book = new Dictionary<EquippedItem, Reveal>();
book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time);
}
// This observer resolves `subject`'s gear (ContactOf's Identified gate) and `item` of it is destroyed: the one rule
// for a destroyed item an observer perceives, read by the book's entry (RecordReveal) and its end (Sense).
private bool PerceivesDestroyed(Entity subject, EquippedItem item) =>
item.EquippableItem.IsDestroyed
&& EntityInfoGathered.TryGetValue(subject, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold;
// The book's one way out (caller: Sense): `item` null ends every reveal of `subject`, otherwise that item's.
// The subject's key goes with its last entry.
private void EndReveals(Entity subject, EquippedItem item)
@ -964,15 +971,14 @@ public abstract class Entity
if (perSignature < 0f) perSignature = PassiveInfo(other.Position.xz, 1f);
if (signature.Strength * perSignature > threshold) RecordReveal(other, signature.Item, signature.Channel);
}
// The observer resolves this ship's gear (ContactOf's own Identified gate), so it perceives an item of it
// destroyed. Ending after recording: a destroyed item's lingering flash cannot re-enter the book.
if (EntityInfoGathered.TryGetValue(other, out var info) && info >= ItemManager.GameplaySettings.TargetGearInfoThreshold
&& _reveals.TryGetValue(other, out var book))
// The observer resolves this ship's gear (PerceivesDestroyed), so it perceives an item of it destroyed and
// that reveal ends here; RecordReveal refuses the same item's lingering flash.
if (_reveals.TryGetValue(other, out var book))
{
_endedItems.Clear();
foreach (var item in book.Keys)
if (item.EquippableItem.Durability < .01f) _endedItems.Add(item);
if (PerceivesDestroyed(other, item)) _endedItems.Add(item);
for (var j = 0; j < _endedItems.Count; j++) EndReveals(other, _endedItems[j]);
_endedItems.Clear();
}
}
// A subject that left the zone (death, dock, warp, any removal) is perceived gone under the light-act gate: as
@ -987,7 +993,6 @@ public abstract class Entity
// What stays of a departed subject is memory: pruned once its freshest entry is as old as ContactOf stops
// remembering it.
var memory = ItemManager.GameplaySettings.BearingMemory;
_agedSubjects.Clear();
foreach (var pair in _reveals)
{
if (entities.Contains(pair.Key)) continue;
@ -996,6 +1001,7 @@ public abstract class Entity
if (Zone.Time - freshest >= memory) _agedSubjects.Add(pair.Key);
}
for (var i = 0; i < _agedSubjects.Count; i++) EndReveals(_agedSubjects[i], null);
_agedSubjects.Clear();
}
public Entity(ItemManager itemManager, Zone zone, EquippableItem hull, EntitySettings settings)
@ -1012,10 +1018,10 @@ public abstract class Entity
for(int i=0; i<itemManager.GameplaySettings.WeaponGroupCount; i++)
WeaponGroups[i] = (new List<Weapon>(), new List<EquippedItem>());
ItemDestroyed = ItemDamage.Where(x => x.item.EquippableItem.Durability < .01f).Select(x=>x.item);
ItemDestroyed = ItemDamage.Where(x => x.item.EquippableItem.IsDestroyed).Select(x=>x.item);
WeaponDestroyed = ItemDestroyed.Select(x => x.Behaviors.FirstOrDefault(b => b is Weapon) as Weapon).Where(x => x != null);
HullArmorDepleted = ArmorDamage.Where(x => Armor[x.pos.x, x.pos.y] < .01f).Select(x => x.pos);
Death = HullDamage.Where(_ => Hull.Durability < .01f).Select(_ => CauseOfDeath.HullDestroyed)
Death = HullDamage.Where(_ => Hull.IsDestroyed).Select(_ => CauseOfDeath.HullDestroyed)
.Merge(HeatstrokeDeath.Select(_ => CauseOfDeath.Heatstroke))
.Merge(HypothermiaDeath.Select(_ => CauseOfDeath.Hypothermia))
.Merge(ItemDestroyed.Where(i=>i.GetBehavior<Cockpit>()!=null).Select(_ => CauseOfDeath.CockpitDestroyed))
@ -2193,7 +2199,7 @@ public class EquippedItem : IStatContext
deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent
) * Data.Durability / Data.ThermalResilience;
ThermalOnline.Value = ThermalPerformance > performanceThreshold || Entity.OverrideShutdown && EquippableItem.OverrideShutdown;
DurabilityOnline.Value = EquippableItem.Durability > .01f;
DurabilityOnline.Value = !EquippableItem.IsDestroyed;
oldTemperature = temp;
}

View file

@ -168,7 +168,7 @@ public static class FireControl
// failing its own tier -- ranked.IndexOf(item) then returns -1 for it (the same "no longer on this
// target" path below), and the survivors' tiers close up over the gap instead of leaving one.
var ranked = target.Equipment
.Where(e => e.Data.HardpointType != HardpointType.Hull && e.EquippableItem.Durability >= .01f)
.Where(e => e.Data.HardpointType != HardpointType.Hull && !e.EquippableItem.IsDestroyed)
.OrderByDescending(e => e.Data.HardpointType != HardpointType.Tool)
.ThenByDescending(e => e.Data.Shape.Coordinates.Length)
.ToList();

View file

@ -53,6 +53,9 @@ public class EquippableItem : CraftedItemInstance
[JsonProperty("durability"), Key(7)] public float Durability;
[JsonProperty("override"), Key(8)] public bool OverrideShutdown;
// The one rule for a destroyed item: its wear is spent.
[IgnoreMember] public bool IsDestroyed => Durability < .01f;
// Cut 5 (docs/stats-and-power-cut.md §1.3, §0b "A power tier"): a player choice, stored on the unit rather
// than the design, so two units of the same design can be prioritized differently. PowerTiers.Unassigned
// until the first equip seeds a behaviour-kind default (EquippedItem's constructor) or the player overrides

View file

@ -386,7 +386,7 @@ public class PropertiesPanel : MonoBehaviour
private void AddEquippableItemProperties(EquippableItem item, Func<PerformanceStat, float> statValueFunction)
{
if (item.Durability < .01f)
if (item.IsDestroyed)
{
return;
}
@ -473,7 +473,7 @@ public class PropertiesPanel : MonoBehaviour
var gearData = GameManager.ItemManager.GetData(item.EquippableItem);
var statusSheet = AddStatSheet();
if (item.EquippableItem.Durability < .01f)
if (item.EquippableItem.IsDestroyed)
statusSheet.AddStat("Durability", () => "Item Destroyed!");
else statusSheet.AddStat("Durability", () => $"{(int)(item.EquippableItem.Durability / gearData.Durability * 100)}%");
statusSheet.AddStat("Temperature", () => ActionGameManager.PlayerSettings.FormatTemperature(item.Temperature));

View file

@ -935,6 +935,161 @@ public sealed class ContactRevealTests : IDisposable
Assert.Empty(o.RevealsOf(e));
}
// A destroyed line, not a destroyed zero: Durability below .01 is destroyed and .01 or more is not, for the
// observer that identifies the ship.
[Theory]
[InlineData(.009f, true)]
[InlineData(.011f, false)]
public void AnItemIsDestroyedBelowTheDurabilityLine(float durability, bool destroyed)
{
var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var guns = Guns(e);
Fire(e, guns[0]);
Fire(e, guns[1]);
o.VisibleEntities.Add(e);
o.EntityInfoGathered[e] = 1f;
guns[0].EquippableItem.Durability = durability;
SenseOf(o)();
Assert.Equal(destroyed, !o.RevealsOf(e).Any(r => r.Item == guns[0]));
Assert.Contains(o.RevealsOf(e), r => r.Item == guns[1]);
}
// ---- Departure notes ----
// A kill inside FireControl.Step happens after this tick's Sense: the observers' next Sense reads it, whatever
// order the zone lists them in.
[Theory]
[InlineData(true)]
[InlineData(false)]
public void AKillInsideFireControlStepIsReadByTheNextSense(bool observerFirst)
{
Entity strong = null;
if (observerFirst) strong = Add(float3(-1, 0, 0), new[] { "Ear" });
var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" });
var killer = Add(float3(60, 0, 100), new[] { "Gun" });
if (!observerFirst) strong = Add(float3(-1, 0, 0), new[] { "Ear" });
var guns = Guns(e);
Fire(e, guns[0]);
Fire(e, guns[1]);
Tick(1f);
ShowOnly(e, 2f, guns[0]);
Assert.True(e.Visibility * strong.PassiveInfo(e.Position.xz, 1f) > _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: the ear passes the gate");
var killed = false;
void Kill(ShotOutcome _)
{
if (killed || !_zone.Entities.Contains(e)) return;
killed = true;
e.Hull.Durability = 0f;
e.HullDamage.OnNext(1f);
}
UniRx.ObservableExtensions.Subscribe(_zone.ShotCommitted, Kill);
UniRx.ObservableExtensions.Subscribe(_zone.ShotResolved, Kill);
var killerGun = (InstantWeapon) Guns(killer)[0].Behaviors.Single(b => b is InstantWeapon);
((InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(killerGun)).AddCharge(1000f);
killer.SetTarget(TargetRef.None);
killerGun.Activate();
for (var ticks = 0; !killed && ticks < 20; ticks++) Tick(.1f);
Assert.True(killed, "fixture: the kill lands inside a tick");
Assert.DoesNotContain(e, _zone.Entities);
Assert.Equal(2, strong.RevealsOf(e).Count()); // this tick's Sense came before the kill
Tick(.1f);
Assert.Empty(strong.RevealsOf(e));
Assert.False(strong.ContactOf(e).Detected);
}
// The note of a removal is read by exactly one Sense: an observer that could not see the departure on that tick
// does not judge it again later from where it then is.
[Fact]
public void ADepartureIsJudgedOnTheTickAfterItsRemovalAndNeverAgain()
{
var e = Add(float3(0, 0, 100), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
Fire(e, gun);
ShowOnly(e, 1f, gun);
var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold;
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= threshold, "fixture: from where o is, the departure is below the gate");
_zone.Entities.Remove(e);
Tick(.1f);
Assert.Single(o.RevealsOf(e));
o.Position = float3(0, 0, 90); // now o is close enough to have seen it go
Assert.True(e.Visibility * o.PassiveInfo(float2(0, 100), 1f) > threshold, "fixture: from here the same departure would pass the gate");
Tick(.1f);
Assert.Single(o.RevealsOf(e));
Assert.True(o.ContactOf(e).Detected);
}
// Every departure of a tick is judged, whatever order they were removed in.
[Theory]
[InlineData(true)]
[InlineData(false)]
public void EveryDepartureOfATickIsJudged(bool firstThenSecond)
{
var o = Add(float3(-1, 0, 0), new[] { "Ear" });
var a = Add(float3(0, 0, 100), new[] { "Gun" });
var b = Add(float3(0, 0, -100), new[] { "Gun" });
Fire(a, Guns(a)[0]);
Fire(b, Guns(b)[0]);
Assert.Single(o.RevealsOf(a));
Assert.Single(o.RevealsOf(b));
ShowOnly(a, 2f, Guns(a)[0]);
ShowOnly(b, 2f, Guns(b)[0]);
var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold;
Assert.True(a.Visibility * o.PassiveInfo(a.Position.xz, 1f) > threshold && b.Visibility * o.PassiveInfo(b.Position.xz, 1f) > threshold, "fixture: both departures pass the gate");
_zone.Entities.Remove(firstThenSecond ? a : b);
_zone.Entities.Remove(firstThenSecond ? b : a);
Tick(.1f);
Assert.Empty(o.RevealsOf(a));
Assert.Empty(o.RevealsOf(b));
Assert.Equal(0, RevealBookSubjects(o));
}
// Nothing the observer keeps for a pass reaches a ship that has left: a ship with one gun ended as destroyed and
// one aged out by the prune is garbage once it is gone and its memory has lapsed.
[Fact]
public void ADepartedSubjectIsUnreachableFromTheObserverAfterThePrune()
{
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gone = AShipIsIdentifiedHalfDestroyedAndRemoved(o);
for (var i = 0; i < 40; i++) Tick(.1f); // past BearingMemory (2 s)
Assert.Equal(0, RevealBookSubjects(o));
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Assert.False(gone[0].IsAlive, "the ship");
Assert.False(gone[1].IsAlive, "its destroyed gun");
Assert.False(gone[2].IsAlive, "its other gun");
}
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private WeakReference[] AShipIsIdentifiedHalfDestroyedAndRemoved(Entity observer)
{
var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" });
var guns = Guns(e);
Fire(e, guns[0]);
Fire(e, guns[1]);
Tick(1f);
observer.EntityInfoGathered[e] = 1f;
ShowOnly(e, 0f, guns[1]); // its departure is below the observer's gate: the other gun's reveal waits for the prune
guns[0].EquippableItem.Durability = 0f;
Tick(.01f);
Assert.Same(guns[1], Assert.Single(observer.RevealsOf(e)).Item);
_zone.Entities.Remove(e);
return new[] { new WeakReference(e), new WeakReference(guns[0]), new WeakReference(guns[1]) };
}
// ---- The one rule for info per signature ----
[Fact]
@ -968,10 +1123,15 @@ public sealed class ContactRevealTests : IDisposable
var sense = SenseOf(o);
for (var i = 0; i < 10; i++) sense();
Assert.Equal(19, _zone.Entities.Where(x => x != o).Count(x => o.RevealsOf(x).Any())); // fixture: every ship is revealed
// One of them is identified by o (its destroyed gun is ended in the pass), and one has left, quiet, so its
// entries await the prune and its departure sits in the zone's list.
// One of them is identified by o and its gun is destroyed while it still glows, so the pass ends that reveal
// and the flash cannot re-enter; one has left, quiet, so its entries await the prune and its departure sits
// in the zone's list.
var identified = gunItems.Select(g => _zone.Entities.Single(x => x.Equipment.Contains(g))).First();
o.EntityInfoGathered[identified] = 1f;
gunItems[0].EquippableItem.Durability = 0f;
sense();
Assert.Empty(o.RevealsOf(identified)); // fixture: its gun is destroyed, ended, and still lit
Assert.Contains(identified.VisibilitySources.Values, v => v.Item == gunItems[0] && v.Strength > 0f);
var departed = _zone.Entities.Single(x => x.Equipment.Contains(gunItems[18]));
foreach (var source in departed.VisibilitySources.Keys.ToArray()) departed.SetSignature(source, SignatureChannel.Reflection, 0f, gunItems[18]);
_zone.Entities.Remove(departed);
@ -979,10 +1139,17 @@ public sealed class ContactRevealTests : IDisposable
sense();
Assert.Single(o.RevealsOf(departed)); // fixture: awaiting its prune
var before = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 600; i++) sense();
var after = GC.GetAllocatedBytesForCurrentThread();
// The smallest of several rounds is the steady-state allocation (FireControlCut12Tests, the Lane and
// Silhouette pin): a one-off JIT or tier-up allocation lands in one round of a busy full-suite run, a
// per-call allocation lands in every round.
var smallest = long.MaxValue;
for (var round = 0; round < 20 && smallest != 0; round++)
{
var before = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 600; i++) sense();
smallest = Math.Min(smallest, GC.GetAllocatedBytesForCurrentThread() - before);
}
Assert.Equal(0L, after - before);
Assert.Equal(0L, smallest);
}
}