Tests: inspection scanner catalog, command, schematic gear rows; scanner/emission/stance residuals
InspectionScannerCatalogTests and InspectionScannerCommandTests (new); AFinishedScanListsTheGearOnTheSchematic; AScanReadsNoHostility pins pace, emit and a target-side sensor; AScanTakesTheSamePathForAnyPilot uses a Minion in CombatState; an emission heard within Range over the lot's alpha (ping and scan); the docked-subject test is a Theory over true and false; a ReveEal formatting probe. Not yet run: may not build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dbace27ee7
commit
283d1365c4
5 changed files with 452 additions and 19 deletions
|
|
@ -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<SensorData>().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<SensorData>().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<ScannerData>().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;
|
||||
|
|
|
|||
|
|
@ -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<EquippableItemData>(design), _maker, .123f);
|
||||
_items.Lots[lot].Roles = new List<RoleFill> { 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
134
tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs
Normal file
134
tests/Aetheria.Shared.Tests/InspectionScannerCatalogTests.cs
Normal file
|
|
@ -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<GearData>(), 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<GearData>(), 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<ScannerData>(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<FactionProductData>().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<string, (float ScanArray, float Discipline, float Dev)>
|
||||
{
|
||||
["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<FactionProductData>().Single(p => p.Design.Key.Equals(key) && Cache.Get(p.Manufacturer).ShortName == maker);
|
||||
var hull = Cache.GetByName<HullData>("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<ItemData>(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings);
|
||||
Assert.True(ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(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<ScannerData>(Assert.Single(Scanner.Data.Behaviors));
|
||||
public float Read(Func<ScannerData, PerformanceStat> 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");
|
||||
}
|
||||
}
|
||||
167
tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs
Normal file
167
tests/Aetheria.Shared.Tests/InspectionScannerCommandTests.cs
Normal file
|
|
@ -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<GearData>(), g => g.Name == DesignName);
|
||||
_shippedDesign = Pack(design);
|
||||
var key = cache.RefOf(design).Key;
|
||||
var products = cache.GetAll<FactionProductData>().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<GearData>().Where(g => g.Name == DesignName).ToArray();
|
||||
var keys = designs.Select(d => db.Cache.RefOf(d).Key).ToArray();
|
||||
var products = db.Cache.GetAll<FactionProductData>().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<FactionProductData, bool> products, Func<GearData, bool> designs)
|
||||
{
|
||||
using var cache = new CultCache(TestCatalog.Registry());
|
||||
cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes);
|
||||
foreach (var product in cache.GetAll<FactionProductData>().Where(products).ToArray()) Assert.True(cache.Remove(cache.RefOf(product).Key));
|
||||
foreach (var design in cache.GetAll<GearData>().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<string, CultPersistedRecord> 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<InvalidOperationException>(() => Program.InspectionScannerCatalog(apply: true, root: _root));
|
||||
Assert.Equal(partial, File.ReadAllBytes(CatalogPath));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CombatState>(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<HullData>(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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue