From 1d14edea3a799658f95742799df01de51cbe7e5b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:13:24 +0200 Subject: [PATCH 1/8] Signatures: every light signature names its channel and item through Entity.SetSignature VisibilitySources holds Signature (channel, strength, item); the eight direct writes go through the one writer; Visibility stays the sum; SignatureOn(channel) splits it. VisibilityData gains Key(3) Channel. No number changes. Not yet built or tested at this commit (verification follows). Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/ConstantWeapon.cs | 2 +- .../ServerShared/Behaviors/InstantWeapon.cs | 2 +- .../ServerShared/Behaviors/Radiator.cs | 2 +- .../ServerShared/Behaviors/Reflector.cs | 2 +- .../Scripts/ServerShared/Behaviors/Sensor.cs | 2 +- .../ServerShared/Behaviors/Thruster.cs | 4 +- .../ServerShared/Behaviors/Visibility.cs | 5 +- Assets/Scripts/ServerShared/Entity.cs | 17 +- Assets/Scripts/ServerShared/Sensing.cs | 30 ++ Assets/Scripts/ServerShared/Sensing.cs.meta | 11 + .../FireControlPerWeaponTests.cs | 4 +- tests/Aetheria.Shared.Tests/SignatureTests.cs | 364 ++++++++++++++++++ .../Aetheria.Shared.Tests/VapourCloudTests.cs | 2 +- 13 files changed, 431 insertions(+), 16 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Sensing.cs create mode 100644 Assets/Scripts/ServerShared/Sensing.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/SignatureTests.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index e9dab4ce6..b75611031 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -170,7 +170,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC CauseWearDamage(dt); AddHeat(Evaluate(_data.Heat) * dt); - Entity.VisibilitySources[this] = Evaluate(_data.Visibility); + Entity.SetSignature(this, SignatureChannel.LaunchFlash, Evaluate(_data.Visibility), Item); // Cut 4 (docs/fire-control-cut.md): a beam is a sequence of rolls, not a continuous truth -- one // FireControl.Fire per BeamResolveInterval, for that interval's worth of Damage, through the same diff --git a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs index 2801110c9..ad93524db 100644 --- a/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/InstantWeapon.cs @@ -277,7 +277,7 @@ public class InstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerCo } CauseWearDamage(1); AddHeat(Heat); - Entity.VisibilitySources[this] = Visibility; + Entity.SetSignature(this, SignatureChannel.LaunchFlash, Visibility, Item); } return true; } diff --git a/Assets/Scripts/ServerShared/Behaviors/Radiator.cs b/Assets/Scripts/ServerShared/Behaviors/Radiator.cs index a43a1a5c7..0c9239a0a 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Radiator.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Radiator.cs @@ -118,7 +118,7 @@ public class Radiator : Behavior, IAlwaysUpdatedBehavior, IInitializableBehavior Emissivity = Evaluate(_data.Emissivity); var rad = pow(RadiatorTemperature, ItemManager.GameplaySettings.HeatRadiationExponent) * ItemManager.GameplaySettings.HeatRadiationMultiplier * Emissivity; RadiatorTemperature -= rad * delta; - Entity.VisibilitySources[this] = rad; + Entity.SetSignature(this, SignatureChannel.Thermal, rad, Item); } public void Initialize() diff --git a/Assets/Scripts/ServerShared/Behaviors/Reflector.cs b/Assets/Scripts/ServerShared/Behaviors/Reflector.cs index b5c4b4ad6..6b7f70a10 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Reflector.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Reflector.cs @@ -43,7 +43,7 @@ public class Reflector : Behavior public override bool Execute(float dt) { - Entity.VisibilitySources[this] = Evaluate(_data.CrossSection) * Entity.Zone.GetLight(Entity.Position.xz); + Entity.SetSignature(this, SignatureChannel.Reflection, Evaluate(_data.CrossSection) * Entity.Zone.GetLight(Entity.Position.xz), Item); return true; } diff --git a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs index d1af7645e..4225df4f7 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Sensor.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Sensor.cs @@ -114,7 +114,7 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer // nothing would ever fill this capacitor; bypass it rather than starving such an instance forever. if(_pingCooldown < 0 && (Item == null || _capacitor.TrySpend(_capacitor.Capacity))) { - Entity.VisibilitySources[this] = Evaluate(_data.PingVisibility); + Entity.SetSignature(this, SignatureChannel.Emission, Evaluate(_data.PingVisibility), Item); // interim: emissions-heard replaces this with a range-gated emission _pinging = true; _pingCooldown = 1; _pingLerp = 0; diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index 2388bb1c0..4a36d9528 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -119,8 +119,8 @@ public class Thruster : Behavior, IPowerConsumer Entity.TurnRate += turnRate; AddHeat(_input * Evaluate(_data.Heat) * dt); var vis = _input * Evaluate(_data.Visibility); - if (!Entity.VisibilitySources.ContainsKey(this) || vis > Entity.VisibilitySources[this]) - Entity.VisibilitySources[this] = vis; + if (!Entity.VisibilitySources.TryGetValue(this, out var burn) || vis > burn.Strength) + Entity.SetSignature(this, SignatureChannel.DrivePlume, vis, Item); return true; } return false; diff --git a/Assets/Scripts/ServerShared/Behaviors/Visibility.cs b/Assets/Scripts/ServerShared/Behaviors/Visibility.cs index 1311f7cff..07dcb3cf2 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Visibility.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Visibility.cs @@ -15,6 +15,9 @@ public class VisibilityData : BehaviorData [Inspectable, JsonProperty("visibilityDecay"), Key(2)] public PerformanceStat VisibilityDecay = new PerformanceStat(); + + [Inspectable, JsonProperty("channel"), Key(3)] + public SignatureChannel Channel = SignatureChannel.Thermal; public override Behavior CreateInstance(EquippedItem item) { @@ -41,7 +44,7 @@ public class Visibility : Behavior public override bool Execute(float dt) { - Entity.VisibilitySources[this] = Evaluate(_data.Visibility); + Entity.SetSignature(this, _data.Channel, Evaluate(_data.Visibility), Item); return true; } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index b94ea6f30..bfb771ff7 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -85,7 +85,12 @@ public abstract class Entity // public Dictionary Personality = new Dictionary(); public readonly Dictionary Messages = new Dictionary(); - public readonly Dictionary VisibilitySources = new Dictionary(); + public readonly Dictionary VisibilitySources = new Dictionary(); + + // The one writer of VisibilitySources (the decay loop in Update is the one decayer). Every light signature + // names its channel and the item that produced it (null for the hull). + public void SetSignature(object source, SignatureChannel channel, float strength, EquippedItem item) => + VisibilitySources[source] = new Signature(channel, strength, item); public readonly ReactiveDictionary EntityInfoGathered = new ReactiveDictionary(); public (List weapons, List items)[] WeaponGroups; @@ -158,7 +163,9 @@ public abstract class Entity public ItemManager ItemManager { get; } public int AssignedPopulation => PopulationAssignments.Sum(pa => pa.AssignedPopulation); public float Mass { get; private set; } - public float Visibility => VisibilitySources.Values.Sum(); + public float Visibility => VisibilitySources.Values.Sum(s => s.Strength); + public float SignatureOn(SignatureChannel channel) => + VisibilitySources.Values.Where(s => s.Channel == channel).Sum(s => s.Strength); public Subject IncomingHit = new Subject(); public Subject<(int2 pos, float damage)> ArmorDamage = new Subject<(int2, float)>(); @@ -1423,9 +1430,9 @@ public abstract class Entity foreach (var v in VisibilitySources.Keys.ToArray()) { - VisibilitySources[v] = decay(VisibilitySources[v], ItemManager.GameplaySettings.VisibilityDecay, delta); + VisibilitySources[v] = VisibilitySources[v].WithStrength(decay(VisibilitySources[v].Strength, ItemManager.GameplaySettings.VisibilityDecay, delta)); - if (VisibilitySources[v] < 0.1f) VisibilitySources.Remove(v); + if (VisibilitySources[v].Strength < 0.1f) VisibilitySources.Remove(v); } UpdateTemperature(delta); @@ -1616,7 +1623,7 @@ public abstract class Entity MaxTemp = NewTemperature[v.x, v.y]; } - VisibilitySources[this] = radiation; + SetSignature(this, SignatureChannel.Thermal, radiation, null); var swap = Temperature; Temperature = NewTemperature; NewTemperature = swap; diff --git a/Assets/Scripts/ServerShared/Sensing.cs b/Assets/Scripts/ServerShared/Sensing.cs new file mode 100644 index 000000000..449b25a4a --- /dev/null +++ b/Assets/Scripts/ServerShared/Sensing.cs @@ -0,0 +1,30 @@ +/* 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/. */ + +// Light signatures split by channel (ruling visibility-per-capability-reveal): an observer learns what a ship +// is by which channel carries the light. Append-only: values are serialized in VisibilityData. +public enum SignatureChannel +{ + Thermal, + Reflection, + DrivePlume, + LaunchFlash, + Emission +} + +public readonly struct Signature +{ + public readonly SignatureChannel Channel; + public readonly float Strength; + public readonly EquippedItem Item; + + public Signature(SignatureChannel channel, float strength, EquippedItem item) + { + Channel = channel; + Strength = strength; + Item = item; + } + + public Signature WithStrength(float strength) => new Signature(Channel, strength, Item); +} diff --git a/Assets/Scripts/ServerShared/Sensing.cs.meta b/Assets/Scripts/ServerShared/Sensing.cs.meta new file mode 100644 index 000000000..0cffdf792 --- /dev/null +++ b/Assets/Scripts/ServerShared/Sensing.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a3c91e5d7b2f4086b1d4e9f07c6a2358 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 9ccc5fe98..5617eaa0b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -449,7 +449,7 @@ public sealed class FireControlPerWeaponTests : IDisposable costs.Ammo = ammoBefore - weapon.Ammo; costs.ChargeSpent = chargeBefore - capacitor.Charge; costs.Heat = r.Gun.Item.Temperature - heatBefore; - costs.Visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + costs.Visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v.Strength > 1f; costs.CanFire = weapon.CanFire; return costs; } @@ -511,7 +511,7 @@ public sealed class FireControlPerWeaponTests : IDisposable var request = weapon.PowerRequest(.1f); for (var i = 0; i < 5; i++) r.Zone.Update(.1f); - var visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v > 1f; + var visible = r.Shooter.VisibilitySources.TryGetValue(weapon, out var v) && v.Strength > 1f; return (shots, wears, request, visible, r.Gun.Item.Temperature - heatBefore, stops, weapon.Firing); } diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs new file mode 100644 index 000000000..8b4d32b95 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -0,0 +1,364 @@ +/* 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 System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CultMath; +using GameCult.Caching; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Visibility cut 1 (ruling visibility-per-capability-reveal): every light signature names the channel it travels +// on and the item that produced it, through the one writer Entity.SetSignature, and Visibility stays the sum. No +// number changes. Each fixture is the production path the rule is about: a real ship, real gear, real ticks. +public sealed class SignatureTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-signature-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + public SignatureTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var c in _caches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static GameplaySettings Settings(float visibilityDecay) => 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, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 2f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = 1000f, + AgentMinHitProbability = .2f, + BeamResolveInterval = .1f, + VisibilityDecay = visibilityDecay, + // The hull radiates nothing, so every sum below is the sum of the authored constants. + HeatRadiationMultiplier = 0f + }; + + private static GearData Gear(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 enum Armament { None, Gun, Beam } + + private sealed class Rig + { + public Zone Zone; + public Ship Ship; + public EquippedItem WeaponItem; + public Weapon Weapon; + } + + private static Shape Solid(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // A 5x5 hull with a reactor and, optionally, a fused gun or beam (it fires at no target: Range 40 outruns its + // arming distance 30), then the given gear. A thruster goes on the centre column so it needs no torque. + private Rig Build(Armament armament, float visibilityDecay, ThrusterData thruster, params BehaviorData[] gear) + { + var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new VerseGrammar { Revision = 1 }); + var hull = new HullData + { + Name = "Skiff", HullType = HullType.Ship, Shape = Solid(5, 5), Durability = 1000000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + cache.Upsert(hull); + cache.Upsert(Gear("Reactor", new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) })); + cache.Upsert(Gear("Targeting", new TargetingSystemData { Accuracy = Constant(1), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) })); + if (armament != Armament.None) + { + WeaponData behavior = armament == Armament.Beam + ? new ConstantWeaponData() + : new InstantWeaponData { Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1) }; + behavior.Damage = Constant(100); + behavior.Range = Constant(40); + behavior.MinRange = Constant(0); + behavior.Velocity = Constant(0); + behavior.Spread = Constant(0); + behavior.DamageSpread = Constant(0); + behavior.Penetration = Constant(0); + behavior.Energy = Constant(50); + behavior.Heat = Constant(0); + behavior.Visibility = Constant(100); + behavior.MagazineSize = armament == Armament.Gun ? 5 : 0; + behavior.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 = { behavior } + }); + } + if (thruster != null) cache.Upsert(Gear("Thruster", thruster)); + for (var i = 0; i < gear.Length; i++) cache.Upsert(Gear("Gear" + i, gear[i])); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 100; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, Settings(visibilityDecay), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Signatures", Owner = null }, null); + var lot = 1; + EquippableItem Make(string name) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = 10, Lot = lot++ + }; + + var ship = new Ship(items, zone, new EquippableItem { Data = items.ItemData.RefOf(items.ItemData.GetByName("Skiff")), Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var rig = new Rig { Zone = zone, Ship = ship }; + if (armament != Armament.None) + { + var gun = Make("Gun"); + Assert.True(ship.TryEquip(gun, new int2(0, 0))); + rig.WeaponItem = ship.Equipment.Single(x => x.EquippableItem == gun); + rig.Weapon = (Weapon) rig.WeaponItem.Behaviors.Single(b => b is Weapon); + } + if (thruster != null) Assert.True(ship.TryEquip(Make("Thruster"), new int2(2, 1))); + foreach (var name in new[] { "Targeting", "Reactor" }.Concat(gear.Select((_, i) => "Gear" + i))) + { + 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.Aim = float3(0, 0, 1); + zone.Update(1f); + return rig; + } + + private static void Fire(Rig r) + { + if (r.Weapon is InstantWeapon gun) + { + var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun); + capacitor.AddCharge(1000f); + } + r.Ship.SetTarget(TargetRef.None); + r.Weapon.Activate(); + for (var i = 0; i < 5; i++) r.Zone.Update(.1f); + } + + private static Signature Of(Ship ship, object source) => ship.VisibilitySources[source]; + + private static T Single(Ship ship) where T : Behavior => ship.GetBehavior(); + + // ---- EveryWriterNamesItsChannel ---- + + [Fact] + public void AFiredGunShowsALaunchFlashWithItsItem() + { + var r = Build(Armament.Gun, 0f, null); + Fire(r); + var s = Of(r.Ship, r.Weapon); + Assert.Equal(SignatureChannel.LaunchFlash, s.Channel); + Assert.Same(r.WeaponItem, s.Item); + Assert.True(s.Strength > 1f, "fixture: the gun fired"); + } + + [Fact] + public void ABurningBeamShowsALaunchFlashWithItsItem() + { + var r = Build(Armament.Beam, 0f, null); + Fire(r); + var s = Of(r.Ship, r.Weapon); + Assert.Equal(SignatureChannel.LaunchFlash, s.Channel); + Assert.Same(r.WeaponItem, s.Item); + Assert.True(s.Strength > 1f, "fixture: the beam burned"); + } + + private static ThrusterData Drive(float visibility) => + new ThrusterData { Thrust = Constant(100), Visibility = Constant(visibility), Heat = Constant(0), EnergyUsage = Constant(10) }; + + private static void Burn(Rig r, float input) + { + r.Ship.MovementDirection = float2(0, -input); + r.Zone.Update(.1f); + } + + [Fact] + public void ABurningThrusterShowsADrivePlumeWithItsItem() + { + var r = Build(Armament.None, 0f, Drive(10)); + var thruster = Single(r.Ship); + thruster.Item.UpdatePerformance(); + Burn(r, 1f); + var s = Of(r.Ship, thruster); + Assert.Equal(SignatureChannel.DrivePlume, s.Channel); + Assert.Same(thruster.Item, s.Item); + Assert.True(s.Strength > 1f, "fixture: the thruster burned"); + } + + [Fact] + public void ARadiatorShowsAThermalSignatureWithItsItem() + { + var r = Build(Armament.None, 0f, null, new RadiatorData + { + Emissivity = Constant(1), PumpedHeat = Constant(0), TemperatureFloor = 0, WasteHeat = Constant(0), EnergyUsage = Constant(0), ThermalMass = Constant(1000) + }); + var radiator = Single(r.Ship); + var s = Of(r.Ship, radiator); + Assert.Equal(SignatureChannel.Thermal, s.Channel); + Assert.Same(radiator.Item, s.Item); + } + + [Fact] + public void AReflectorShowsAReflectionWithItsItem() + { + var r = Build(Armament.None, 0f, null, new ReflectorData { CrossSection = Constant(3) }); + var reflector = Single(r.Ship); + var s = Of(r.Ship, reflector); + Assert.Equal(SignatureChannel.Reflection, s.Channel); + Assert.Same(reflector.Item, s.Item); + } + + [Fact] + public void TheHullRadiatesOnTheThermalChannelWithNoItem() + { + var r = Build(Armament.None, 0f, null); + var s = Of(r.Ship, r.Ship); + Assert.Equal(SignatureChannel.Thermal, s.Channel); + Assert.Null(s.Item); + } + + [Fact] + public void APingShowsAnEmissionWithItsItem() + { + var r = Build(Armament.None, 0f, null, new SensorData + { + Sensitivity = Constant(3f), PingBoost = Constant(.02f), PingEnergy = Constant(0f), PingVisibility = Constant(5f), + PingRange = Constant(300f), PingCooldown = Constant(4f) + }); + var sensor = Single(r.Ship); + sensor.Ping(); + var s = Of(r.Ship, sensor); + Assert.Equal(SignatureChannel.Emission, s.Channel); + Assert.Same(sensor.Item, s.Item); + Assert.True(s.Strength > 1f, "fixture: the ping lit the ship"); + } + + // A catalog-authored Visibility behaviour travels on the channel its data names, thermal unless it says more. + [Theory] + [InlineData(SignatureChannel.Thermal)] + [InlineData(SignatureChannel.DrivePlume)] + [InlineData(SignatureChannel.Emission)] + public void AVisibilityBehaviourTravelsOnItsAuthoredChannel(SignatureChannel channel) + { + var r = Build(Armament.None, 0f, null, new VisibilityData { Visibility = Constant(7), VisibilityDecay = Constant(0), Channel = channel }); + var behaviour = Single(r.Ship); + var s = Of(r.Ship, behaviour); + Assert.Equal(channel, s.Channel); + Assert.Same(behaviour.Item, s.Item); + Assert.Equal(7f, s.Strength, 3); + } + + [Fact] + public void AVisibilityBehaviourDefaultsToThermal() + { + Assert.Equal(SignatureChannel.Thermal, new VisibilityData().Channel); + } + + // ---- VisibilityIsStillTheSum ---- + + // Every source kind at once: a fired gun (100 on LaunchFlash), a thermal Visibility gear (7), a reflector + // under no sun (0), and the non-radiating hull (0). Visibility is the sum of the strengths, 107, the value + // the single-number build gave on this fixture; SignatureOn splits the same total by channel. + [Fact] + public void VisibilityIsStillTheSum() + { + var r = Build(Armament.Gun, 0f, null, + new VisibilityData { Visibility = Constant(7), VisibilityDecay = Constant(0) }, + new ReflectorData { CrossSection = Constant(3) }); + Fire(r); + var ship = r.Ship; + + Assert.Equal(107f, ship.Visibility, 2); + Assert.Equal(ship.VisibilitySources.Values.Sum(s => s.Strength), ship.Visibility, 3); + Assert.Equal(100f, ship.SignatureOn(SignatureChannel.LaunchFlash), 2); + Assert.Equal(7f, ship.SignatureOn(SignatureChannel.Thermal), 2); + Assert.Equal(0f, ship.SignatureOn(SignatureChannel.Reflection), 2); + Assert.Equal(0f, ship.SignatureOn(SignatureChannel.Emission), 2); + Assert.Equal(0f, ship.SignatureOn(SignatureChannel.DrivePlume), 2); + } + + // ---- DecayKeepsChannelAndItem ---- + + [Fact] + public void DecayKeepsChannelAndItem() + { + const float lambda = .5f; + var r = Build(Armament.Gun, lambda, null); + Fire(r); + var before = Of(r.Ship, r.Weapon); + Assert.Equal(SignatureChannel.LaunchFlash, before.Channel); + + r.Zone.Update(1f); + var after = Of(r.Ship, r.Weapon); + Assert.Equal(decay(before.Strength, lambda, 1f), after.Strength, 3); + Assert.True(after.Strength < before.Strength); + Assert.Equal(SignatureChannel.LaunchFlash, after.Channel); + Assert.Same(r.WeaponItem, after.Item); + + for (var i = 0; i < 30; i++) r.Zone.Update(1f); + Assert.False(r.Ship.VisibilitySources.ContainsKey(r.Weapon), "a signature below 0.1 is gone"); + } + + // ---- APlumeKeepsTheLargerBurn ---- + + [Fact] + public void APlumeKeepsTheLargerBurn() + { + var r = Build(Armament.None, 0f, Drive(10)); + var thruster = Single(r.Ship); + thruster.Item.UpdatePerformance(); + + Burn(r, 1f); + var full = Of(r.Ship, thruster).Strength; + Burn(r, .5f); + Assert.Equal(full, Of(r.Ship, thruster).Strength, 3); + Assert.True(full > 1f, "fixture: the full burn lit the plume"); + } + + [Fact] + public void APlumeRisesToALargerBurn() + { + var r = Build(Armament.None, 0f, Drive(10)); + var thruster = Single(r.Ship); + thruster.Item.UpdatePerformance(); + + Burn(r, .5f); + var half = Of(r.Ship, thruster).Strength; + Burn(r, 1f); + var full = Of(r.Ship, thruster).Strength; + Assert.True(full > half && half > 0f, $"half {half}, full {full}"); + } +} diff --git a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs index 372e2585c..58ede257f 100644 --- a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs +++ b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs @@ -150,7 +150,7 @@ public sealed class VapourCloudTests : IDisposable { foreach (var ship in new[] { lab.Observer, lab.Target }) { - ship.VisibilitySources[Glow] = 100f; + ship.SetSignature(Glow, SignatureChannel.Thermal, 100f, null); if (ship != lab.Target || lab.HoldTarget) ship.Velocity = float2.zero; } lab.Observer.Direction = float2(0, 1); From 78c6c78e60e896ce46bce1a2f8e2f2ebddef4edc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:59:05 +0200 Subject: [PATCH 2/8] SignatureTests: light every channel in the sum fixture (a mutant dropping Reflection from the sum survived on a zero) Builds and passes are verified at the previous commit; this commit changes the test only. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/SignatureTests.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 8b4d32b95..3a73ec617 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -289,25 +289,29 @@ public sealed class SignatureTests : IDisposable // ---- VisibilityIsStillTheSum ---- - // Every source kind at once: a fired gun (100 on LaunchFlash), a thermal Visibility gear (7), a reflector - // under no sun (0), and the non-radiating hull (0). Visibility is the sum of the strengths, 107, the value - // the single-number build gave on this fixture; SignatureOn splits the same total by channel. + // Every channel lit at once: a fired gun (100 on LaunchFlash), Visibility gear on Thermal (7), Reflection (4), + // DrivePlume (3) and Emission (2), a reflector under no sun (0) and the non-radiating hull (0). Visibility is the + // sum of the strengths, 116, the value the single-number build gave on this fixture; SignatureOn splits the + // same total by channel. [Fact] public void VisibilityIsStillTheSum() { var r = Build(Armament.Gun, 0f, null, new VisibilityData { Visibility = Constant(7), VisibilityDecay = Constant(0) }, + new VisibilityData { Visibility = Constant(4), VisibilityDecay = Constant(0), Channel = SignatureChannel.Reflection }, + new VisibilityData { Visibility = Constant(3), VisibilityDecay = Constant(0), Channel = SignatureChannel.DrivePlume }, + new VisibilityData { Visibility = Constant(2), VisibilityDecay = Constant(0), Channel = SignatureChannel.Emission }, new ReflectorData { CrossSection = Constant(3) }); Fire(r); var ship = r.Ship; - Assert.Equal(107f, ship.Visibility, 2); + Assert.Equal(116f, ship.Visibility, 2); Assert.Equal(ship.VisibilitySources.Values.Sum(s => s.Strength), ship.Visibility, 3); Assert.Equal(100f, ship.SignatureOn(SignatureChannel.LaunchFlash), 2); Assert.Equal(7f, ship.SignatureOn(SignatureChannel.Thermal), 2); - Assert.Equal(0f, ship.SignatureOn(SignatureChannel.Reflection), 2); - Assert.Equal(0f, ship.SignatureOn(SignatureChannel.Emission), 2); - Assert.Equal(0f, ship.SignatureOn(SignatureChannel.DrivePlume), 2); + Assert.Equal(4f, ship.SignatureOn(SignatureChannel.Reflection), 2); + Assert.Equal(2f, ship.SignatureOn(SignatureChannel.Emission), 2); + Assert.Equal(3f, ship.SignatureOn(SignatureChannel.DrivePlume), 2); } // ---- DecayKeepsChannelAndItem ---- From 6f2dea38494f6305ee252a78c4d9c44d12ea5d49 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 11:37:07 +0200 Subject: [PATCH 3/8] Entity.VisibilitySources is a read-only view: SetSignature and the decay loop are the only writers The dictionary is private; the public member is an IReadOnlyDictionary over it, so a write around SetSignature is a compile error, not a grep. Readers (Thruster, tests) use only the read members. Builds; the test project at this commit still compiles (no reader writes). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index bfb771ff7..bf30ee927 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -85,12 +85,14 @@ public abstract class Entity // public Dictionary Personality = new Dictionary(); public readonly Dictionary Messages = new Dictionary(); - public readonly Dictionary VisibilitySources = new Dictionary(); + private readonly Dictionary _visibilitySources = new Dictionary(); + // A read-only view: the only writes are SetSignature and the decay loop in Update, both below. + public IReadOnlyDictionary VisibilitySources => _visibilitySources; // The one writer of VisibilitySources (the decay loop in Update is the one decayer). Every light signature // names its channel and the item that produced it (null for the hull). public void SetSignature(object source, SignatureChannel channel, float strength, EquippedItem item) => - VisibilitySources[source] = new Signature(channel, strength, item); + _visibilitySources[source] = new Signature(channel, strength, item); public readonly ReactiveDictionary EntityInfoGathered = new ReactiveDictionary(); public (List weapons, List items)[] WeaponGroups; @@ -1428,11 +1430,11 @@ public abstract class Entity var localSecurityLevel = Zone.GetSecurityLevel(Position.xz); if (CurrentSecurityLevel.Value != localSecurityLevel) CurrentSecurityLevel.Value = localSecurityLevel; - foreach (var v in VisibilitySources.Keys.ToArray()) + foreach (var v in _visibilitySources.Keys.ToArray()) { - VisibilitySources[v] = VisibilitySources[v].WithStrength(decay(VisibilitySources[v].Strength, ItemManager.GameplaySettings.VisibilityDecay, delta)); + _visibilitySources[v] = _visibilitySources[v].WithStrength(decay(_visibilitySources[v].Strength, ItemManager.GameplaySettings.VisibilityDecay, delta)); - if (VisibilitySources[v].Strength < 0.1f) VisibilitySources.Remove(v); + if (_visibilitySources[v].Strength < 0.1f) _visibilitySources.Remove(v); } UpdateTemperature(delta); From dc24468609cc51cbc4410346d196b36bd4be8d3c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 11:37:08 +0200 Subject: [PATCH 4/8] SignatureTests: pin decay items, key 3 and ordinals, radiator and ping strength, channels at two strengths Decay with a gun, a drive and two Visibility gears checks every entry keeps its own item. Key 3 of union 22 by golden bytes, the old three-slot record reads Thermal, the five ordinals, json "channel", and a reopened catalog file. The radiator's strength is HeatRadiationMultiplier x Emissivity at exponent 0; the ping's is its authored PingVisibility at two values. Gun, beam and thruster channels hold at a strong and a sub-threshold strength. Not yet built or run at this commit. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/SignatureTests.cs | 167 ++++++++++++++++-- 1 file changed, 148 insertions(+), 19 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 3a73ec617..46e75e0a7 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -9,6 +9,10 @@ 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; @@ -33,7 +37,12 @@ public sealed class SignatureTests : IDisposable private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; - private static GameplaySettings Settings(float visibilityDecay) => new GameplaySettings + // Per-test authoring of the numbers a rule is a function of (all default to the values the other tests rely on). + private float _weaponVisibility = 100f; + private float _radiationMultiplier = 0f; // the hull radiates nothing unless a test says so + private float _radiationExponent = 1f; + + private GameplaySettings Settings(float visibilityDecay) => new GameplaySettings { DefaultEntitySettings = new EntitySettings(), Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, @@ -50,8 +59,9 @@ public sealed class SignatureTests : IDisposable AgentMinHitProbability = .2f, BeamResolveInterval = .1f, VisibilityDecay = visibilityDecay, - // The hull radiates nothing, so every sum below is the sum of the authored constants. - HeatRadiationMultiplier = 0f + // The hull radiates nothing by default, so every sum below is the sum of the authored constants. + HeatRadiationMultiplier = _radiationMultiplier, + HeatRadiationExponent = _radiationExponent }; private static GearData Gear(string name, BehaviorData behavior) => new GearData @@ -107,7 +117,7 @@ public sealed class SignatureTests : IDisposable behavior.Penetration = Constant(0); behavior.Energy = Constant(50); behavior.Heat = Constant(0); - behavior.Visibility = Constant(100); + behavior.Visibility = Constant(_weaponVisibility); behavior.MagazineSize = armament == Armament.Gun ? 5 : 0; behavior.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }; cache.Upsert(new WeaponItemData @@ -174,26 +184,34 @@ public sealed class SignatureTests : IDisposable // ---- EveryWriterNamesItsChannel ---- - [Fact] - public void AFiredGunShowsALaunchFlashWithItsItem() + // The channel is the writer's meaning, not the strength's: a strong flash and one below any plausible threshold + // travel on the same channel. + [Theory] + [InlineData(100f)] + [InlineData(.5f)] + public void AFiredGunShowsALaunchFlashWithItsItem(float visibility) { + _weaponVisibility = visibility; var r = Build(Armament.Gun, 0f, null); Fire(r); var s = Of(r.Ship, r.Weapon); Assert.Equal(SignatureChannel.LaunchFlash, s.Channel); Assert.Same(r.WeaponItem, s.Item); - Assert.True(s.Strength > 1f, "fixture: the gun fired"); + Assert.Equal(visibility, s.Strength, 3); } - [Fact] - public void ABurningBeamShowsALaunchFlashWithItsItem() + [Theory] + [InlineData(100f)] + [InlineData(.5f)] + public void ABurningBeamShowsALaunchFlashWithItsItem(float visibility) { + _weaponVisibility = visibility; var r = Build(Armament.Beam, 0f, null); Fire(r); var s = Of(r.Ship, r.Weapon); Assert.Equal(SignatureChannel.LaunchFlash, s.Channel); Assert.Same(r.WeaponItem, s.Item); - Assert.True(s.Strength > 1f, "fixture: the beam burned"); + Assert.Equal(visibility, s.Strength, 3); } private static ThrusterData Drive(float visibility) => @@ -205,30 +223,37 @@ public sealed class SignatureTests : IDisposable r.Zone.Update(.1f); } - [Fact] - public void ABurningThrusterShowsADrivePlumeWithItsItem() + [Theory] + [InlineData(10f)] + [InlineData(.5f)] + public void ABurningThrusterShowsADrivePlumeWithItsItem(float visibility) { - var r = Build(Armament.None, 0f, Drive(10)); + var r = Build(Armament.None, 0f, Drive(visibility)); var thruster = Single(r.Ship); thruster.Item.UpdatePerformance(); Burn(r, 1f); var s = Of(r.Ship, thruster); Assert.Equal(SignatureChannel.DrivePlume, s.Channel); Assert.Same(thruster.Item, s.Item); - Assert.True(s.Strength > 1f, "fixture: the thruster burned"); + Assert.Equal(visibility, s.Strength, 3); } + // The radiator's light is HeatRadiationMultiplier x Emissivity at a radiation exponent of 0 (the temperature + // drops out), so the strength is a function of two authored inputs: .25 x 3. [Fact] public void ARadiatorShowsAThermalSignatureWithItsItem() { + _radiationMultiplier = .25f; + _radiationExponent = 0f; var r = Build(Armament.None, 0f, null, new RadiatorData { - Emissivity = Constant(1), PumpedHeat = Constant(0), TemperatureFloor = 0, WasteHeat = Constant(0), EnergyUsage = Constant(0), ThermalMass = Constant(1000) + Emissivity = Constant(3), PumpedHeat = Constant(0), TemperatureFloor = 0, WasteHeat = Constant(0), EnergyUsage = Constant(0), ThermalMass = Constant(1000) }); var radiator = Single(r.Ship); var s = Of(r.Ship, radiator); Assert.Equal(SignatureChannel.Thermal, s.Channel); Assert.Same(radiator.Item, s.Item); + Assert.Equal(.75f, s.Strength, 3); } [Fact] @@ -250,12 +275,14 @@ public sealed class SignatureTests : IDisposable Assert.Null(s.Item); } - [Fact] - public void APingShowsAnEmissionWithItsItem() + [Theory] + [InlineData(5f)] + [InlineData(9f)] + public void APingShowsAnEmissionWithItsItem(float pingVisibility) { var r = Build(Armament.None, 0f, null, new SensorData { - Sensitivity = Constant(3f), PingBoost = Constant(.02f), PingEnergy = Constant(0f), PingVisibility = Constant(5f), + Sensitivity = Constant(3f), PingBoost = Constant(.02f), PingEnergy = Constant(0f), PingVisibility = Constant(pingVisibility), PingRange = Constant(300f), PingCooldown = Constant(4f) }); var sensor = Single(r.Ship); @@ -263,7 +290,7 @@ public sealed class SignatureTests : IDisposable var s = Of(r.Ship, sensor); Assert.Equal(SignatureChannel.Emission, s.Channel); Assert.Same(sensor.Item, s.Item); - Assert.True(s.Strength > 1f, "fixture: the ping lit the ship"); + Assert.Equal(pingVisibility, s.Strength, 3); } // A catalog-authored Visibility behaviour travels on the channel its data names, thermal unless it says more. @@ -336,6 +363,30 @@ public sealed class SignatureTests : IDisposable Assert.False(r.Ship.VisibilitySources.ContainsKey(r.Weapon), "a signature below 0.1 is gone"); } + // Decay rebuilds every entry from its own: with a gun, a drive and two Visibility gears all decaying together, + // each entry still names the item of the source that wrote it (and the hull's names none). + [Fact] + public void DecayKeepsEachSourcesOwnItem() + { + var r = Build(Armament.Gun, .5f, Drive(10), + new VisibilityData { Visibility = Constant(40), VisibilityDecay = Constant(0), Channel = SignatureChannel.Emission }, + new VisibilityData { Visibility = Constant(30), VisibilityDecay = Constant(0), Channel = SignatureChannel.Reflection }); + var thruster = Single(r.Ship); + thruster.Item.UpdatePerformance(); + Burn(r, 1f); + Fire(r); + + var itemed = r.Ship.VisibilitySources.Where(kv => kv.Key is Behavior).ToArray(); + Assert.Equal(4, itemed.Length); + Assert.Equal(4, itemed.Select(kv => kv.Value.Item).Distinct().Count()); + + r.Zone.Update(1f); + var after = r.Ship.VisibilitySources.Where(kv => kv.Key is Behavior).ToArray(); + Assert.Equal(4, after.Length); + foreach (var kv in after) Assert.Same(((Behavior) kv.Key).Item, kv.Value.Item); + Assert.All(r.Ship.VisibilitySources.Where(kv => kv.Key is Entity), kv => Assert.Null(kv.Value.Item)); + } + // ---- APlumeKeepsTheLargerBurn ---- [Fact] @@ -365,4 +416,82 @@ public sealed class SignatureTests : IDisposable var full = Of(r.Ship, thruster).Strength; Assert.True(full > half && half > 0f, $"half {half}, full {full}"); } + + // ---- The persisted field ---- + + private static MessagePackSerializerOptions Options => CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly); + + // The channel is Key 3 of VisibilityData (union 22): the golden bytes, not a round trip against itself. + [Fact] + public void TheChannelIsKey3OfUnion22() + { + var data = new VisibilityData { Visibility = Constant(1f), VisibilityDecay = Constant(2f), Channel = SignatureChannel.Emission }; + var bytes = MessagePackSerializer.Serialize(data, Options); + + var reader = new MessagePackReader(bytes); + Assert.Equal(2, reader.ReadArrayHeader()); + Assert.Equal(22, reader.ReadInt32()); + Assert.Equal(4, reader.ReadArrayHeader()); + reader.Skip(); // Group + Assert.Equal(1f, MessagePackSerializer.Deserialize(ref reader, Options).Min); + Assert.Equal(2f, MessagePackSerializer.Deserialize(ref reader, Options).Min); + Assert.Equal(4, reader.ReadInt32()); + } + + // Append-only: a record written before the field existed (three slots) reads as Thermal. + [Fact] + public void ARecordWithoutTheChannelReadsAsThermal() + { + var buffer = new System.Buffers.ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(2); + writer.Write(22); + writer.WriteArrayHeader(3); + writer.Write(0); + writer.Flush(); + MessagePackSerializer.Serialize(buffer, Constant(7f), Options); + MessagePackSerializer.Serialize(buffer, Constant(0f), Options); + + var data = Assert.IsType(MessagePackSerializer.Deserialize(buffer.WrittenMemory, Options)); + Assert.Equal(SignatureChannel.Thermal, data.Channel); + Assert.Equal(7f, data.Visibility.Min); + } + + // Persisted ordinals: reordering the enum would silently re-label every authored record. + [Fact] + public void TheChannelOrdinalsAreFixed() + { + Assert.Equal(0, (int) SignatureChannel.Thermal); + Assert.Equal(1, (int) SignatureChannel.Reflection); + Assert.Equal(2, (int) SignatureChannel.DrivePlume); + Assert.Equal(3, (int) SignatureChannel.LaunchFlash); + Assert.Equal(4, (int) SignatureChannel.Emission); + Assert.Equal(5, Enum.GetValues(typeof(SignatureChannel)).Length); + } + + [Fact] + public void TheChannelIsTheJsonPropertyChannel() + { + var json = JObject.Parse(JsonConvert.SerializeObject(new VisibilityData { Channel = SignatureChannel.Emission })); + Assert.Equal(4, (int) json["channel"]); + } + + // Through the real catalog store: authored channels come back from a reopened file, the unauthored one Thermal. + [Fact] + public void AnAuthoredChannelSurvivesTheCatalogFile() + { + var path = Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(Gear("Plume", new VisibilityData { Visibility = Constant(1), Channel = SignatureChannel.DrivePlume })); + cache.Upsert(Gear("Plain", new VisibilityData { Visibility = Constant(1) })); + cache.FlushAsync().Wait(); + } + + using var reopened = AetheriaStores.Open(path, catalogWritable: true); + VisibilityData Read(string name) => Assert.IsType(reopened.GetByName(name).Behaviors.Single()); + Assert.Equal(SignatureChannel.DrivePlume, Read("Plume").Channel); + Assert.Equal(SignatureChannel.Thermal, Read("Plain").Channel); + } } From fb0c18b4fd0829e0621538956ea3871254860875 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 12:05:18 +0200 Subject: [PATCH 5/8] SignatureTests: decay test uses sources nothing rewrites (the item-swap mutant survived the first fixture) Mutant decay-item-from-last-entry survived dc244686: the gun was the last-inserted entry, so Values.Last().Item was its own, and every other entry was rewritten by its Execute after the decay loop. The fixture now stops the drive and pings once, so the gun, the drive and the ping are touched by decay alone and their three items differ. Builds. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/SignatureTests.cs | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 46e75e0a7..066be454b 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -363,28 +363,38 @@ public sealed class SignatureTests : IDisposable Assert.False(r.Ship.VisibilitySources.ContainsKey(r.Weapon), "a signature below 0.1 is gone"); } - // Decay rebuilds every entry from its own: with a gun, a drive and two Visibility gears all decaying together, - // each entry still names the item of the source that wrote it (and the hull's names none). + // Decay rebuilds every entry from its own. A gun, a drive that has stopped burning and a sensor that has pinged + // each wrote once and are never rewritten, so after a long tick the only thing touching them is decay; each + // still names the item of the source that wrote it, and the three items differ. [Fact] public void DecayKeepsEachSourcesOwnItem() { - var r = Build(Armament.Gun, .5f, Drive(10), - new VisibilityData { Visibility = Constant(40), VisibilityDecay = Constant(0), Channel = SignatureChannel.Emission }, - new VisibilityData { Visibility = Constant(30), VisibilityDecay = Constant(0), Channel = SignatureChannel.Reflection }); + var r = Build(Armament.Gun, .5f, Drive(10), new SensorData + { + Sensitivity = Constant(3f), PingBoost = Constant(.02f), PingEnergy = Constant(0f), PingVisibility = Constant(5f), + PingRange = Constant(300f), PingCooldown = Constant(4f) + }); var thruster = Single(r.Ship); + var sensor = Single(r.Ship); thruster.Item.UpdatePerformance(); Burn(r, 1f); + Burn(r, 0f); // the drive stops: its entry is no longer rewritten + sensor.Ping(); Fire(r); - var itemed = r.Ship.VisibilitySources.Where(kv => kv.Key is Behavior).ToArray(); - Assert.Equal(4, itemed.Length); - Assert.Equal(4, itemed.Select(kv => kv.Value.Item).Distinct().Count()); + var sources = new Behavior[] { thruster, sensor, r.Weapon }; + var before = sources.Select(b => Of(r.Ship, b)).ToArray(); + Assert.Equal(3, before.Select(e => e.Item).Distinct().Count()); + Assert.All(before, e => Assert.True(e.Strength > 1f, "fixture: each source has a live entry")); r.Zone.Update(1f); - var after = r.Ship.VisibilitySources.Where(kv => kv.Key is Behavior).ToArray(); - Assert.Equal(4, after.Length); - foreach (var kv in after) Assert.Same(((Behavior) kv.Key).Item, kv.Value.Item); - Assert.All(r.Ship.VisibilitySources.Where(kv => kv.Key is Entity), kv => Assert.Null(kv.Value.Item)); + for (var i = 0; i < sources.Length; i++) + { + var after = Of(r.Ship, sources[i]); + Assert.True(after.Strength < before[i].Strength, "fixture: decay touched the entry"); + Assert.Same(sources[i].Item, after.Item); + Assert.Equal(before[i].Channel, after.Channel); + } } // ---- APlumeKeepsTheLargerBurn ---- From c76584ee2114058c38cd00f8545c9f0548cbdd65 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:06:04 +0200 Subject: [PATCH 6/8] Entity.VisibilitySources: a ReadOnlyDictionary created once over the private field, so a cast back to Dictionary fails Finding one-writer-castable. Test commit follows; builds. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index bf30ee927..bbca6a2ad 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -5,6 +5,7 @@ using GameCult.Caching; using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using MessagePack; using UniRx; @@ -87,7 +88,9 @@ public abstract class Entity public readonly Dictionary Messages = new Dictionary(); private readonly Dictionary _visibilitySources = new Dictionary(); // A read-only view: the only writes are SetSignature and the decay loop in Update, both below. - public IReadOnlyDictionary VisibilitySources => _visibilitySources; + // A ReadOnlyDictionary over the private field, created once: a cast back to Dictionary fails. + private ReadOnlyDictionary _visibilityView; + public IReadOnlyDictionary VisibilitySources => _visibilityView ??= new ReadOnlyDictionary(_visibilitySources); // The one writer of VisibilitySources (the decay loop in Update is the one decayer). Every light signature // names its channel and the item that produced it (null for the hull). From 4059e533516e89b4e0226c38a6cea382378bd50b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:06:27 +0200 Subject: [PATCH 7/8] SignatureTests: decay channels named per writer, radiator strength at a nonzero exponent and two temperatures, the view is not a Dictionary Findings decay-channel-baseline-vacuous (expected DrivePlume, Emission, LaunchFlash per source, not a post-tick baseline), radiator-temperature-factor-unpinned (T^2 x multiplier x emissivity at two temperatures) and one-writer-castable (the exposed view is not a Dictionary, is the same object each read, refuses writes). Not built locally; verified on Yggdrasil. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/SignatureTests.cs | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 066be454b..2438b9162 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -256,6 +256,32 @@ public sealed class SignatureTests : IDisposable Assert.Equal(.75f, s.Strength, 3); } + // At a radiation exponent of 2 the strength is T^2 x multiplier x emissivity of the radiator's own temperature, + // at two different temperatures: a signature that ignores temperature cannot match both. + [Fact] + public void ARadiatorsStrengthFollowsItsTemperature() + { + _radiationMultiplier = .001f; + _radiationExponent = 2f; + var r = Build(Armament.None, 0f, null, new RadiatorData + { + Emissivity = Constant(3), PumpedHeat = Constant(0), TemperatureFloor = 0, WasteHeat = Constant(0), EnergyUsage = Constant(0), ThermalMass = Constant(1000) + }); + var radiator = Single(r.Ship); + var seen = new System.Collections.Generic.List(); + for (var round = 0; round < 2; round++) + { + var t = radiator.RadiatorTemperature; + seen.Add(t); + Assert.True(t > 1f, "fixture: the radiator is warm"); + radiator.Update(0f); // no cooling: the entry is the formula at exactly t + var expected = Math.Pow(t, 2) * .001 * 3; + Assert.InRange(Of(r.Ship, radiator).Strength, expected * .999, expected * 1.001); + r.Zone.Update(1f); // the radiator cools + } + Assert.True(Math.Abs(seen[0] - seen[1]) > 1e-3, "fixture: the two temperatures differ"); + } + [Fact] public void AReflectorShowsAReflectionWithItsItem() { @@ -383,6 +409,8 @@ public sealed class SignatureTests : IDisposable Fire(r); var sources = new Behavior[] { thruster, sensor, r.Weapon }; + // What each writer authors, named here and not read back from the fixture's own ticks. + var writes = new[] { SignatureChannel.DrivePlume, SignatureChannel.Emission, SignatureChannel.LaunchFlash }; var before = sources.Select(b => Of(r.Ship, b)).ToArray(); Assert.Equal(3, before.Select(e => e.Item).Distinct().Count()); Assert.All(before, e => Assert.True(e.Strength > 1f, "fixture: each source has a live entry")); @@ -393,10 +421,21 @@ public sealed class SignatureTests : IDisposable var after = Of(r.Ship, sources[i]); Assert.True(after.Strength < before[i].Strength, "fixture: decay touched the entry"); Assert.Same(sources[i].Item, after.Item); - Assert.Equal(before[i].Channel, after.Channel); + Assert.Equal(writes[i], after.Channel); } } + // The exposed view is not the private dictionary: a cast back to Dictionary cannot write around SetSignature. + [Fact] + public void TheVisibilityViewIsNotTheWritableDictionary() + { + var r = Build(Armament.None, 0f, null); + var view = r.Ship.VisibilitySources; + Assert.False(view is Dictionary); + Assert.Same(view, r.Ship.VisibilitySources); + Assert.Throws(() => ((IDictionary) view)[r.Ship] = new Signature(SignatureChannel.Thermal, 1f, null)); + } + // ---- APlumeKeepsTheLargerBurn ---- [Fact] From f30c3550abff1ec0fe8fa8a4cffc3c3bdc7cbc1b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:24:52 +0200 Subject: [PATCH 8/8] SignatureTests: the one-view check fails by assertion, not by a test-host abort (Assert.Same formatted the entity graph) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/SignatureTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index 2438b9162..cc0dbd7d8 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -432,7 +432,7 @@ public sealed class SignatureTests : IDisposable var r = Build(Armament.None, 0f, null); var view = r.Ship.VisibilitySources; Assert.False(view is Dictionary); - Assert.Same(view, r.Ship.VisibilitySources); + Assert.True(ReferenceEquals(view, r.Ship.VisibilitySources), "one view object per entity"); // not Assert.Same: its failure message formats the entity graph and aborts the test host Assert.Throws(() => ((IDictionary) view)[r.Ship] = new Signature(SignatureChannel.Thermal, 1f, null)); }