diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index e1d0d8cea..4bc5731a7 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -186,6 +186,7 @@ public interface IPowerConsumer // Cut 2 (docs/fire-control-cut.md): 39 is the next free index against this list, not the 39 the // superseded draft quoted -- checked directly here rather than trusted from that document. Union(39, typeof(TargetingSystemData)), + Union(40, typeof(MineLayerData)), JsonObject(MemberSerialization.OptIn)] public abstract class BehaviorData { diff --git a/Assets/Scripts/ServerShared/Behaviors/MineLayer.cs b/Assets/Scripts/ServerShared/Behaviors/MineLayer.cs new file mode 100644 index 000000000..a2a7b0a7d --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/MineLayer.cs @@ -0,0 +1,27 @@ +using MessagePack; +using Newtonsoft.Json; + +// A weapon that lays a mine instead of firing a round: InstantWeapon pays for it (cooldown, ammo, energy, heat, +// wear, visibility) and FireControl.Fire routes it to Lay. The mine's own timings live here until gear supplies +// them (ruling missile-stats-now-gear-later). The blast radius is the item's BlastRadius, read through +// FireControl.FuseOf like every other blast. +[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] +public class MineLayerData : InstantWeaponData +{ + // Seconds after laying before the mine can be triggered. + [Inspectable, JsonProperty("armingDelay"), Key(21)] + public float ArmingDelay = 2f; + + // Seconds from the trigger to the blast. + [Inspectable, JsonProperty("fuseDelay"), Key(22)] + public float FuseDelay = 2f; + + // Seconds after laying at which an untriggered mine detonates. + [Inspectable, JsonProperty("lifetime"), Key(23)] + public float Lifetime = 30f; + + public override Behavior CreateInstance(EquippedItem item) + { + return new InstantWeapon(this, item); + } +} diff --git a/Assets/Scripts/ServerShared/Behaviors/MineLayer.cs.meta b/Assets/Scripts/ServerShared/Behaviors/MineLayer.cs.meta new file mode 100644 index 000000000..52c10fd9c --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/MineLayer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1cc95be85eee47c79199bea20a6d7509 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 768077deb..6d4e66ac7 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -84,14 +84,17 @@ public static class FireControl toTarget = float3(at.x, 0, at.y) - shooter.Position; } else toTarget = target.Entity.Position - shooter.Position; - return FuseOf(weapon.Item, out _) != null || InArc(weapon.Item, toTarget); + return FuseOf(weapon.Item, out _) != null || IsMineLayer(weapon) || InArc(weapon.Item, toTarget); } // Whether the shooter's next round from this weapon is refused: the weapons ask before they spend anything, so // a refused round costs no ammo, energy, sound, heat, wear or visibility (operator ruling 2026-09-30). The // answer is Solve's own -- there is no second copy of the arming test. public static bool Refuses(Weapon weapon, Entity shooter) => - Solve(weapon, weapon.Item, shooter, shooter.Target.Value.Entity).Outcome == FireOutcome.Refused; + !IsMineLayer(weapon) && Solve(weapon, weapon.Item, shooter, shooter.Target.Value.Entity).Outcome == FireOutcome.Refused; + + // A mine layer lays a body, not a round: it is never refused, never arc-gated, and Fire routes it to Lay. + public static bool IsMineLayer(Weapon weapon) => weapon.WeaponData is MineLayerData; // The agents' trigger decision (Combat, TurretController), one weapon at a time (operator ruling 2026-09-30). // A weapon that fires at what it hits fires when the shot is worth taking. A fused weapon fires whenever its @@ -100,6 +103,8 @@ public static class FireControl // never one Solve refuses: a refused weapon is not a weapon it can use. public static bool AgentFires(Weapon weapon, Entity shooter, Entity target) { + // A mine layer lays only at a designated target in range, never at nothing and never on a hit probability. + if (IsMineLayer(weapon)) return Solve(weapon, weapon.Item, shooter, target).Designated; if (FuseOf(weapon.Item, out _) == null) return HitProbability(weapon, shooter, target) >= shooter.ItemManager.GameplaySettings.AgentMinHitProbability; var solution = Solve(weapon, weapon.Item, shooter, target); @@ -530,6 +535,11 @@ public static class FireControl public static int Fire(Weapon weapon, EquippedItem item, Entity source, float? damageOverride = null) { var zone = source.Zone; + if (IsMineLayer(weapon)) + { + Lay(weapon, item, source); + return 0; + } // Mining Cut 3: a chunk target fires as an untargeted round until Cut 4 routes shots at chunks. var target = source.Target.Value.Entity; var now = zone.Time; @@ -596,6 +606,44 @@ public static class FireControl return shot.ShotId; } + // Lays a mine for a mine layer: freezes the payload as evaluated now (stats, the item's blast radius, the + // layer and its faction) and hands the body to Zone.Lay, the one adder. It launches at the shooter's velocity + // plus the mount direction turned by a dice angle within +-Spread/2, times Velocity; the dice are the mine's + // own (zone seed, MineStream, body id), so no other roll is perturbed. No target, no PendingShot, no price: + // InstantWeapon has already paid. A layer whose item has no blast radius lays nothing. + public static void Lay(Weapon weapon, EquippedItem item, Entity source) + { + var zone = source.Zone; + var data = (MineLayerData) weapon.WeaponData; + if (FuseOf(item, out var blastRadius) == null) + { + zone.Log?.Invoke("A mine layer has no blast radius and lays nothing."); + return; + } + + var id = zone.NextBodyId(); + var half = weapon.Spread / 2f; + var angle = radians(SimulationDice.For(zone.CombatSeed, SimulationDice.MineStream, id.Value).NextFloat(-half, half)); + var s = sin(angle); + var c = cos(angle); + var mount = MountDirection(item).xz; + var direction = float2(mount.x * c - mount.y * s, mount.x * s + mount.y * c); + + zone.Lay(new Mine + { + Id = id, + Body = new KinematicBody { Position = source.Position.xz, Velocity = source.Velocity + direction * weapon.Velocity }, + ArmingDelay = data.ArmingDelay, + FuseDelay = data.FuseDelay, + Lifetime = data.Lifetime, + BlastRadius = blastRadius, + Damage = weapon.Damage, + DamageType = weapon.WeaponData.DamageType, + Layer = source, + LayerFaction = source.Faction + }); + } + // Cut 3, R4: ages every shot in the zone, commits the ones that have reached their horizon and resolves // the ones that have arrived. Called from Zone.Update after the entity loop. A shot whose target has left // the zone resolves as a miss and is removed outright (0b table), whichever stage it is at. A round outlives @@ -1405,6 +1453,19 @@ public static class FireControl } } + // Whether the disc reaches any occupied hull cell of the entity: Detonate's own coverage test (the same + // ToSchematicPoint, SchematicCellSize and CircleSquareOverlap), so what triggers a mine and what its blast + // reaches are one geometry. + public static bool Touches(Entity entity, float2 worldPlanar, float radius) + { + var rCells = radius / entity.ItemManager.GameplaySettings.SchematicCellSize; + var centre = entity.ToSchematicPoint(worldPlanar); + var coords = (entity.ItemManager.GetData(entity.Hull) as HullData).Shape.Coordinates; + for (var i = 0; i < coords.Length; i++) + if (CircleSquareOverlap(centre, rCells, coords[i]) > 0f) return true; + return false; + } + // Cut 12.4(b) (docs/fire-control-cut.md, "Exact overlap, in float"): the exact area of a unit square, // centred on `cell` (the unit square centred on its integer coordinate, the same convention every other // frame rule uses), that lies inside a disc of radius `r` centred at `diskCentre` -- both in the same diff --git a/Assets/Scripts/ServerShared/Mines.cs b/Assets/Scripts/ServerShared/Mines.cs new file mode 100644 index 000000000..f69376fc1 --- /dev/null +++ b/Assets/Scripts/ServerShared/Mines.cs @@ -0,0 +1,25 @@ +// A laid mine: a free-floating body with a frozen payload. Not an Entity, never priced or rolled, never saved. +// Zone.Lay is the only adder and Zone's mine step the only remover; every removal is its blast. +public sealed class Mine +{ + public FloatingBodyId Id; + public KinematicBody Body; + public double LaidAt; + public float ArmingDelay; + public float FuseDelay; + public float Lifetime; + public float BlastRadius; + public float Damage; + public DamageType DamageType; + // The layer and its faction, frozen at lay (ruling mine-trigger-iff). + public Entity Layer; + public Faction LayerFaction; + // Set once, when an eligible hull first touches the armed disc; the fuse counts from it. + public double? TriggeredAt; + + public bool Armed(double now) => now >= LaidAt + ArmingDelay; + + // Ruling mine-trigger-iff: only an entity that is neither the layer nor of the layer's faction triggers it. + public bool Triggers(Entity entity) => + entity != Layer && (LayerFaction == null || entity.Faction != LayerFaction); +} diff --git a/Assets/Scripts/ServerShared/Mines.cs.meta b/Assets/Scripts/ServerShared/Mines.cs.meta new file mode 100644 index 000000000..07b13201f --- /dev/null +++ b/Assets/Scripts/ServerShared/Mines.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4aea0eb6ee234016a258266945ed2db9 +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 2e2fe6290..087cf79bf 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -16,6 +16,10 @@ public class Zone { public Action Log; public ReactiveCollection Entities = new ReactiveCollection(); + + // 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(); public Dictionary Planets = new Dictionary(); public Dictionary PlanetInstances = new Dictionary(); @@ -219,12 +223,47 @@ public class Zone foreach (var entity in Entities.ToArray()) entity.Update(deltaTime); + StepMines(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 // resolves in this same call. FireControl.Step(this, deltaTime); } + public void Lay(Mine mine) + { + mine.LaidAt = _time; + Mines.Add(mine); + } + + // 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 + // blast reads no faction; only the trigger does. + private void StepMines(float deltaTime) + { + if (Mines.Count == 0) return; + var settings = _itemManager.GameplaySettings; + foreach (var mine in Mines.ToArray()) + { + mine.Body.Step(deltaTime, GetForce(mine.Body.Position), settings); + + if (mine.TriggeredAt == null && mine.Armed(_time)) + foreach (var entity in Entities.ToArray()) + if (mine.Triggers(entity) && FireControl.Touches(entity, mine.Body.Position, mine.BlastRadius)) + { + mine.TriggeredAt = _time; + break; + } + + var fused = mine.TriggeredAt + mine.FuseDelay <= _time; + if (!fused && mine.LaidAt + mine.Lifetime > _time) continue; + Mines.Remove(mine); + FireControl.Detonate(this, mine.Body.Position, mine.BlastRadius, mine.Damage, mine.DamageType); + } + } + // Determine orbital position recursively, caching parent positions to avoid repeated calculations public float2 GetOrbitPosition(CultRecordKey orbitID) { diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs new file mode 100644 index 000000000..eb4e73a5d --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -0,0 +1,605 @@ +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; + +// Mines in the sim (aetheria-release cut sim-mines): a mine layer lays a free-floating body in the zone that +// drifts, arms, triggers on a hostile hull its blast disc touches and detonates through FireControl.Detonate. +// Rulings: mines-float-like-loot, mine-trigger-iff (hostile trigger, faction-blind blast), mine-owner-leash +// (no leash), one-gravity-law. SchematicCellSize is 2 throughout so a missing cell-size division shows. +public sealed class MineTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-mines-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + public MineTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var c in _caches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static readonly Faction A = new Faction { Name = "A" }; + private static readonly Faction B = new Faction { Name = "B" }; + + 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; + } + + // A bottom row and a left column of a 5x5 grid: its centre of mass sits well away from its far end. + private static Shape Ell() + { + var shape = new Shape(5, 5); + for (var i = 0; i < 5; i++) { shape[new int2(i, 0)] = true; shape[new int2(0, i)] = true; } + return shape; + } + + private sealed class Lab + { + public ItemManager Items; + public Zone Zone; + public Ship Layer; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public MineLayerData Data; + public WeaponItemData ItemData; + public int Lot = 1; + public EquippableItem Make(string name, float durability) => new EquippableItem + { + Data = Items.ItemData.RefOf(Items.ItemData.GetByName(name)), Durability = durability, Lot = Lot++ + }; + } + + private Lab Build(float blast = 4f, float arming = 2f, float fuse = 2f, float life = 30f, float spread = 0f, float velocity = 0f, + float damage = 100f, float range = 1000f, float cooldown = 1000f, Faction layerFaction = null, + WeaponFuse? fuseKind = WeaponFuse.Proximity, bool well = false, float3? layerAt = null, int magazine = 100) + { + 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 = .1f, TargetArmorInfoThreshold = .2f, TargetGearInfoThreshold = .8f, + FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 2f, + UnaidedAccuracy = .05f, UnaidedTracking = 10f, UnaidedPrecision = 1000f, AgentMinHitProbability = 0f + }; + var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), catalogWritable: true); + _caches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + HullData Hull(string name, Shape shape) => new HullData + { + Name = name, HullType = HullType.Ship, Shape = shape, Durability = 1000000, Mass = 1000, Armor = 0, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + cache.Upsert(Hull("Solid", Solid(5, 5))); + cache.Upsert(Hull("Ell", Ell())); + cache.Upsert(Hull("Layer", Solid(5, 5))); + var data = new MineLayerData + { + ArmingDelay = arming, FuseDelay = fuse, Lifetime = life, + Damage = Constant(damage), Range = Constant(range), MinRange = Constant(0), Velocity = Constant(velocity), + Spread = Constant(spread), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), + BurstTime = Constant(0), Cooldown = Constant(cooldown), MagazineSize = magazine, + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + }; + cache.Upsert(new WeaponItemData + { + Name = "Mine Layer", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Fuse = fuseKind, BlastRadius = blast, Behaviors = { data } + }); + GearData Gear(string name, BehaviorData behavior, float durability) => new GearData + { + Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = durability, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { behavior } + }; + cache.Upsert(Gear("Targeting", new TargetingSystemData { Accuracy = Constant(1), Resolution = Constant(1000f), Precision = Constant(1000f), Tracking = Constant(1000000f) }, 1)); + cache.Upsert(Gear("Reactor", new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) }, 10)); + cache.Upsert(Gear("Shield", new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(1000000), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) }, 10)); + cache.Upsert(Gear("TinyShield", new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(.001f), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) }, 10)); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 500; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var planets = well ? new PlanetSettings { ZoneDepth = 1000, ZoneDepthExponent = 1, GravityStrength = 1 } : new PlanetSettings(); + var zone = new Zone(items, planets, new ZonePack { Radius = well ? 1000 : 0 }, new GalaxyZone { Name = "Mines", Owner = null }, null); + var lab = new Lab { Items = items, Zone = zone, Data = data, ItemData = cache.GetByName("Mine Layer") }; + + var layerRef = items.ItemData.RefOf(items.ItemData.GetByName("Layer")); + var layer = new Ship(items, zone, new EquippableItem { Data = layerRef, Durability = 1000000, Lot = lab.Lot++ }, new EntitySettings()) { Faction = layerFaction }; + var gun = new EquippableItem { Data = items.ItemData.RefOf(lab.ItemData), Durability = 1, Lot = lab.Lot++ }; + Assert.True(layer.TryEquip(gun, new int2(0, 0))); + lab.WeaponItem = layer.Equipment.Single(x => x.EquippableItem == gun); + lab.Weapon = (InstantWeapon) lab.WeaponItem.Behaviors.Single(b => b is Weapon); + var targeting = lab.Make("Targeting", 1); + Assert.True(layer.TryFindSpace(targeting, out var tpos)); + Assert.True(layer.TryEquip(targeting, tpos)); + zone.Entities.Add(layer); + layer.Activate(); + layer.Position = layerAt ?? float3(-53, 0, -101); + lab.Layer = layer; + zone.Update(1f); + return lab; + } + + private static Ship Add(Lab lab, string hull, Faction faction, float3 at, string shield = null) + { + var items = lab.Items; + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName(hull)); + var ship = new Ship(items, lab.Zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lab.Lot++ }, new EntitySettings()) { Faction = faction }; + if (shield != null) + { + var reactor = lab.Make("Reactor", 10); + Assert.True(ship.TryFindSpace(reactor, out var rpos)); + Assert.True(ship.TryEquip(reactor, rpos)); + var gear = lab.Make(shield, 10); + Assert.True(ship.TryFindSpace(gear, out var spos)); + Assert.True(ship.TryEquip(gear, spos)); + } + lab.Zone.Entities.Add(ship); + ship.Activate(); + ship.Position = at; + if (shield != null) ship.Shield.Item.Enabled.Value = true; + return ship; + } + + // One trigger pull: the layer lays through InstantWeapon -> FireControl.Fire, the production path. + private static Mine LayOne(Lab lab, float dt = .1f) + { + var before = lab.Zone.Mines.Count; + lab.Weapon.Activate(); + lab.Zone.Update(dt); + Assert.Equal(before + 1, lab.Zone.Mines.Count); + return lab.Zone.Mines.Last(); + } + + // A mine placed by hand through the public adder, for the geometry and drift tests. + private static Mine Place(Lab lab, float2 at, float blast, float arming = 0f, float fuse = 100f, float life = 1000f, + float2? velocity = null) + { + var mine = new Mine + { + Id = lab.Zone.NextBodyId(), Body = new KinematicBody { Position = at, Velocity = velocity ?? float2.zero }, + ArmingDelay = arming, FuseDelay = fuse, Lifetime = life, BlastRadius = blast, Damage = 100f, + DamageType = DamageType.Kinetic, Layer = lab.Layer, LayerFaction = lab.Layer.Faction + }; + lab.Zone.Lay(mine); + return mine; + } + + private static IEnumerable Steps(float seconds, float dt) => Enumerable.Repeat(dt, (int) Math.Round(seconds / dt)); + + [Theory] + [InlineData(1f / 60f)] + [InlineData(.1f)] + public void AMineArmsAfterItsDelay(float dt) + { + var lab = Build(arming: 2f, fuse: 50f, life: 100f, layerFaction: A); + Add(lab, "Solid", B, lab.Layer.Position); + var mine = LayOne(lab, dt); + var armAt = mine.LaidAt + 2.0; + + double? first = null; + foreach (var step in Steps(4f, dt)) + { + lab.Zone.Update(step); + var now = (double) lab.Zone.Time; + if (mine.TriggeredAt == null) Assert.True(now < armAt + 1e-3, "an armed mine with a hostile in its disc must trigger"); + else + { + first ??= now; + Assert.True(now >= armAt - 1e-3, "a mine must not trigger before its arming delay"); + } + } + Assert.NotNull(first); + Assert.InRange(first.Value, armAt - 1e-3, armAt + dt + 1e-3); + } + + // The far end of the L is the one occupied cell inside the disc; its centre of mass is not. + [Theory] + [InlineData(5.4f, true)] + [InlineData(5.6f, false)] + public void ItTriggersOnAHullTheDiscTouches(float schematicX, bool touches) + { + var lab = Build(layerFaction: A); + var ell = Add(lab, "Ell", B, float3(300, 0, 300)); + var at = ell.ToWorldPoint(float2(schematicX, 0)); + Assert.True(length(at - ell.Position.xz) > 2f, "the hull's centre of mass must lie outside the blast disc"); + + var mine = Place(lab, at, 2f); + lab.Zone.Update(.1f); + lab.Zone.Update(.1f); + + Assert.Equal(touches, mine.TriggeredAt != null); + Assert.Equal(touches, FireControl.Touches(ell, at, 2f)); + // Touches is Detonate's own coverage: the disc deals damage to the hull exactly when it touches it. + var before = ell.Hull.Durability; + FireControl.Detonate(lab.Zone, at, 2f, 100f, DamageType.Kinetic); + Assert.Equal(touches, ell.Hull.Durability < before); + } + + [Theory] + [InlineData("A", "A", false)] // the layer's faction mates never trigger it + [InlineData("A", "B", true)] + [InlineData("A", null, true)] // a factionless hull is no faction mate of anyone + [InlineData("B", "A", true)] + [InlineData(null, null, true)] // a factionless layer has no faction mates either + [InlineData(null, "A", true)] + public void OnlyHostilesTriggerAMine(string layerFaction, string other, bool triggers) + { + Faction Of(string name) => name == "A" ? A : name == "B" ? B : null; + var lab = Build(arming: 1f, fuse: 50f, life: 100f, layerFaction: Of(layerFaction)); + Add(lab, "Solid", Of(other), lab.Layer.Position); + var mine = LayOne(lab); + + foreach (var step in Steps(5f, .1f)) lab.Zone.Update(step); + + Assert.Equal(triggers, mine.TriggeredAt != null); + } + + [Fact] + public void TheLayerNeverTriggersItsOwnMineButItsBlastHurtsIt() + { + var lab = Build(arming: 1f, fuse: 1f, life: 4f, layerFaction: A, damage: 100f); + var mine = LayOne(lab); + var durability = lab.Layer.Hull.Durability; + var removed = 0; + using var sub = lab.Zone.Mines.ObserveRemove().Subscribe(_ => removed++); + + foreach (var step in Steps(3.5f, .1f)) + { + lab.Zone.Update(step); + Assert.Null(mine.TriggeredAt); + } + Assert.Equal(0, removed); + Assert.Equal(durability, lab.Layer.Hull.Durability); + + foreach (var step in Steps(1f, .1f)) lab.Zone.Update(step); + + // The untriggered mine blew at its lifetime, and the blast asked no faction: the layer in its disc took it. + Assert.Equal(1, removed); + Assert.Empty(lab.Zone.Mines); + Assert.True(lab.Layer.Hull.Durability < durability); + } + + [Theory] + [InlineData(.1f)] + [InlineData(1f / 60f)] + public void ATriggeredMineDetonatesAfterItsFuseDelay(float dt) + { + var lab = Build(arming: 1f, fuse: 2f, life: 100f, layerFaction: A, damage: 100f); + var hostile = Add(lab, "Solid", B, lab.Layer.Position); + var bystander = Add(lab, "Solid", A, lab.Layer.Position); + var mine = LayOne(lab, dt); + var removedAt = -1.0; + using var sub = lab.Zone.Mines.ObserveRemove().Subscribe(_ => removedAt = lab.Zone.Time); + + var left = false; + foreach (var step in Steps(5f, dt)) + { + lab.Zone.Update(step); + if (mine.TriggeredAt != null && !left) + { + // The trigger leaves the disc at once; the countdown does not care. + hostile.Position = float3(5000, 0, 5000); + left = true; + } + } + + Assert.NotNull(mine.TriggeredAt); + Assert.InRange(removedAt, mine.TriggeredAt.Value + 2.0 - 1e-3, mine.TriggeredAt.Value + 2.0 + dt + 1e-3); + Assert.Equal(1000000f, hostile.Hull.Durability); + Assert.True(bystander.Hull.Durability < 1000000f, "the blast damages whoever the disc covers then, its own side included"); + } + + [Fact] + public void AZeroFuseDelayDetonatesInTheTriggeringStep() + { + var lab = Build(arming: 1f, fuse: 0f, life: 100f, layerFaction: A); + Add(lab, "Solid", B, lab.Layer.Position); + var mine = LayOne(lab); + var removedAt = -1.0; + using var sub = lab.Zone.Mines.ObserveRemove().Subscribe(_ => removedAt = lab.Zone.Time); + + foreach (var step in Steps(3f, .1f)) lab.Zone.Update(step); + + Assert.NotNull(mine.TriggeredAt); + Assert.InRange(removedAt, mine.LaidAt + 1.0 - 1e-3, mine.LaidAt + 1.1 + 1e-3); + Assert.Equal(removedAt, mine.TriggeredAt.Value, 4); + } + + [Fact] + public void AnUntriggeredMineDetonatesAtItsLifetimeExactlyOnce() + { + var lab = Build(arming: 1f, fuse: 1f, life: 5f, layerFaction: A, damage: 100f); + var ally = Add(lab, "Solid", A, lab.Layer.Position); + var mine = LayOne(lab); + var removals = new List(); + using var sub = lab.Zone.Mines.ObserveRemove().Subscribe(_ => removals.Add(lab.Zone.Time)); + float? hurtAt = null; + + foreach (var step in Steps(8f, .1f)) + { + lab.Zone.Update(step); + if (hurtAt == null && ally.Hull.Durability < 1000000f) hurtAt = lab.Zone.Time; + } + + Assert.Null(mine.TriggeredAt); + Assert.Single(removals); + Assert.InRange(removals[0], mine.LaidAt + 5.0 - 1e-3, mine.LaidAt + 5.1 + 1e-3); + Assert.Equal(removals[0], hurtAt); + } + + // Two zones built alike; one mine blast in the first, one direct Detonate at the same point and payload in the + // second. Entities: the layer, wholly inside, at the edge (a proportionally smaller share), a shield that + // absorbs, and a shield that breaks. + private static List Populate(Lab lab) + { + var at = lab.Layer.Position; + return new List + { + lab.Layer, + Add(lab, "Solid", B, at + float3(1, 0, 1)), + Add(lab, "Solid", B, at + float3(15, 0, 0)), + Add(lab, "Solid", B, at + float3(0, 0, -14), "Shield"), + Add(lab, "Solid", B, at + float3(-14, 0, 0), "TinyShield") + }; + } + + [Fact] + public void TheBlastIsDetonate() + { + var a = Build(blast: 12f, arming: 5f, life: 1f, damage: 5000f, layerFaction: A); + var b = Build(blast: 12f, arming: 5f, life: 1f, damage: 5000f, layerFaction: A); + var shipsA = Populate(a); + var shipsB = Populate(b); + var removed = new List(); + using var sub = a.Zone.Mines.ObserveRemove().Subscribe(e => removed.Add(e.Value)); + + LayOne(a); + foreach (var step in Steps(2f, .1f)) a.Zone.Update(step); + Assert.Single(removed); + var mine = removed[0]; + FireControl.Detonate(b.Zone, mine.Body.Position, mine.BlastRadius, mine.Damage, mine.DamageType); + + for (var i = 0; i < shipsA.Count; i++) + { + Assert.Equal(shipsB[i].Hull.Durability, shipsA[i].Hull.Durability); + Assert.Equal(shipsB[i].Shield?.Item.Active.Value, shipsA[i].Shield?.Item.Active.Value); + } + // Wholly inside and the layer are hurt; the edge ship is hurt less; the absorbing shield stops it; the + // tiny shield breaks and its hull takes the share. + Assert.True(shipsA[1].Hull.Durability < shipsA[2].Hull.Durability); + Assert.True(shipsA[2].Hull.Durability < 1000000f); + Assert.True(shipsA[0].Hull.Durability < 1000000f); + Assert.Equal(1000000f, shipsA[3].Hull.Durability); + Assert.True(shipsA[4].Hull.Durability < 1000000f); + } + + [Fact] + public void LayingFiresNoShot() + { + var lab = Build(spread: 40f, velocity: 5f, layerFaction: A, magazine: 5); + lab.Layer.Velocity = float2(3, 1); + float2 shooterVelocity = float2.zero, shooterAt = float2.zero; + lab.Weapon.OnFire += _ => { shooterVelocity = lab.Layer.Velocity; shooterAt = lab.Layer.Position.xz; }; + var atLay = default(KinematicBody); + using var sub = lab.Zone.Mines.ObserveAdd().Subscribe(e => atLay = e.Value.Body); + var ammo = lab.Weapon.Ammo; + Assert.True(lab.Weapon.CanFire); + + var mine = LayOne(lab); + + Assert.Empty(lab.Zone.PendingShots); + Assert.Equal(ammo - 1, lab.Weapon.Ammo); + Assert.False(lab.Weapon.CanFire); + Assert.True(lab.Layer.VisibilitySources.ContainsKey(lab.Weapon)); + Assert.Same(lab.Layer, mine.Layer); + Assert.Equal(shooterAt, atLay.Position); + var relative = atLay.Velocity - shooterVelocity; + Assert.Equal(5f, length(relative), 3); + var mount = FireControl.MountDirection(lab.WeaponItem).xz; + var angle = degrees(acos(clamp(dot(normalize(relative), mount), -1f, 1f))); + Assert.InRange(angle, 0f, 20f + 1e-2f); + } + + [Fact] + public void ASpreadlessLayerLaysDownTheMount() + { + var lab = Build(spread: 0f, velocity: 5f, layerFaction: A); + lab.Layer.Velocity = float2(3, 1); + var shooterVelocity = float2.zero; + lab.Weapon.OnFire += _ => shooterVelocity = lab.Layer.Velocity; + var atLay = default(KinematicBody); + using var sub = lab.Zone.Mines.ObserveAdd().Subscribe(e => atLay = e.Value.Body); + + LayOne(lab); + + var expected = shooterVelocity + FireControl.MountDirection(lab.WeaponItem).xz * 5f; + Assert.Equal(expected.x, atLay.Velocity.x, 4); + Assert.Equal(expected.y, atLay.Velocity.y, 4); + } + + [Fact] + public void LayingIsDeterministicAndFillsItsSpread() + { + var a = Build(spread: 40f, velocity: 5f, cooldown: .01f, layerFaction: A); + var b = Build(spread: 40f, velocity: 5f, cooldown: .01f, layerFaction: A); + var angles = new List(); + using var sub = a.Zone.Mines.ObserveAdd().Subscribe(e => + { + var v = normalize(e.Value.Body.Velocity); + angles.Add(degrees(atan2(v.x, v.y))); + }); + + for (var i = 0; i < 24; i++) + { + // The item manager's own stream is drawn from between the lays in B: a mine's roll is its own. + b.Items.Random.NextFloat(); + b.Items.Random.NextFloat(); + foreach (var lab in new[] { a, b }) + { + lab.Weapon.Activate(); + lab.Zone.Update(.1f); + lab.Zone.Update(.1f); + } + Assert.Equal(a.Zone.Mines.Count, b.Zone.Mines.Count); + Assert.Equal(i + 1, a.Zone.Mines.Count); + for (var m = 0; m < a.Zone.Mines.Count; m++) + { + Assert.Equal(a.Zone.Mines[m].Id, b.Zone.Mines[m].Id); + Assert.Equal(a.Zone.Mines[m].Body.Position, b.Zone.Mines[m].Body.Position); + Assert.Equal(a.Zone.Mines[m].Body.Velocity, b.Zone.Mines[m].Body.Velocity); + Assert.Equal(a.Zone.Mines[m].LaidAt, b.Zone.Mines[m].LaidAt); + } + } + + Assert.Equal(24, angles.Count); + Assert.True(angles.Distinct().Count() > 12, "every mine rolls its own angle"); + Assert.All(angles, x => Assert.InRange(x, -20f - 1e-2f, 20f + 1e-2f)); + Assert.True(angles.Max() > 10f && angles.Min() < -10f, "the angle spans +-Spread/2, not a narrower cone"); + } + + [Fact] + public void ThePayloadIsFrozen() + { + var lab = Build(blast: 12f, arming: 2f, fuse: 2f, life: 3f, damage: 5000f, layerFaction: A); + var twin = Build(blast: 12f, arming: 2f, fuse: 2f, life: 3f, damage: 5000f, layerFaction: A); + var mine = LayOne(lab); + var frozen = (mine.ArmingDelay, mine.FuseDelay, mine.Lifetime, mine.BlastRadius, mine.Damage, mine.DamageType, mine.Layer, mine.LayerFaction); + var position = mine.Body.Position; + + lab.Data.ArmingDelay = 99f; + lab.Data.FuseDelay = 99f; + lab.Data.Lifetime = 999f; + lab.Data.Damage = Constant(1f); + lab.ItemData.BlastRadius = 1f; + lab.Layer.Faction = B; + lab.Zone.Update(.1f); + lab.Zone.Update(.1f); + + Assert.Equal(frozen, (mine.ArmingDelay, mine.FuseDelay, mine.Lifetime, mine.BlastRadius, mine.Damage, mine.DamageType, mine.Layer, mine.LayerFaction)); + var durability = lab.Layer.Hull.Durability; + foreach (var step in Steps(4f, .1f)) lab.Zone.Update(step); + Assert.Empty(lab.Zone.Mines); + + var before = twin.Layer.Hull.Durability; + FireControl.Detonate(twin.Zone, position, 12f, 5000f, DamageType.Kinetic); + Assert.True(before > twin.Layer.Hull.Durability); + Assert.Equal(before - twin.Layer.Hull.Durability, durability - lab.Layer.Hull.Durability, 2); + } + + [Fact] + public void AMineDriftsAsABody() + { + var lab = Build(well: true, layerAt: float3(400, 0, 0)); + var settings = lab.Items.GameplaySettings; + var start = float2(400, 20); + var launch = float2(1.5f, -.5f); + var mine = Place(lab, start, 4f, arming: 1000f, life: 100000f, velocity: launch); + var solo = new KinematicBody { Position = start, Velocity = launch }; + Assert.NotEqual(float2.zero, lab.Zone.GetForce(start)); + + foreach (var step in Steps(3f, 1f / 30f)) + { + lab.Zone.Update(step); + solo.Step(step, lab.Zone.GetForce(solo.Position), settings); + Assert.Equal(solo.Position.x, mine.Body.Position.x, 4); + Assert.Equal(solo.Position.y, mine.Body.Position.y, 4); + Assert.Equal(solo.Velocity.x, mine.Body.Velocity.x, 4); + Assert.Equal(solo.Drift.x, mine.Body.Drift.x, 4); + Assert.Equal(solo.Drift.y, mine.Body.Drift.y, 4); + } + Assert.True(length(solo.Drift) > 0f); + } + + [Fact] + public void TheLeashIsTheRuling() + { + var lab = Build(arming: 1f, fuse: 1f, life: 6f, range: 100f, layerFaction: A); + var mine = LayOne(lab); + var removed = 0; + using var sub = lab.Zone.Mines.ObserveRemove().Subscribe(_ => removed++); + + foreach (var step in Steps(1.5f, .1f)) lab.Zone.Update(step); + Assert.True(mine.Armed(lab.Zone.Time)); + // The layer flies far beyond its Range, then dies: the mine neither notices nor cares. + lab.Layer.Position = float3(5000, 0, 5000); + foreach (var step in Steps(1f, .1f)) lab.Zone.Update(step); + lab.Layer.DamageHull(5000000f); + Assert.DoesNotContain(lab.Layer, lab.Zone.Entities); + foreach (var step in Steps(1.5f, .1f)) lab.Zone.Update(step); + + Assert.Equal(0, removed); + Assert.Single(lab.Zone.Mines); + Assert.Null(mine.TriggeredAt); + + foreach (var step in Steps(3f, .1f)) lab.Zone.Update(step); + Assert.Equal(1, removed); + Assert.Empty(lab.Zone.Mines); + } + + [Fact] + public void AgentsLayOnlyAtATarget() + { + var lab = Build(range: 100f, layerFaction: A); + var near = Add(lab, "Solid", B, lab.Layer.Position + float3(0, 0, 40)); + var far = Add(lab, "Solid", B, lab.Layer.Position + float3(0, 0, 500)); + foreach (var target in new[] { near, far }) + { + lab.Layer.SetTarget(target); + lab.Layer.EntityInfoGathered[target] = 1f; + lab.Layer.SetIff(target, true); + } + lab.Zone.Update(1f); + + Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, null)); + Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, far)); + Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near)); + Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer)); + } + + [Fact] + public void MinesAreNotSaved() + { + var lab = Build(layerFaction: A); + LayOne(lab); + + var pack = lab.Zone.PackZone(); + var restored = new Zone(lab.Items, new PlanetSettings(), pack, new GalaxyZone { Name = "Mines", Owner = null }, null); + + Assert.Single(lab.Zone.Mines); + Assert.Empty(restored.Mines); + } + + [Fact] + public void ALayerWithoutABlastRadiusLaysNothing() + { + var lab = Build(blast: 0f, layerFaction: A); + var log = new List(); + lab.Zone.Log = log.Add; + + lab.Weapon.Activate(); + lab.Zone.Update(.1f); + + Assert.Empty(lab.Zone.Mines); + Assert.Single(log); + Assert.Empty(lab.Zone.PendingShots); + } +}