diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 9f2e292a6..7515d55e7 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -187,6 +187,7 @@ public interface IPowerConsumer // superseded draft quoted -- checked directly here rather than trusted from that document. Union(39, typeof(TargetingSystemData)), Union(40, typeof(MineLayerData)), + Union(42, typeof(VapourDumpData)), JsonObject(MemberSerialization.OptIn)] public abstract class BehaviorData { diff --git a/Assets/Scripts/ServerShared/Behaviors/VapourDump.cs b/Assets/Scripts/ServerShared/Behaviors/VapourDump.cs new file mode 100644 index 000000000..3e035f9b8 --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/VapourDump.cs @@ -0,0 +1,61 @@ +using MessagePack; +using Newtonsoft.Json; + +// A coolant dump that becomes a vapour cloud: the first time it runs it vents one cloud around its venter, +// drifting with the venter's velocity. Radius, Opacity and Lifetime are evaluated through the host, so role +// quality applies as for any other stat. Later runs vent nothing. +[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] +public class VapourDumpData : BehaviorData +{ + [Inspectable, JsonProperty("radius"), Key(1), RuntimeInspectable] + public PerformanceStat Radius = new PerformanceStat(); + + // How much of a sight line's signal a fresh cloud removes, in [0, 1]. + [Inspectable, JsonProperty("opacity"), Key(2), RuntimeInspectable] + public PerformanceStat Opacity = new PerformanceStat(); + + // Seconds from venting until the cloud has faded away and is removed. + [Inspectable, JsonProperty("lifetime"), Key(3), RuntimeInspectable] + public PerformanceStat Lifetime = new PerformanceStat(); + + public override Behavior CreateInstance(EquippedItem item) + { + return new VapourDump(this, item); + } + + public override Behavior CreateInstance(ConsumableItemEffect item) + { + return new VapourDump(this, item); + } +} + +public class VapourDump : Behavior +{ + private VapourDumpData _data; + private bool _vented; + + public VapourDump(VapourDumpData data, EquippedItem item) : base(data, item) + { + _data = data; + } + + public VapourDump(VapourDumpData data, ConsumableItemEffect item) : base(data, item) + { + _data = data; + } + + public override bool Execute(float dt) + { + if (_vented) return true; + _vented = true; + Entity.Zone.Vent(new VapourCloud + { + Body = new KinematicBody { Position = Entity.Position.xz, Velocity = Entity.Velocity }, + Radius = Evaluate(_data.Radius), + Opacity = Evaluate(_data.Opacity), + Lifetime = Evaluate(_data.Lifetime), + Venter = Entity + }); + return true; + } +} diff --git a/Assets/Scripts/ServerShared/Behaviors/VapourDump.cs.meta b/Assets/Scripts/ServerShared/Behaviors/VapourDump.cs.meta new file mode 100644 index 000000000..86ef08913 --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/VapourDump.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 961a74683c574807a300eb26694f206d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/VapourClouds.cs b/Assets/Scripts/ServerShared/VapourClouds.cs new file mode 100644 index 000000000..d6d1aaa24 --- /dev/null +++ b/Assets/Scripts/ServerShared/VapourClouds.cs @@ -0,0 +1,31 @@ +using CultMath; +using static CultMath.math; +using float2 = CultMath.float2; + +// A vented vapour cloud: a free-floating disc that thins every sensor sight line through it. Not an Entity, +// never priced or rolled, never saved. Zone.Vent is the only adder and Zone's cloud step the only remover; +// Zone.Obscuration is the one reader detection uses. +public sealed class VapourCloud +{ + public FloatingBodyId Id; + public KinematicBody Body; + public double VentedAt; + public float Lifetime; + public float Radius; + // The opacity at the moment of venting; it fades linearly to nothing over Lifetime. + public float Opacity; + public Entity Venter; + + public float OpacityAt(double now) => + Lifetime <= 0f ? 0f : saturate(Opacity) * saturate(1f - (float) (now - VentedAt) / Lifetime); + + // Whether the segment a-b reaches the disc: the closest point of the segment to the centre lies within the + // radius. A cloud past either end of the segment touches it only if it is near that end. + public bool Touches(float2 a, float2 b) + { + var ab = b - a; + var length2 = lengthsq(ab); + var t = length2 > 0f ? saturate(dot(Body.Position - a, ab) / length2) : 0f; + return lengthsq(Body.Position - (a + ab * t)) <= Radius * Radius; + } +} diff --git a/Assets/Scripts/ServerShared/VapourClouds.cs.meta b/Assets/Scripts/ServerShared/VapourClouds.cs.meta new file mode 100644 index 000000000..cdaa4b80d --- /dev/null +++ b/Assets/Scripts/ServerShared/VapourClouds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ced45a9738974452b0065b57ee527e8a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index df07d1f57..0aa822775 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -20,6 +20,10 @@ public class Zone // Laid mines, in lay order. Zone owns their existence: Lay is the one adder, the mine step the one remover, // and every removal is a blast. Runtime state, never saved. public ReactiveCollection Mines = new ReactiveCollection(); + + // Vented vapour clouds, in vent order. Zone owns their existence: Vent is the one adder, StepClouds the one + // remover, and Obscuration the one reader that detection uses. Runtime state, never saved. + public ReactiveCollection Clouds = new ReactiveCollection(); public Dictionary Planets = new Dictionary(); public Dictionary PlanetInstances = new Dictionary(); @@ -225,6 +229,7 @@ public class Zone foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); StepMines(deltaTime); + StepClouds(deltaTime); // Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that // firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and @@ -238,6 +243,39 @@ public class Zone Mines.Add(mine); } + public void Vent(VapourCloud cloud) + { + cloud.Id = NextBodyId(); + cloud.VentedAt = _time; + Clouds.Add(cloud); + } + + // The fraction of a sight line's signal that survives the clouds between `a` and `b`: the product of + // (1 - opacity now) over every cloud whose disc the segment touches, so 1f when none does. Derived from the + // live clouds at each call and stored nowhere; Sensor.Gain is where it enters detection. + public float Obscuration(float2 a, float2 b) + { + var through = 1f; + for (var i = 0; i < Clouds.Count; i++) + { + var cloud = Clouds[i]; + if (cloud.Touches(a, b)) through *= 1f - cloud.OpacityAt(_time); + } + return through; + } + + // After StepMines: each cloud drifts as a KinematicBody on the zone's force, as a mine does, and is removed + // once its lifetime has run out. Nothing else removes a cloud. + private void StepClouds(float deltaTime) + { + var settings = _itemManager.GameplaySettings; + foreach (var cloud in Clouds.ToArray()) + { + cloud.Body.Step(deltaTime, GetForce(cloud.Body.Position), settings); + if (cloud.VentedAt + cloud.Lifetime <= _time) Clouds.Remove(cloud); + } + } + // After the entity loop and before FireControl.Step, in lay order: each mine drifts as a KinematicBody, is // triggered by the first eligible hull its armed disc touches, and detonates through FireControl.Detonate at // its trigger plus fuse delay or at its lifetime, whatever its layer does (ruling mine-owner-leash). The diff --git a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs new file mode 100644 index 000000000..d829de565 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Vapour clouds in the sim (aetheria-release cut vapour-cloud, ruling vapour-cloud-obscuration): a VapourDump +// behaviour vents a zone cloud that thins every sensor sight line through it, both ways, through Sensor.Gain's +// one detection rule; a lock falls by the existing detection-threshold rule and nothing else. The scene is two +// ships facing each other along z with a fixed visibility, so what a sensor gathers is a number the clouds +// alone change: steady info is visibility x sensitivity / (distance x TargetInfoDecay), and visibility 100, +// sensitivity 1, decay .5 puts it at .5 at range 400 (above the .1 threshold) and saturated at range 40. +public sealed class VapourCloudTests : IDisposable +{ + private const float Dt = .05f; + private const float Threshold = .1f; + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-vapour-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + private static readonly object Glow = new object(); + + public VapourCloudTests() => 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 Shape Solid(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private sealed class Lab + { + public ItemManager Items; + public Zone Zone; + public Ship Observer; + public Ship Target; + public EquippedItem Dump; + public LockWeapon Lock; + public int Lot = 1; + public bool HoldTarget = true; + } + + private Lab Build(float distance, float radius = 20f, float opacity = .9f, float lifetime = 1000f) + { + var settings = 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 = Threshold, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f, + TargetInfoDecay = .5f, VisibilityDecay = 0f, + FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 1f + }; + var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(new HullData + { + Name = "Hull", 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() } } + }); + GearData Gear(string name, HardpointType hardpoint, BehaviorData behavior) => new GearData + { + Name = name, Hardpoint = hardpoint, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { behavior } + }; + cache.Upsert(Gear("Eye", HardpointType.Tool, new SensorData + { + Sensitivity = Constant(1f), + SensitivityCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, + PingBoost = Constant(.02f), PingEnergy = Constant(0f), PingVisibility = Constant(5f), + PingRange = Constant(300f), PingCooldown = Constant(4f) + })); + cache.Upsert(Gear("Vent", HardpointType.Tool, new VapourDumpData + { + Radius = Constant(radius), Opacity = Constant(opacity), Lifetime = Constant(lifetime) + })); + cache.Upsert(Gear("LockGun", HardpointType.Sensors, new LockWeaponData + { + Damage = Constant(0), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), + Spread = Constant(0), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), + BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, + LockSpeed = Constant(1), SensorImpact = Constant(1), LockAngle = Constant(170), + DirectionImpact = Constant(1), Decay = Constant(1) + })); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 50; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 5000 }, new GalaxyZone { Name = "Vapour", Owner = null }, null); + var lab = new Lab { Items = items, Zone = zone }; + + EquippableItem Make(string name) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = 1, Lot = lab.Lot++ + }; + Ship NewShip() + { + var hull = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var ship = new Ship(items, zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = lab.Lot++ }, new EntitySettings()); + Assert.True(ship.TryEquip(Make("Eye"))); + return ship; + } + + lab.Observer = NewShip(); + Assert.True(lab.Observer.TryEquip(Make("LockGun"), new int2(0, 0))); + lab.Target = NewShip(); + var dump = Make("Vent"); + Assert.True(lab.Target.TryEquip(dump)); + lab.Dump = lab.Target.Equipment.Single(x => x.EquippableItem == dump); + // Held off until a test pulls the trigger: the dump vents on its first run. + lab.Dump.Enabled.Value = false; + + zone.Entities.Add(lab.Observer); + zone.Entities.Add(lab.Target); + lab.Observer.Activate(); + lab.Target.Activate(); + lab.Observer.Position = float3(0, 0, 0); + lab.Observer.Aim = float3(0, 0, 1); + lab.Target.Position = float3(0, 0, distance); + lab.Target.Aim = float3(0, 0, -1); + lab.Observer.SetIff(lab.Target, true); + lab.Target.SetIff(lab.Observer, true); + zone.Update(0f); + lab.Lock = lab.Observer.Weapons.OfType().Single(); + return lab; + } + + // One tick: both ships stay where they were put, facing each other, each a fixed visibility to the other's sensor. + private static void Tick(Lab lab) + { + foreach (var ship in new[] { lab.Observer, lab.Target }) + { + ship.VisibilitySources[Glow] = 100f; + if (ship != lab.Target || lab.HoldTarget) ship.Velocity = float2.zero; + } + lab.Observer.Direction = float2(0, 1); + lab.Target.Direction = float2(0, -1); + lab.Zone.Update(Dt); + } + + private static void Run(Lab lab, int ticks) + { + for (var i = 0; i < ticks; i++) Tick(lab); + } + + private static float Info(Ship watcher, Ship watched) => watcher.EntityInfoGathered[watched]; + + // The observer has settled on the target and holds a full lock on it: the state a cloud has to break. + private static void Establish(Lab lab) + { + Run(lab, 100); + Assert.True(Info(lab.Observer, lab.Target) > Threshold, "the observer never gathered the target"); + Assert.Contains(lab.Target, lab.Observer.VisibleEnemies); + lab.Observer.SetTarget(lab.Target); + Run(lab, 100); + Assert.True(lab.Lock.IsLocked, "the observer never locked the target"); + Assert.Empty(lab.Zone.Clouds); + } + + private static void Vent(Lab lab) + { + lab.Dump.Enabled.Value = true; + for (var i = 0; i < 5 && lab.Zone.Clouds.Count == 0; i++) Tick(lab); + Assert.Single(lab.Zone.Clouds); + } + + private static VapourCloud Hand(Zone zone, float2 at, float radius, float opacity, float lifetime = 1000f) + { + var cloud = new VapourCloud { Body = new KinematicBody { Position = at }, Radius = radius, Opacity = opacity, Lifetime = lifetime }; + zone.Vent(cloud); + return cloud; + } + + [Fact] + public void ACloudFadesAndIsRemoved() + { + var lab = Build(400f); + var cloud = Hand(lab.Zone, float2(0, 0), 10f, .8f, lifetime: 10f); + var a = float2(-5, 0); + var b = float2(5, 0); + Assert.Equal(.2f, lab.Zone.Obscuration(a, b), 5); + + for (var i = 0; i < 5; i++) lab.Zone.Update(1f); + Assert.Equal(.4f, cloud.OpacityAt(lab.Zone.Time), 5); + Assert.Equal(.6f, lab.Zone.Obscuration(a, b), 5); + Assert.Single(lab.Zone.Clouds); + + for (var i = 0; i < 5; i++) lab.Zone.Update(1f); + Assert.Empty(lab.Zone.Clouds); + Assert.Equal(1f, lab.Zone.Obscuration(a, b)); + } + + [Fact] + public void ACloudWithNoLifetimeObscuresNothing() + { + var lab = Build(400f); + var cloud = Hand(lab.Zone, float2(0, 0), 10f, .8f, lifetime: 0f); + Assert.Equal(0f, cloud.OpacityAt(lab.Zone.Time)); + Assert.Equal(1f, lab.Zone.Obscuration(float2(-5, 0), float2(5, 0))); + } + + [Fact] + public void AnEffectVentsOnce() + { + var lab = Build(400f); + lab.HoldTarget = false; + Run(lab, 2); + lab.Target.Velocity = float2(3, 0); + VapourCloud vented = null; + float2 venterVelocity = float2.zero; + float2 venterAt = float2.zero; + using var sub = lab.Zone.Clouds.ObserveAdd().Subscribe(add => + { + vented = add.Value; + venterVelocity = lab.Target.Velocity; + venterAt = lab.Target.Position.xz; + }); + + lab.Dump.Enabled.Value = true; + Run(lab, 100); + + Assert.Single(lab.Zone.Clouds); + Assert.Same(vented, lab.Zone.Clouds.Single()); + Assert.True(length(venterVelocity) > 0f, "the venter was not moving"); + Assert.Equal(venterVelocity, vented.Body.Velocity); + Assert.Equal(venterAt, vented.Body.Position); + Assert.Same(lab.Target, vented.Venter); + } + + [Fact] + public void ACloudThinsOnlyTheSightLinesItTouches() + { + var lab = Build(400f); + Hand(lab.Zone, float2(0, 0), 10f, .6f); + float Through(float2 a, float2 b) => lab.Zone.Obscuration(a, b); + + Assert.Equal(.4f, Through(float2(-50, 0), float2(50, 0)), 5); + Assert.Equal(.4f, Through(float2(50, 0), float2(-50, 0)), 5); + // Passing wide of the centre but within the radius, neither end inside the disc. + Assert.Equal(.4f, Through(float2(-50, 9.9f), float2(50, 9.9f)), 5); + Assert.Equal(1f, Through(float2(-50, 10.1f), float2(50, 10.1f))); + // Ending inside the disc, and a point inside it (the observer inside its own cloud). + Assert.Equal(.4f, Through(float2(-50, 0), float2(5, 0)), 5); + Assert.Equal(.4f, Through(float2(3, 0), float2(3, 0)), 5); + Assert.Equal(1f, Through(float2(30, 0), float2(30, 0))); + // A segment that stops short of the disc, or starts past it, touches nothing even on the same line. + Assert.Equal(1f, Through(float2(-50, 0), float2(-20, 0))); + Assert.Equal(1f, Through(float2(20, 0), float2(50, 0))); + + // Two clouds on one sight line multiply. + Hand(lab.Zone, float2(30, 0), 5f, .5f); + Assert.Equal(.2f, Through(float2(-50, 0), float2(50, 0)), 5); + Assert.Equal(.4f, Through(float2(-50, 0), float2(15, 0)), 5); + } +}