Mines in the sim: a mine layer lays a free-floating mine body in Zone
MineLayerData (union 40) lays through InstantWeapon -> FireControl.Fire -> Lay; Zone.Mines steps each mine as a KinematicBody after the entity loop, arms it, triggers it on a hostile hull its blast disc touches (FireControl.Touches, the coverage Detonate uses) and detonates it through FireControl.Detonate at the fuse delay or lifetime. Hostile trigger, faction-blind blast, no owner leash. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9f8b3f4536
commit
23aa848315
8 changed files with 782 additions and 2 deletions
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
27
Assets/Scripts/ServerShared/Behaviors/MineLayer.cs
Normal file
27
Assets/Scripts/ServerShared/Behaviors/MineLayer.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Behaviors/MineLayer.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Behaviors/MineLayer.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 1cc95be85eee47c79199bea20a6d7509
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
|
|
|
|||
25
Assets/Scripts/ServerShared/Mines.cs
Normal file
25
Assets/Scripts/ServerShared/Mines.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
11
Assets/Scripts/ServerShared/Mines.cs.meta
Normal file
11
Assets/Scripts/ServerShared/Mines.cs.meta
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4aea0eb6ee234016a258266945ed2db9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -16,6 +16,10 @@ public class Zone
|
|||
{
|
||||
public Action<string> Log;
|
||||
public ReactiveCollection<Entity> Entities = new ReactiveCollection<Entity>();
|
||||
|
||||
// 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<Mine> Mines = new ReactiveCollection<Mine>();
|
||||
public Dictionary<CultRecordKey, BodyData> Planets = new Dictionary<CultRecordKey, BodyData>();
|
||||
public Dictionary<CultRecordKey, Planet> PlanetInstances = new Dictionary<CultRecordKey, Planet>();
|
||||
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
605
tests/Aetheria.Shared.Tests/MineTests.cs
Normal file
605
tests/Aetheria.Shared.Tests/MineTests.cs
Normal file
|
|
@ -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<CultCache> _caches = new List<CultCache>();
|
||||
|
||||
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<ItemData>(Items.ItemData.GetByName<GearData>(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<WeaponItemData>("Mine Layer") };
|
||||
|
||||
var layerRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("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<ItemData>(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<ItemData>(items.ItemData.GetByName<HullData>(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<float> 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<float>();
|
||||
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<Ship> Populate(Lab lab)
|
||||
{
|
||||
var at = lab.Layer.Position;
|
||||
return new List<Ship>
|
||||
{
|
||||
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<Mine>();
|
||||
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<float>();
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue