From c212ec0232c0876a49aa691fa70b794afd6227bd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 02:20:46 +0200 Subject: [PATCH] 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 --- Assets/Scripts/Gameplay/EntityInstance.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 30 +-- Assets/Scripts/ServerShared/FireControl.cs | 2 +- Assets/Scripts/ServerShared/ItemInstance.cs | 3 + .../UI/Properties Panel/PropertiesPanel.cs | 4 +- .../ContactRevealTests.cs | 179 +++++++++++++++++- 6 files changed, 198 insertions(+), 22 deletions(-) diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 318a3007e..53e5dbc71 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -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; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 80bce7a01..6618bd9bb 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -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(); 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(), new List()); - 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()!=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; } diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 261e1a4f3..2a2b51471 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -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(); diff --git a/Assets/Scripts/ServerShared/ItemInstance.cs b/Assets/Scripts/ServerShared/ItemInstance.cs index d4e7d9d89..80a891666 100644 --- a/Assets/Scripts/ServerShared/ItemInstance.cs +++ b/Assets/Scripts/ServerShared/ItemInstance.cs @@ -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 diff --git a/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs b/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs index a39226ccc..33d3050c5 100644 --- a/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs +++ b/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs @@ -386,7 +386,7 @@ public class PropertiesPanel : MonoBehaviour private void AddEquippableItemProperties(EquippableItem item, Func 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)); diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index c06ebb216..7fc4d6967 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -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); } }