From 86843648a721a0644aaa9bd8229cb1ebeffce027 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 02:07:17 +0200 Subject: [PATCH] Add the inspection scanner: a held scan on a tracked target reveals all its gear Scanner (ScannerData, Union 51) builds Progress while the ship's Target is tracked within Range, paints the target (PaintMode.Scan), emits at Range / InterceptFactor and decays in IAlwaysUpdatedBehavior.Update when not held. At 1 Entity.RecordInspection records every fitted non-hull non-destroyed item through RecordReveal with channel null and the subject's claim. Reveal.Channel and RecordReveal take SignatureChannel?. Scanner.cs.meta is hand-written with a fresh guid; Unity has not compiled this yet. Builds on the headless test project only if the next verify says so. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/Behaviors.cs | 1 + .../Scripts/ServerShared/Behaviors/Scanner.cs | 91 ++++++ .../ServerShared/Behaviors/Scanner.cs.meta | 11 + Assets/Scripts/ServerShared/Entity.cs | 23 +- Assets/Scripts/ServerShared/Sensing.cs | 13 +- .../ContactRevealTests.cs | 3 + tests/Aetheria.Shared.Tests/ScannerTests.cs | 282 ++++++++++++++++++ 7 files changed, 415 insertions(+), 9 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Behaviors/Scanner.cs create mode 100644 Assets/Scripts/ServerShared/Behaviors/Scanner.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/ScannerTests.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 95cf4dd6b..5bd42eb32 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -191,6 +191,7 @@ public interface IPowerConsumer Union(47, typeof(MunitionFuelData)), Union(48, typeof(PickupData)), Union(49, typeof(LaunchSuppressorData)), + Union(51, typeof(ScannerData)), JsonObject(MemberSerialization.OptIn)] public abstract class BehaviorData { diff --git a/Assets/Scripts/ServerShared/Behaviors/Scanner.cs b/Assets/Scripts/ServerShared/Behaviors/Scanner.cs new file mode 100644 index 000000000..5d229a70f --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/Scanner.cs @@ -0,0 +1,91 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using MessagePack; +using Newtonsoft.Json; +using static CultMath.math; + +// Inspection (campaign aetheria-release, rulings quiet-gear-identifiable-hull-layout-known, scan-held-beam, +// scan-is-an-act): a gear that, held on the ship's tracked target within Range for ScanTime, reveals everything fitted to +// that target, quiet gear that never acts included. The scan is an emission like a lock: the target is painted and +// third parties hear it at Range over the intercept factor. +[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] +public class ScannerData : BehaviorData +{ + // The planar distance to the target within which a scan builds. + [Inspectable, JsonProperty("range"), Key(1), RuntimeInspectable] + public PerformanceStat Range = new PerformanceStat(); + + // Seconds a held scan takes to finish; a broken scan decays over the same span. + [Inspectable, JsonProperty("scanTime"), Key(2), RuntimeInspectable] + public PerformanceStat ScanTime = new PerformanceStat(); + + // The ratio of how far this scanner sees to how far others hear it (ruling sensor-stat-set); a design without it + // reads as 1. + [Inspectable, JsonProperty("interceptFactor"), Key(3)] + public PerformanceStat InterceptFactor; + + public override Behavior CreateInstance(EquippedItem item) + { + return new Scanner(this, item); + } + public override Behavior CreateInstance(ConsumableItemEffect item) + { + return new Scanner(this, item); + } +} + +public class Scanner : Behavior, IAlwaysUpdatedBehavior, IProgressBehavior +{ + private readonly ScannerData _data; + private Entity _target; + private bool _held; + + public float Progress { get; private set; } + + public Scanner(ScannerData data, EquippedItem item) : base(data, item) + { + _data = data; + } + public Scanner(ScannerData data, ConsumableItemEffect item) : base(data, item) + { + _data = data; + } + + // Runs only while the item is Active. The scan builds while the ship's own Target is a tracked ship within Range, and + // is painted and emitted on every tick it builds; at 1 the target's fitted items enter the book and the scan starts over. + public override bool Execute(float dt) + { + var target = Entity.Target.Value.Entity; + if (_target != target) + { + Progress = 0; + _target = target; + } + + var range = Evaluate(_data.Range); + if (target != null && Entity.VisibleEntities.Contains(target) && Entity.TargetRange <= range) + { + Progress = saturate(Progress + dt / Evaluate(_data.ScanTime)); + target.ReceivePaint(Entity, Item, PaintMode.Scan, Progress); + Entity.Emit(Item, range / (_data.InterceptFactor == null ? 1f : Evaluate(_data.InterceptFactor))); + _held = true; + if (Progress >= 1f) + { + Entity.RecordInspection(target); + Progress = 0; + } + } + + return true; + } + + // Every tick, after Execute: a scan not held this tick (out of range, untracked, or the item inactive so Execute did + // not run) decays over ScanTime (ruling scan-held-beam). The one decay site. + public void Update(float delta) + { + if (!_held) Progress = saturate(Progress - delta / Evaluate(_data.ScanTime)); + _held = false; + } +} diff --git a/Assets/Scripts/ServerShared/Behaviors/Scanner.cs.meta b/Assets/Scripts/ServerShared/Behaviors/Scanner.cs.meta new file mode 100644 index 000000000..1eb6935e7 --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/Scanner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf9e034c7a31410ea1c516f46b43825c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 087a69398..d3e0b1b48 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -247,7 +247,7 @@ public abstract class Entity // sensor-stat-set): written in Update, read by Sense in the same tick, cleared by the next PublishInbox. private readonly List _emissions = new List(); // 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 + // item). Written only by RecordReveal (an act, or a scan through RecordInspection, 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(); @@ -934,14 +934,14 @@ public abstract class Entity _paintInbox.Add(new PendingPaint(new PaintWarning(normalize((emitter.Position - Position).xz), emitter.ClaimOf(item), 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 + // The reveal book's one way in and its one membership gate (callers: Sense, PublishInbox and RecordInspection, all 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. `claimed` is the class the carrier (signature, emission or paint) stamped: what this observer's // classifying readers will believe of the act; `item` stays the aim and book key. - internal void RecordReveal(Entity subject, EquippedItem item, SignatureChannel channel, EquippableItemData claimed) + internal void RecordReveal(Entity subject, EquippedItem item, SignatureChannel? channel, EquippableItemData claimed) { if (!_active || item == null || !Zone.Entities.Contains(subject) || PerceivesDestroyed(subject, item)) return; if (!_reveals.TryGetValue(subject, out var book)) @@ -949,6 +949,21 @@ public abstract class Entity book[item] = new Reveal(item, channel, normalize((subject.Position - Position).xz), Zone.Time, claimed); } + // A finished scan (Scanner.Execute; rulings scan-is-an-act, quiet-gear-identifiable-hull-layout-known: quiet gear + // that never acts stays identifiable through special gear): this observer learns every fitted item of `subject`, + // channel null. It records what the item claims, not what it is (ClaimOf, as every act's carrier stamps it: the + // aetheria-ew one judge, classify-from-belief), skips the hull (its class is the track's to give) and a destroyed + // item, and goes through RecordReveal's gate like any act. It calls no EndReveals: a scan ends nothing + // (reveal-ends-on-perception). + internal void RecordInspection(Entity subject) + { + foreach (var item in subject.Equipment) + { + if (item == subject.EquippedHull || item.EquippableItem.IsDestroyed) continue; + RecordReveal(subject, item, null, subject.ClaimOf(item)); + } + } + // 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) => diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs index 7cf119ff0..da1144a75 100644 --- a/Assets/Scripts/ServerShared/Sensing.cs +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -38,11 +38,13 @@ public readonly struct Signature // What a painted ship learns of a beam on it (ruling sensor-stat-set item 4): a Search paint is a ping sweeping // it (Strength 0); a Track paint is a lock (Strength is the emitter's own lock progress, 0..1, ruling -// lock-warning-strength). Never summed. +// lock-warning-strength). A Scan paint is a held scanner: Strength is its scan progress 0..1 (ruling scan-is-an-act). +// Never summed. public enum PaintMode { Search, - Track + Track, + Scan } // An ally's ping crossed `Subject` at `IlluminatedRange` through `IlluminatedObscuration`; `MaxRange` is that @@ -100,16 +102,17 @@ public readonly struct PaintWarning // 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. `Item` stays the true item: it is the // book's key and what aim reads. `Claimed` is the class the act claimed, and is what every classifying reader reads -// (aetheria-ew, classify-from-belief). +// (aetheria-ew, classify-from-belief). A null `Channel` is a reveal by the observer's own scan (Entity.RecordInspection), +// whose `Claimed` is the subject's claim for the item. public readonly struct Reveal { public readonly EquippedItem Item; public readonly EquippableItemData Claimed; - public readonly SignatureChannel Channel; + public readonly SignatureChannel? Channel; public readonly float2 Bearing; public readonly float Time; - public Reveal(EquippedItem item, SignatureChannel channel, float2 bearing, float time, EquippableItemData claimed) + public Reveal(EquippedItem item, SignatureChannel? channel, float2 bearing, float time, EquippableItemData claimed) { Item = item; Claimed = claimed; diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 49f371fc0..0a0981d9e 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -99,6 +99,9 @@ public sealed partial class ContactRevealTests : IDisposable cache.Upsert(SpannedWeapon("SpanBeam", Spanned(new ConstantWeaponData()))); cache.Upsert(Locker("Locker", interceptFactor: null)); cache.Upsert(Locker("Hushed", interceptFactor: 4f)); + // Scanners (cut inspection-scanner): range 200 and a two second scan; "QuietScanner" is heard at a quarter of it. + cache.Upsert(Tool("Scanner", ScannerBehavior(range: 200f, scanTime: 2f))); + cache.Upsert(Tool("QuietScanner", ScannerBehavior(range: 200f, scanTime: 2f, interceptFactor: 4f))); cache.Upsert(new ConsumableItemData { Name = "Pinger", Duration = 30f, Shape = new Shape(), diff --git a/tests/Aetheria.Shared.Tests/ScannerTests.cs b/tests/Aetheria.Shared.Tests/ScannerTests.cs new file mode 100644 index 000000000..7fdacdb2c --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ScannerTests.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// Inspection (rulings quiet-gear-identifiable-hull-layout-known, scan-held-beam, scan-is-an-act, reveal-ends-on-perception, +// sensor-stat-set): a scanner held on its ship's tracked target within Range for ScanTime reveals everything fitted to it, +// as claimed, and is itself an emission the target is warned of and a third party can hear. The fixtures are the +// catalog's: "Scanner" has Range 200 and ScanTime 2, "QuietScanner" the same with intercept factor 4 (heard at 50). A tick +// is 1/8 s, so a scan builds 1/16 a tick and finishes on the sixteenth with no float drift. The scanner's ship keeps +// its track on the target at .5 each tick (above the detection gate, below the one at which gear is resolved, so +// Sense neither loses the track nor perceives a destruction). Every fixture drives the production path, ticking inside +// Zone.Update. +public sealed partial class ContactRevealTests +{ + private const float ScanStep = .125f; + + private static ScannerData ScannerBehavior(float range, float scanTime, float? interceptFactor = null) => new ScannerData + { + Range = Const(range), ScanTime = Const(scanTime), + InterceptFactor = interceptFactor.HasValue ? Const(interceptFactor.Value) : null + }; + + private static Scanner ScannerOf(Entity e) => e.Equipment.SelectMany(i => i.Behaviors).OfType().Single(); + + private static EquippedItem ScannerItemOf(Entity e) => e.Equipment.Single(i => i.Behaviors.Any(b => b is Scanner)); + + // A scanner ship at the origin, with a passive eye and the named scanner, tracking and targeting a ship `distance` + // ahead that carries a reactor, a radiator and a suppressor, none of which ever acts. + private (Ship scanner, Ship target) ScanOn(string scanner = "Scanner", float distance = 100f) + { + var target = Add(float3(0, 0, distance), tools: new[] { "Reactor", "Radiator", "SuppressorCold" }); + var s = Add(float3(0, 0, 0), new[] { "Eye" }, new[] { scanner }); + s.EntityInfoGathered[target] = .5f; + s.SetTarget(target); + Assert.Same(target, s.Target.Value.Entity); // fixture + return (s, target); + } + + // Ticks `ticks` steps, holding the observer's track on each of `tracked` at `info` before each. + private void Hold(Entity observer, int ticks, float info, params Entity[] tracked) + { + for (var i = 0; i < ticks; i++) + { + foreach (var t in tracked) observer.EntityInfoGathered[t] = info; + Tick(ScanStep); + } + } + + private void Hold(Entity observer, int ticks, params Entity[] tracked) => Hold(observer, ticks, .5f, tracked); + + private static EquippedItem[] FittedBesidesTheHull(Entity e) => e.Equipment.Where(i => i != e.EquippedHull).ToArray(); + + // ---- AScanRevealsQuietGear ---- + + [Fact] + public void AScanRevealsQuietGear() + { + var (s, target) = ScanOn(); + var quiet = FittedBesidesTheHull(target); + Assert.Equal(3, quiet.Length); + Assert.Empty(s.RevealsOf(target)); // fixture: nothing acts + + Hold(s, 15, target); + + Assert.Empty(s.RevealsOf(target)); + Assert.Equal(15f / 16f, ScannerOf(s).Progress, 4); + + Hold(s, 1, target); + + foreach (var item in quiet) + { + var reveal = Assert.Single(s.RevealsOf(target), r => r.Item == item); + Assert.Null(reveal.Channel); + AssertBearing(0f, 1f, reveal.Bearing); // normalize((target - scanner).xz) + Assert.Equal(_zone.Time, reveal.Time); + } + Assert.Equal(0f, ScannerOf(s).Progress); // a finished scan starts over + } + + // ---- AScanSkipsTheHull ---- + + [Fact] + public void AScanSkipsTheHull() + { + var (s, target) = ScanOn(); + Assert.Contains(target.EquippedHull, target.Equipment); // fixture: the hull is an item of Equipment + + Hold(s, 16, target); + + Assert.Equal(3, s.RevealsOf(target).Count()); // fixture: the scan finished + Assert.DoesNotContain(s.RevealsOf(target), r => r.Item == target.EquippedHull); + } + + // ---- AScanSkipsDestroyedGear ---- + + [Fact] + public void AScanSkipsDestroyedGear() + { + var (s, target) = ScanOn(); + var items = FittedBesidesTheHull(target); + items[0].EquippableItem.Durability = 0f; + Assert.True(items[0].EquippableItem.IsDestroyed); + + Hold(s, 16, target); + + Assert.DoesNotContain(s.RevealsOf(target), r => r.Item == items[0]); + Assert.Equal(2, s.RevealsOf(target).Count()); + } + + // ---- AScanRecordsTheClaim ---- + + [Fact] + public void AScanRecordsTheClaim() + { + var (s, target) = ScanOn(); + var reactor = target.Equipment.Single(i => i.Behaviors.Any(b => b is Reactor)); + var radiator = DataOf("Radiator"); + target.Claim(reactor, radiator); + + Hold(s, 16, target); + + var reveal = Assert.Single(s.RevealsOf(target), r => r.Item == reactor); + Assert.Same(radiator, reveal.Claimed); + Assert.NotSame(reactor.Data, reveal.Claimed); + } + + // ---- AScanNeedsATrackAndRange ---- + + [Theory] + [InlineData(199f, true, true)] + [InlineData(201f, true, false)] + [InlineData(100f, false, false)] + public void AScanNeedsATrackAndRange(float distance, bool tracked, bool builds) + { + var (s, target) = ScanOn(distance: distance); + var third = Add(float3(40, 0, 0), new[] { "Eye" }); // within 200 of the scanner: it hears an emission + var info = tracked ? .5f : 0f; + s.EntityInfoGathered[target] = info; + Assert.Equal(tracked, s.VisibleEntities.Contains(target)); // fixture + + Hold(s, 4, info, target); + + Assert.Equal(builds, ScannerOf(s).Progress > 0f); + Assert.Equal(builds, target.IncomingPaints.Count > 0); + Assert.Equal(builds, target.RevealsOf(s).Any()); + Assert.Equal(builds, third.RevealsOf(s).Any()); + } + + // ---- ABrokenScanDecays ---- + + // A scan at .5 broken for half of ScanTime (one second) falls to 0, a quarter in half of that: by range (Execute + // runs and does not hold) and by the item switched off (Execute does not run at all). + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ABrokenScanDecays(bool byRange) + { + var (s, target) = ScanOn(); + Hold(s, 8, target); + Assert.Equal(.5f, ScannerOf(s).Progress, 4); + if (byRange) target.Position = float3(0, 0, 400); + else ScannerItemOf(s).Enabled.Value = false; + + Hold(s, 4, target); + Assert.Equal(.25f, ScannerOf(s).Progress, 4); + + Hold(s, 4, target); + Assert.Equal(0f, ScannerOf(s).Progress, 4); + Hold(s, 2, target); + Assert.Equal(0f, ScannerOf(s).Progress, 4); // never below 0 + } + + // ---- ATargetChangeResetsTheScan ---- + + [Fact] + public void ATargetChangeResetsTheScan() + { + var (s, first) = ScanOn(); + var second = Add(float3(100, 0, 0)); + Hold(s, 11, first, second); + Assert.Equal(11f / 16f, ScannerOf(s).Progress, 4); + + s.SetTarget(second); + Hold(s, 1, first, second); + + Assert.Equal(1f / 16f, ScannerOf(s).Progress, 4); // reset, then one tick on the new target + Assert.Empty(s.RevealsOf(first)); + } + + // ---- TheScannedShipIsWarned ---- + + // A paint made in a tick is published the next one, so after four ticks the target holds the third tick's paint. + [Fact] + public void TheScannedShipIsWarned() + { + var (s, target) = ScanOn(); + + Hold(s, 4, target); + + var paint = Assert.Single(target.IncomingPaints); + Assert.Equal(PaintMode.Scan, paint.Mode); + Assert.Equal(3f / 16f, paint.Strength, 4); + Assert.Same(DataOf("Scanner"), paint.EmitterClass); + AssertBearing(0f, -1f, paint.Bearing); // normalize((scanner - target).xz) + var warned = Assert.Single(target.RevealsOf(s)); + Assert.Same(ScannerItemOf(s), warned.Item); + Assert.Equal(SignatureChannel.Emission, warned.Channel); + } + + // ---- AThirdPartyHearsTheScanner ---- + + [Theory] + [InlineData("QuietScanner", 49f, true)] + [InlineData("QuietScanner", 51f, false)] + [InlineData("Scanner", 199f, true)] + [InlineData("Scanner", 201f, false)] + public void AThirdPartyHearsTheScanner(string scanner, float distance, bool heard) + { + var (s, target) = ScanOn(scanner); + var third = Add(float3(-distance, 0, 0), new[] { "Eye" }); + + Hold(s, 3, target); + + Assert.Empty(third.IncomingPaints); // fixture: only the target is painted + if (!heard) + { + Assert.Empty(third.RevealsOf(s)); + return; + } + var reveal = Assert.Single(third.RevealsOf(s)); + Assert.Same(ScannerItemOf(s), reveal.Item); + Assert.Equal(SignatureChannel.Emission, reveal.Channel); + AssertBearing(1f, 0f, reveal.Bearing); // normalize((scanner - third).xz) + } + + // ---- AFinishedScanMakesTheReactorSelectable ---- + + [Fact] + public void AFinishedScanMakesTheReactorSelectable() + { + var (s, target) = ScanOn(); + var reactor = target.Equipment.Single(i => i.Behaviors.Any(b => b is Reactor)); + Hold(s, 15, target); + Assert.False(FireControl.IsRevealed(s, reactor)); + Assert.False(s.TrySelectTargetItem(reactor)); + + Hold(s, 1, target); + + Assert.True(FireControl.IsRevealed(s, reactor)); + Assert.True(s.TrySelectTargetItem(reactor)); + } + + // ---- AScanEndsNothing ---- + + // A gun revealed by firing and then destroyed, while the observer's track stays between the detection and gear + // thresholds (so Sense perceives no destruction): a scan neither ends its reveal nor refreshes it. + [Fact] + public void AScanEndsNothing() + { + var target = Add(float3(0, 0, 100), new[] { "Gun" }); + var s = Add(float3(0, 0, 0), new[] { "Eye" }, new[] { "Scanner" }); + var gun = Guns(target)[0]; + var firedAt = Fire(target, gun); + var before = Assert.Single(s.RevealsOf(target), r => r.Item == gun); + Assert.Equal(firedAt, before.Time); // fixture: the gun is in the book + gun.EquippableItem.Durability = 0f; + s.EntityInfoGathered[target] = .5f; + s.SetTarget(target); + + Hold(s, 16, target); + + var after = Assert.Single(s.RevealsOf(target), r => r.Item == gun); + Assert.Equal(firedAt, after.Time); + Assert.Equal(SignatureChannel.LaunchFlash, after.Channel); + Assert.Contains(s.RevealsOf(target), r => r.Item != gun && r.Channel == null); // fixture: the scan finished + } +}