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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 02:07:17 +02:00
commit 86843648a7
7 changed files with 415 additions and 9 deletions

View file

@ -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
{

View file

@ -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;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bf9e034c7a31410ea1c516f46b43825c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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<ActiveEmission> _emissions = new List<ActiveEmission>();
// 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<Entity, Dictionary<EquippedItem, Reveal>> _reveals = new Dictionary<Entity, Dictionary<EquippedItem, Reveal>>();
private readonly List<EquippedItem> _endedItems = new List<EquippedItem>();
@ -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) =>

View file

@ -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;

View file

@ -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(),

View file

@ -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<Scanner>().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
}
}