ReachIsWhatTheShipCanDo and TheThrustersReadLiveThrust built their ships with the generator, whose draw follows the run's item stream and so the catalog's census; the applied sensor catalog shifted it. They now fit the Djinni (a Talaria on each thruster hardpoint, reactor, cockpit) and the Longinus (EvLonginus) by name. AConsumableListenerHearsAtItsMultiple pins Entity.HearingMultiple over an active consumable's Listener. Not yet verified. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1392 lines
65 KiB
C#
1392 lines
65 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using CultMath;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
using MessagePack;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
using Xunit;
|
|
using static CultMath.math;
|
|
using float2 = CultMath.float2;
|
|
using float3 = CultMath.float3;
|
|
using int2 = CultMath.int2;
|
|
|
|
// Visibility cut 3 (rulings visibility-per-capability-reveal, quiet-gear-identifiable-hull-layout-known,
|
|
// vapour-cloud-obscuration, ally-echo-bistatic-oval): an observer records what each ship's items have shown it, item
|
|
// by item with a bearing, and derives the contact's level from its track and those reveals. Every fixture drives
|
|
// the production path: real ships with real guns, thrusters, radiators and launchers, ticking inside Zone.Update.
|
|
// The numbers a rule is a function of are authored here and the expectations are their arithmetic. The observer's
|
|
// one passive sensor has sensitivity 2 and a flat curve, so a signature of strength S at planar distance d settles
|
|
// its info at S x 2 / (d x TargetInfoDecay), and is revealed exactly when that exceeds TargetDetectionInfoThreshold
|
|
// (.1). Signatures decay at once (VisibilityDecay 100), so an act is a single tick's light and builds no track.
|
|
// The observer senses after every ship has updated, so the zone's list order never decides when an act is revealed.
|
|
public sealed partial class ContactRevealTests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-contact-" + Guid.NewGuid().ToString("N"));
|
|
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
|
private CultCache _openCache;
|
|
private ItemManager _items;
|
|
private Zone _zone;
|
|
private CultRecordRef<Faction> _maker;
|
|
private int _nextLot = 1000;
|
|
|
|
public ContactRevealTests()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
|
|
{
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
cache.Upsert(new Faction { Name = "Maker", ShortName = "MKR" });
|
|
var solid = new Shape(5, 5);
|
|
foreach (var cell in solid.AllCoordinates) solid[cell] = true;
|
|
cache.Upsert(new HullData
|
|
{
|
|
Name = "Rack", HullType = HullType.Ship, Shape = solid, Durability = 1000000, Mass = 1000,
|
|
Hardpoints =
|
|
{
|
|
new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() },
|
|
new HardpointData { Type = HardpointType.Sensors, Position = new int2(1, 0), Shape = new Shape() },
|
|
new HardpointData { Type = HardpointType.Sensors, Position = new int2(2, 0), Shape = new Shape() }
|
|
}
|
|
});
|
|
cache.Upsert(Eye("Eye", sensitivity: 2f));
|
|
cache.Upsert(Eye("Ear", sensitivity: 4f));
|
|
// Narrow beams (10 degrees, facing +z) so a hearer off the beam is never painted: the ping is heard at its
|
|
// PingRange (300) over the intercept factor.
|
|
cache.Upsert(Eye("Shout", sensitivity: 2f, interceptFactor: 2f, beamWidth: 10f));
|
|
cache.Upsert(Eye("Roar", sensitivity: 2f, interceptFactor: .5f, beamWidth: 10f));
|
|
cache.Upsert(Listener("Listener", 3f));
|
|
cache.Upsert(new ConsumableItemData { Name = "Earpiece", Duration = 30f, Shape = new Shape(), Behaviors = { new ListenerData { Multiple = Const(3f) } } });
|
|
cache.Upsert(Tool("Reactor", new ReactorData { Charge = Const(1000), Efficiency = Const(1), OverloadEfficiency = Const(1), ThrottlingFactor = Const(2) }));
|
|
cache.Upsert(Tool("Targeting", new TargetingSystemData { Accuracy = Const(1), Resolution = Const(1000f), Precision = Const(1000f), Tracking = Const(1000000f) }));
|
|
cache.Upsert(Tool("Thruster", new ThrusterData { Thrust = Const(100), Visibility = Const(10), Heat = Const(0), EnergyUsage = Const(10) }));
|
|
cache.Upsert(Tool("Radiator", new RadiatorData
|
|
{
|
|
Emissivity = Const(3), PumpedHeat = Const(0), TemperatureFloor = 0, WasteHeat = Const(0), EnergyUsage = Const(0), ThermalMass = Const(1000)
|
|
}));
|
|
// Launch suppressors (aetheria-ew): one solid cell each, so the heat they pay has a cell to land in. Rated 400.
|
|
cache.Upsert(Suppressor("Suppressor", factor: .1f, heatPerUnit: 2f));
|
|
cache.Upsert(Suppressor("SuppressorCold", factor: .1f, heatPerUnit: 0f));
|
|
cache.Upsert(Suppressor("SuppressorOpen", factor: 1f, heatPerUnit: 2f));
|
|
cache.Upsert(Suppressor("SuppressorHalf", factor: .5f, heatPerUnit: 1f));
|
|
cache.Upsert(Suppressor("SuppressorFifth", factor: .2f, heatPerUnit: 10f));
|
|
cache.Upsert(Suppressor("SuppressorBright", factor: 3f, heatPerUnit: 2f));
|
|
cache.Upsert(Suppressor("SuppressorNegative", factor: -1f, heatPerUnit: 2f));
|
|
var gun = Armed(new InstantWeaponData { Count = Const(1), BurstTime = Const(0), Cooldown = Const(1) }, 5);
|
|
cache.Upsert(new WeaponItemData
|
|
{
|
|
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
|
|
Behaviors = { gun }
|
|
});
|
|
cache.Upsert(new WeaponItemData
|
|
{
|
|
Name = "Beam", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
|
|
Behaviors = { Armed(new ConstantWeaponData(), 0) }
|
|
});
|
|
cache.Upsert(Locker("Locker", interceptFactor: null));
|
|
cache.Upsert(Locker("Hushed", interceptFactor: 4f));
|
|
cache.Upsert(new ConsumableItemData
|
|
{
|
|
Name = "Pinger", Duration = 30f, Shape = new Shape(),
|
|
Behaviors = { SensorBehavior(sensitivity: 2f) }
|
|
});
|
|
cache.FlushAsync().Wait();
|
|
}
|
|
|
|
_openCache = AetheriaStores.Open(Catalog, catalogWritable: true);
|
|
var ledger = new ProvenanceLedger();
|
|
for (var lot = 1000; lot < 2000; lot++) ledger.Lots[lot] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
_items = new ItemManager(_openCache, ledger, 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,
|
|
TargetGearInfoThreshold = .8f,
|
|
TargetInfoDecay = .5f,
|
|
VisibilityDecay = 100f,
|
|
BearingMemory = 2f,
|
|
FiringArc = 170,
|
|
CommitHorizon = .5f,
|
|
SchematicCellSize = 2f,
|
|
UnaidedAccuracy = .05f,
|
|
UnaidedTracking = 10f,
|
|
UnaidedPrecision = 1000f,
|
|
AgentMinHitProbability = 0f,
|
|
BeamResolveInterval = .1f,
|
|
HeatRadiationMultiplier = 0f, // a hull radiates nothing unless a test says so
|
|
HeatRadiationExponent = 1f
|
|
}, _ => { });
|
|
_maker = _openCache.RefOf(_openCache.GetByName<Faction>("Maker"));
|
|
_zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_openCache?.Dispose();
|
|
Directory.Delete(_root, true);
|
|
}
|
|
|
|
private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v };
|
|
|
|
// The weapon fields every fixture weapon shares; a gun and a beam differ only in their behaviour type and magazine.
|
|
private static WeaponData Armed(WeaponData weapon, int magazine)
|
|
{
|
|
weapon.Damage = Const(100);
|
|
weapon.Range = Const(40);
|
|
weapon.MinRange = Const(0);
|
|
weapon.Velocity = Const(0);
|
|
weapon.Spread = Const(0);
|
|
weapon.DamageSpread = Const(0);
|
|
weapon.Penetration = Const(0);
|
|
weapon.Energy = Const(50);
|
|
weapon.Heat = Const(0);
|
|
weapon.Visibility = Const(5);
|
|
weapon.MagazineSize = magazine;
|
|
weapon.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } };
|
|
return weapon;
|
|
}
|
|
|
|
private static GearData Suppressor(string name, float factor, float heatPerUnit)
|
|
{
|
|
var cell = new Shape(1, 1);
|
|
cell[new int2(0, 0)] = true;
|
|
return new GearData
|
|
{
|
|
Name = name, Hardpoint = HardpointType.Tool, Shape = cell, Durability = 10,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Behaviors = { new LaunchSuppressorData { Factor = Const(factor), HeatPerUnit = Const(heatPerUnit), RatedTemperature = Const(400f) } }
|
|
};
|
|
}
|
|
|
|
// A launcher with Range 1000, heard at that Range over its intercept factor while it paints.
|
|
private static GearData Locker(string name, float? interceptFactor) => new GearData
|
|
{
|
|
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Behaviors =
|
|
{
|
|
new LauncherData
|
|
{
|
|
Damage = Const(0), Range = Const(1000), MinRange = Const(0), Velocity = Const(0),
|
|
Spread = Const(0), DamageSpread = Const(0), Penetration = Const(0), Count = Const(1),
|
|
BurstTime = Const(0), Cooldown = Const(1000),
|
|
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } },
|
|
LockSpeed = Const(.6f), SensorImpact = Const(1), LockAngle = Const(30), DirectionImpact = Const(1), Decay = Const(.1f),
|
|
InterceptFactor = interceptFactor.HasValue ? Const(interceptFactor.Value) : null
|
|
},
|
|
// A munition design carries one of each part (missile-stats); the reveal tests read none of them.
|
|
new MunitionHullData(), new MunitionThrusterData(), new MunitionSeekerData(), new MunitionControlData(), new MunitionFuelData()
|
|
}
|
|
};
|
|
|
|
private static SensorData SensorBehavior(float sensitivity, float? interceptFactor = null, float? beamWidth = null) => new SensorData
|
|
{
|
|
Sensitivity = Const(sensitivity),
|
|
SensitivityCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }, // the same gain at every bearing
|
|
PingBoost = Const(.0001f), PingEnergy = Const(0), PingRange = Const(300), PingCooldown = Const(4),
|
|
InterceptFactor = interceptFactor.HasValue ? Const(interceptFactor.Value) : null,
|
|
BeamWidth = beamWidth.HasValue ? Const(beamWidth.Value) : null
|
|
};
|
|
|
|
private static GearData Eye(string name, float sensitivity, float? interceptFactor = null, float? beamWidth = null) => new GearData
|
|
{
|
|
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Behaviors = { SensorBehavior(sensitivity, interceptFactor, beamWidth) }
|
|
};
|
|
|
|
private static GearData Tool(string name, BehaviorData behavior) => new GearData
|
|
{
|
|
Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Behaviors = { behavior }
|
|
};
|
|
|
|
private EquippableItem Make(string name) => new EquippableItem
|
|
{
|
|
Data = _items.ItemData.RefOf<ItemData>(_items.ItemData.GetByName<ItemData>(name)), Durability = 10, Lot = _nextLot++
|
|
};
|
|
|
|
// A ship at `position` with `mounts` on its sensor hardpoints in order, the tools on free cells and a thruster
|
|
// on the centre column. A ship with a gun also gets the reactor and targeting a gun needs to fire.
|
|
private Ship Add(float3 position, string[] mounts = null, string[] tools = null, bool thruster = false)
|
|
{
|
|
var hullRef = _items.ItemData.RefOf<ItemData>(_items.ItemData.GetByName<HullData>("Rack"));
|
|
var ship = new Ship(_items, _zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = _nextLot++ }, new EntitySettings())
|
|
{ Faction = new Faction { Name = "F" + Guid.NewGuid().ToString("N") } };
|
|
var cell = 0;
|
|
foreach (var mount in mounts ?? new string[0]) Assert.True(ship.TryEquip(Make(mount), new int2(cell++, 0)));
|
|
if (thruster) Assert.True(ship.TryEquip(Make("Thruster"), new int2(2, 1)));
|
|
foreach (var name in (tools ?? new string[0]).Concat(mounts != null && mounts.Contains("Gun") ? new[] { "Targeting", "Reactor" } : new string[0]))
|
|
{
|
|
var item = Make(name);
|
|
Assert.True(ship.TryFindSpace(item, out var at));
|
|
Assert.True(ship.TryEquip(item, at));
|
|
}
|
|
_zone.Admit(ship, piloted: false);
|
|
ship.Position = position;
|
|
ship.Aim = float3(0, 0, 1);
|
|
_zone.Update(0f); // brings the gear online
|
|
return ship;
|
|
}
|
|
|
|
private void Tick(float dt) => _zone.Update(dt);
|
|
|
|
private static EquippedItem[] Guns(Entity e) => e.Equipment.Where(i => i.Behaviors.Any(b => b is InstantWeapon && !(b is LockWeapon))).ToArray();
|
|
|
|
// Fires the given gun and ticks until its light shows; returns the zone clock of that tick.
|
|
private float Fire(Ship ship, EquippedItem item)
|
|
{
|
|
var gun = (InstantWeapon) item.Behaviors.Single(b => b is InstantWeapon);
|
|
var capacitor = (InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(gun);
|
|
capacitor.AddCharge(1000f);
|
|
ship.SetTarget(TargetRef.None);
|
|
gun.Activate();
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
Tick(.1f);
|
|
if (ship.VisibilitySources.ContainsKey(gun)) return _zone.Time;
|
|
}
|
|
throw new Exception("fixture: the gun fires");
|
|
}
|
|
|
|
private static float Track(Entity observer, Entity subject) =>
|
|
observer.EntityInfoGathered.TryGetValue(subject, out var info) ? info : 0f;
|
|
|
|
private static void AssertBearing(float x, float y, float2 bearing)
|
|
{
|
|
Assert.Equal(x, bearing.x, 4);
|
|
Assert.Equal(y, bearing.y, 4);
|
|
}
|
|
|
|
private static VapourCloud Hand(Zone zone, float2 at, float radius, float opacity)
|
|
{
|
|
var cloud = new VapourCloud { Body = new KinematicBody { Position = at }, Radius = radius, Opacity = opacity, Lifetime = 1000f };
|
|
zone.Vent(cloud);
|
|
return cloud;
|
|
}
|
|
|
|
// ---- AFiredGunIsRevealedWithItsBearing ----
|
|
|
|
[Fact]
|
|
public void AFiredGunIsRevealedWithItsBearing()
|
|
{
|
|
var e = Add(float3(30, 0, 40), new[] { "Gun", "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var guns = Guns(e);
|
|
Assert.Empty(o.RevealsOf(e)); // nothing shown yet
|
|
|
|
var at = Fire(e, guns[0]);
|
|
|
|
var reveal = Assert.Single(o.RevealsOf(e));
|
|
Assert.Same(guns[0], reveal.Item);
|
|
Assert.Equal(SignatureChannel.LaunchFlash, reveal.Channel);
|
|
AssertBearing(.6f, .8f, reveal.Bearing); // normalize((E - O).xz)
|
|
Assert.Equal(at, reveal.Time);
|
|
Assert.DoesNotContain(o.RevealsOf(e), r => r.Item == guns[1]); // the unfired second gun
|
|
Assert.Empty(e.RevealsOf(o)); // a ship is not told what it shows
|
|
}
|
|
|
|
// ---- AFiredGunIsAimableAndItsTwinIsNot ----
|
|
|
|
// Two guns of one design: firing one makes that gun, and only that gun, something the observer can select and
|
|
// keep aimed at. The book is per item, not per design or per hardpoint type.
|
|
[Fact]
|
|
public void AFiredGunIsAimableAndItsTwinIsNot()
|
|
{
|
|
var e = Add(float3(30, 0, 40), new[] { "Gun", "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var guns = Guns(e);
|
|
Assert.Same(guns[0].Data, guns[1].Data); // the same design
|
|
o.SetTarget(e);
|
|
Assert.False(FireControl.IsRevealed(o, guns[0]));
|
|
Assert.False(o.TrySelectTargetItem(guns[0]));
|
|
|
|
Fire(e, guns[0]);
|
|
o.SetTarget(e);
|
|
|
|
Assert.True(FireControl.IsRevealed(o, guns[0]));
|
|
Assert.False(FireControl.IsRevealed(o, guns[1]));
|
|
Assert.False(o.TrySelectTargetItem(guns[1]));
|
|
Assert.True(o.TrySelectTargetItem(guns[0]));
|
|
for (var i = 0; i < 300; i++) Tick(.1f);
|
|
Assert.Same(guns[0], o.ResolvedTargetItem);
|
|
Assert.False(FireControl.IsRevealed(o, guns[1]));
|
|
}
|
|
|
|
// ---- AnActAboveTheReachRevealsNothing ----
|
|
|
|
// Info settles at S x 2 / (d k): with S = 5 the act is revealed inside d = 100 / k and not beyond it, whatever k is.
|
|
[Theory]
|
|
[InlineData(.5f, 190f, true)]
|
|
[InlineData(.5f, 210f, false)]
|
|
[InlineData(.25f, 390f, true)]
|
|
[InlineData(.25f, 410f, false)]
|
|
public void AnActAboveTheReachRevealsNothing(float infoDecay, float distance, bool revealed)
|
|
{
|
|
_items.GameplaySettings.TargetInfoDecay = infoDecay;
|
|
var e = Add(float3(0, 0, distance), new[] { "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
|
|
Fire(e, Guns(e)[0]);
|
|
|
|
Assert.Equal(revealed ? 1 : 0, o.RevealsOf(e).Count());
|
|
}
|
|
|
|
// ---- AFogBankHidesTheAct ----
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void AFogBankHidesTheAct(bool fog)
|
|
{
|
|
var e = Add(float3(0, 0, 150), new[] { "Gun" }); // 5 x 2 / (150 x .5) = .133 clear
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
if (fog) Hand(_zone, float2(0, 75), 5f, .6f); // a bank on the sight line passes .4 of the light: .053
|
|
|
|
Fire(e, Guns(e)[0]);
|
|
|
|
Assert.Equal(fog ? 0 : 1, o.RevealsOf(e).Count());
|
|
}
|
|
|
|
// ---- ABurnRevealsTheDriveAndOpenRadiatorsRevealThemselves ----
|
|
|
|
[Fact]
|
|
public void ABurnRevealsTheDriveAndOpenRadiatorsRevealThemselves()
|
|
{
|
|
// The hull radiates .25 a border cell at any temperature; the radiator adds .25 x 3.
|
|
_items.GameplaySettings.HeatRadiationMultiplier = .25f;
|
|
_items.GameplaySettings.HeatRadiationExponent = 0f;
|
|
var burner = Add(float3(0, 0, 100), tools: new[] { "Reactor", "Targeting" }, thruster: true); // 10 x 4 / 100 = .4
|
|
var glow = Add(float3(20, 0, 0), tools: new[] { "Reactor", "Radiator" }); // .75 x 4 / 20 = .15
|
|
var bare = Add(float3(0, 0, -10)); // the hull alone, brighter than any act
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var thrusterItem = burner.Equipment.Single(i => i.Behaviors.Any(b => b is Thruster));
|
|
thrusterItem.UpdatePerformance();
|
|
|
|
burner.MovementDirection = float2(0, -1);
|
|
Tick(.1f);
|
|
|
|
var plume = Assert.Single(o.RevealsOf(burner));
|
|
Assert.Same(thrusterItem, plume.Item);
|
|
Assert.Equal(SignatureChannel.DrivePlume, plume.Channel);
|
|
var radiator = Assert.Single(o.RevealsOf(glow));
|
|
Assert.Same(glow.Equipment.Single(i => i.Behaviors.Any(b => b is Radiator)), radiator.Item);
|
|
Assert.Equal(SignatureChannel.Thermal, radiator.Channel);
|
|
Assert.True(bare.Visibility > 1f, "fixture: the bare hull is lit");
|
|
Assert.Empty(o.RevealsOf(bare));
|
|
}
|
|
|
|
// ---- APaintRevealsTheEmitterEvenUnseen ----
|
|
|
|
[Fact]
|
|
public void APaintRevealsTheEmitterEvenUnseen()
|
|
{
|
|
var locker = Add(float3(0, 0, 200), new[] { "Locker" });
|
|
var o = Add(float3(0, 0, 0)); // deaf: no active sensor, so the reveal is the paint's alone, never the lock's emission heard
|
|
locker.SetIff(o, true);
|
|
locker.EntityInfoGathered[o] = 1f;
|
|
locker.SetTarget(o);
|
|
locker.Aim = normalize(o.Position - locker.Position);
|
|
|
|
Tick(.5f);
|
|
Tick(.5f);
|
|
|
|
Assert.Single(o.IncomingPaints); // fixture: the lock painted it
|
|
Assert.Equal(0f, Track(o, locker)); // unseen
|
|
var reveal = Assert.Single(o.RevealsOf(locker));
|
|
Assert.Same(locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon)), reveal.Item);
|
|
Assert.Equal(SignatureChannel.Emission, reveal.Channel);
|
|
AssertBearing(0f, 1f, reveal.Bearing);
|
|
Assert.False(o.ContactOf(locker).Tracked);
|
|
}
|
|
|
|
[Fact]
|
|
public void AConsumableBorneEmitterRecordsNothing()
|
|
{
|
|
var p = Add(float3(0, 0, 100));
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var data = _openCache.GetByName<ConsumableItemData>("Pinger");
|
|
p.ActivateConsumable(Assert.IsType<ConsumableItem>(_items.CreateInstance(_items.CreateLot(data, _maker, .5f))));
|
|
var sensor = (Sensor) p.FindActiveConsumable(data).Behaviors.Single(b => b is Sensor);
|
|
Tick(.01f);
|
|
sensor.Ping();
|
|
|
|
var paints = 0;
|
|
for (var tick = 0; tick < 30; tick++)
|
|
{
|
|
Tick(.1f);
|
|
paints += o.IncomingPaints.Count;
|
|
}
|
|
|
|
Assert.Equal(1, paints); // fixture: the sweep painted the observer
|
|
Assert.Empty(o.RevealsOf(p));
|
|
}
|
|
|
|
// Ruling consumable-signature-null-until-reveal: a consumable-hosted signature names no item (Item null, as
|
|
// hull radiation does), so however bright and near it reveals nothing; the reveal cut leaves what it names to
|
|
// follow-up reveal-names-consumable-signature.
|
|
[Fact]
|
|
public void ANullItemSignatureFromASourceThatIsNotTheHullRevealsNothing()
|
|
{
|
|
var e = Add(float3(0, 0, 20));
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var data = _openCache.GetByName<ConsumableItemData>("Pinger");
|
|
e.ActivateConsumable(Assert.IsType<ConsumableItem>(_items.CreateInstance(_items.CreateLot(data, _maker, .5f))));
|
|
var source = e.FindActiveConsumable(data).Behaviors.Single();
|
|
e.SetSignature(source, SignatureChannel.Reflection, 1000f, null);
|
|
|
|
Tick(.01f);
|
|
|
|
Assert.True(e.VisibilitySources.ContainsKey(source), "fixture: the consumable's light is on");
|
|
Assert.Empty(o.RevealsOf(e));
|
|
}
|
|
|
|
// A ping is the pinger's act on its beam, not light (R1): it adds nothing to the pinger's Visibility, so it raises
|
|
// no observer's track of the pinger. The control run is the same scene without the Ping() call.
|
|
[Fact]
|
|
public void APingNoLongerBrightensThePinger()
|
|
{
|
|
var (pingedLight, pingedTrack) = RunPinger(ping: true);
|
|
var (controlLight, controlTrack) = RunPinger(ping: false);
|
|
|
|
Assert.Equal(controlLight, pingedLight);
|
|
Assert.Equal(controlTrack, pingedTrack, 6);
|
|
}
|
|
|
|
// P is past the ping's radius from O; its Visibility is read on the call's own tick and on each tick after.
|
|
private static (float[] visibility, float track) RunPinger(bool ping)
|
|
{
|
|
using var t = new ContactRevealTests();
|
|
var p = t.Add(float3(0, 0, 1000), new[] { "Eye" });
|
|
var o = t.Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var sensor = p.Equipment.SelectMany(i => i.Behaviors).OfType<Sensor>().Single();
|
|
var fired = false;
|
|
sensor.OnPingStart += () => fired = true;
|
|
t.Tick(.1f); // a sensor's first tick opens its ping cooldown; Ping() is refused before it
|
|
if (ping) sensor.Ping();
|
|
Assert.Equal(ping, fired); // fixture: the ping started exactly when asked, so the run is not a control in disguise
|
|
var light = new List<float> { p.Visibility };
|
|
for (var tick = 0; tick < 10; tick++)
|
|
{
|
|
t.Tick(.1f);
|
|
light.Add(p.Visibility);
|
|
}
|
|
return (light.ToArray(), Track(o, p));
|
|
}
|
|
|
|
// Two items revealed at different times: the contact's bearing, while untracked, is the freshest reveal's.
|
|
[Fact]
|
|
public void TheFreshestRevealGivesTheBearing()
|
|
{
|
|
var e = Add(float3(30, 0, 40), new[] { "Gun", "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var guns = Guns(e);
|
|
Fire(e, guns[0]);
|
|
e.Position = float3(-50, 0, 0);
|
|
Fire(e, guns[1]);
|
|
|
|
var contact = o.ContactOf(e);
|
|
|
|
Assert.Equal(2, o.RevealsOf(e).Count());
|
|
Assert.False(contact.Tracked);
|
|
AssertBearing(-1f, 0f, contact.Bearing);
|
|
}
|
|
|
|
// ---- ALaunchGivesABearingNotATrack ----
|
|
|
|
[Fact]
|
|
public void AnActGivesABearingNotATrack()
|
|
{
|
|
var e = Add(float3(0, 0, 150), new[] { "Gun" }); // 5 x 2 / (150 x .5) = .133: revealed, but one tick of light builds no track
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var gun = Guns(e)[0];
|
|
var at = Fire(e, gun);
|
|
e.Position = float3(150, 0, 0); // it slips away: the reveal keeps the bearing of the act
|
|
|
|
var seen = o.ContactOf(e);
|
|
Assert.True(Track(o, e) < .1f, "fixture: no track");
|
|
Assert.True(seen.Detected);
|
|
Assert.True(seen.Classified);
|
|
Assert.False(seen.Tracked);
|
|
Assert.False(seen.Identified);
|
|
AssertBearing(0f, 1f, seen.Bearing);
|
|
|
|
// Younger than BearingMemory (2 s): still Detected. Older: not, and the gun is still known.
|
|
while (_zone.Time - at < 1.5f) Tick(.1f);
|
|
Assert.True(o.ContactOf(e).Detected);
|
|
while (_zone.Time - at < 2.5f) Tick(.1f);
|
|
var later = o.ContactOf(e);
|
|
Assert.False(later.Detected);
|
|
Assert.False(later.Classified);
|
|
Assert.False(later.Tracked);
|
|
Assert.Same(gun, Assert.Single(o.RevealsOf(e)).Item);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1f)]
|
|
[InlineData(5f)]
|
|
public void TheBearingMemoryIsAGameplaySetting(float memory)
|
|
{
|
|
_items.GameplaySettings.BearingMemory = memory;
|
|
var e = Add(float3(0, 0, 150), new[] { "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var at = Fire(e, Guns(e)[0]);
|
|
|
|
while (_zone.Time - at < memory - .3f) Tick(.1f);
|
|
Assert.True(o.ContactOf(e).Detected);
|
|
while (_zone.Time - at < memory + .3f) Tick(.1f);
|
|
Assert.False(o.ContactOf(e).Detected);
|
|
}
|
|
|
|
// ---- ContactLevels ----
|
|
|
|
// E fires from (30, 40) and moves to (-50, 0). Tracked is exactly membership of VisibleEntities, Identified
|
|
// exactly a track at or above TargetGearInfoThreshold; the bearing is the true one while tracked and the act's
|
|
// while the ship is only remembered.
|
|
[Theory]
|
|
[InlineData(.05f, false, false)]
|
|
[InlineData(.1f, false, false)]
|
|
[InlineData(.5f, true, false)]
|
|
[InlineData(.79f, true, false)]
|
|
[InlineData(.8f, true, true)]
|
|
[InlineData(1f, true, true)]
|
|
public void ContactLevels(float info, bool tracked, bool identified)
|
|
{
|
|
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
Fire(e, Guns(e)[0]);
|
|
e.Position = float3(-50, 0, 0);
|
|
|
|
o.EntityInfoGathered[e] = info;
|
|
|
|
var contact = o.ContactOf(e);
|
|
Assert.Equal(o.VisibleEntities.Contains(e), contact.Tracked);
|
|
Assert.Equal(tracked, contact.Tracked);
|
|
Assert.Equal(identified, contact.Identified);
|
|
Assert.True(contact.Detected);
|
|
Assert.True(contact.Classified);
|
|
Assert.Equal(info, contact.Info);
|
|
if (tracked) AssertBearing(-1f, 0f, contact.Bearing);
|
|
else AssertBearing(.6f, .8f, contact.Bearing);
|
|
}
|
|
|
|
[Fact]
|
|
public void AShipNothingHasShownIsNotDetected()
|
|
{
|
|
var e = Add(float3(0, 0, 150));
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
|
|
var contact = o.ContactOf(e);
|
|
|
|
Assert.False(contact.Detected);
|
|
Assert.False(contact.Tracked);
|
|
Assert.False(contact.Classified);
|
|
Assert.False(contact.Identified);
|
|
}
|
|
|
|
// ---- A subject leaves ----
|
|
|
|
private Action SenseOf(Entity observer) =>
|
|
(Action) Delegate.CreateDelegate(typeof(Action), observer, typeof(Entity).GetMethod("Sense", BindingFlags.Instance | BindingFlags.NonPublic));
|
|
|
|
// The ways a ship leaves a zone: removed by anything, the warp path (Deactivate, then Remove) and death.
|
|
private static void Leave(Zone zone, Entity e, string how)
|
|
{
|
|
switch (how)
|
|
{
|
|
case "remove": zone.Remove(e); break;
|
|
case "warp": e.Deactivate(); zone.Remove(e); break;
|
|
case "death": e.Hull.Durability = 0f; e.HullDamage.OnNext(1f); break;
|
|
default: throw new Exception("fixture: " + how);
|
|
}
|
|
Assert.DoesNotContain(e, zone.Entities);
|
|
}
|
|
|
|
// Leaves the ship showing exactly `strength` of light: whatever its guns left glowing is put out first.
|
|
private static void ShowOnly(Entity e, float strength, EquippedItem item)
|
|
{
|
|
foreach (var source in e.VisibilitySources.Keys.ToArray()) e.SetSignature(source, SignatureChannel.Reflection, 0f, item);
|
|
e.SetSignature(new object(), SignatureChannel.Reflection, strength, item);
|
|
Assert.Equal(strength, e.Visibility, 4);
|
|
}
|
|
|
|
private static float Freshest(Entity observer, Entity subject) => observer.RevealsOf(subject).Max(r => r.Time);
|
|
|
|
// Ruling reveal-ends-on-perception: a ship leaving the zone does not by itself empty any book. Out of this
|
|
// observer's sight (the light it showed at removal is below the observer's gate) its reveals stay, unrefreshed,
|
|
// and the contact stays Detected on the freshest bearing, exactly until that bearing is BearingMemory old; then
|
|
// the book drops the ship. A live quiet ship's old reveal is another matter: it stays in the book.
|
|
[Fact]
|
|
public void AnUnseenDepartureKeepsTheContactUntilTheBearingLapses()
|
|
{
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var q = Add(float3(40, 0, 100), new[] { "Gun" });
|
|
var guns = Guns(e);
|
|
Fire(e, guns[0]);
|
|
Fire(e, guns[1]);
|
|
Fire(q, Guns(q)[0]);
|
|
var memory = _items.GameplaySettings.BearingMemory;
|
|
var times = o.RevealsOf(e).ToDictionary(r => r.Item, r => r.Time);
|
|
var freshest = times.Values.Max();
|
|
var bearing = o.ContactOf(e).Bearing;
|
|
ShowOnly(e, 1f, guns[0]);
|
|
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its last light is below o's gate");
|
|
Assert.Equal(2, times.Count);
|
|
|
|
_zone.Remove(e);
|
|
Assert.Equal(2, o.RevealsOf(e).Count());
|
|
|
|
var lapsed = false;
|
|
var ticks = 0;
|
|
while (!lapsed && ticks++ < 100)
|
|
{
|
|
Tick(.1f);
|
|
var within = _zone.Time - freshest < memory;
|
|
lapsed = !within;
|
|
Assert.Equal(within, o.RevealsOf(e).Count() == 2); // exactly while the bearing is remembered
|
|
Assert.Equal(within, o.ContactOf(e).Detected);
|
|
Assert.Equal(within ? 1 : 0, RevealBookSubjects(o) - 1); // the quiet live ship's key stays either way
|
|
if (!within) continue;
|
|
var contact = o.ContactOf(e);
|
|
Assert.True(contact.Classified && !contact.Tracked);
|
|
AssertBearing(bearing.x, bearing.y, contact.Bearing);
|
|
foreach (var reveal in o.RevealsOf(e)) Assert.Equal(times[reveal.Item], reveal.Time); // never refreshed
|
|
}
|
|
|
|
Assert.True(lapsed, "fixture: the bearing lapsed");
|
|
Assert.Empty(o.RevealsOf(e));
|
|
Assert.False(o.ContactOf(e).Detected);
|
|
Assert.Equal(1, RevealBookSubjects(o)); // only the live ship's key
|
|
|
|
for (var i = 0; i < 30; i++) Tick(.2f); // q, live and quiet, was revealed 6 s ago, far more than twice the memory
|
|
Assert.Single(o.RevealsOf(q));
|
|
Assert.False(o.ContactOf(q).Detected);
|
|
}
|
|
|
|
// The same departure seen: every way a ship leaves is one departure fact, judged when the observer next senses.
|
|
// A strong ear (sensitivity 4) and a weak eye (2) at the same place, listed on either side of the ship, both hold
|
|
// both reveals right after the removal; after the next tick only the observer whose gate the ship's light at
|
|
// removal passes (signature 2 x info 8/d = .16 against .08) has let them go.
|
|
[Theory]
|
|
[InlineData("remove", false)]
|
|
[InlineData("remove", true)]
|
|
[InlineData("warp", false)]
|
|
[InlineData("death", false)]
|
|
[InlineData("death", true)]
|
|
public void ASeenDepartureEndsEveryRevealOfTheSubject(string how, bool strongFirst)
|
|
{
|
|
Entity strong = null, weak = null;
|
|
if (strongFirst) strong = Add(float3(-1, 0, 0), new[] { "Ear" });
|
|
else weak = Add(float3(1, 0, 0), new[] { "Eye" });
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" });
|
|
if (strongFirst) weak = Add(float3(1, 0, 0), new[] { "Eye" });
|
|
else strong = Add(float3(-1, 0, 0), new[] { "Ear" });
|
|
var guns = Guns(e);
|
|
Fire(e, guns[0]);
|
|
Fire(e, guns[1]);
|
|
foreach (var observer in new[] { strong, weak }) Assert.Equal(2, observer.RevealsOf(e).Count());
|
|
ShowOnly(e, 2f, guns[0]);
|
|
var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold;
|
|
Assert.True(e.Visibility * strong.PassiveInfo(e.Position.xz, 1f) > threshold, "fixture: the ear passes the gate");
|
|
Assert.True(e.Visibility * weak.PassiveInfo(e.Position.xz, 1f) <= threshold, "fixture: the eye fails it");
|
|
|
|
Leave(_zone, e, how);
|
|
|
|
Assert.Equal(2, strong.RevealsOf(e).Count()); // nothing ends at the removal itself
|
|
Assert.Equal(2, weak.RevealsOf(e).Count());
|
|
Tick(.1f);
|
|
|
|
Assert.Empty(strong.RevealsOf(e));
|
|
Assert.Equal(0, RevealBookSubjects(strong));
|
|
Assert.False(strong.ContactOf(e).Detected);
|
|
Assert.Equal(2, weak.RevealsOf(e).Count());
|
|
Assert.True(weak.ContactOf(e).Detected);
|
|
}
|
|
|
|
// The gate at its edge, at one fixed distance: the light at removal a hair above the observer's threshold ends
|
|
// the reveals, a hair below does not. The factor is the production gate's own: threshold over the observer's
|
|
// passive info on the spot where the ship was.
|
|
[Theory]
|
|
[InlineData(1.01f, true)]
|
|
[InlineData(.99f, false)]
|
|
public void ADepartureEndsRevealsOnlyAboveTheLightActGate(float against, bool ended)
|
|
{
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
Fire(e, Guns(e)[0]);
|
|
Tick(.01f);
|
|
Assert.Single(o.RevealsOf(e));
|
|
var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f);
|
|
ShowOnly(e, gate * against, Guns(e)[0]);
|
|
|
|
_zone.Remove(e);
|
|
Tick(.1f);
|
|
|
|
Assert.Equal(ended ? 0 : 1, o.RevealsOf(e).Count());
|
|
}
|
|
|
|
// The departure records the subject's whole Visibility: no channel is excepted. The light at removal sits on each
|
|
// channel in turn, a hair above the observer's gate, and ends the reveals whichever channel carries it.
|
|
[Theory]
|
|
[InlineData(SignatureChannel.Thermal)]
|
|
[InlineData(SignatureChannel.DrivePlume)]
|
|
[InlineData(SignatureChannel.Reflection)]
|
|
[InlineData(SignatureChannel.LaunchFlash)]
|
|
public void ADepartureCountsLightOnEveryChannel(SignatureChannel channel)
|
|
{
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
Fire(e, Guns(e)[0]);
|
|
Tick(.01f);
|
|
Assert.Single(o.RevealsOf(e));
|
|
var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f);
|
|
foreach (var source in e.VisibilitySources.Keys.ToArray()) e.SetSignature(source, SignatureChannel.Reflection, 0f, Guns(e)[0]);
|
|
e.SetSignature(new object(), channel, gate * 1.01f, Guns(e)[0]);
|
|
|
|
_zone.Remove(e);
|
|
Tick(.1f);
|
|
|
|
Assert.Empty(o.RevealsOf(e));
|
|
}
|
|
|
|
// The hull's own radiation names no item, and the departure still counts it: the light at removal sits on an item-less
|
|
// entry alone, a hair above the observer's gate (ends the reveals) or a hair below (ends nothing).
|
|
[Theory]
|
|
[InlineData(1.01f, true)]
|
|
[InlineData(.99f, false)]
|
|
public void ADepartureCountsTheHullsOwnLight(float against, bool ended)
|
|
{
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
Fire(e, Guns(e)[0]);
|
|
Tick(.01f);
|
|
Assert.Single(o.RevealsOf(e));
|
|
var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f);
|
|
foreach (var source in e.VisibilitySources.Keys.ToArray()) e.SetSignature(source, SignatureChannel.Reflection, 0f, Guns(e)[0]);
|
|
e.SetSignature(new object(), SignatureChannel.Thermal, gate * against, null);
|
|
Assert.Equal(gate * against, e.Visibility, 4);
|
|
Assert.All(e.VisibilitySources.Values.Where(v => v.Strength > 0f), v => Assert.Null(v.Item)); // fixture: the deciding light names no item
|
|
|
|
_zone.Remove(e);
|
|
Tick(.1f);
|
|
|
|
Assert.Equal(ended ? 0 : 1, o.RevealsOf(e).Count());
|
|
}
|
|
|
|
// The gate is strict, as the light-act gate is: a light at removal that lands exactly on the threshold ends nothing.
|
|
// At 32 the observer's info per unit light is 4 / 32, a power of two, so threshold x 8 is the light whose product
|
|
// with it is exactly the threshold.
|
|
[Fact]
|
|
public void ADepartureExactlyAtTheLightActGateEndsNothing()
|
|
{
|
|
var e = Add(float3(0, 0, 32), new[] { "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
Fire(e, Guns(e)[0]);
|
|
Tick(.01f);
|
|
Assert.Single(o.RevealsOf(e));
|
|
var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold;
|
|
Assert.Equal(.125f, o.PassiveInfo(e.Position.xz, 1f));
|
|
ShowOnly(e, threshold * 8f, Guns(e)[0]);
|
|
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) == threshold, "fixture: the ship's light at removal is exactly the gate");
|
|
|
|
_zone.Remove(e);
|
|
Tick(.1f);
|
|
|
|
Assert.Single(o.RevealsOf(e));
|
|
}
|
|
|
|
// A ship removed and put back before anyone senses has not left: its reveals stand, and its next act refreshes them.
|
|
[Fact]
|
|
public void AReAdmittedSubjectHasNotDeparted()
|
|
{
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var gun = Guns(e)[0];
|
|
var at = Fire(e, gun);
|
|
Tick(1f); // the gun cools down
|
|
ShowOnly(e, 1000f, gun); // bright enough for any gate
|
|
|
|
_zone.Remove(e);
|
|
e.Deactivate();
|
|
_zone.Admit(e, piloted: false);
|
|
Tick(.1f);
|
|
|
|
Assert.Equal(at, Assert.Single(o.RevealsOf(e)).Time);
|
|
var later = Fire(e, gun);
|
|
Assert.True(later > at);
|
|
Assert.Equal(later, Assert.Single(o.RevealsOf(e)).Time);
|
|
}
|
|
|
|
private static int RevealBookSubjects(Entity observer) =>
|
|
((System.Collections.IDictionary) typeof(Entity).GetField("_reveals", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(observer)).Count;
|
|
|
|
// Ruling contact-reads-book-aim-reads-live: what an observer knows of a ship comes from its own book. A gun
|
|
// destroyed by a third party out of its sight changes none of its levels and stays in its reveal list, while
|
|
// the aiming read, which asks about the item now, refuses the dead gun and still serves the live one. No Sense
|
|
// runs after the destruction here; the unseen case with a Sense is AnIdentifiedObserverSeesGearDestroyed's
|
|
// below-threshold case.
|
|
[Fact]
|
|
public void GearDestroyedOutOfSightLeavesWhatTheObserverKnowsAlone()
|
|
{
|
|
var e = Add(float3(0, 0, 150), new[] { "Gun", "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var guns = Guns(e);
|
|
Fire(e, guns[0]);
|
|
Fire(e, guns[1]);
|
|
var before = o.ContactOf(e);
|
|
Assert.True(before.Detected && before.Classified && !before.Tracked, "fixture: a bearing and a class, no track");
|
|
Assert.True(FireControl.IsRevealed(o, guns[0]) && FireControl.IsRevealed(o, guns[1]), "fixture: both guns are aimable");
|
|
|
|
guns[0].EquippableItem.Durability = 0f; // destroyed by someone else; o saw nothing
|
|
|
|
Assert.Equal(2, o.RevealsOf(e).Count());
|
|
var after = o.ContactOf(e);
|
|
Assert.True(after.Detected);
|
|
Assert.True(after.Classified);
|
|
Assert.Equal(before.Bearing.x, after.Bearing.x);
|
|
Assert.Equal(before.Bearing.y, after.Bearing.y);
|
|
Assert.False(FireControl.IsRevealed(o, guns[0]));
|
|
Assert.True(FireControl.IsRevealed(o, guns[1]));
|
|
|
|
guns[1].EquippableItem.Durability = 0f; // the last of its gear dies unseen too
|
|
|
|
Assert.Equal(2, o.RevealsOf(e).Count());
|
|
Assert.True(o.ContactOf(e).Detected);
|
|
Assert.True(o.ContactOf(e).Classified);
|
|
}
|
|
|
|
// Ruling reveal-ends-on-perception: an observer that resolves a ship's gear (its track at TargetGearInfoThreshold)
|
|
// perceives an item of it destroyed, and the reveal of that item ends in the same Sense pass. One that does not
|
|
// resolve it perceives nothing.
|
|
[Theory]
|
|
[InlineData(.8f, 1, 1, 1)]
|
|
[InlineData(.8f, 2, 0, 0)]
|
|
[InlineData(.79f, 1, 2, 1)]
|
|
[InlineData(.79f, 2, 2, 1)]
|
|
public void AnIdentifiedObserverSeesGearDestroyed(float info, int destroyed, int remaining, int classifiedKeys)
|
|
{
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var guns = Guns(e);
|
|
Fire(e, guns[0]);
|
|
Fire(e, guns[1]);
|
|
o.VisibleEntities.Add(e); // tracked, so what the track holds is not the question
|
|
o.EntityInfoGathered[e] = info;
|
|
var sense = SenseOf(o);
|
|
for (var i = 0; i < destroyed; i++) guns[i].EquippableItem.Durability = 0f;
|
|
|
|
sense();
|
|
|
|
Assert.Equal(remaining, o.RevealsOf(e).Count());
|
|
var contact = o.ContactOf(e);
|
|
Assert.True(contact.Detected);
|
|
Assert.Equal(remaining > 0, contact.Classified);
|
|
Assert.Equal(remaining > 0 ? classifiedKeys : 0, RevealBookSubjects(o));
|
|
if (remaining == 1 && info >= .8f) Assert.Same(guns[1], o.RevealsOf(e).Single().Item);
|
|
}
|
|
|
|
// The same through a real tick: a gun destroyed while its flash still shows, seen by an observer that identifies
|
|
// the ship, is not in the book after the tick.
|
|
[Fact]
|
|
public void ADestroyedGunStillFlashingIsNotRecordedByAnIdentifyingObserver()
|
|
{
|
|
var e = Add(float3(0, 0, 60), new[] { "Gun", "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var guns = Guns(e);
|
|
Fire(e, guns[0]);
|
|
Fire(e, guns[1]);
|
|
e.SetSignature(new object(), SignatureChannel.Reflection, 1000f, guns[0]); // the dying gun's glare outlasts the tick
|
|
guns[0].EquippableItem.Durability = 0f;
|
|
o.EntityInfoGathered[e] = 1f; // o's sensor keeps it at about 1 across the tick (decay .5 x dt)
|
|
|
|
Tick(.01f);
|
|
|
|
Assert.True(Track(o, e) >= .8f, "fixture: o still identifies e");
|
|
|
|
Assert.DoesNotContain(o.RevealsOf(e), r => r.Item == guns[0]);
|
|
Assert.Contains(o.RevealsOf(e), r => r.Item == guns[1]);
|
|
}
|
|
|
|
// A paint inboxed in the tick its emitter leaves the zone is published the next tick, and is no longer the act of
|
|
// a subject in the zone: it warns for its tick and neither creates nor refreshes a reveal. The reveal the
|
|
// locker's earlier paint made stays what it was, as a departure out of sight (below the observer's gate) leaves it.
|
|
[Fact]
|
|
public void APaintFromAShipThatHasLeftTheZoneRevealsNothing()
|
|
{
|
|
var locker = Add(float3(0, 0, 200), new[] { "Locker" });
|
|
var o = Add(float3(0, 0, 0)); // deaf: no active sensor, so the reveal is the paint's alone, never the lock's emission heard
|
|
locker.SetIff(o, true);
|
|
locker.EntityInfoGathered[o] = 1f;
|
|
locker.SetTarget(o);
|
|
locker.Aim = normalize(o.Position - locker.Position);
|
|
for (var tick = 0; tick < 10 && !o.RevealsOf(locker).Any(); tick++) Tick(.5f);
|
|
var first = Assert.Single(o.RevealsOf(locker)); // fixture: the lock paints every tick, and the first one is recorded
|
|
Assert.True(locker.Visibility * o.PassiveInfo(locker.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its departure is below o's gate");
|
|
|
|
_zone.Remove(locker); // dies, warps or docks while its latest paint is still in o's inbox
|
|
var inboxed = ((System.Collections.IList) typeof(Entity).GetField("_paintInbox", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(o)).Count;
|
|
Assert.True(inboxed > 0 || o.IncomingPaints.Count > 0, "fixture: a paint is in flight");
|
|
Tick(.1f);
|
|
|
|
Assert.Single(o.IncomingPaints); // fixture: the stale paint was published
|
|
var after = Assert.Single(o.RevealsOf(locker));
|
|
Assert.Equal(first.Time, after.Time);
|
|
Assert.Equal(1, RevealBookSubjects(o));
|
|
}
|
|
|
|
// Ruling sensor-stat-set 4: a painted target always learns the emitter's class, whichever the mode. A ping
|
|
// crossing the observer (a Search paint) reveals the pinger's sensor item, with channel Emission and the
|
|
// bearing of the pinger.
|
|
[Fact]
|
|
public void ASearchPaintRevealsThePingersSensor()
|
|
{
|
|
var p = Add(float3(0, 0, 100), new[] { "Eye" });
|
|
var o = Add(float3(0, 0, 0)); // deaf: the reveal is the paint's alone, never the ping's emission heard
|
|
var item = p.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor));
|
|
var sensor = (Sensor) item.Behaviors.Single(b => b is Sensor);
|
|
Tick(.01f);
|
|
sensor.Ping();
|
|
|
|
var modes = new List<PaintMode>();
|
|
for (var tick = 0; tick < 30; tick++)
|
|
{
|
|
Tick(.1f);
|
|
modes.AddRange(o.IncomingPaints.Select(paint => paint.Mode));
|
|
}
|
|
|
|
Assert.Equal(new[] { PaintMode.Search }, modes); // fixture: the sweep painted o once, as a search
|
|
var reveal = Assert.Single(o.RevealsOf(p));
|
|
Assert.Same(item, reveal.Item);
|
|
Assert.Equal(SignatureChannel.Emission, reveal.Channel);
|
|
AssertBearing(0f, 1f, reveal.Bearing);
|
|
}
|
|
|
|
// A search paint (a ping crossing the observer) from a pinger that has left the zone before the paint is
|
|
// published warns once and creates no reveal and no key.
|
|
[Fact]
|
|
public void ASearchPaintFromAPingerThatHasLeftCreatesNoReveal()
|
|
{
|
|
var p = Add(float3(0, 0, 100), new[] { "Eye" });
|
|
var o = Add(float3(0, 0, 0)); // deaf: it heard nothing of the sweep while the pinger stood in the zone
|
|
var sensor = (Sensor) p.Equipment.Single(i => i.Behaviors.Any(b => b is Sensor)).Behaviors.Single(b => b is Sensor);
|
|
Tick(.01f);
|
|
sensor.Ping();
|
|
var inbox = (System.Collections.IList) typeof(Entity).GetField("_paintInbox", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(o);
|
|
for (var tick = 0; tick < 100 && inbox.Count == 0; tick++) Tick(.1f);
|
|
Assert.NotEmpty(inbox); // fixture: the ping crossed o and its paint waits to be published
|
|
|
|
_zone.Remove(p);
|
|
Tick(.1f);
|
|
|
|
Assert.Single(o.IncomingPaints);
|
|
Assert.Equal(PaintMode.Search, o.IncomingPaints.Single().Mode);
|
|
Assert.Empty(o.RevealsOf(p));
|
|
Assert.Equal(0, RevealBookSubjects(o));
|
|
Assert.False(o.ContactOf(p).Detected);
|
|
}
|
|
|
|
// An observer's own gear is not another ship's act: a ship that fires its own gun, with its own sensor at the
|
|
// very place of the light, records nothing of itself, while a neighbour does record the gun.
|
|
[Fact]
|
|
public void AnObserverNeverRecordsItsOwnGear()
|
|
{
|
|
var self = Add(float3(0, 0, 0), new[] { "Gun", "Eye" });
|
|
var neighbour = Add(float3(0, 0, 30), new[] { "Eye" });
|
|
|
|
Fire(self, Guns(self)[0]);
|
|
|
|
Assert.Single(neighbour.RevealsOf(self)); // fixture: the gun's light reaches a sensor
|
|
Assert.Empty(self.RevealsOf(self));
|
|
Assert.False(self.ContactOf(self).Detected);
|
|
}
|
|
|
|
// The zone's list order is not a rule of the game: two observers equidistant from a gun, one listed before the
|
|
// gun's ship and one after, record the same reveal with the same bearing and the same clock. The ship may be
|
|
// moving: the bearing is that of the ship where the tick left it, whatever the order the zone lists them in.
|
|
[Theory]
|
|
[InlineData(0f, 0f)]
|
|
[InlineData(30f, -20f)]
|
|
[InlineData(-45f, 10f)]
|
|
public void AnActIsRevealedOnItsOwnTickWhateverTheListOrder(float vx, float vz)
|
|
{
|
|
var before = Add(float3(-30, 0, 40), new[] { "Eye" });
|
|
var e = Add(float3(30, 0, 40), new[] { "Gun" });
|
|
var after = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var start = e.Position;
|
|
e.Velocity = float2(vx, vz);
|
|
|
|
var at = Fire(e, Guns(e)[0]);
|
|
|
|
if (vx != 0f || vz != 0f) Assert.True(length(e.Position - start) > 1f, "fixture: e moved during the firing tick");
|
|
foreach (var observer in new Entity[] { before, after })
|
|
{
|
|
var reveal = Assert.Single(observer.RevealsOf(e));
|
|
Assert.Equal(at, reveal.Time);
|
|
var toward = normalize((e.Position - observer.Position).xz);
|
|
AssertBearing(toward.x, toward.y, reveal.Bearing);
|
|
}
|
|
}
|
|
|
|
// ---- A quiet ship ----
|
|
|
|
// Ruling quiet-gear-identifiable-hull-layout-known: a ship that has shown nothing is Detected while it is
|
|
// tracked and Identified once the track reaches the gear threshold, with no act in any book; with no reveal
|
|
// it is not Classified.
|
|
[Theory]
|
|
[InlineData(.05f, false, false)]
|
|
[InlineData(.5f, true, false)]
|
|
[InlineData(.79f, true, false)]
|
|
[InlineData(.8f, true, true)]
|
|
[InlineData(1f, true, true)]
|
|
public void AQuietTrackedShipIsDetectedAndIdentifiedByTheTrackAlone(float info, bool tracked, bool identified)
|
|
{
|
|
var e = Add(float3(30, 0, 40), new[] { "Gun" }); // armed, but it never fires
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
|
|
o.EntityInfoGathered[e] = info;
|
|
|
|
Assert.Empty(o.RevealsOf(e));
|
|
var contact = o.ContactOf(e);
|
|
Assert.Equal(tracked, o.VisibleEntities.Contains(e));
|
|
Assert.Equal(tracked, contact.Tracked);
|
|
Assert.Equal(tracked, contact.Detected);
|
|
Assert.False(contact.Classified);
|
|
Assert.Equal(identified, contact.Identified);
|
|
Assert.Equal(info, contact.Info);
|
|
if (tracked) AssertBearing(.6f, .8f, contact.Bearing);
|
|
}
|
|
|
|
[Fact]
|
|
public void RevealsLeaveWithTheObserver()
|
|
{
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
Fire(e, Guns(e)[0]);
|
|
Assert.Single(o.RevealsOf(e));
|
|
|
|
o.Deactivate();
|
|
|
|
Assert.Empty(o.RevealsOf(e));
|
|
}
|
|
|
|
// A destroyed line, not a destroyed zero: Durability below .01 is destroyed and .01 or more is not, for the
|
|
// observer that identifies the ship.
|
|
[Theory]
|
|
[InlineData(.009f, true)]
|
|
[InlineData(.011f, false)]
|
|
public void AnItemIsDestroyedBelowTheDurabilityLine(float durability, bool destroyed)
|
|
{
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var guns = Guns(e);
|
|
Fire(e, guns[0]);
|
|
Fire(e, guns[1]);
|
|
o.VisibleEntities.Add(e);
|
|
o.EntityInfoGathered[e] = 1f;
|
|
guns[0].EquippableItem.Durability = durability;
|
|
|
|
SenseOf(o)();
|
|
|
|
Assert.Equal(destroyed, !o.RevealsOf(e).Any(r => r.Item == guns[0]));
|
|
Assert.Contains(o.RevealsOf(e), r => r.Item == guns[1]);
|
|
}
|
|
|
|
// ---- Departure notes ----
|
|
|
|
// A kill inside FireControl.Step happens after this tick's Sense: the observers' next Sense reads it, whatever
|
|
// order the zone lists them in.
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void AKillInsideFireControlStepIsReadByTheNextSense(bool observerFirst)
|
|
{
|
|
Entity strong = null;
|
|
if (observerFirst) strong = Add(float3(-1, 0, 0), new[] { "Ear" });
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" });
|
|
var killer = Add(float3(60, 0, 100), new[] { "Gun" });
|
|
if (!observerFirst) strong = Add(float3(-1, 0, 0), new[] { "Ear" });
|
|
var guns = Guns(e);
|
|
Fire(e, guns[0]);
|
|
Fire(e, guns[1]);
|
|
Tick(1f);
|
|
var killed = false;
|
|
void Kill(ShotOutcome _)
|
|
{
|
|
if (killed || !_zone.Entities.Contains(e)) return;
|
|
killed = true;
|
|
ShowOnly(e, 2f, guns[0]); // lit at the moment it dies, as the ear's gate sees it
|
|
Assert.True(e.Visibility * strong.PassiveInfo(e.Position.xz, 1f) > _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: the ear passes the gate");
|
|
e.Hull.Durability = 0f;
|
|
e.HullDamage.OnNext(1f);
|
|
}
|
|
UniRx.ObservableExtensions.Subscribe(_zone.ShotCommitted, Kill);
|
|
UniRx.ObservableExtensions.Subscribe(_zone.ShotResolved, Kill);
|
|
var killerGun = (InstantWeapon) Guns(killer)[0].Behaviors.Single(b => b is InstantWeapon);
|
|
((InputCapacitor) typeof(InstantWeapon).GetField("_capacitor", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(killerGun)).AddCharge(1000f);
|
|
killer.SetTarget(TargetRef.None);
|
|
killerGun.Activate();
|
|
for (var ticks = 0; !killed && ticks < 20; ticks++) Tick(.1f);
|
|
Assert.True(killed, "fixture: the kill lands inside a tick");
|
|
Assert.DoesNotContain(e, _zone.Entities);
|
|
|
|
Assert.Equal(2, strong.RevealsOf(e).Count()); // this tick's Sense came before the kill
|
|
|
|
Tick(.1f);
|
|
|
|
Assert.Empty(strong.RevealsOf(e));
|
|
Assert.False(strong.ContactOf(e).Detected);
|
|
}
|
|
|
|
// The note of a removal is read by exactly one Sense: an observer that could not see the departure on that tick
|
|
// does not judge it again later from where it then is.
|
|
[Fact]
|
|
public void ADepartureIsJudgedOnTheTickAfterItsRemovalAndNeverAgain()
|
|
{
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var gun = Guns(e)[0];
|
|
Fire(e, gun);
|
|
ShowOnly(e, 1f, gun);
|
|
var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold;
|
|
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= threshold, "fixture: from where o is, the departure is below the gate");
|
|
_zone.Remove(e);
|
|
Tick(.1f);
|
|
Assert.Single(o.RevealsOf(e));
|
|
|
|
o.Position = float3(0, 0, 90); // now o is close enough to have seen it go
|
|
Assert.True(e.Visibility * o.PassiveInfo(float2(0, 100), 1f) > threshold, "fixture: from here the same departure would pass the gate");
|
|
Tick(.1f);
|
|
|
|
Assert.Single(o.RevealsOf(e));
|
|
Assert.True(o.ContactOf(e).Detected);
|
|
}
|
|
|
|
// Every departure of a tick is judged, whatever order they were removed in.
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void EveryDepartureOfATickIsJudged(bool firstThenSecond)
|
|
{
|
|
var o = Add(float3(-1, 0, 0), new[] { "Ear" });
|
|
var a = Add(float3(0, 0, 100), new[] { "Gun" });
|
|
var b = Add(float3(0, 0, -100), new[] { "Gun" });
|
|
Fire(a, Guns(a)[0]);
|
|
Fire(b, Guns(b)[0]);
|
|
Assert.Single(o.RevealsOf(a));
|
|
Assert.Single(o.RevealsOf(b));
|
|
ShowOnly(a, 2f, Guns(a)[0]);
|
|
ShowOnly(b, 2f, Guns(b)[0]);
|
|
var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold;
|
|
Assert.True(a.Visibility * o.PassiveInfo(a.Position.xz, 1f) > threshold && b.Visibility * o.PassiveInfo(b.Position.xz, 1f) > threshold, "fixture: both departures pass the gate");
|
|
|
|
_zone.Remove(firstThenSecond ? a : b);
|
|
_zone.Remove(firstThenSecond ? b : a);
|
|
Tick(.1f);
|
|
|
|
Assert.Empty(o.RevealsOf(a));
|
|
Assert.Empty(o.RevealsOf(b));
|
|
Assert.Equal(0, RevealBookSubjects(o));
|
|
}
|
|
|
|
// Nothing the observer keeps for a pass reaches a ship that has left: a ship with one gun ended as destroyed and
|
|
// one aged out by the prune cannot be reached from the observer (its fields, collections and closures, not the
|
|
// zone it shares with everyone) once the ship is gone and its memory has lapsed.
|
|
[Fact]
|
|
public void ADepartedSubjectIsUnreachableFromTheObserverAfterThePrune()
|
|
{
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun", "Gun" });
|
|
var guns = Guns(e);
|
|
Fire(e, guns[0]);
|
|
Fire(e, guns[1]);
|
|
Tick(1f);
|
|
o.EntityInfoGathered[e] = 1f;
|
|
ShowOnly(e, 0f, guns[1]); // its departure is below the observer's gate: the other gun's reveal waits for the prune
|
|
guns[0].EquippableItem.Durability = 0f;
|
|
Tick(.01f);
|
|
Assert.Same(guns[1], Assert.Single(o.RevealsOf(e)).Item); // fixture: one gun ended as destroyed, one waiting
|
|
e.Deactivate(); // the warp path: the ship's own subscriptions to the observer go with it
|
|
_zone.Remove(e);
|
|
for (var i = 0; i < 40; i++) Tick(.1f); // past BearingMemory (2 s)
|
|
Assert.Equal(0, RevealBookSubjects(o)); // fixture: pruned
|
|
|
|
foreach (var held in new object[] { e, guns[0], guns[1] })
|
|
Assert.Null(PathFrom(o, held));
|
|
}
|
|
|
|
// Cut contact-hud: selection tells the selected ship nothing. A ship that selects another, which is neither painted
|
|
// by it nor sees it, leaves no fact on the selected one: no paint in its inbox, no contact, no reveal.
|
|
[Fact]
|
|
public void NothingReadsSelection()
|
|
{
|
|
var selector = Add(float3(0, 0, 200), new[] { "Gun" });
|
|
var selected = Add(float3(0, 0, 0)); // deaf and unseen: no sensor, nothing it could hear
|
|
selector.SetTarget(selected);
|
|
Assert.True(ReferenceEquals(selected, selector.Target.Value.Entity)); // fixture: it is selected
|
|
|
|
var paints = 0;
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
Tick(.1f);
|
|
paints += selected.IncomingPaints.Count; // a warning lives one tick, so it is read on every tick
|
|
}
|
|
|
|
Assert.Equal(0, paints);
|
|
Assert.False(selected.ContactOf(selector).Detected);
|
|
Assert.Equal(0, selected.RevealedSubjects.Count);
|
|
}
|
|
|
|
// The one list of remembered contacts: the subjects whose reveals the observer holds. A tracked subject with no
|
|
// reveal is not in it; a revealed subject that leaves unseen stays until its bearing is BearingMemory old, then
|
|
// goes; Deactivate empties it.
|
|
[Fact]
|
|
public void RevealedSubjectsListsTheBook()
|
|
{
|
|
var e = Add(float3(0, 0, 100), new[] { "Gun" });
|
|
var tracked = Add(float3(50, 0, 0));
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var gun = Guns(e)[0];
|
|
o.EntityInfoGathered[tracked] = 1f;
|
|
Assert.True(o.ContactOf(tracked).Tracked); // fixture: tracked, no reveal
|
|
Assert.Equal(0, o.RevealedSubjects.Count); // counts, not the collection: a failure message would print whole entities
|
|
|
|
Fire(e, gun);
|
|
Assert.True(o.RevealedSubjects.Count == 1 && o.RevealedSubjects.Contains(e));
|
|
|
|
var q = Add(float3(40, 0, 100), new[] { "Gun" });
|
|
Fire(q, Guns(q)[0]);
|
|
Assert.True(o.RevealedSubjects.Count == 2 && o.RevealedSubjects.Contains(e) && o.RevealedSubjects.Contains(q)); // two subjects at once: both listed
|
|
|
|
var freshest = Freshest(o, e);
|
|
ShowOnly(e, 1f, gun);
|
|
Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its last light is below o's gate");
|
|
_zone.Remove(e);
|
|
var memory = _items.GameplaySettings.BearingMemory;
|
|
var lapsed = false;
|
|
for (var i = 0; i < 200 && !lapsed; i++)
|
|
{
|
|
Tick(.1f);
|
|
lapsed = _zone.Time - freshest >= memory;
|
|
Assert.Equal(!lapsed, o.RevealedSubjects.Contains(e)); // exactly while the bearing is remembered
|
|
Assert.True(o.RevealedSubjects.Contains(q), "the other subject stays listed while e departs");
|
|
}
|
|
Assert.True(lapsed, "fixture: the bearing lapsed");
|
|
Assert.True(o.RevealedSubjects.Count == 1 && o.RevealedSubjects.Contains(q)); // only the departed one dropped
|
|
o.Deactivate();
|
|
Assert.Equal(0, o.RevealedSubjects.Count); // counts, not the collection: a failure message would print whole entities
|
|
}
|
|
|
|
// The first field path from `root` to `target`, not entering the zone or the item manager, or null.
|
|
private static string PathFrom(object root, object target)
|
|
{
|
|
var seen = new HashSet<object>(System.Collections.Generic.ReferenceEqualityComparer.Instance) { root };
|
|
var queue = new Queue<(object node, string path)>();
|
|
queue.Enqueue((root, "observer"));
|
|
while (queue.Count > 0)
|
|
{
|
|
var (node, path) = queue.Dequeue();
|
|
if (ReferenceEquals(node, target)) return path;
|
|
var type = node.GetType();
|
|
if (node is Zone || node is ItemManager || node is Type || node is string || type.IsPrimitive || type.IsEnum
|
|
|| type.Namespace == "System.Reflection") continue;
|
|
IEnumerable<(object value, string name)> children;
|
|
if (node is Array array)
|
|
children = array.Cast<object>().Select((v, i) => (v, "[" + i + "]"));
|
|
else
|
|
children = Fields(type).Select(f => (f.GetValue(node), "." + f.Name));
|
|
foreach (var (value, name) in children)
|
|
if (value != null && seen.Add(value)) queue.Enqueue((value, path + name));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static IEnumerable<FieldInfo> Fields(Type type)
|
|
{
|
|
for (; type != null; type = type.BaseType)
|
|
foreach (var f in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
|
if (!f.FieldType.IsPointer) yield return f;
|
|
}
|
|
|
|
// ---- The one rule for info per signature ----
|
|
|
|
[Fact]
|
|
public void PassiveInfoAveragesTheActiveSensors()
|
|
{
|
|
var one = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var two = Add(float3(500, 0, 0), new[] { "Eye", "Ear" });
|
|
var none = Add(float3(1000, 0, 0));
|
|
|
|
// sum(s) / (d n k): 2 / (100 x .5) with one sensor, (2 + 4) / (100 x 2 x .5) with two; linear in the signature.
|
|
Assert.Equal(.04f, one.PassiveInfo(float2(0, 100), 1f), 5);
|
|
Assert.Equal(.12f, one.PassiveInfo(float2(0, 100), 3f), 5);
|
|
Assert.Equal(.06f, two.PassiveInfo(float2(500, 100), 1f), 5);
|
|
Assert.Equal(0f, none.PassiveInfo(float2(1000, 100), 1f));
|
|
}
|
|
|
|
// ---- SenseCostIsBounded ----
|
|
|
|
[Fact]
|
|
public void SenseAllocatesNothingOnceWarm()
|
|
{
|
|
var gunItems = new List<EquippedItem>();
|
|
for (var i = 0; i < 19; i++)
|
|
{
|
|
var ship = Add(float3(i * 3, 0, 10 + i), new[] { "Gun" });
|
|
var item = Guns(ship)[0];
|
|
for (var s = 0; s < 10; s++) ship.SetSignature(new object(), SignatureChannel.Reflection, 1000f, item);
|
|
gunItems.Add(item);
|
|
}
|
|
// One ship holds a lock paint on another, so the observer's sense hears an emission on every pass (the observer's
|
|
// own Add runs the zone's last update, which is where the emission is made; no update follows).
|
|
var painted = Add(float3(40, 0, 300));
|
|
var painter = Add(float3(0, 0, 300), new[] { "Locker" });
|
|
painter.SetIff(painted, true);
|
|
painter.EntityInfoGathered[painted] = 1f;
|
|
painter.SetTarget(painted);
|
|
painter.Aim = float3(1, 0, 0);
|
|
var o = Add(float3(0, 0, 0), new[] { "Eye" });
|
|
var sense = SenseOf(o);
|
|
for (var i = 0; i < 10; i++) sense();
|
|
Assert.Equal(19, _zone.Entities.Where(x => x != o && x != painter && x != painted).Count(x => o.RevealsOf(x).Any())); // fixture: every ship is revealed
|
|
Assert.Single(o.RevealsOf(painter)); // fixture: the lock's emission is heard
|
|
// One of them is identified by o and its gun is destroyed while it still glows, so the pass ends that reveal
|
|
// and the flash cannot re-enter; one has left, quiet, so its entries await the prune and its departure sits
|
|
// in the zone's list.
|
|
var identified = gunItems.Select(g => _zone.Entities.Single(x => x.Equipment.Contains(g))).First();
|
|
o.EntityInfoGathered[identified] = 1f;
|
|
gunItems[0].EquippableItem.Durability = 0f;
|
|
sense();
|
|
Assert.Empty(o.RevealsOf(identified)); // fixture: its gun is destroyed, ended, and still lit
|
|
Assert.Contains(identified.VisibilitySources.Values, v => v.Item == gunItems[0] && v.Strength > 0f);
|
|
var departed = _zone.Entities.Single(x => x.Equipment.Contains(gunItems[18]));
|
|
foreach (var source in departed.VisibilitySources.Keys.ToArray()) departed.SetSignature(source, SignatureChannel.Reflection, 0f, gunItems[18]);
|
|
_zone.Remove(departed);
|
|
Assert.Single((System.Collections.IList) typeof(Zone).GetProperty("Departures", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(_zone));
|
|
sense();
|
|
Assert.Single(o.RevealsOf(departed)); // fixture: awaiting its prune
|
|
|
|
// The smallest of several rounds is the steady-state allocation (FireControlCut12Tests, the Lane and
|
|
// Silhouette pin): a one-off JIT or tier-up allocation lands in one round of a busy full-suite run, a
|
|
// per-call allocation lands in every round.
|
|
var smallest = long.MaxValue;
|
|
for (var round = 0; round < 20 && smallest != 0; round++)
|
|
{
|
|
var before = GC.GetAllocatedBytesForCurrentThread();
|
|
for (var i = 0; i < 600; i++) sense();
|
|
smallest = Math.Min(smallest, GC.GetAllocatedBytesForCurrentThread() - before);
|
|
}
|
|
|
|
Assert.Equal(0L, smallest);
|
|
}
|
|
}
|