- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it. - RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed. - Continue seeds the item stream from the saved run's seed (ContinueStep); a scene with no galaxy uses NoRunSeed. - Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream, 1-based pack ordinal), never the item stream. - Loadout's scratch manager and AetherDb's catalog command take fixed seeds; every test and Editor construction gains 1u (mechanical rewrite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
847 lines
38 KiB
C#
847 lines
38 KiB
C#
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,
|
|
float minRange = 0f, uint? runSeed = null)
|
|
{
|
|
var settings = new GameplaySettings
|
|
{
|
|
DefaultEntitySettings = new EntitySettings(),
|
|
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
|
|
QualityPriceModifier = new ExponentialLerp(),
|
|
TargetDetectionInfoThreshold = .1f, 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"), Path.Combine(_root, Guid.NewGuid().ToString("N") + ".run.cc"), catalogWritable: true);
|
|
_caches.Add(cache);
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
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(minRange),
|
|
Velocity = Constant(velocity),
|
|
Spread = Constant(spread), DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1),
|
|
BurstTime = Constant(0), Cooldown = Constant(cooldown), MagazineSize = magazine,
|
|
};
|
|
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 galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, "Mines", runSeed.Value) : null;
|
|
|
|
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(1u, cache, ledger, settings, _ => { });
|
|
var planets = new PlanetSettings();
|
|
var pack = new ZonePack { Radius = well ? 1000 : 0 };
|
|
if (well) WellFixture.Dig(cache, pack, planets, 300, 100);
|
|
var zone = galaxy != null
|
|
? new Zone(items, planets, pack, galaxy.Zones[0], galaxy)
|
|
: new Zone(items, planets, pack, 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.Admit(layer, piloted: false);
|
|
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.Admit(ship, piloted: false);
|
|
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);
|
|
}
|
|
|
|
// This fixture differs from OnlyHostilesTriggerAMine only in what the rule cares about: the layer has no
|
|
// faction and is the only hull in the disc, so nothing but its own identity can keep it from triggering.
|
|
[Fact]
|
|
public void AFactionlessLayersOwnHullNeverTriggersItsMine()
|
|
{
|
|
var lab = Build(arming: 1f, fuse: 50f, life: 100f, layerFaction: null);
|
|
var mine = LayOne(lab);
|
|
Assert.True(FireControl.Touches(lab.Layer, mine.Body.Position, mine.BlastRadius), "the layer's own hull must lie in the disc");
|
|
|
|
foreach (var step in Steps(5f, .1f)) lab.Zone.Update(step);
|
|
|
|
Assert.Null(mine.TriggeredAt);
|
|
}
|
|
|
|
// The rule reads the faction frozen at lay, whichever way the layer's faction moves afterwards. The other
|
|
// hull is parked in the disc; only the layer's faction changes between lay and arming.
|
|
[Theory]
|
|
[InlineData("B", true)] // hostile at lay, allied once the layer joins B: still triggers
|
|
[InlineData("A", false)] // allied at lay, hostile once the layer joins B: still never triggers
|
|
public void TheTriggerReadsTheFactionFrozenAtLay(string otherFaction, bool triggers)
|
|
{
|
|
var lab = Build(arming: 1f, fuse: 50f, life: 100f, layerFaction: A);
|
|
Add(lab, "Solid", otherFaction == "A" ? A : B, lab.Layer.Position);
|
|
var mine = LayOne(lab);
|
|
|
|
lab.Layer.Faction = B;
|
|
foreach (var step in Steps(5f, .1f)) lab.Zone.Update(step);
|
|
|
|
Assert.Equal(triggers, mine.TriggeredAt != null);
|
|
}
|
|
|
|
// The trigger disc is the blast disc, not a wider one: a hull the blast disc misses by a hair is not a trigger.
|
|
[Fact]
|
|
public void AHullJustOutsideTheBlastDiscDoesNotTrigger()
|
|
{
|
|
var lab = Build(layerFaction: A);
|
|
var ell = Add(lab, "Ell", B, float3(300, 0, 300));
|
|
// Find the schematic x where the disc of radius 2 stops touching the L's far cell, then stand just beyond it.
|
|
float lo = 5.4f, hi = 5.6f;
|
|
Assert.True(FireControl.Touches(ell, ell.ToWorldPoint(float2(lo, 0)), 2f));
|
|
Assert.False(FireControl.Touches(ell, ell.ToWorldPoint(float2(hi, 0)), 2f));
|
|
for (var i = 0; i < 30; i++)
|
|
{
|
|
var mid = (lo + hi) / 2f;
|
|
if (FireControl.Touches(ell, ell.ToWorldPoint(float2(mid, 0)), 2f)) lo = mid; else hi = mid;
|
|
}
|
|
var at = ell.ToWorldPoint(float2(hi + .01f, 0));
|
|
Assert.False(FireControl.Touches(ell, at, 2f), "just outside the blast disc");
|
|
Assert.True(FireControl.Touches(ell, at, 2.2f), "and inside a disc a tenth wider");
|
|
|
|
var mine = Place(lab, at, 2f);
|
|
lab.Zone.Update(.1f);
|
|
lab.Zone.Update(.1f);
|
|
|
|
Assert.Null(mine.TriggeredAt);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
// Wholly inside and the layer are hurt; the edge ship is hurt less.
|
|
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.True(shipsA[4].Hull.Durability < 1000000f);
|
|
}
|
|
|
|
// The trigger gate never arcs a mine layer (FireControl.ArcPermitsFire; IsMineLayer is its own half of the
|
|
// exemption). The layer is unfused, so the fused half cannot pass it: only the mine-layer exemption can. An unfused
|
|
// layer lays nothing (Lay), so the gate is the layer this is decided at. The fixture checks the aim is outside
|
|
// the arc, with no target designated, so the gate has no other way to pass.
|
|
[Fact]
|
|
public void AMineLayerPassesTheTriggerGateWithTheAimOutsideItsArc()
|
|
{
|
|
var lab = Build(fuseKind: null);
|
|
Assert.Null(FireControl.FuseOf(lab.WeaponItem, out _));
|
|
Assert.True(FireControl.IsMineLayer(lab.Weapon));
|
|
lab.Layer.Aim = -FireControl.MountDirection(lab.WeaponItem);
|
|
Assert.False(FireControl.InArc(lab.WeaponItem, lab.Layer.Aim), "fixture: the aim must be outside the arc");
|
|
Assert.False(FireControl.Solution(lab.Weapon, lab.Layer, lab.Layer.Target.Value).Free);
|
|
Assert.False(FireControl.Solution(lab.Weapon, lab.Layer, lab.Layer.Target.Value).Bears);
|
|
|
|
Assert.True(FireControl.ArcPermitsFire(lab.Weapon, lab.Layer));
|
|
}
|
|
|
|
[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");
|
|
}
|
|
|
|
// Mines are flat (ruling effective-range-authoring-ruled): a layer's MinRange and Range gate where it may lay, and the one
|
|
// RangeFactor reads 1 inside them, so the mine's damage, the AI's figure and the panel's function agree at every distance.
|
|
[Theory]
|
|
[InlineData(0f)]
|
|
[InlineData(5f)]
|
|
public void AMineLayerIsFlatForTheMineTheAIAndThePanel(float minRange)
|
|
{
|
|
var lab = Build(damage: 100f, range: 1000f, minRange: minRange, layerFaction: A);
|
|
var mine = LayOne(lab);
|
|
Assert.Equal(100f, mine.Damage, 3);
|
|
|
|
var data = lab.Weapon.WeaponData;
|
|
foreach (var at in new[] { 5f, 10f, 100f, 500f, 999f })
|
|
{
|
|
Assert.Equal(mine.Damage, lab.Weapon.RangeDamagePerSecond(at) * lab.Weapon.Cooldown, 3);
|
|
Assert.Equal(mine.Damage, 100f * WeaponData.RangeFactor(data.MinRange.Max, data.EffectiveRange.Max, data.Range.Max, data.DamageAtMaxRange, at), 3);
|
|
}
|
|
}
|
|
|
|
// A falloff on a mine layer would act on nothing the mine does, so the catalog refuses it where it is authored.
|
|
[Theory]
|
|
[InlineData(10f, 0f)]
|
|
[InlineData(float.PositiveInfinity, .3f)]
|
|
public void AMineLayerAuthoringFalloffIsRefused(float effective, float end)
|
|
{
|
|
var item = new WeaponItemData
|
|
{
|
|
Name = "Mine Layer", Hardpoint = HardpointType.Sensors, Shape = new Shape(), BlastRadius = 4f,
|
|
Behaviors = { new MineLayerData { Range = Constant(1000f), EffectiveRange = Constant(effective), DamageAtMaxRange = end } }
|
|
};
|
|
var refusal = Assert.Throws<InvalidOperationException>(() => StatValidation.ValidateMineLayerFlat(item.Name, item.Behaviors));
|
|
Assert.Contains("falloff", refusal.Message);
|
|
Assert.Throws<InvalidOperationException>(() => CultRecordRefs.Validate(item));
|
|
}
|
|
|
|
[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.False(lab.Zone.Entities.Any(x => ReferenceEquals(x, lab.Layer)), "the layer is removed from the zone");
|
|
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, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability));
|
|
Assert.False(FireControl.AgentFires(lab.Weapon, lab.Layer, far, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability));
|
|
Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability));
|
|
Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer));
|
|
}
|
|
|
|
// A new game scatters its mines differently: the same lay, at another run seed, launches at another velocity.
|
|
[Fact]
|
|
public void ANewGameScattersMinesDifferently()
|
|
{
|
|
Assert.NotEqual(LaunchAt(1), LaunchAt(2));
|
|
}
|
|
|
|
private float2 LaunchAt(uint? runSeed)
|
|
{
|
|
var lab = Build(spread: 40f, velocity: 5f, layerFaction: A, runSeed: runSeed);
|
|
var atLay = default(KinematicBody);
|
|
using var sub = lab.Zone.Mines.ObserveAdd().Subscribe(e => atLay = e.Value.Body);
|
|
lab.Layer.Direction = normalize(float2(1, 2));
|
|
LayOne(lab);
|
|
return atLay.Velocity;
|
|
}
|
|
|
|
// Pinned at eb27ced1, before run-seed: a zone with no galaxy, and a zone of a save written before run-seed (seed 0),
|
|
// scatter their mines on the zone's name alone.
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void AZoneWithoutARunSeedKeepsItsMineScatter(bool restoredFromAnOldSave)
|
|
{
|
|
var velocity = LaunchAt(restoredFromAnOldSave ? 0u : (uint?) null);
|
|
Assert.Equal(3.2546253f, velocity.x, 3);
|
|
Assert.Equal(3.795711f, velocity.y, 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void TheLaunchAngleIsTheMinesOwnDice()
|
|
{
|
|
var lab = Build(spread: 40f, velocity: 5f, layerFaction: A);
|
|
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);
|
|
lab.Layer.Velocity = float2(-2, 4);
|
|
// A mount along neither axis, so a wrong term in the turn cannot hide behind a zero component.
|
|
lab.Layer.Direction = normalize(float2(1, 2));
|
|
|
|
var mine = LayOne(lab);
|
|
|
|
// The angle is the first draw of (the zone's DropSeed, mine stream, body id), in +-Spread/2, turning the mount
|
|
// counterclockwise in the (x, y) plane.
|
|
var degreesTurned = SimulationDice.For(lab.Zone.DropSeed, SimulationDice.MineStream, mine.Id.Value).NextFloat(-20f, 20f);
|
|
var a = radians(degreesTurned);
|
|
var m = FireControl.MountDirection(lab.WeaponItem).xz;
|
|
var turned = float2(m.x * cos(a) - m.y * sin(a), m.x * sin(a) + m.y * cos(a));
|
|
var expected = shooterVelocity + turned * 5f;
|
|
Assert.Equal(expected.x, atLay.Velocity.x, 4);
|
|
Assert.Equal(expected.y, atLay.Velocity.y, 4);
|
|
Assert.True(Math.Abs(degreesTurned) > .1f, "the fixture must draw a real turn");
|
|
}
|
|
|
|
[Fact]
|
|
public void AMineLayerLaysWhateverItsBlastRadiusAgainstItsRange()
|
|
{
|
|
// A fused round whose arming distance exceeds Range is refused; a mine is not a round, so it is not.
|
|
var lab = Build(blast: 150f, range: 100f, layerFaction: A);
|
|
var near = Add(lab, "Solid", B, lab.Layer.Position + float3(0, 0, 40));
|
|
lab.Layer.SetTarget(near);
|
|
lab.Layer.EntityInfoGathered[near] = 1f;
|
|
lab.Layer.SetIff(near, true);
|
|
lab.Zone.Update(1f);
|
|
|
|
Assert.False(FireControl.Refuses(lab.Weapon, lab.Layer));
|
|
Assert.True(FireControl.AgentFires(lab.Weapon, lab.Layer, near, lab.Layer.ItemManager.GameplaySettings.AgentMinHitProbability));
|
|
var mine = LayOne(lab);
|
|
Assert.Equal(150f, mine.BlastRadius);
|
|
}
|
|
|
|
// Steps of .5 land on the delays exactly (binary fractions), so each boundary is met with equality.
|
|
[Fact]
|
|
public void EveryDelayIsInclusiveAtItsExactStep()
|
|
{
|
|
var lab = Build(arming: 2f, fuse: 1f, life: 100f, layerFaction: A);
|
|
Add(lab, "Solid", B, lab.Layer.Position);
|
|
var mine = LayOne(lab, .5f);
|
|
var removedAt = -1.0;
|
|
using var sub = lab.Zone.Mines.ObserveRemove().Subscribe(_ => removedAt = lab.Zone.Time);
|
|
foreach (var step in Steps(6f, .5f)) lab.Zone.Update(step);
|
|
Assert.Equal(mine.LaidAt + 2.0, mine.TriggeredAt.Value);
|
|
Assert.Equal(mine.LaidAt + 3.0, removedAt);
|
|
|
|
var timed = Build(arming: 1f, fuse: 1f, life: 5f, layerFaction: A);
|
|
var untriggered = LayOne(timed, .5f);
|
|
var lifetimeEnd = -1.0;
|
|
using var sub2 = timed.Zone.Mines.ObserveRemove().Subscribe(_ => lifetimeEnd = timed.Zone.Time);
|
|
foreach (var step in Steps(8f, .5f)) timed.Zone.Update(step);
|
|
Assert.Equal(untriggered.LaidAt + 5.0, lifetimeEnd);
|
|
|
|
Assert.True(new Mine { LaidAt = 10.0, ArmingDelay = 2f }.Armed(12.0));
|
|
Assert.False(new Mine { LaidAt = 10.0, ArmingDelay = 2f }.Armed(11.999));
|
|
}
|
|
|
|
[Fact]
|
|
public void MinesAreNotSaved()
|
|
{
|
|
var lab = Build();
|
|
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.False(string.IsNullOrWhiteSpace(log[0]));
|
|
Assert.Empty(lab.Zone.PendingShots);
|
|
}
|
|
|
|
// Loot is drawn on its own stream (aetheria-release loot-1): a mine laid with body id 1 and the zone's first
|
|
// death share ordinal 1, so only the stream tells their draws apart. The lay's first draw fixes the mine's
|
|
// turn; setting LootDropProbability just either side of that same number decides the first gate draw of the
|
|
// first death. With one stream the gate would follow the mine's turn.
|
|
private (Lab Lab, float U) LayThenReadTurn(float? probability)
|
|
{
|
|
var lab = Build(spread: 40f, velocity: 5f, layerFaction: A);
|
|
lab.Layer.Velocity = float2.zero;
|
|
lab.Layer.Direction = normalize(float2(1, 2));
|
|
var mine = LayOne(lab);
|
|
var mount = FireControl.MountDirection(lab.WeaponItem).xz;
|
|
var turned = mine.Body.Velocity / 5f;
|
|
var degrees = (MathF.Atan2(turned.y, turned.x) - MathF.Atan2(mount.y, mount.x)) * 180f / MathF.PI;
|
|
if (probability.HasValue) lab.Items.GameplaySettings.LootDropProbability = probability.Value;
|
|
return (lab, (degrees + 20f) / 40f);
|
|
}
|
|
|
|
private static bool FirstGearDropped(Lab lab)
|
|
{
|
|
var gear = lab.Layer.Equipment.First(e => e != lab.Layer.EquippedHull).EquippableItem;
|
|
lab.Layer.HeatstrokeDeath.OnNext(UniRx.Unit.Default);
|
|
return lab.Zone.FloatingItems.Any(f => f.Item == gear);
|
|
}
|
|
|
|
[Fact]
|
|
public void LootDiceAreNotTheMineDice()
|
|
{
|
|
var u = LayThenReadTurn(null).U;
|
|
Assert.InRange(u, .01f, .99f);
|
|
var above = FirstGearDropped(LayThenReadTurn(u + .003f).Lab);
|
|
var below = FirstGearDropped(LayThenReadTurn(u - .003f).Lab);
|
|
Assert.False(above && !below, "the first loot gate draw equals the first mine draw");
|
|
}
|
|
}
|