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..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; @@ -85,7 +86,16 @@ 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. + // 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). + 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 +168,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)>(); @@ -1421,11 +1433,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] = 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 +1628,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..cc0dbd7d8 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -0,0 +1,546 @@ +/* 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 GameCult.Caching.MessagePack; +using MessagePack; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Visibility cut 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 }; + + // 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) } }, + 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 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 + { + 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(_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 + { + 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 ---- + + // 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.Equal(visibility, s.Strength, 3); + } + + [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.Equal(visibility, s.Strength, 3); + } + + 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); + } + + [Theory] + [InlineData(10f)] + [InlineData(.5f)] + public void ABurningThrusterShowsADrivePlumeWithItsItem(float visibility) + { + 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.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(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); + } + + // 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() + { + 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); + } + + [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(pingVisibility), + 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.Equal(pingVisibility, s.Strength, 3); + } + + // 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 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(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(4f, ship.SignatureOn(SignatureChannel.Reflection), 2); + Assert.Equal(2f, ship.SignatureOn(SignatureChannel.Emission), 2); + Assert.Equal(3f, 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"); + } + + // 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 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 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")); + + r.Zone.Update(1f); + 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(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.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)); + } + + // ---- 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}"); + } + + // ---- 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); + } +} 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);