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/Behaviors/LockWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs index ac067a1ad..ee01e6172 100644 --- a/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/LockWeapon.cs @@ -102,7 +102,7 @@ public class LockWeapon : InstantWeapon { var lerp = 1 - unlerp(0, 90, degrees); _lock = saturate(_lock + pow(lerp, DirectionImpact) * dt * LockSpeed * pow(Entity.EntityInfoGathered[target], SensorImpact)); - target.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Track, Lock); + target.ReceivePaint(Entity, Item, PaintMode.Track, Lock); } else _lock = saturate(_lock - dt * Decay); } diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index f55017fc5..9b83c0330 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -198,7 +198,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer if (sweeping && dist < _pingRadius && _reached.Add(entity) && InBeam(diff, dist)) { heard = true; - entity.ReceivePaint(Entity.Position, Item?.Data, PaintMode.Search, 0); + entity.ReceivePaint(Entity, Item, PaintMode.Search, 0); credit += Gain(entity.Visibility, sensitivity, response, dist, dt, true, obscuration); var pingReturn = new PingReturn(entity, dist, obscuration, Evaluate(_data.PingRange)); foreach (var ally in Entity.Zone.Entities) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index b12eb6c7f..6618bd9bb 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -189,7 +189,22 @@ public abstract class Entity // sweeping this ship (Sensor.Execute); a track paint is a lock (LockWeapon.Execute). Each reports into the // inbox the tick it paints; PublishInbox publishes the inbox once, so a warning lives exactly one tick after // its last paint. - private readonly List _paintInbox = new List(); + // The inbox keeps who painted and with what beside the warning: the warning is all a reader of IncomingPaints + // gets, the emitter and item are for PublishInbox, which records the emitter's reveal. + private readonly struct PendingPaint + { + public readonly PaintWarning Warning; + public readonly Entity Emitter; + public readonly EquippedItem Item; + + public PendingPaint(PaintWarning warning, Entity emitter, EquippedItem item) + { + Warning = warning; + Emitter = emitter; + Item = item; + } + } + private readonly List _paintInbox = new List(); private readonly List _incomingPaints = new List(); public IReadOnlyList IncomingPaints => _incomingPaints; // An ally's sensor posts a return on the tick its ping crosses a subject; published with paints, read by this @@ -197,6 +212,12 @@ public abstract class Entity private readonly List _returnInbox = new List(); private readonly List _pingReturns = new List(); internal IReadOnlyList PingReturns => _pingReturns; + // What this observer has seen each ship's items do (ruling visibility-per-capability-reveal), keyed (subject, + // item). Written only by RecordReveal (an act enters) and EndReveals (an end the observer perceives, or a departed + // subject's entries aged past BearingMemory), cleared by Deactivate (ruling reveal-ends-on-perception). + private readonly Dictionary> _reveals = new Dictionary>(); + private readonly List _endedItems = new List(); + private readonly List _agedSubjects = new List(); public ReactiveProperty CurrentSecurityLevel = new ReactiveProperty(SecurityLevel.Open); public ReadOnlyReactiveProperty PresencePermitted; @@ -395,22 +416,30 @@ public abstract class Entity // Mining Cut 3 (Q14 A): this entity's info on a chunk, computed when asked and stored nowhere. It is the value // the per-tick sensor rule (Sensor.Execute, through Sensor.Gain) would settle at for a target this bright, - // here, now. Each active sensor applies x <- saturate(x + G_i dt)(1 - k dt) every tick, so with n sensors the - // info settles where the gain balances the decay: sum(G_i) / (n k), capped at 1. A ping adds nothing that - // lasts, so only the passive gain enters. + // here, now (PassiveInfo at the chunk's brightness), capped at 1. public float ChunkInfo(ChunkId chunk) { var visibility = Zone.ChunkVisibility(chunk); if (visibility <= 0f) return 0f; - var position = Zone.ChunkPose(chunk.Field, chunk.Index).xy; + return saturate(PassiveInfo(Zone.ChunkPose(chunk.Field, chunk.Index).xy, visibility)); + } + + // The info a target of `signature` at `position` would settle at under this entity's passive sensing, before + // the cap at 1. Each active sensor applies x <- saturate(x + G_i dt)(1 - k dt) every tick, so with n sensors the + // info settles where the gain balances the decay: sum(G_i) / (n k). A ping adds nothing that lasts, so only the + // passive gain enters. ChunkInfo asks it for a chunk's brightness; Sense asks it for a unit signature once per + // ship, because it is linear in the signature below saturation and so answers for every act at a place. Zero + // with no active sensor. + public float PassiveInfo(float2 position, float signature) + { var rate = 0f; var sensors = 0; foreach (var sensor in ActiveSensors()) { - rate += sensor.PassiveRate(visibility, position); + rate += sensor.PassiveRate(signature, position); sensors++; } - return sensors == 0 ? 0f : saturate(rate / (sensors * ItemManager.GameplaySettings.TargetInfoDecay)); + return sensors == 0 ? 0f : rate / (sensors * ItemManager.GameplaySettings.TargetInfoDecay); } // Mining Cut 3: the detection threshold, read through the chunk path. A chunk is visible exactly as an entity @@ -637,10 +666,25 @@ public abstract class Entity return max(gain, 0f) / (sensors * ItemManager.GameplaySettings.TargetInfoDecay * threshold) * 1.0001f; } - // Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables. - private IEnumerable ActiveSensors() => - Equipment.Where(item => item.Active.Value).SelectMany(item => item.Behaviors).OfType() - .Concat(_activeConsumables.SelectMany(effect => effect.Behaviors).OfType()); + // Every sensor behaviour that runs this tick: those on active equipment, and those of active consumables. The + // list is this entity's one buffer, refilled per call (Sense asks once per other ship per tick, allocating + // nothing), so callers use it before asking again. + private readonly List _activeSensors = new List(); + private List ActiveSensors() + { + _activeSensors.Clear(); + for (var i = 0; i < Equipment.Count; i++) + { + var item = Equipment[i]; + if (!item.Active.Value) continue; + foreach (var behavior in item.Behaviors) + if (behavior is Sensor sensor) _activeSensors.Add(sensor); + } + foreach (var effect in _activeConsumables) + foreach (var behavior in effect.Behaviors) + if (behavior is Sensor sensor) _activeSensors.Add(sensor); + return _activeSensors; + } // Cut 12.3 (docs/fire-control-cut.md): "armour absorbs first" -- the per-cell armour phase, split out of // what used to be Absorb's own first half (moved verbatim from DamageSchematic's per-cell body, Cut 3) so @@ -811,6 +855,7 @@ public abstract class Entity _incomingPaints.Clear(); _returnInbox.Clear(); _pingReturns.Clear(); + _reveals.Clear(); } // Zone.Update runs this on every entity before any entity updates, so a paint made during tick N is published @@ -819,7 +864,11 @@ public abstract class Entity internal void PublishInbox() { _incomingPaints.Clear(); - _incomingPaints.AddRange(_paintInbox); + foreach (var paint in _paintInbox) + { + _incomingPaints.Add(paint.Warning); + RecordReveal(paint.Emitter, paint.Item, SignatureChannel.Emission); + } _paintInbox.Clear(); _pingReturns.Clear(); _pingReturns.AddRange(_returnInbox); @@ -833,11 +882,126 @@ public abstract class Entity } // The callers are Sensor.Execute (a Search paint, Strength 0) and LockWeapon.Execute (a Track paint, Strength - // the emitter's own Lock at that paint). - internal void ReceivePaint(float3 emitterPosition, EquippableItemData emitterClass, PaintMode mode, float strength) + // the emitter's own Lock at that paint). The emitter's class is the item's data (null with no item: a + // consumable's effect, which has no item and so is never revealed). + internal void ReceivePaint(Entity emitter, EquippedItem item, PaintMode mode, float strength) { if (!_active) return; - _paintInbox.Add(new PaintWarning(normalize((emitterPosition - Position).xz), emitterClass, mode, strength)); + _paintInbox.Add(new PendingPaint(new PaintWarning(normalize((emitter.Position - Position).xz), item?.Data, mode, strength), emitter, item)); + } + + // 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, 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) || 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) + { + if (!_reveals.TryGetValue(subject, out var book)) return; + if (item != null) book.Remove(item); + else book.Clear(); + if (book.Count == 0) _reveals.Remove(subject); + } + + // What this observer has seen `subject`'s items do: each item's latest act, and nothing the subject's present + // gear or Durability says (ruling contact-reads-book-aim-reads-live). A reveal ends only on what this observer + // perceives (ruling reveal-ends-on-perception): the destruction of an item of a ship it identifies, the death, + // dock or warp of a subject its instruments could see leave, or, for a subject that has left the zone, the + // freshest entry aging past BearingMemory; Deactivate clears it all. Whether an item can be aimed at now is + // FireControl.IsRevealed's question, not this one. + public IEnumerable RevealsOf(Entity subject) => + _reveals.TryGetValue(subject, out var book) ? book.Values : Enumerable.Empty(); + + // The one derivation of how much this observer holds of `subject`: see Contact. + public Contact ContactOf(Entity subject) + { + var settings = ItemManager.GameplaySettings; + var info = EntityInfoGathered.TryGetValue(subject, out var gathered) ? gathered : 0f; + var tracked = VisibleEntities.Contains(subject); + var any = false; + var freshest = default(Reveal); + foreach (var reveal in RevealsOf(subject)) + if (!any || reveal.Time > freshest.Time) + { + any = true; + freshest = reveal; + } + var remembered = any && Zone.Time - freshest.Time < settings.BearingMemory; + var detected = tracked || remembered; + var bearing = tracked ? normalize((subject.Position - Position).xz) : remembered ? freshest.Bearing : default; + return new Contact(detected, tracked, detected && any, info >= settings.TargetGearInfoThreshold, bearing, info); + } + + // What this observer's instruments receive of other ships' acts this tick (Zone.Update calls it once, after every + // entity has updated): every light signature of an item that would alone settle this observer's passive info above + // TargetDetectionInfoThreshold is revealed (ruling visibility-per-capability-reveal). Hull radiation names no + // item and only feeds the track; an Emission entry is never light. One PassiveInfo per ship. Zone.Update calls it + // for every entity after all of them have updated, so what a ship shows is what every observer receives on that + // tick whatever order the zone lists them in. + internal void Sense() + { + if (!_active) return; + var threshold = ItemManager.GameplaySettings.TargetDetectionInfoThreshold; + var entities = Zone.Entities; + for (var i = 0; i < entities.Count; i++) + { + var other = entities[i]; + if (other == this) continue; + var perSignature = -1f; + foreach (var signature in other._visibilitySources.Values) + { + if (signature.Item == null || signature.Channel == SignatureChannel.Emission) continue; + 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 (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)) + { + foreach (var item in book.Keys) + 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 + // bright as its non-Emission signature was at removal, from where it was. A re-admitted subject did not leave. + var departures = Zone.Departures; + for (var i = 0; i < departures.Count; i++) + { + var departure = departures[i]; + if (!_reveals.ContainsKey(departure.Subject) || entities.Contains(departure.Subject)) continue; + if (departure.Signature * PassiveInfo(departure.Position, 1f) > threshold) EndReveals(departure.Subject, null); + } + // 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; + foreach (var pair in _reveals) + { + if (entities.Contains(pair.Key)) continue; + var freshest = float.MinValue; + foreach (var reveal in pair.Value.Values) freshest = Math.Max(freshest, reveal.Time); + 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) @@ -854,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)) @@ -2035,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/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index bed29a429..129513dda 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -73,3 +73,63 @@ public readonly struct PaintWarning Strength = strength; } } + +// What an observer has seen `Item` do (ruling visibility-per-capability-reveal): the channel it showed on, the +// bearing at that act and the zone clock then. Held per (subject, item) in the observer's Entity; the latest act +// replaces the earlier one. Position is never carried: only the track gives it. +public readonly struct Reveal +{ + public readonly EquippedItem Item; + public readonly SignatureChannel Channel; + public readonly float2 Bearing; + public readonly float Time; + + public Reveal(EquippedItem item, SignatureChannel channel, float2 bearing, float time) + { + Item = item; + Channel = channel; + Bearing = bearing; + Time = time; + } +} + +// A subject's removal from a zone as the zone recorded it (death, dock, warp, any removal): where it was and how +// bright its light was (its signature outside the Emission channel). Judged by each observer's Sense under the +// light-act gate; notification only, one tick long, it decides nothing. +public readonly struct Departure +{ + public readonly Entity Subject; + public readonly float2 Position; + public readonly float Signature; + + public Departure(Entity subject, float2 position, float signature) + { + Subject = subject; + Position = position; + Signature = signature; + } +} + +// How much an observer holds of one subject, derived on read by Entity.ContactOf from its track, its reveals and +// the zone clock. Detected: tracked, or a reveal younger than GameplaySettings.BearingMemory. Tracked: exactly +// membership of VisibleEntities. Classified: detected, with any reveal in the book. Identified: the track has reached +// TargetGearInfoThreshold. Bearing is the true bearing while tracked, else the freshest reveal's. +public readonly struct Contact +{ + public readonly bool Detected; + public readonly bool Tracked; + public readonly bool Classified; + public readonly bool Identified; + public readonly float2 Bearing; + public readonly float Info; + + public Contact(bool detected, bool tracked, bool classified, bool identified, float2 bearing, float info) + { + Detected = detected; + Tracked = tracked; + Classified = classified; + Identified = identified; + Bearing = bearing; + Info = info; + } +} diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 9c4bd459f..a983b97f3 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -213,9 +213,12 @@ public class GameplaySettings public ExponentialLerp LockSpinSpeed; public float TorqueMultiplier; public float VisibilityDecay; + // Seconds a reveal's bearing keeps a ship Detected after its last act with no track (Entity.ContactOf). + public float BearingMemory = 6; public float TargetInfoDecay; public float TargetDetectionInfoThreshold; public float TargetArmorInfoThreshold; + // Also the Identified threshold: the track level at which a ship's hull class, and with it its layout, is known. public float TargetGearInfoThreshold; public float ConvergenceMinimumDistance; public float FiringArc = 120; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 4aa2063cc..97554a9ad 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -72,6 +72,9 @@ public class Zone return false; } + private readonly List _departures = new List(); + internal IReadOnlyList Departures => _departures; + public float Time { get => (float) _time; @@ -109,6 +112,9 @@ public class Zone // every join, whichever caller admits the entity (deserialization below, a jump, a spawned turret) -- // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); })); + // The one record of a removal for observers (ruling reveal-ends-on-perception): read by the next Sense pass. + Entities.ObserveRemove().Subscribe(remove => _departures.Add(new Departure(remove.Value, remove.Value.Position.xz, + remove.Value.Visibility - remove.Value.SignatureOn(SignatureChannel.Emission)))); Targets.Add(new EntityTargets()); @@ -229,6 +235,8 @@ public class Zone var entities = Entities.ToArray(); foreach (var entity in entities) entity.PublishInbox(); foreach (var entity in entities) entity.Update(deltaTime); + foreach (var entity in entities) entity.Sense(); + _departures.Clear(); StepMines(deltaTime); StepClouds(deltaTime); 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 new file mode 100644 index 000000000..a1aabcfd6 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -0,0 +1,1198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Visibility cut 3 (rulings visibility-per-capability-reveal, quiet-gear-identifiable-hull-layout-known, +// vapour-cloud-obscuration, ally-echo-bistatic-oval): an observer records what each ship's items have shown it, item +// by item with a bearing, and derives the contact's level from its track and those reveals. Every fixture drives +// the production path: real ships with real guns, thrusters, radiators and launchers, ticking inside Zone.Update. +// The numbers a rule is a function of are authored here and the expectations are their arithmetic. The observer's +// one passive sensor has sensitivity 2 and a flat curve, so a signature of strength S at planar distance d settles +// its info at S x 2 / (d x TargetInfoDecay), and is revealed exactly when that exceeds TargetDetectionInfoThreshold +// (.1). Signatures decay at once (VisibilityDecay 100), so an act is a single tick's light and builds no track. +// The observer senses after every ship has updated, so the zone's list order never decides when an act is revealed. +public sealed class ContactRevealTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-contact-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private CultCache _openCache; + private ItemManager _items; + private Zone _zone; + private CultRecordRef _maker; + private int _nextLot = 1000; + + public ContactRevealTests() + { + Directory.CreateDirectory(_root); + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" }); + var solid = new Shape(5, 5); + foreach (var cell in solid.AllCoordinates) solid[cell] = true; + cache.Upsert(new HullData + { + Name = "Rack", HullType = HullType.Ship, Shape = solid, Durability = 1000000, Mass = 1000, + Hardpoints = + { + new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() }, + new HardpointData { Type = HardpointType.Sensors, Position = new int2(1, 0), Shape = new Shape() }, + new HardpointData { Type = HardpointType.Sensors, Position = new int2(2, 0), Shape = new Shape() } + } + }); + cache.Upsert(Eye("Eye", sensitivity: 2f)); + cache.Upsert(Eye("Ear", sensitivity: 4f)); + cache.Upsert(Tool("Reactor", new ReactorData { Charge = Const(1000), Efficiency = Const(1), OverloadEfficiency = Const(1), ThrottlingFactor = Const(2) })); + cache.Upsert(Tool("Targeting", new TargetingSystemData { Accuracy = Const(1), Resolution = Const(1000f), Precision = Const(1000f), Tracking = Const(1000000f) })); + cache.Upsert(Tool("Thruster", new ThrusterData { Thrust = Const(100), Visibility = Const(10), Heat = Const(0), EnergyUsage = Const(10) })); + cache.Upsert(Tool("Radiator", new RadiatorData + { + Emissivity = Const(3), PumpedHeat = Const(0), TemperatureFloor = 0, WasteHeat = Const(0), EnergyUsage = Const(0), ThermalMass = Const(1000) + })); + var gun = new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(1) }; + gun.Damage = Const(100); + gun.Range = Const(40); + gun.MinRange = Const(0); + gun.Velocity = Const(0); + gun.Spread = Const(0); + gun.DamageSpread = Const(0); + gun.Penetration = Const(0); + gun.Energy = Const(50); + gun.Heat = Const(0); + gun.Visibility = Const(5); + gun.MagazineSize = 5; + gun.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; + cache.Upsert(new WeaponItemData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = WeaponFuse.Proximity, BlastRadius = 30f, + Behaviors = { gun } + }); + cache.Upsert(new GearData + { + Name = "Locker", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = + { + new LauncherData + { + Damage = Const(0), Range = Const(1000), MinRange = Const(0), Velocity = Const(0), + Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1), + BurstTime = Const(0), Cooldown = Const(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, + LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), DirectionImpact = Const(1), Decay = Const(.1f) + } + } + }); + cache.Upsert(new ConsumableItemData + { + Name = "Pinger", Duration = 30f, Shape = new Shape(), + Behaviors = { SensorBehavior(sensitivity: 2f) } + }); + cache.FlushAsync().Wait(); + } + + _openCache = AetheriaStores.Open(Catalog, catalogWritable: true); + var ledger = new ProvenanceLedger(); + for (var lot = 1000; lot < 2000; lot++) ledger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 }; + _items = new ItemManager(_openCache, ledger, new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + TargetInfoDecay = .5f, + VisibilityDecay = 100f, + BearingMemory = 2f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 2f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 1000f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f, + HeatRadiationMultiplier = 0f, // a hull radiates nothing unless a test says so + HeatRadiationExponent = 1f + }, _ => { }); + _maker = _openCache.RefOf(_openCache.GetByName("Maker")); + _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + } + + public void Dispose() + { + _openCache?.Dispose(); + Directory.Delete(_root, true); + } + + private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v }; + + private static SensorData SensorBehavior(float sensitivity) => new SensorData + { + Sensitivity = Const(sensitivity), + SensitivityCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, // the same gain at every bearing + PingBoost = Const(.0001f), PingEnergy = Const(0), PingVisibility = Const(5), PingRange = Const(300), PingCooldown = Const(4) + }; + + private static GearData Eye(string name, float sensitivity) => new GearData + { + Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { SensorBehavior(sensitivity) } + }; + + private static GearData Tool(string name, BehaviorData behavior) => new GearData + { + Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { behavior } + }; + + private EquippableItem Make(string name) => new EquippableItem + { + Data = _items.ItemData.RefOf(_items.ItemData.GetByName(name)), Durability = 10, Lot = _nextLot++ + }; + + // A ship at `position` with `mounts` on its sensor hardpoints in order, the tools on free cells and a thruster + // on the centre column. A ship with a gun also gets the reactor and targeting a gun needs to fire. + private Ship Add(float3 position, string[] mounts = null, string[] tools = null, bool thruster = false) + { + var hullRef = _items.ItemData.RefOf(_items.ItemData.GetByName("Rack")); + var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = _nextLot++ }, new EntitySettings()) + { Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } }; + var cell = 0; + foreach (var mount in mounts ?? new string[0]) Assert.True(ship.TryEquip(Make(mount), new int2(cell++, 0))); + if (thruster) Assert.True(ship.TryEquip(Make("Thruster"), new int2(2, 1))); + foreach (var name in (tools ?? new string[0]).Concat(mounts != null && mounts.Contains("Gun") ? new[] { "Targeting", "Reactor" } : new string[0])) + { + var item = Make(name); + Assert.True(ship.TryFindSpace(item, out var at)); + Assert.True(ship.TryEquip(item, at)); + } + _zone.Entities.Add(ship); + ship.Activate(); + ship.Position = position; + ship.Aim = float3(0, 0, 1); + _zone.Update(0f); // brings the gear online + return ship; + } + + private void Tick(float dt) => _zone.Update(dt); + + private static EquippedItem[] Guns(Entity e) => e.Equipment.Where(i => i.Behaviors.Any(b => b is InstantWeapon && !(b is LockWeapon))).ToArray(); + + // Fires the given gun and ticks until its light shows; returns the zone clock of that tick. + private float Fire(Ship ship, EquippedItem item) + { + var gun = (InstantWeapon) item.Behaviors.Single(b => b is InstantWeapon); + var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun); + capacitor.AddCharge(1000f); + ship.SetTarget(TargetRef.None); + gun.Activate(); + for (var i = 0; i < 10; i++) + { + Tick(.1f); + if (ship.VisibilitySources.ContainsKey(gun)) return _zone.Time; + } + throw new Exception("fixture: the gun fires"); + } + + private static float Track(Entity observer, Entity subject) => + observer.EntityInfoGathered.TryGetValue(subject, out var info) ? info : 0f; + + private static void AssertBearing(float x, float y, float2 bearing) + { + Assert.Equal(x, bearing.x, 4); + Assert.Equal(y, bearing.y, 4); + } + + private static VapourCloud Hand(Zone zone, float2 at, float radius, float opacity) + { + var cloud = new VapourCloud { Body = new KinematicBody { Position = at }, Radius = radius, Opacity = opacity, Lifetime = 1000f }; + zone.Vent(cloud); + return cloud; + } + + // ---- AFiredGunIsRevealedWithItsBearing ---- + + [Fact] + public void AFiredGunIsRevealedWithItsBearing() + { + var e = Add(float3(30, 0, 40), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Assert.Empty(o.RevealsOf(e)); // nothing shown yet + + var at = Fire(e, guns[0]); + + var reveal = Assert.Single(o.RevealsOf(e)); + Assert.Same(guns[0], reveal.Item); + Assert.Equal(SignatureChannel.LaunchFlash, reveal.Channel); + AssertBearing(.6f, .8f, reveal.Bearing); // normalize((E - O).xz) + Assert.Equal(at, reveal.Time); + Assert.DoesNotContain(o.RevealsOf(e), r => r.Item == guns[1]); // the unfired second gun + Assert.Empty(e.RevealsOf(o)); // a ship is not told what it shows + } + + // ---- AnActAboveTheReachRevealsNothing ---- + + // Info settles at S x 2 / (d k): with S = 5 the act is revealed inside d = 100 / k and not beyond it, whatever k is. + [Theory] + [InlineData(.5f, 190f, true)] + [InlineData(.5f, 210f, false)] + [InlineData(.25f, 390f, true)] + [InlineData(.25f, 410f, false)] + public void AnActAboveTheReachRevealsNothing(float infoDecay, float distance, bool revealed) + { + _items.GameplaySettings.TargetInfoDecay = infoDecay; + var e = Add(float3(0, 0, distance), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + + Fire(e, Guns(e)[0]); + + Assert.Equal(revealed ? 1 : 0, o.RevealsOf(e).Count()); + } + + // ---- AFogBankHidesTheAct ---- + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AFogBankHidesTheAct(bool fog) + { + var e = Add(float3(0, 0, 150), new[] { "Gun" }); // 5 x 2 / (150 x .5) = .133 clear + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + if (fog) Hand(_zone, float2(0, 75), 5f, .6f); // a bank on the sight line passes .4 of the light: .053 + + Fire(e, Guns(e)[0]); + + Assert.Equal(fog ? 0 : 1, o.RevealsOf(e).Count()); + } + + // ---- ABurnRevealsTheDriveAndOpenRadiatorsRevealThemselves ---- + + [Fact] + public void ABurnRevealsTheDriveAndOpenRadiatorsRevealThemselves() + { + // The hull radiates .25 a border cell at any temperature; the radiator adds .25 x 3. + _items.GameplaySettings.HeatRadiationMultiplier = .25f; + _items.GameplaySettings.HeatRadiationExponent = 0f; + var burner = Add(float3(0, 0, 100), tools: new[] { "Reactor", "Targeting" }, thruster: true); // 10 x 4 / 100 = .4 + var glow = Add(float3(20, 0, 0), tools: new[] { "Reactor", "Radiator" }); // .75 x 4 / 20 = .15 + var bare = Add(float3(0, 0, -10)); // the hull alone, brighter than any act + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var thrusterItem = burner.Equipment.Single(i => i.Behaviors.Any(b => b is Thruster)); + thrusterItem.UpdatePerformance(); + + burner.MovementDirection = float2(0, -1); + Tick(.1f); + + var plume = Assert.Single(o.RevealsOf(burner)); + Assert.Same(thrusterItem, plume.Item); + Assert.Equal(SignatureChannel.DrivePlume, plume.Channel); + var radiator = Assert.Single(o.RevealsOf(glow)); + Assert.Same(glow.Equipment.Single(i => i.Behaviors.Any(b => b is Radiator)), radiator.Item); + Assert.Equal(SignatureChannel.Thermal, radiator.Channel); + Assert.True(bare.Visibility > 1f, "fixture: the bare hull is lit"); + Assert.Empty(o.RevealsOf(bare)); + } + + // ---- APaintRevealsTheEmitterEvenUnseen ---- + + [Fact] + public void APaintRevealsTheEmitterEvenUnseen() + { + var locker = Add(float3(0, 0, 200), new[] { "Locker" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + locker.SetIff(o, true); + locker.EntityInfoGathered[o] = 1f; + locker.SetTarget(o); + locker.Aim = normalize(o.Position - locker.Position); + + Tick(.5f); + Tick(.5f); + + Assert.Single(o.IncomingPaints); // fixture: the lock painted it + Assert.Equal(0f, Track(o, locker)); // unseen + var reveal = Assert.Single(o.RevealsOf(locker)); + Assert.Same(locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), reveal.Item); + Assert.Equal(SignatureChannel.Emission, reveal.Channel); + AssertBearing(0f, 1f, reveal.Bearing); + Assert.False(o.ContactOf(locker).Tracked); + } + + [Fact] + public void AConsumableBorneEmitterRecordsNothing() + { + var p = Add(float3(0, 0, 100)); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var data = _openCache.GetByName("Pinger"); + p.ActivateConsumable(Assert.IsType(_items.CreateInstance(_items.CreateLot(data, _maker, .5f)))); + var sensor = (Sensor) p.FindActiveConsumable(data).Behaviors.Single(b => b is Sensor); + Tick(.01f); + sensor.Ping(); + + var paints = 0; + for (var tick = 0; tick < 30; tick++) + { + Tick(.1f); + paints += o.IncomingPaints.Count; + } + + Assert.Equal(1, paints); // fixture: the sweep painted the observer + Assert.Empty(o.RevealsOf(p)); + } + + // Ruling consumable-signature-null-until-reveal: a consumable-hosted signature names no item (Item null, as + // hull radiation does), so however bright and near it reveals nothing; the reveal cut leaves what it names to + // follow-up reveal-names-consumable-signature. + [Fact] + public void ANullItemSignatureFromASourceThatIsNotTheHullRevealsNothing() + { + var e = Add(float3(0, 0, 20)); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var data = _openCache.GetByName("Pinger"); + e.ActivateConsumable(Assert.IsType(_items.CreateInstance(_items.CreateLot(data, _maker, .5f)))); + var source = e.FindActiveConsumable(data).Behaviors.Single(); + e.SetSignature(source, SignatureChannel.Reflection, 1000f, null); + + Tick(.01f); + + Assert.True(e.VisibilitySources.ContainsKey(source), "fixture: the consumable's light is on"); + Assert.Empty(o.RevealsOf(e)); + } + + // An Emission entry is never light (R1): a bright, near one reveals nothing through Sense. Emission reaches the + // book only through a published paint. + [Fact] + public void AnEmissionEntryIsNotLight() + { + var e = Add(float3(0, 0, 20), new[] { "Eye" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + e.SetSignature(new object(), SignatureChannel.Emission, 1000f, e.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor))); + + Tick(.01f); + + Assert.Empty(o.RevealsOf(e)); + } + + // Two items revealed at different times: the contact's bearing, while untracked, is the freshest reveal's. + [Fact] + public void TheFreshestRevealGivesTheBearing() + { + var e = Add(float3(30, 0, 40), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Fire(e, guns[0]); + e.Position = float3(-50, 0, 0); + Fire(e, guns[1]); + + var contact = o.ContactOf(e); + + Assert.Equal(2, o.RevealsOf(e).Count()); + Assert.False(contact.Tracked); + AssertBearing(-1f, 0f, contact.Bearing); + } + + // ---- ALaunchGivesABearingNotATrack ---- + + [Fact] + public void AnActGivesABearingNotATrack() + { + var e = Add(float3(0, 0, 150), new[] { "Gun" }); // 5 x 2 / (150 x .5) = .133: revealed, but one tick of light builds no track + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var gun = Guns(e)[0]; + var at = Fire(e, gun); + e.Position = float3(150, 0, 0); // it slips away: the reveal keeps the bearing of the act + + var seen = o.ContactOf(e); + Assert.True(Track(o, e) < .1f, "fixture: no track"); + Assert.True(seen.Detected); + Assert.True(seen.Classified); + Assert.False(seen.Tracked); + Assert.False(seen.Identified); + AssertBearing(0f, 1f, seen.Bearing); + + // Younger than BearingMemory (2 s): still Detected. Older: not, and the gun is still known. + while (_zone.Time - at < 1.5f) Tick(.1f); + Assert.True(o.ContactOf(e).Detected); + while (_zone.Time - at < 2.5f) Tick(.1f); + var later = o.ContactOf(e); + Assert.False(later.Detected); + Assert.False(later.Classified); + Assert.False(later.Tracked); + Assert.Same(gun, Assert.Single(o.RevealsOf(e)).Item); + } + + [Theory] + [InlineData(1f)] + [InlineData(5f)] + public void TheBearingMemoryIsAGameplaySetting(float memory) + { + _items.GameplaySettings.BearingMemory = memory; + var e = Add(float3(0, 0, 150), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var at = Fire(e, Guns(e)[0]); + + while (_zone.Time - at < memory - .3f) Tick(.1f); + Assert.True(o.ContactOf(e).Detected); + while (_zone.Time - at < memory + .3f) Tick(.1f); + Assert.False(o.ContactOf(e).Detected); + } + + // ---- ContactLevels ---- + + // E fires from (30, 40) and moves to (-50, 0). Tracked is exactly membership of VisibleEntities, Identified + // exactly a track at or above TargetGearInfoThreshold; the bearing is the true one while tracked and the act's + // while the ship is only remembered. + [Theory] + [InlineData(.05f, false, false)] + [InlineData(.1f, false, false)] + [InlineData(.5f, true, false)] + [InlineData(.79f, true, false)] + [InlineData(.8f, true, true)] + [InlineData(1f, true, true)] + public void ContactLevels(float info, bool tracked, bool identified) + { + var e = Add(float3(30, 0, 40), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + Fire(e, Guns(e)[0]); + e.Position = float3(-50, 0, 0); + + o.EntityInfoGathered[e] = info; + + var contact = o.ContactOf(e); + Assert.Equal(o.VisibleEntities.Contains(e), contact.Tracked); + Assert.Equal(tracked, contact.Tracked); + Assert.Equal(identified, contact.Identified); + Assert.True(contact.Detected); + Assert.True(contact.Classified); + Assert.Equal(info, contact.Info); + if (tracked) AssertBearing(-1f, 0f, contact.Bearing); + else AssertBearing(.6f, .8f, contact.Bearing); + } + + [Fact] + public void AShipNothingHasShownIsNotDetected() + { + var e = Add(float3(0, 0, 150)); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + + var contact = o.ContactOf(e); + + Assert.False(contact.Detected); + Assert.False(contact.Tracked); + Assert.False(contact.Classified); + Assert.False(contact.Identified); + } + + // ---- A subject leaves ---- + + private Action SenseOf(Entity observer) => + (Action) Delegate.CreateDelegate(typeof(Action), observer, typeof(Entity).GetMethod("Sense", BindingFlags.Instance | BindingFlags.NonPublic)); + + // The ways a ship leaves a zone: removed by anything, the warp path (Deactivate, then Remove) and death. + private static void Leave(Zone zone, Entity e, string how) + { + switch (how) + { + case "remove": zone.Entities.Remove(e); break; + case "warp": e.Deactivate(); zone.Entities.Remove(e); break; + case "death": e.Hull.Durability = 0f; e.HullDamage.OnNext(1f); break; + default: throw new Exception("fixture: " + how); + } + Assert.DoesNotContain(e, zone.Entities); + } + + // Leaves the ship showing exactly `strength` of light: whatever its guns left glowing is put out first. + private static void ShowOnly(Entity e, float strength, EquippedItem item) + { + foreach (var source in e.VisibilitySources.Keys.ToArray()) e.SetSignature(source, SignatureChannel.Reflection, 0f, item); + e.SetSignature(new object(), SignatureChannel.Reflection, strength, item); + Assert.Equal(strength, e.Visibility, 4); + } + + private static float Freshest(Entity observer, Entity subject) => observer.RevealsOf(subject).Max(r => r.Time); + + // Ruling reveal-ends-on-perception: a ship leaving the zone does not by itself empty any book. Out of this + // observer's sight (the light it showed at removal is below the observer's gate) its reveals stay, unrefreshed, + // and the contact stays Detected on the freshest bearing, exactly until that bearing is BearingMemory old; then + // the book drops the ship. A live quiet ship's old reveal is another matter: it stays in the book. + [Fact] + public void AnUnseenDepartureKeepsTheContactUntilTheBearingLapses() + { + var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var q = Add(float3(40, 0, 100), new[] { "Gun" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + Fire(q, Guns(q)[0]); + var memory = _items.GameplaySettings.BearingMemory; + var times = o.RevealsOf(e).ToDictionary(r => r.Item, r => r.Time); + var freshest = times.Values.Max(); + var bearing = o.ContactOf(e).Bearing; + ShowOnly(e, 1f, guns[0]); + Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its last light is below o's gate"); + Assert.Equal(2, times.Count); + + _zone.Entities.Remove(e); + Assert.Equal(2, o.RevealsOf(e).Count()); + + var lapsed = false; + var ticks = 0; + while (!lapsed && ticks++ < 100) + { + Tick(.1f); + var within = _zone.Time - freshest < memory; + lapsed = !within; + Assert.Equal(within, o.RevealsOf(e).Count() == 2); // exactly while the bearing is remembered + Assert.Equal(within, o.ContactOf(e).Detected); + Assert.Equal(within ? 1 : 0, RevealBookSubjects(o) - 1); // the quiet live ship's key stays either way + if (!within) continue; + var contact = o.ContactOf(e); + Assert.True(contact.Classified && !contact.Tracked); + AssertBearing(bearing.x, bearing.y, contact.Bearing); + foreach (var reveal in o.RevealsOf(e)) Assert.Equal(times[reveal.Item], reveal.Time); // never refreshed + } + + Assert.True(lapsed, "fixture: the bearing lapsed"); + Assert.Empty(o.RevealsOf(e)); + Assert.False(o.ContactOf(e).Detected); + Assert.Equal(1, RevealBookSubjects(o)); // only the live ship's key + + for (var i = 0; i < 30; i++) Tick(.2f); // q, live and quiet, was revealed 6 s ago, far more than twice the memory + Assert.Single(o.RevealsOf(q)); + Assert.False(o.ContactOf(q).Detected); + } + + // The same departure seen: every way a ship leaves is one departure fact, judged when the observer next senses. + // A strong ear (sensitivity 4) and a weak eye (2) at the same place, listed on either side of the ship, both hold + // both reveals right after the removal; after the next tick only the observer whose gate the ship's light at + // removal passes (signature 2 x info 8/d = .16 against .08) has let them go. + [Theory] + [InlineData("remove", false)] + [InlineData("remove", true)] + [InlineData("warp", false)] + [InlineData("death", false)] + [InlineData("death", true)] + public void ASeenDepartureEndsEveryRevealOfTheSubject(string how, bool strongFirst) + { + Entity strong = null, weak = null; + if (strongFirst) strong = Add(float3(-1, 0, 0), new[] { "Ear" }); + else weak = Add(float3(1, 0, 0), new[] { "Eye" }); + var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); + if (strongFirst) weak = Add(float3(1, 0, 0), new[] { "Eye" }); + else strong = Add(float3(-1, 0, 0), new[] { "Ear" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + foreach (var observer in new[] { strong, weak }) Assert.Equal(2, observer.RevealsOf(e).Count()); + ShowOnly(e, 2f, guns[0]); + var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; + Assert.True(e.Visibility * strong.PassiveInfo(e.Position.xz, 1f) > threshold, "fixture: the ear passes the gate"); + Assert.True(e.Visibility * weak.PassiveInfo(e.Position.xz, 1f) <= threshold, "fixture: the eye fails it"); + + Leave(_zone, e, how); + + Assert.Equal(2, strong.RevealsOf(e).Count()); // nothing ends at the removal itself + Assert.Equal(2, weak.RevealsOf(e).Count()); + Tick(.1f); + + Assert.Empty(strong.RevealsOf(e)); + Assert.Equal(0, RevealBookSubjects(strong)); + Assert.False(strong.ContactOf(e).Detected); + Assert.Equal(2, weak.RevealsOf(e).Count()); + Assert.True(weak.ContactOf(e).Detected); + } + + // The gate at its edge, at one fixed distance: the light at removal a hair above the observer's threshold ends + // the reveals, a hair below does not. The factor is the production gate's own: threshold over the observer's + // passive info on the spot where the ship was. + [Theory] + [InlineData(1.01f, true)] + [InlineData(.99f, false)] + public void ADepartureEndsRevealsOnlyAboveTheLightActGate(float against, bool ended) + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + Fire(e, Guns(e)[0]); + Tick(.01f); + Assert.Single(o.RevealsOf(e)); + var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f); + ShowOnly(e, gate * against, Guns(e)[0]); + + _zone.Entities.Remove(e); + Tick(.1f); + + Assert.Equal(ended ? 0 : 1, o.RevealsOf(e).Count()); + } + + // The gate is strict, as the light-act gate is: a light at removal that lands exactly on the threshold ends nothing. + // At 32 the observer's info per unit light is 4 / 32, a power of two, so threshold x 8 is the light whose product + // with it is exactly the threshold. + [Fact] + public void ADepartureExactlyAtTheLightActGateEndsNothing() + { + var e = Add(float3(0, 0, 32), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + Fire(e, Guns(e)[0]); + Tick(.01f); + Assert.Single(o.RevealsOf(e)); + var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; + Assert.Equal(.125f, o.PassiveInfo(e.Position.xz, 1f)); + ShowOnly(e, threshold * 8f, Guns(e)[0]); + Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) == threshold, "fixture: the ship's light at removal is exactly the gate"); + + _zone.Entities.Remove(e); + Tick(.1f); + + Assert.Single(o.RevealsOf(e)); + } + + // A signature on the Emission channel (paint strength, a ping) is not light: it does not make a departure seen. + [Fact] + public void AnEmissionSignatureDoesNotMakeADepartureSeen() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + Fire(e, Guns(e)[0]); + Tick(.01f); + ShowOnly(e, 0f, Guns(e)[0]); + e.SetSignature(new object(), SignatureChannel.Emission, 1000f, Guns(e)[0]); + + _zone.Entities.Remove(e); + Tick(.1f); + + Assert.Single(o.RevealsOf(e)); + } + + // A ship removed and put back before anyone senses has not left: its reveals stand, and its next act refreshes them. + [Fact] + public void AReAdmittedSubjectHasNotDeparted() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var gun = Guns(e)[0]; + var at = Fire(e, gun); + Tick(1f); // the gun cools down + ShowOnly(e, 1000f, gun); // bright enough for any gate + + _zone.Entities.Remove(e); + _zone.Entities.Add(e); + Tick(.1f); + + Assert.Equal(at, Assert.Single(o.RevealsOf(e)).Time); + var later = Fire(e, gun); + Assert.True(later > at); + Assert.Equal(later, Assert.Single(o.RevealsOf(e)).Time); + } + + private static int RevealBookSubjects(Entity observer) => + ((System.Collections.IDictionary) typeof(Entity).GetField("_reveals", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(observer)).Count; + + // Ruling contact-reads-book-aim-reads-live: what an observer knows of a ship comes from its own book. A gun + // destroyed by a third party out of its sight changes none of its levels and stays in its reveal list, while + // the aiming read, which asks about the item now, refuses the dead gun and still serves the live one. No Sense + // runs after the destruction here (the aiming read needs info 1, at which a Sense would perceive it); the + // unseen case with a Sense is AnIdentifiedObserverSeesGearDestroyed's below-threshold case. + [Fact] + public void GearDestroyedOutOfSightLeavesWhatTheObserverKnowsAlone() + { + var e = Add(float3(0, 0, 150), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + var before = o.ContactOf(e); + Assert.True(before.Detected && before.Classified && !before.Tracked, "fixture: a bearing and a class, no track"); + o.EntityInfoGathered[e] = 1f; // the aiming read needs info to resolve the target's gear + Assert.True(FireControl.IsRevealed(o, guns[0]) && FireControl.IsRevealed(o, guns[1]), "fixture: both guns are aimable"); + + guns[0].EquippableItem.Durability = 0f; // destroyed by someone else; o saw nothing + + Assert.Equal(2, o.RevealsOf(e).Count()); + var after = o.ContactOf(e); + Assert.True(after.Detected); + Assert.True(after.Classified); + Assert.Equal(before.Bearing.x, after.Bearing.x); + Assert.Equal(before.Bearing.y, after.Bearing.y); + Assert.False(FireControl.IsRevealed(o, guns[0])); + Assert.True(FireControl.IsRevealed(o, guns[1])); + + guns[1].EquippableItem.Durability = 0f; // the last of its gear dies unseen too + + Assert.Equal(2, o.RevealsOf(e).Count()); + Assert.True(o.ContactOf(e).Detected); + Assert.True(o.ContactOf(e).Classified); + } + + // Ruling reveal-ends-on-perception: an observer that resolves a ship's gear (its track at TargetGearInfoThreshold) + // perceives an item of it destroyed, and the reveal of that item ends in the same Sense pass. One that does not + // resolve it perceives nothing. + [Theory] + [InlineData(.8f, 1, 1, 1)] + [InlineData(.8f, 2, 0, 0)] + [InlineData(.79f, 1, 2, 1)] + [InlineData(.79f, 2, 2, 1)] + public void AnIdentifiedObserverSeesGearDestroyed(float info, int destroyed, int remaining, int classifiedKeys) + { + 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); // tracked, so what the track holds is not the question + o.EntityInfoGathered[e] = info; + var sense = SenseOf(o); + for (var i = 0; i < destroyed; i++) guns[i].EquippableItem.Durability = 0f; + + sense(); + + Assert.Equal(remaining, o.RevealsOf(e).Count()); + var contact = o.ContactOf(e); + Assert.True(contact.Detected); + Assert.Equal(remaining > 0, contact.Classified); + Assert.Equal(remaining > 0 ? classifiedKeys : 0, RevealBookSubjects(o)); + if (remaining == 1 && info >= .8f) Assert.Same(guns[1], o.RevealsOf(e).Single().Item); + } + + // The same through a real tick: a gun destroyed while its flash still shows, seen by an observer that identifies + // the ship, is not in the book after the tick. + [Fact] + public void ADestroyedGunStillFlashingIsNotRecordedByAnIdentifyingObserver() + { + var e = Add(float3(0, 0, 60), new[] { "Gun", "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + e.SetSignature(new object(), SignatureChannel.Reflection, 1000f, guns[0]); // the dying gun's glare outlasts the tick + guns[0].EquippableItem.Durability = 0f; + o.EntityInfoGathered[e] = 1f; // o's sensor keeps it at about 1 across the tick (decay .5 x dt) + + Tick(.01f); + + Assert.True(Track(o, e) >= .8f, "fixture: o still identifies e"); + + Assert.DoesNotContain(o.RevealsOf(e), r => r.Item == guns[0]); + Assert.Contains(o.RevealsOf(e), r => r.Item == guns[1]); + } + + // A paint inboxed in the tick its emitter leaves the zone is published the next tick, and is no longer the act of + // a subject in the zone: it warns for its tick and neither creates nor refreshes a reveal. The reveal the + // locker's earlier paint made stays what it was, as a departure out of sight (below the observer's gate) leaves it. + [Fact] + public void APaintFromAShipThatHasLeftTheZoneRevealsNothing() + { + var locker = Add(float3(0, 0, 200), new[] { "Locker" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + locker.SetIff(o, true); + locker.EntityInfoGathered[o] = 1f; + locker.SetTarget(o); + locker.Aim = normalize(o.Position - locker.Position); + for (var tick = 0; tick < 10 && !o.RevealsOf(locker).Any(); tick++) Tick(.5f); + var first = Assert.Single(o.RevealsOf(locker)); // fixture: the lock paints every tick, and the first one is recorded + Assert.True(locker.Visibility * o.PassiveInfo(locker.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its departure is below o's gate"); + + _zone.Entities.Remove(locker); // dies, warps or docks while its latest paint is still in o's inbox + var inboxed = ((System.Collections.IList) typeof(Entity).GetField("_paintInbox", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(o)).Count; + Assert.True(inboxed > 0 || o.IncomingPaints.Count > 0, "fixture: a paint is in flight"); + Tick(.1f); + + Assert.Single(o.IncomingPaints); // fixture: the stale paint was published + var after = Assert.Single(o.RevealsOf(locker)); + Assert.Equal(first.Time, after.Time); + Assert.Equal(1, RevealBookSubjects(o)); + } + + // Ruling sensor-stat-set 4: a painted target always learns the emitter's class, whichever the mode. A ping + // crossing the observer (a Search paint) reveals the pinger's sensor item, with channel Emission and the + // bearing of the pinger. + [Fact] + public void ASearchPaintRevealsThePingersSensor() + { + var p = Add(float3(0, 0, 100), new[] { "Eye" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var item = p.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)); + var sensor = (Sensor) item.Behaviors.Single(b => b is Sensor); + Tick(.01f); + sensor.Ping(); + + var modes = new List(); + for (var tick = 0; tick < 30; tick++) + { + Tick(.1f); + modes.AddRange(o.IncomingPaints.Select(paint => paint.Mode)); + } + + Assert.Equal(new[] { PaintMode.Search }, modes); // fixture: the sweep painted o once, as a search + var reveal = Assert.Single(o.RevealsOf(p)); + Assert.Same(item, reveal.Item); + Assert.Equal(SignatureChannel.Emission, reveal.Channel); + AssertBearing(0f, 1f, reveal.Bearing); + } + + // A search paint (a ping crossing the observer) from a pinger that has left the zone before the paint is + // published warns once and creates no reveal and no key. + [Fact] + public void ASearchPaintFromAPingerThatHasLeftCreatesNoReveal() + { + var p = Add(float3(0, 0, 100), new[] { "Eye" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var sensor = (Sensor) p.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)).Behaviors.Single(b => b is Sensor); + Tick(.01f); + sensor.Ping(); + var inbox = (System.Collections.IList) typeof(Entity).GetField("_paintInbox", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(o); + for (var tick = 0; tick < 100 && inbox.Count == 0; tick++) Tick(.1f); + Assert.NotEmpty(inbox); // fixture: the ping crossed o and its paint waits to be published + + _zone.Entities.Remove(p); + Tick(.1f); + + Assert.Single(o.IncomingPaints); + Assert.Equal(PaintMode.Search, o.IncomingPaints.Single().Mode); + Assert.Empty(o.RevealsOf(p)); + Assert.Equal(0, RevealBookSubjects(o)); + Assert.False(o.ContactOf(p).Detected); + } + + // An observer's own gear is not another ship's act: a ship that fires its own gun, with its own sensor at the + // very place of the light, records nothing of itself, while a neighbour does record the gun. + [Fact] + public void AnObserverNeverRecordsItsOwnGear() + { + var self = Add(float3(0, 0, 0), new[] { "Gun", "Eye" }); + var neighbour = Add(float3(0, 0, 30), new[] { "Eye" }); + + Fire(self, Guns(self)[0]); + + Assert.Single(neighbour.RevealsOf(self)); // fixture: the gun's light reaches a sensor + Assert.Empty(self.RevealsOf(self)); + Assert.False(self.ContactOf(self).Detected); + } + + // The zone's list order is not a rule of the game: two observers equidistant from a gun, one listed before the + // gun's ship and one after, record the same reveal with the same bearing and the same clock. The ship may be + // moving: the bearing is that of the ship where the tick left it, whatever the order the zone lists them in. + [Theory] + [InlineData(0f, 0f)] + [InlineData(30f, -20f)] + [InlineData(-45f, 10f)] + public void AnActIsRevealedOnItsOwnTickWhateverTheListOrder(float vx, float vz) + { + var before = Add(float3(-30, 0, 40), new[] { "Eye" }); + var e = Add(float3(30, 0, 40), new[] { "Gun" }); + var after = Add(float3(0, 0, 0), new[] { "Eye" }); + var start = e.Position; + e.Velocity = float2(vx, vz); + + var at = Fire(e, Guns(e)[0]); + + if (vx != 0f || vz != 0f) Assert.True(length(e.Position - start) > 1f, "fixture: e moved during the firing tick"); + foreach (var observer in new Entity[] { before, after }) + { + var reveal = Assert.Single(observer.RevealsOf(e)); + Assert.Equal(at, reveal.Time); + var toward = normalize((e.Position - observer.Position).xz); + AssertBearing(toward.x, toward.y, reveal.Bearing); + } + } + + // ---- A quiet ship ---- + + // Ruling quiet-gear-identifiable-hull-layout-known: a ship that has shown nothing is Detected while it is + // tracked and Identified once the track reaches the gear threshold, with no act in any book; with no reveal + // it is not Classified. + [Theory] + [InlineData(.05f, false, false)] + [InlineData(.5f, true, false)] + [InlineData(.79f, true, false)] + [InlineData(.8f, true, true)] + [InlineData(1f, true, true)] + public void AQuietTrackedShipIsDetectedAndIdentifiedByTheTrackAlone(float info, bool tracked, bool identified) + { + var e = Add(float3(30, 0, 40), new[] { "Gun" }); // armed, but it never fires + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + + o.EntityInfoGathered[e] = info; + + Assert.Empty(o.RevealsOf(e)); + var contact = o.ContactOf(e); + Assert.Equal(tracked, o.VisibleEntities.Contains(e)); + Assert.Equal(tracked, contact.Tracked); + Assert.Equal(tracked, contact.Detected); + Assert.False(contact.Classified); + Assert.Equal(identified, contact.Identified); + Assert.Equal(info, contact.Info); + if (tracked) AssertBearing(.6f, .8f, contact.Bearing); + } + + [Fact] + public void RevealsLeaveWithTheObserver() + { + var e = Add(float3(0, 0, 100), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + Fire(e, Guns(e)[0]); + Assert.Single(o.RevealsOf(e)); + + o.Deactivate(); + + 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); + var killed = false; + void Kill(ShotOutcome _) + { + if (killed || !_zone.Entities.Contains(e)) return; + killed = true; + ShowOnly(e, 2f, guns[0]); // lit at the moment it dies, as the ear's gate sees it + Assert.True(e.Visibility * strong.PassiveInfo(e.Position.xz, 1f) > _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: the ear passes the gate"); + 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 cannot be reached from the observer (its fields, collections and closures, not the + // zone it shares with everyone) once the ship is gone and its memory has lapsed. + [Fact] + public void ADepartedSubjectIsUnreachableFromTheObserverAfterThePrune() + { + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" }); + var guns = Guns(e); + Fire(e, guns[0]); + Fire(e, guns[1]); + Tick(1f); + o.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(o.RevealsOf(e)).Item); // fixture: one gun ended as destroyed, one waiting + e.Deactivate(); // the warp path: the ship's own subscriptions to the observer go with it + _zone.Entities.Remove(e); + for (var i = 0; i < 40; i++) Tick(.1f); // past BearingMemory (2 s) + Assert.Equal(0, RevealBookSubjects(o)); // fixture: pruned + + foreach (var held in new object[] { e, guns[0], guns[1] }) + Assert.Null(PathFrom(o, held)); + } + + // The first field path from `root` to `target`, not entering the zone or the item manager, or null. + private static string PathFrom(object root, object target) + { + var seen = new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance) { root }; + var queue = new Queue<(object node, string path)>(); + queue.Enqueue((root, "observer")); + while (queue.Count > 0) + { + var (node, path) = queue.Dequeue(); + if (ReferenceEquals(node, target)) return path; + var type = node.GetType(); + if (node is Zone || node is ItemManager || node is Type || node is string || type.IsPrimitive || type.IsEnum + || type.Namespace == "System.Reflection") continue; + IEnumerable<(object value, string name)> children; + if (node is Array array) + children = array.Cast().Select((v, i) => (v, "[" + i + "]")); + else + children = Fields(type).Select(f => (f.GetValue(node), "." + f.Name)); + foreach (var (value, name) in children) + if (value != null && seen.Add(value)) queue.Enqueue((value, path + name)); + } + return null; + } + + private static IEnumerable Fields(Type type) + { + for (; type != null; type = type.BaseType) + foreach (var f in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + if (!f.FieldType.IsPointer) yield return f; + } + + // ---- The one rule for info per signature ---- + + [Fact] + public void PassiveInfoAveragesTheActiveSensors() + { + var one = Add(float3(0, 0, 0), new[] { "Eye" }); + var two = Add(float3(500, 0, 0), new[] { "Eye", "Ear" }); + var none = Add(float3(1000, 0, 0)); + + // sum(s) / (d n k): 2 / (100 x .5) with one sensor, (2 + 4) / (100 x 2 x .5) with two; linear in the signature. + Assert.Equal(.04f, one.PassiveInfo(float2(0, 100), 1f), 5); + Assert.Equal(.12f, one.PassiveInfo(float2(0, 100), 3f), 5); + Assert.Equal(.06f, two.PassiveInfo(float2(500, 100), 1f), 5); + Assert.Equal(0f, none.PassiveInfo(float2(1000, 100), 1f)); + } + + // ---- SenseCostIsBounded ---- + + [Fact] + public void SenseAllocatesNothingOnceWarm() + { + var gunItems = new List(); + for (var i = 0; i < 19; i++) + { + var ship = Add(float3(i * 3, 0, 10 + i), new[] { "Gun" }); + var item = Guns(ship)[0]; + for (var s = 0; s < 10; s++) ship.SetSignature(new object(), SignatureChannel.Reflection, 1000f, item); + gunItems.Add(item); + } + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + 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 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); + Assert.Single((System.Collections.IList) typeof(Zone).GetProperty("Departures", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(_zone)); + sense(); + Assert.Single(o.RevealsOf(departed)); // fixture: awaiting its prune + + // 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, smallest); + } +} diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 289abb1bf..ea29e418b 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -313,6 +313,32 @@ public sealed partial class MiningCut3Tests : IDisposable $"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})"); } + // ChunkInfo's value is not changed by moving its arithmetic under PassiveInfo: six observers at different + // places and bearings, one with a second sensor so the average is live, read the same chunk, and every value + // matches the bits recorded at base 12dfcef5, before the extraction (a different order of the multiplication + // by the chunk's brightness moves them in the last bits). + private static readonly uint[] ChunkInfoAtBase = new uint[] { 1045120030u, 1039274086u, 1030543422u, 1029120970u, 1036363351u, 1035122917u }; + + [Fact] + public void ChunkInfoIsBitIdenticalToItsValueBeforeTheExtraction() + { + var kind = Kind("Asteroid", 1f); + var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f))); + var chunk = new ChunkId(s.Belts[0], 0); + var places = new[] { float2(50, -150), float2(-80, -60), float2(10, 200), float2(-300, 40), float2(120, 120), float2(0, -400) }; + var values = new List(); + for (var i = 0; i < places.Length; i++) + { + var observer = SpawnShip(s, places[i], sensor: true, secondSensor: i == 2); + Tick((observer, places[i])); // brings the sensors online + values.Add(observer.ChunkInfo(chunk)); + } + + Assert.All(values, v => Assert.InRange(v, 1e-4f, .99f)); // a live, unsaturated reading at every place + var actual = values.Select(Bits).ToArray(); + Assert.True(actual.SequenceEqual(ChunkInfoAtBase), "ChunkInfo bits: " + string.Join(",", actual)); + } + // A chunk outside every sun's light is dark: no visibility, no info, and it cannot be picked, while a lit // chunk of the same belt and kind can. [Fact]