diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 1c2f1a629..3bc49037d 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -63,12 +63,12 @@ public sealed partial class ContactRevealTests : IDisposable // emission-discipline role, exponent log2(3) so a middling part (.5) reads 1. var disciplined = Eye("Disciplined", sensitivity: 2f); disciplined.Roles.Add(new ItemRole { Name = "emission-discipline" }); - disciplined.Behaviors.OfType().Single().InterceptFactor = new PerformanceStat - { - Min = .5f, Max = 2f, - Terms = { new StatTerm { Source = StatSource.Quality, Exponent = MathF.Log(3f) / MathF.Log(2f), Role = "emission-discipline" } } - }; + disciplined.Behaviors.OfType().Single().InterceptFactor = DisciplineStat(); cache.Upsert(disciplined); + var disciplinedScanner = Tool("DisciplinedScanner", ScannerBehavior(range: 200f, scanTime: 2f)); + disciplinedScanner.Roles.Add(new ItemRole { Name = "emission-discipline" }); + disciplinedScanner.Behaviors.OfType().Single().InterceptFactor = DisciplineStat(); + cache.Upsert(disciplinedScanner); cache.Upsert(Listener("Listener", 3f)); cache.Upsert(new ConsumableItemData { Name = "Earpiece", Duration = 30f, Shape = new Shape(), Behaviors = { new ListenerData { Multiple = Const(3f) } } }); cache.Upsert(Listener("Wide", 5f)); @@ -258,6 +258,14 @@ public sealed partial class ContactRevealTests : IDisposable Behaviors = { SensorBehavior(sensitivity, interceptFactor, beamWidth) } }; + // The catalog's intercept factor (AetherDb sensor-stat-set and inspection-scanner): .5 to 2 over one Quality term on the + // lot's emission-discipline, exponent log2(3). + private static PerformanceStat DisciplineStat() => new PerformanceStat + { + Min = .5f, Max = 2f, + Terms = { new StatTerm { Source = StatSource.Quality, Exponent = MathF.Log(3f) / MathF.Log(2f), Role = "emission-discipline" } } + }; + private static GearData Tool(string name, BehaviorData behavior) => new GearData { Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, @@ -1685,15 +1693,26 @@ public sealed partial class ContactRevealTests : IDisposable Assert.Equal(0L, smallest); } - // A docked subject leaves the zone parented to its dock and rejoins on undock: the observer's decision about it outlives - // the lapse of the bearing, and is still there when it rejoins. - [Fact] - public void ADockedSubjectKeepsTheObserversStanceAfterTheBearingLapses() + // The observer's stored decision about a subject (SetIff), read as the entity holds it: a neutral decision (false) is also + // what an unset stance derives to here, so IsHostileTo alone cannot tell it kept from dropped. + private static bool? StoredStance(Entity observer, Entity subject) + { + var overrides = (System.Collections.IDictionary) typeof(Entity).GetField("_iffOverrides", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(observer); + return overrides.Contains(subject) ? (bool) overrides[subject] : (bool?) null; + } + + // A docked subject leaves the zone parented to its dock and rejoins on undock: the observer's decision about it, hostile or + // neutral, outlives the lapse of the bearing, and is still there when it rejoins. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ADockedSubjectKeepsTheObserversStanceAfterTheBearingLapses(bool hostile) { var e = Add(float3(0, 0, 100), new[] { "Gun" }); var dock = Add(float3(200, 0, 0)); var o = Add(float3(0, 0, 0), new[] { "Eye" }); - o.SetIff(e, true); + o.SetIff(e, hostile); + Assert.Equal(hostile, StoredStance(o, e)); // fixture: the decision is stored var gun = Guns(e)[0]; Fire(e, gun); ShowOnly(e, 1f, gun); @@ -1702,10 +1721,28 @@ public sealed partial class ContactRevealTests : IDisposable _zone.Remove(e); for (var i = 0; i < 100 && o.RevealedSubjects.Contains(e); i++) Tick(.1f); Assert.False(o.RevealedSubjects.Contains(e), "fixture: the book dropped the docked subject"); - Assert.True(o.IsHostileTo(e), "a docked subject keeps the observer's decision"); + Assert.Equal(hostile, StoredStance(o, e)); // a docked subject keeps the observer's decision + Assert.Equal(hostile, o.IsHostileTo(e)); e.Parent = null; _zone.Admit(e, piloted: false); - Assert.True(o.IsHostileTo(e), "and it is still there when the subject rejoins"); + Assert.Equal(hostile, StoredStance(o, e)); // and it is still there when the subject rejoins + Assert.Equal(hostile, o.IsHostileTo(e)); + } + + // A failing assertion over a Reveal formats the value for its message: if that walked the Entity graph the message would be + // enormous or the host would die on it. Probe of follow-up bearing-markers-test-residuals (2). + [Fact] + public void AFailingRevealAssertionFormatsBriefly() + { + var e = Add(float3(30, 0, 40), new[] { "Gun" }); + var o = Add(float3(0, 0, 0), new[] { "Eye" }); + Fire(e, Guns(e)[0]); + var reveal = Assert.Single(o.RevealsOf(e)); + + var text = Xunit.Sdk.ArgumentFormatter.Format(reveal); + + Assert.True(text.Length < 400, $"formatted length {text.Length}"); + Assert.Contains("Gun", text); } private static int PaintInbox(Entity e) => ((System.Collections.ICollection) typeof(Entity).GetField("_paintInbox", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(e)).Count; diff --git a/tests/Aetheria.Shared.Tests/EmissionTests.cs b/tests/Aetheria.Shared.Tests/EmissionTests.cs index cfafc82d5..c6e2927b8 100644 --- a/tests/Aetheria.Shared.Tests/EmissionTests.cs +++ b/tests/Aetheria.Shared.Tests/EmissionTests.cs @@ -332,4 +332,46 @@ public sealed partial class ContactRevealTests Assert.Equal(expected, SensorOf(ship).InterceptFactor, 3); } + + // A ping and a scan through a lot whose emission-discipline is .2 (alpha .6168) are heard by a third party within + // Range over that alpha and not beyond it: a ping (reach 300) at 486, a scan (Range 200) at 324. The hearers stand + // between the true bound and the neighbours a stat reading Min (.5: 600 and 400) or Max (2: 150 and 100) would give, so + // either wrong end flips a hearer. + [Theory] + [InlineData(true, 400f, true)] + [InlineData(true, 550f, false)] + [InlineData(false, 250f, true)] + [InlineData(false, 360f, false)] + public void AnEmissionIsHeardWithinRangeOverTheLotsAlpha(bool ping, float distance, bool heard) + { + var design = ping ? "Disciplined" : "DisciplinedScanner"; + var lot = _items.CreateLot(_openCache.GetByName(design), _maker, .123f); + _items.Lots[lot].Roles = new List { new RoleFill { Role = "emission-discipline", Quality = .2f } }; + _preparedLots[design] = lot; + var third = Add(float3(-distance, 0, 0), new[] { "Eye" }); + Ship emitter; + if (ping) + { + emitter = Add(float3(0, 0, 0), new[] { design }); + PingAt(emitter); + Assert.Empty(third.IncomingPaints); // fixture: beyond the ping's radius, so never painted + } + else + { + var target = Add(float3(0, 0, 100)); + emitter = Add(float3(0, 0, 0), tools: new[] { design }); + emitter.EntityInfoGathered[target] = .5f; + emitter.SetTarget(target); + Hold(emitter, 3, target); + Assert.NotEmpty(target.IncomingPaints); // fixture: the scan is on + Assert.Empty(third.IncomingPaints); + } + + if (!heard) + { + Assert.Empty(third.RevealsOf(emitter)); + return; + } + Assert.Equal(SignatureChannel.Emission, Assert.Single(third.RevealsOf(emitter)).Channel); + } } diff --git a/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs b/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs new file mode 100644 index 000000000..368762624 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using Xunit; + +// The Inspection Scanner the catalog sells (campaign aetheria-release, cut scanner-catalog; rulings designs-are-tech-nodes, +// catalog-breadth-roles-and-components, sensor-stat-set, scan-held-beam, miss-terris-belongs-to-emily-r3), read from the shipped +// catalog and fitted to a shipped hull: what the design says, who sells it, and that a lot's roles move the figures they are +// authored on. The scanner's behaviour belongs to ScannerTests; this file owns the catalog's figures. +public sealed class InspectionScannerCatalogTests +{ + internal const string DesignName = "Inspection Scanner"; + private const string ScanArray = "scan array"; + private const string Discipline = "emission-discipline"; + + private static GearData Design(CultCache cache) => Assert.Single(cache.GetAll(), g => g.Name == DesignName); + + private static void AssertStat(PerformanceStat stat, float min, float max, string role, float exponent) + { + Assert.Equal(min, stat.Min, 4); + Assert.Equal(max, stat.Max, 4); + var term = Assert.Single(stat.Terms); + Assert.Equal(StatSource.Quality, term.Source); + Assert.Equal(role, term.Role); + Assert.Equal(exponent, term.Exponent, 4); + } + + [Fact] + public void TheCatalogSellsAnInspectionScanner() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var design = Design(cache); + var computer = Assert.Single(cache.GetAll(), g => g.Name == "Targeting Computer"); + + Assert.Equal(HardpointType.Tool, design.Hardpoint); + Assert.Equal((1, 1), (design.Shape.Width, design.Shape.Height)); + Assert.True(design.Shape.Cells[0, 0], "the one cell is solid"); + Assert.False(string.IsNullOrWhiteSpace(design.Description)); + Assert.Equal(new[] { ScanArray, Discipline }, design.Roles.Select(r => r.Name).ToArray()); + // The thermal constants and temperatures are the Targeting Computer's, the catalog's 1x1 tool. + Assert.Equal( + (computer.SpecificHeat, computer.Conductivity, computer.MinimumTemperature, computer.MaximumTemperature, computer.OptimalTemperature, computer.PlateauWidth, computer.ThermalResilience), + (design.SpecificHeat, design.Conductivity, design.MinimumTemperature, design.MaximumTemperature, design.OptimalTemperature, design.PlateauWidth, design.ThermalResilience)); + CultRecordRefs.Validate(design); + + // One behaviour, one linear term per figure on the scan array, the sensor curve on emission discipline. + var data = Assert.IsType(Assert.Single(design.Behaviors)); + AssertStat(data.Range, 350f, 550f, ScanArray, 1f); + AssertStat(data.ScanTime, 10f, 6f, ScanArray, 1f); + AssertStat(data.InterceptFactor, .5f, 2f, Discipline, MathF.Log2(3f)); + + // Three products from three makers, none Miss Terri's or the Pirates', each authoring both roles; the voice is in the + // emission-discipline mean: quiet Finch, middling AU, loud Lucent. + var key = cache.RefOf(design).Key; + var products = cache.GetAll().Where(p => p.Design.Key.Equals(key)).ToArray(); + Assert.Equal(new[] { "AU", "Finch", "Lucent" }, products.Select(p => cache.Get(p.Manufacturer).ShortName).OrderBy(m => m, StringComparer.Ordinal).ToArray()); + Assert.Equal(3, products.Select(p => p.Name).Distinct().Count()); + var expected = new Dictionary + { + ["Finch"] = (.65f, .8f, .20f), ["AU"] = (.5f, .5f, .12f), ["Lucent"] = (.6f, .2f, .15f), + }; + Assert.All(products, product => + { + var (scan, discipline, dev) = expected[cache.Get(product.Manufacturer).ShortName]; + Assert.Equal(new[] { Discipline, ScanArray }, product.Roles.Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray()); + Assert.Equal(scan, product.Roles.Single(r => r.Role == ScanArray).Mean, 3); + Assert.Equal(discipline, product.Roles.Single(r => r.Role == Discipline).Mean, 3); + Assert.All(product.Roles, r => Assert.Equal(dev, r.StandardDeviation, 3)); + Assert.False(string.IsNullOrWhiteSpace(product.Description)); + }); + } + + // The shipped hull with the scanner fitted before the ship goes live, from a lot minted at the product's own role means + // (the mean is what the product's distribution is centred on; the roll's noise is not under test). + private sealed class Rig : IDisposable + { + public readonly CultCache Cache = RestoredHullsTests.OpenCatalog(); + public readonly EquippedItem Scanner; + + public Rig(string maker) + { + var design = Design(Cache); + var key = Cache.RefOf(design).Key; + var product = Cache.GetAll().Single(p => p.Design.Key.Equals(key) && Cache.Get(p.Manufacturer).ShortName == maker); + var hull = Cache.GetByName("Longinus"); + var settings = RestoredHullsTests.Settings(); + var ledger = new ProvenanceLedger(); + ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = .5f }; + ledger.Lots[2] = new Lot + { + Origin = new Attributed(), Quality = .5f, + Roles = product.Roles.Select(r => new RoleFill { Role = r.Role, Quality = r.Mean }).ToList(), + }; + var items = new ItemManager(Cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var ship = new Ship(items, zone, new EquippableItem { Data = Cache.RefOf(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings); + Assert.True(ship.TryEquip(new EquippableItem { Data = Cache.RefOf(design), Durability = design.Durability, Lot = 2 })); + zone.Admit(ship, piloted: false); + zone.Update(0f); + Scanner = ship.Equipment.Single(e => e.Data.Name == DesignName); + } + + public void Dispose() => Cache.Dispose(); + + public ScannerData Data => Assert.IsType(Assert.Single(Scanner.Data.Behaviors)); + public float Read(Func stat) => Scanner.Evaluate(stat(Data)); + } + + // Each figure reads the lot's quality for the role it is authored on, linearly (Range, ScanTime) or on the sensor curve + // (InterceptFactor): the Finch lot (scan array .65, discipline .8) reads 480 and 7.4 s and an alpha of 1.5534, the Lucent lot + // (.6, .2) 470, 7.6 s and .6168. A stat reading the wrong role, the wrong end or another curve lands elsewhere. + [Theory] + [InlineData("Finch", 480f, 7.4f, 1.5534f)] + [InlineData("AU", 450f, 8f, 1f)] + [InlineData("Lucent", 470f, 7.6f, .6168f)] + public void AnInspectionScannersFiguresReadItsLotsRoles(string maker, float range, float scanTime, float alpha) + { + using var rig = new Rig(maker); + + Assert.Equal(range, rig.Read(d => d.Range), 2); + Assert.Equal(scanTime, rig.Read(d => d.ScanTime), 2); + Assert.Equal(alpha, rig.Read(d => d.InterceptFactor), 3); + } + + [Fact] + public void AQuietMakersLotIsHeardLessThanALoudMakersAtTheSameRange() + { + using var finch = new Rig("Finch"); + using var lucent = new Rig("Lucent"); + + Assert.True(finch.Read(d => d.InterceptFactor) > lucent.Read(d => d.InterceptFactor), "alpha is how far it sees over how far it is heard: quiet is high"); + } +} diff --git a/tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs b/tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs new file mode 100644 index 000000000..e2d453f51 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; + +// AetherDb's inspection-scanner command (cut scanner-catalog), run on a scratch copy of the shipped catalog with the design and +// its three products removed from it. Apply must write what the shipped catalog holds, only its own records, and a catalog that +// is neither untouched nor fully authored is refused rather than completed. +public sealed class InspectionScannerCommandTests : IDisposable +{ + private const string DesignName = InspectionScannerCatalogTests.DesignName; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "inspection-scanner-command-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + + // The design and products as the shipped catalog holds them, taken before they are removed from the scratch copy. + private readonly byte[] _shippedDesign; + private readonly string[] _shippedProducts; + + private static byte[] Pack(GearData design) => + MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(GearData).Assembly)); + + private static string Describe(CultCache cache, FactionProductData product) => + $"{cache.Get(product.Design).Name} | {product.Name} | {cache.Get(product.Manufacturer).ShortName} | {product.Description} | " + + string.Join(",", product.Roles.OrderBy(r => r.Role, StringComparer.Ordinal).Select(r => $"{r.Role}:{r.Mean}:{r.StandardDeviation}")); + + public InspectionScannerCommandTests() + { + Directory.CreateDirectory(Path.Combine(_root, "GameData")); + Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources")); + var shipped = AetherDb.FindRoot(); + File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath); + File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset")); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); + var design = Assert.Single(cache.GetAll(), g => g.Name == DesignName); + _shippedDesign = Pack(design); + var key = cache.RefOf(design).Key; + var products = cache.GetAll().Where(p => p.Design.Key.Equals(key)).ToArray(); + _shippedProducts = products.Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + Assert.Equal(3, products.Length); + foreach (var product in products) Assert.True(cache.Remove(cache.RefOf(product).Key)); + Assert.True(cache.Remove(key)); + cache.FlushAsync().Wait(); + } + + public void Dispose() => Directory.Delete(_root, true); + + private (GearData[] Designs, string[] Products) Read() + { + var db = AetherDb.Open(root: _root); + try + { + var designs = db.Cache.GetAll().Where(g => g.Name == DesignName).ToArray(); + var keys = designs.Select(d => db.Cache.RefOf(d).Key).ToArray(); + var products = db.Cache.GetAll().Where(p => keys.Any(k => p.Design.Key.Equals(k))).ToArray(); + return (designs, products.Select(p => Describe(db.Cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray()); + } + finally { db.Cache.Dispose(); } + } + + // Remove from the scratch catalog what the predicate picks: products by name, designs by name. + private void Remove(Func products, Func designs) + { + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); + foreach (var product in cache.GetAll().Where(products).ToArray()) Assert.True(cache.Remove(cache.RefOf(product).Key)); + foreach (var design in cache.GetAll().Where(designs).ToArray()) Assert.True(cache.Remove(cache.RefOf(design).Key)); + cache.FlushAsync().Wait(); + } + + [Fact] + public void A_dry_run_writes_nothing_and_apply_lands_the_shipped_design_once() + { + var before = File.ReadAllBytes(CatalogPath); + Assert.Empty(Read().Designs); + + Assert.Equal(0, Program.InspectionScannerCatalog(apply: false, root: _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + Assert.Empty(Read().Designs); + + Assert.Equal(0, Program.InspectionScannerCatalog(apply: true, root: _root)); + var landed = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(before, landed); + var (designs, products) = Read(); + // The record keys are CultCache's to mint and differ run to run; the design and products do not. + Assert.True(_shippedDesign.AsSpan().SequenceEqual(Pack(Assert.Single(designs))), "the design differs from the shipped catalog's record"); + Assert.Equal(_shippedProducts, products); + + // A second apply finds everything authored and writes nothing; so does a dry run. + Assert.Equal(0, Program.InspectionScannerCatalog(apply: true, root: _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + Assert.Equal(0, Program.InspectionScannerCatalog(apply: false, root: _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + } + + // Re-spell every record's payload in a longer, equally valid MessagePack form (an array16 header for the fixarray one). The + // catalog then holds bytes no re-encode through the current types would reproduce, so a write that re-encodes records it did + // not author shows as changed bytes; a write that lands onto the file as it is leaves them alone. + private int WidenEveryPayload() + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)); + var widened = 0; + foreach (var record in snapshot.Records) + { + var payload = record.Payload; + if (payload.Length == 0 || (payload[0] & 0xF0) != 0x90) continue; + record.Payload = new byte[] { 0xDC, 0x00, (byte)(payload[0] & 0x0F) }.Concat(payload.Skip(1)).ToArray(); + widened++; + } + File.WriteAllBytes(CatalogPath, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + return widened; + } + + private Dictionary Records() => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(CatalogPath)).Records.ToDictionary(r => r.Key, StringComparer.Ordinal); + + [Fact] + public void Apply_writes_only_the_records_it_authors() + { + Assert.True(WidenEveryPayload() > 100, "the scratch catalog should hold many records in the widened spelling"); + var before = Records(); + + Assert.Equal(0, Program.InspectionScannerCatalog(apply: true, root: _root)); + + var after = Records(); + Assert.Equal(before.Count + 1 + _shippedProducts.Length, after.Count); + foreach (var (key, record) in before) + { + Assert.True(after.TryGetValue(key, out var kept), "a record the command did not author is gone"); + Assert.Equal(record.SchemaId, kept.SchemaId); + Assert.Equal(record.StoredAt, kept.StoredAt); + Assert.True(record.Payload.AsSpan().SequenceEqual(kept.Payload), "a record the command did not author has changed bytes"); + } + } + + public enum PartialState { DesignWithSomeProducts, ProductsWithoutTheirDesign, OneProductAlone } + + [Theory] + [InlineData(PartialState.DesignWithSomeProducts)] + [InlineData(PartialState.ProductsWithoutTheirDesign)] + [InlineData(PartialState.OneProductAlone)] + public void Apply_refuses_a_partly_authored_catalog_and_writes_nothing(PartialState state) + { + Assert.Equal(0, Program.InspectionScannerCatalog(apply: true, root: _root)); + switch (state) + { + case PartialState.DesignWithSomeProducts: + Remove(p => p.Name == "Inkling" || p.Name == "Assay", g => false); + break; + case PartialState.ProductsWithoutTheirDesign: + Remove(p => false, g => g.Name == DesignName); + break; + case PartialState.OneProductAlone: + Remove(p => p.Name != "Assay", g => g.Name == DesignName); + break; + } + var partial = File.ReadAllBytes(CatalogPath); + + Assert.Throws(() => Program.InspectionScannerCatalog(apply: true, root: _root)); + Assert.Equal(partial, File.ReadAllBytes(CatalogPath)); + } +} diff --git a/tests/Aetheria.Shared.Tests/ScannerTests.cs b/tests/Aetheria.Shared.Tests/ScannerTests.cs index 2e9ec8ba4..219f53eba 100644 --- a/tests/Aetheria.Shared.Tests/ScannerTests.cs +++ b/tests/Aetheria.Shared.Tests/ScannerTests.cs @@ -34,9 +34,9 @@ public sealed partial class ContactRevealTests // A scanner ship at the origin, with the named scanner and no sensor (an AI ship with a sensor pings once it loses its // track, which would paint the target beside the scan), tracking and targeting a ship `distance` // ahead that carries a reactor, a radiator and a suppressor, none of which ever acts. - private (Ship scanner, Ship target) ScanOn(string scanner = "Scanner", float distance = 100f, float info = .5f) + private (Ship scanner, Ship target) ScanOn(string scanner = "Scanner", float distance = 100f, float info = .5f, string[] targetMounts = null) { - var target = Add(float3(0, 0, distance), tools: new[] { "Reactor", "Radiator", "SuppressorCold" }); + var target = Add(float3(0, 0, distance), targetMounts, tools: new[] { "Reactor", "Radiator", "SuppressorCold" }); var s = Add(float3(0, 0, 0), tools: new[] { scanner }); s.EntityInfoGathered[target] = info; s.SetTarget(target); @@ -306,7 +306,9 @@ public sealed partial class ContactRevealTests // Ruling ew-hostility-from-belief-ruled: a scan is an act of perception and judges nothing, so it proceeds and records the // claim whatever the stance between the two ships, hostile or friendly, honest or lying. The stance is set both ways - // through SetIff, which decides IsHostileTo, EntityHostility and VisibleEnemies alike. + // through SetIff, which decides IsHostileTo, EntityHostility and VisibleEnemies alike. The target carries a sensor that + // tracks the scanner, so a gate on the target's belief about its scanner would stop the scan; the scan builds at the pace + // of one tick's step, shows nothing a tick before it is done, and a third party hears it, in every stance. [Theory] [InlineData(true, false)] [InlineData(true, true)] @@ -314,19 +316,31 @@ public sealed partial class ContactRevealTests [InlineData(false, true)] public void AScanReadsNoHostility(bool hostile, bool lies) { - var (s, target) = ScanOn(); + var (s, target) = ScanOn(targetMounts: new[] { "Eye" }); + var third = Add(float3(-40, 0, 0), new[] { "Eye" }); // inside Range 200 of the scanner: it hears the emission var reactor = target.Equipment.Single(i => i.Behaviors.Any(b => b is Reactor)); var radiator = DataOf("Radiator"); if (lies) target.Claim(reactor, radiator); s.SetIff(target, hostile); target.SetIff(s, hostile); + target.EntityInfoGathered[s] = .5f; Tick(ScanStep); Assert.Equal(hostile, s.IsHostileTo(target)); // fixture Assert.Equal(hostile, s.VisibleEnemies.Contains(target)); // fixture: the stance reached the readers a gate would use + Assert.Equal(hostile, target.IsHostileTo(s)); // fixture: the target's sensor tracks the scanner as an enemy or a friend + var built = (int) MathF.Round(ScannerOf(s).Progress * 16f); - Hold(s, 16, target); + for (var i = 0; i < 15 - built; i++) + { + target.EntityInfoGathered[s] = .5f; + Hold(s, 1, target); + } + Assert.Equal(15f / 16f, ScannerOf(s).Progress, 4); // the pace, a tick short of ScanTime + Assert.Empty(s.RevealsOf(target)); + Hold(s, 1, target); - Assert.Equal(3, s.RevealsOf(target).Count()); + Assert.Equal(4, s.RevealsOf(target).Count()); // the reactor, radiator, suppressor and the target's own sensor + Assert.Equal(SignatureChannel.Emission, Assert.Single(third.RevealsOf(s)).Channel); var reveal = Assert.Single(s.RevealsOf(target), r => r.Item == reactor); Assert.Same(lies ? radiator : reactor.Data, reveal.Claimed); Assert.NotEmpty(target.IncomingPaints.Where(p => p.Mode == PaintMode.Scan)); @@ -346,7 +360,8 @@ public sealed partial class ContactRevealTests target.Claim(reactor, DataOf("Radiator")); var player = Add(float3(-20, 0, 0), tools: new[] { "Scanner" }); var ai = Add(float3(20, 0, 0), tools: new[] { "Scanner" }); - _zone.Agents.Add(new Agent(ai)); // the AI pilot, ticked in Zone.Update beside the scan; a task-less agent needs no orbit to patrol + var agent = new Minion(ai); // the AI pilot, ticked in Zone.Update beside the scan: it takes the combat state once it has a target + _zone.Agents.Add(agent); Assert.Single(_zone.Agents); // fixture: only the second ship has an agent foreach (var scanner in new[] { player, ai }) { @@ -364,6 +379,7 @@ public sealed partial class ContactRevealTests } } Step(15); + Assert.IsType(typeof(Agent).GetField("_currentState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(agent)); // fixture: the AI is fighting while it scans Assert.Empty(player.RevealsOf(target)); // neither pilot has a scan before ScanTime Assert.Empty(ai.RevealsOf(target)); Step(1); @@ -374,6 +390,43 @@ public sealed partial class ContactRevealTests Assert.Equal(book(player), book(ai)); } + // ---- AFinishedScanListsTheGearOnTheSchematic ---- + + // Ruling visibility-per-capability-reveal: what a scan reveals shows on the enemy schematic, as claimed. The target carries + // a gun, two reactors (the gun's own and a fitted one), a radiator, a suppressor and a targeting system; the fitted reactor + // claims to be a radiator, and the hull's own flash is in the book too. A row is made for each scanned item and none for + // the hull: weapons first with their rated range, then the gear in fitted order, each as claimed and with no range. + [Fact] + public void AFinishedScanListsTheGearOnTheSchematic() + { + var target = Add(float3(0, 0, 100), new[] { "Gun" }, new[] { "Reactor", "Radiator", "SuppressorCold" }); + var s = Add(float3(0, 0, 0), new[] { "Eye" }, new[] { "Scanner" }); + s.EntityInfoGathered[target] = .5f; + s.SetTarget(target); + var fitted = FittedBesidesTheHull(target); + var gun = Guns(target)[0]; + var reactor = target.Equipment.Where(i => i.Behaviors.Any(b => b is Reactor)).Last(); + target.Claim(reactor, DataOf("Radiator")); + target.SetSignature(new object(), SignatureChannel.LaunchFlash, 1000000f, target.EquippedHull); + Hold(s, 1, target); + Assert.Equal(target.EquippedHull, Assert.Single(s.RevealsOf(target)).Item); // fixture: the hull is in the book, as a hull class + Assert.IsAssignableFrom(s.RevealsOf(target).Single().Claimed); + Hold(s, 14, target); + Assert.Empty(s.SchematicRows(target)); // fixture: no row before the scan is done + + Hold(s, 1, target); + + var rows = s.SchematicRows(target).ToArray(); + Assert.Equal(fitted.Length, rows.Length); // every scanned item + Assert.DoesNotContain(rows, r => r.Item == target.EquippedHull || r.Claimed is HullData); + Assert.Same(gun, rows[0].Item); // weapons first + Assert.Equal(((WeaponData) DataOf("Gun").Behaviors.Single(b => b is WeaponData)).RatedRange, rows[0].Range); + var gear = rows.Skip(1).ToArray(); + Assert.Equal(fitted.Where(i => i != gun).ToArray(), gear.Select(r => r.Item).ToArray()); // fitted order + Assert.All(gear, r => Assert.Null(r.Range)); + Assert.All(gear, r => Assert.Same(r.Item == reactor ? DataOf("Radiator") : r.Item.Data, r.Claimed)); + } + // ---- AConsumableScannerDecaysOnTheSameTick ---- // The two hosts order Execute and the always-updated pass differently (ConsumableItemEffect.Update runs the pass first),