Merge origin/master into sensor-catalog-r2 (code; catalog is master's)

Conflicts: EvasionTermTests keeps the branch's EvLonginusDrive split with master's generic
design names; AetherDb Program.cs keeps both the sensor-stat-set and generic-designs cases and
lists both in the usage line. GameData/Aetheria.cc is taken from origin/master unchanged;
sensor-stat-set apply is rerun on it in the next commit. Tests that read the sensor catalog
(TheCatalogCarriesSensorStats and the roles checks) do not pass at this commit: it builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-14 03:34:19 +02:00
commit 005e2fc57b
103 changed files with 5392 additions and 668 deletions

View file

@ -24,7 +24,7 @@ using static CultMath.math;
// (to decide whether the pump can keep up this tick). Cut 7 asks for the brownout curve to live on exactly
// the stats the behaviour actually curves (PumpedHeat), but F6 forbade a PowerSupply term on any
// stat a request reads at all, on pain of the exact oscillation Soul measured against the shipped catalog's own
// "OK Disperser" radiator (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates, docs/stats-and-power-cut.md).
// "Heat Pump" radiator (SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates, docs/stats-and-power-cut.md).
// The six tests below that authored one were marked Skip rather than deleted or silently reworked to hide the
// gap, naming the conflict as real, scoped work for a future pass rather than a fix a registry correction should
// smuggle in.

View file

@ -95,6 +95,106 @@ public sealed class CatalogRoleTests
Assert.True(makerless.Length == 0, "products with no maker: " + string.Join(", ", makerless));
}
// A design is named like a node on a tech tree and the brand lives in the product it sells (rulings
// catalog-grows-generic-designs-branded-products, designs-are-tech-nodes): no design's product bears the design's name.
[Fact]
public void EveryProductCarriesABrand()
{
using var cache = RestoredHullsTests.OpenCatalog();
var products = cache.GetAll<FactionProductData>().ToArray();
var unbranded = cache.GetAll<EquippableItemData>().Where(design => design is not HullData)
.Where(design => products.Any(p => p.Design.Key.Equals(cache.RefOf(design).Key) && p.Name == design.Name))
.Select(design => design.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
Assert.True(unbranded.Length == 0, "designs sold under their own name: " + string.Join(", ", unbranded));
}
// The Capacitor is the one Tool design with a role map by name; a key that falls out of step with the design's name leaves
// roles-backfill finding no map for it.
[Fact]
public void TheCapacitorDesignCarriesItsRoleMap()
{
using var cache = RestoredHullsTests.OpenCatalog();
var capacitor = cache.GetAll<EquippableItemData>().Single(design => design.Name == "Capacitor");
Assert.True(Program.HasRoleMap(capacitor), "roles-backfill finds no role map for the Capacitor design");
}
// A design is a tech node: its text may not name a product the catalog sells (the brand lives on the product).
[Fact]
public void NoDesignTextNamesAProduct()
{
using var cache = RestoredHullsTests.OpenCatalog();
var productNames = cache.GetAll<FactionProductData>().Select(p => p.Name).Where(n => n.Length > 3).ToHashSet();
var branded = cache.GetAll<EquippableItemData>().Where(design => design is not HullData && design.Description != null)
.Where(design => productNames.Any(n => design.Description.Contains(n)))
.Select(design => design.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
Assert.True(branded.Length == 0, "designs whose text names a product: " + string.Join(", ", branded));
}
// Authoring notes are not player text, and every product of a design this cut renamed or branded speaks in its own
// voice (Miss Terri's and the Pirates' records are other passes').
[Fact]
public void NoProductTextIsAnAuthoringNote()
{
using var cache = RestoredHullsTests.OpenCatalog();
var products = cache.GetAll<FactionProductData>().ToArray();
string[] notes = { Program.AuthoringNote.Trim(), "[TODO]", "subsidiary plight" };
var noted = products.Where(p => p.Description != null && notes.Any(n => p.Description.Contains(n))).Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
Assert.True(noted.Length == 0, "products whose text is an authoring note: " + string.Join(", ", noted));
var touched = Program.Renames.Select(r => r.Generic).Concat(Program.Brands.Select(b => b.Design)).ToHashSet();
var designs = cache.GetAll<EquippableItemData>().Where(d => touched.Contains(d.Name)).ToArray();
Assert.Equal(touched.Count, designs.Length);
var silent = products
.Where(p => designs.Any(d => p.Design.Key.Equals(cache.RefOf(d).Key)))
.Where(p => cache.Get(p.Manufacturer)?.ShortName is not ("Miss Terri's" or "Pirates"))
.Where(p => string.IsNullOrWhiteSpace(p.Description))
.Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
Assert.True(silent.Length == 0, "products with no text of their own: " + string.Join(", ", silent));
}
// A rename changes Name and Description and nothing else: the design and its products keep their keys and makers, and the
// flavour that moved lives on the product.
[Fact]
public void RenamedDesignsKeepTheirKeysAndProducts()
{
using var cache = RestoredHullsTests.OpenCatalog();
var expected = new (string DesignKey, string Generic, (string Key, string Name, string Maker)[] Products)[]
{
("0271bb97-4ce8-4d64-97c3-8bfd3f76b2c6", "Light Machine Gun", new[] { ("7a1f63b0-68de-4976-a338-bc779db3386a", "ClearPath", "Lightsail") }),
("3baa2c43-b389-4438-94cb-fbb04489cd77", "Heavy Reactor", new[] { ("1edd87f7-2592-4744-a9ba-618f403f91ce", "Manhattan", "Zhestokost") }),
("49b4c15a-c10b-49c0-9dad-31dfb9b3a9b9", "Cargo Bay", new[] { ("4ecc97af-c1fa-4563-902e-72a094ad8322", "Store-All Plus", "Lightsail"), ("ed3c70eb23814d5181d99fa84f6194d4", null, "Pirates") }),
("88e81a2b-45fd-49de-830f-c896adae8d5b", "Racing Drive", new[] { ("9ccc095374d64fde94dd8d04bdc97c2a", "Victoire", "Alakrita") }),
("632420a6-4419-4647-a92b-5e22ea289025", "Lightning Projector", new[] { ("f0f8389f9dd64228b02a68392fcf4007", "DragOnBreath", "Lucent") }),
};
foreach (var (designKey, generic, sold) in expected)
{
var design = Assert.Single(cache.GetAll<EquippableItemData>(), d => cache.RefOf(d).Key.Value == designKey);
Assert.Equal(generic, design.Name);
Assert.Equal(Program.Renames.Single(r => r.Generic == generic).Functional, design.Description);
var products = cache.GetAll<FactionProductData>().Where(p => p.Design.Key.Equals(cache.RefOf(design).Key)).ToArray();
Assert.Equal(sold.Select(p => p.Key).OrderBy(k => k, StringComparer.Ordinal), products.Select(p => cache.RefOf(p).Key.Value).OrderBy(k => k, StringComparer.Ordinal));
foreach (var (key, name, maker) in sold)
{
var product = products.Single(p => cache.RefOf(p).Key.Value == key);
if (name != null) Assert.Equal(name, product.Name);
Assert.Equal(maker, cache.Get(product.Manufacturer).ShortName);
}
}
// The text that moved: Victoire carries the racing drive's old line, DragOnBreath plight's old one.
string TextOf(string productKey) => cache.GetAll<FactionProductData>().Single(p => cache.RefOf(p).Key.Value == productKey).Description;
Assert.Equal("The most responsive thruster on the market, for the most irresponsible of racing pilots.", TextOf("9ccc095374d64fde94dd8d04bdc97c2a"));
Assert.StartsWith("Chain lightning has your enemies chain smoking", TextOf("f0f8389f9dd64228b02a68392fcf4007"));
// The mine launcher: the design takes the functional line its product used to carry; the product is the brand.
var mine = Assert.Single(cache.GetAll<EquippableItemData>(), d => cache.RefOf(d).Key.Value == "1b4a2bb358eb432eb1cfc0ccf3317303");
Assert.Equal("Mine Launcher", mine.Name);
Assert.Equal("Lays a drifting proximity mine that arms after two seconds.", mine.Description);
var brand = Assert.Single(cache.GetAll<FactionProductData>(), p => p.Design.Key.Equals(cache.RefOf(mine).Key));
Assert.Equal("parting gift", brand.Name);
Assert.Equal("DME", cache.Get(brand.Manufacturer).ShortName);
}
// A benefit rises with quality across 0.8x to 1.2x of its authored value (Max/Min 1.5), a cost falls across 1.2x to 0.8x
// (Max/Min 2/3). The ratio and the role are pinned, not the value, so an operator retune of a design stays green.
private static void AssertRange(EquippableItemData design, string field, bool cost, string role)
@ -121,13 +221,13 @@ public sealed class CatalogRoleTests
AssertRange(autocannon, "Range", false, "barrel");
AssertRange(autocannon, "Penetration", false, "barrel");
var lrm = Design("LRMM72");
var lrm = Design("Long-Range Missile Rack");
Assert.Equal(new[] { "guidance system", "thruster", "warhead" }, Roles(lrm));
AssertRange(lrm, "Damage", false, "warhead");
AssertRange(lrm, "Cooldown", true, "guidance system");
AssertRange(lrm, "LockAngle", false, "guidance system");
var srm = Design("SRMM72");
var srm = Design("Short-Range Missile Rack");
Assert.Equal(new[] { "guidance system", "thruster", "warhead" }, Roles(srm));
AssertRange(srm, "Damage", false, "warhead");
AssertRange(srm, "Cooldown", true, "guidance system");
@ -152,12 +252,12 @@ public sealed class CatalogRoleTests
AssertRange(Design("Small Drive"), "Thrust", false, "nozzle");
// Designs whose non-flat stats named no role are pointed at the role their kind's map gives them.
AssertNamed(Design("DeathCluster"), "Count", "feed mechanism");
AssertNamed(Design("Cluster Cannon"), "Count", "feed mechanism");
AssertNamed(Design("Flak Gun"), "Count", "feed mechanism");
AssertNamed(Design("GT 3K"), "MinRange", "guidance system");
AssertNamed(Design("plight"), "DamageSpread", "focusing array");
AssertNamed(Design("MoveOnPro"), "Modifier", "core");
AssertNamed(Design("MoveOnPro Station Reactor"), "Modifier", "core");
AssertNamed(Design("Guided Missile Launcher"), "MinRange", "guidance system");
AssertNamed(Design("Lightning Projector"), "DamageSpread", "focusing array");
AssertNamed(Design("Muon Fusion Reactor"), "Modifier", "core");
AssertNamed(Design("Muon Fusion Station Reactor"), "Modifier", "core");
// Each seller's means come from its maker's profile; the Pirates are middling and inconsistent.
var profiles = new Dictionary<string, (float Mean, float Dev)>

View file

@ -0,0 +1,464 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using CultMath;
using GameCult.Caching;
using Xunit;
using static CultMath.math;
using float3 = CultMath.float3;
// Cut ew-claims-in-the-book (aetheria-ew, classify-from-belief, lie-is-own-emission, belief-one-writer): every act a ship
// makes is stamped with the class its ship claims for the item (the true item's data unless claimed otherwise), the
// observer's book keeps that claim beside the true item, and every classifying read takes the claim while aim keeps the
// truth. The fixtures drive the production path: real ships fire, ping and lock inside Zone.Update.
public sealed partial class ContactRevealTests
{
private EquippableItemData DataOf(string name) => (EquippableItemData) _items.ItemData.GetByName<ItemData>(name);
private static PerformanceStat Span(float min, float max) => new PerformanceStat { Min = min, Max = max };
// A damage curve that falls from 1 to .2 over the range span, so every range argument lands on a different factor.
private static BezierCurve Falloff() => new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, .2f, 0, 0) } };
private static BezierCurve Flat() => new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } };
// ---- ClaimsDefaultToTheTruth ----
[Fact]
public void ClaimsDefaultToTheTruth()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
Fire(e, gun);
Assert.Same(gun.Data, Assert.Single(o.RevealsOf(e)).Claimed); // a fired gun claims its own design
// The hull belief: none below the gear threshold, the true hull at it (TargetGearInfoThreshold .8).
o.EntityInfoGathered[e] = .79f;
Assert.Null(o.ContactOf(e).Profile);
o.EntityInfoGathered[e] = .8f;
Assert.Same(e.HullData, o.ContactOf(e).Profile);
}
[Fact]
public void AHeardPingDefaultsToTheTruth()
{
var p = Add(float3(0, 0, 0), new[] { "Shout" });
var hearer = Add(float3(149, 0, 0), new[] { "Eye" }); // off the beam: heard, never painted
PingAt(p);
Assert.Same(SensorItemOf(p).Data, Assert.Single(hearer.RevealsOf(p)).Claimed);
}
[Fact]
public void APaintDefaultsToTheTruth()
{
var (locker, target) = LockOn("Locker");
Tick(.5f);
Tick(.5f);
var lockItem = locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon));
Assert.Same(lockItem.Data, Assert.Single(target.IncomingPaints).EmitterClass);
Assert.Same(lockItem.Data, Assert.Single(target.RevealsOf(locker)).Claimed);
}
// ---- AnItemClaimIsWhatTheBookSays ----
// The book keeps the true item as its key and the claim beside it; RevealedWeapons lists it by the claim, and aim
// still opens on the true item. Mutation: stamp item.Data in SetSignature, or have RecordReveal store the true data.
[Fact]
public void AnItemClaimIsWhatTheBookSays()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
var other = DataOf("Beam");
e.Claim(gun, other);
Fire(e, gun);
var reveal = Assert.Single(o.RevealsOf(e));
Assert.Same(gun, reveal.Item);
Assert.Same(other, reveal.Claimed);
Assert.Same(gun, Assert.Single(o.RevealedWeapons(e)).Item);
Assert.True(FireControl.IsRevealed(o, gun));
}
// ---- AGunClaimedAsNonWeaponIsNotListed ----
[Fact]
public void AGunClaimedAsNonWeaponIsNotListed()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
e.Claim(gun, DataOf("Eye"));
Fire(e, gun);
Assert.Empty(o.RevealedWeapons(e));
Assert.Single(o.RevealsOf(e)); // the book still holds the gun, for aim
Assert.True(FireControl.IsRevealed(o, gun));
}
// ---- AHullClaimIsTheProfile ----
// Mutation: ContactOf reads subject.HullData, or reads the claim below the gear threshold.
[Fact]
public void AHullClaimIsTheProfile()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var truth = e.HullData;
var hull = e.Hull;
var decoy = new HullData { Name = "Decoy" };
e.Claim(decoy);
Fire(e, Guns(e)[0]);
o.EntityInfoGathered[e] = .79f;
Assert.Null(o.ContactOf(e).Profile);
o.EntityInfoGathered[e] = .8f;
Assert.Same(decoy, o.ContactOf(e).Profile);
Assert.Same(truth, e.HullData); // the truth is untouched: damage, shape and physics keep it
Assert.Same(hull, e.Hull);
}
// ---- APaintCarriesTheClaim ----
[Fact]
public void APaintCarriesTheClaim()
{
var locker = Add(float3(0, 0, 0), new[] { "Locker" });
var target = Add(float3(0, 0, 200));
var lockItem = locker.Equipment.Single(i => i.Behaviors.Any(b => b is LockWeapon));
var claimed = DataOf("Eye");
locker.Claim(lockItem, claimed);
locker.SetIff(target, true);
locker.EntityInfoGathered[target] = 1f;
locker.SetTarget(target);
locker.Aim = normalize(target.Position - locker.Position);
Tick(.5f);
Tick(.5f);
Assert.Same(claimed, Assert.Single(target.IncomingPaints).EmitterClass);
var reveal = Assert.Single(target.RevealsOf(locker));
Assert.Same(lockItem, reveal.Item);
Assert.Same(claimed, reveal.Claimed);
}
// The emission a ping makes is stamped by the ship that makes it. Mutation: Emit stamps item.Data.
[Fact]
public void AHeardPingCarriesTheClaim()
{
var p = Add(float3(0, 0, 0), new[] { "Shout" });
var hearer = Add(float3(149, 0, 0), new[] { "Eye" }); // off the beam: heard, never painted
var claimed = DataOf("Ear");
p.Claim(SensorItemOf(p), claimed);
PingAt(p);
Assert.Same(claimed, Assert.Single(hearer.RevealsOf(p)).Claimed);
}
// ---- AClaimEndsWithItsShip ----
[Fact]
public void AClaimEndsWithItsShip()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var gun = Guns(e)[0];
e.Claim(new HullData { Name = "Decoy" });
e.Claim(gun, DataOf("Eye"));
e.Deactivate();
Assert.Same(e.HullData, e.ClaimedHull);
Assert.Same(gun.Data, e.ClaimOf(gun));
}
// ---- Rated figures (ruling ew-claim-weapon-measure-ruled) ----
// A hull's claim is its own item's claim: the hull's acts (a Reflector's reflection, a launch flash, the hull as the
// item of any signature) carry the class the profile reports, so the book never holds the true hull beside a decoy.
// Mutation: ClaimedHull reads a slot of its own, or Claim(HullData) writes one.
[Fact]
public void AHullClaimReachesTheHullsOwnReveal()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var decoy = new HullData { Name = "Decoy" };
e.Claim(decoy);
Assert.Same(decoy, e.ClaimOf(e.EquippedHull));
Assert.Same(decoy, e.ClaimedHull);
e.SetSignature(new object(), SignatureChannel.LaunchFlash, 1000000f, e.EquippedHull);
Tick(.1f);
var reveal = Assert.Single(o.RevealsOf(e), r => r.Item == e.EquippedHull);
Assert.Same(decoy, reveal.Claimed);
o.EntityInfoGathered[e] = .8f;
Assert.Same(reveal.Claimed, o.ContactOf(e).Profile);
e.Claim(null);
Assert.Same(e.HullData, e.ClaimedHull);
Assert.Same(e.HullData, e.ClaimOf(e.EquippedHull));
}
[Fact]
public void TheHullCanOnlyClaimToBeAHull()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
Assert.Throws<System.ArgumentException>(() => e.Claim(e.EquippedHull, DataOf("Eye")));
Assert.Same(e.HullData, e.ClaimedHull);
}
// ---- Rated figures (ruling ew-claim-weapon-measure-ruled) ----
// A design's rated figure is each stat's Max through the live weapon's own formula. Every stat is authored with
// Min below Max, a falling curve and a MinRange above 0, so reading a Min, scaling the range argument, the wrong
// stat or the wrong operator changes the answer. At 70 the range factor is (70 - 20) / (120 - 20) = .5.
// Mutation: any rated override reads Range.Min or MinRange.Min or a Min, drops the burst divisor, the charge
// multiplier or the charge time; the shared formula scales its range argument.
[Fact]
public void ARatedFigureIsTheDesignsMaxThroughTheSharedFormula()
{
var curve = Falloff();
var half = curve.Evaluate(.5f);
Assert.NotEqual(half, curve.Evaluate(1f)); // the Range.Min and halved-range mutants land elsewhere on the curve
Assert.NotEqual(half, curve.Evaluate(.15f));
Assert.NotEqual(half, curve.Evaluate(70f / 120f));
var instant = Spanned(new InstantWeaponData());
Assert.Equal(100f / 2 * half / 2, instant.RatedRangeDamagePerSecond(70f), 4); // 100 / 2 rounds, curve, / 2 s
Assert.Equal(120f, instant.RatedRange);
var charged = (ChargedWeaponData) Spanned(new ChargedWeaponData
{
ChargeTime = Span(1, 3), ChargeFiringDamageMultiplier = 2f
});
Assert.Equal(100f / 2 * 2 * half / (2 + 3), charged.RatedRangeDamagePerSecond(70f), 4);
var beam = Spanned(new ConstantWeaponData());
Assert.Equal(100f * half, beam.RatedRangeDamagePerSecond(70f), 4);
Assert.Equal(120f, beam.RatedRange);
}
// The rated read and the live read are one formula: a nominal weapon's live figure is its design's rated figure,
// for each kind, at ranges before, inside and past the falloff span, and equals the hand arithmetic.
// Mutation: a rated override with its own arithmetic, or a live read that drifts from the shared formula (the
// charged read's ChargeTime, the range, the curve).
[Fact]
public void ARatedFigureIsTheLiveFigureOfANominalWeapon()
{
var e = Add(float3(0, 0, 0), new[] { "SpanGun", "SpanCharged", "SpanBeam" });
Tick(.1f);
var curve = Falloff();
var expected = new Dictionary<string, Func<float, float>>
{
["SpanGun"] = at => 100f / 2 * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f)) / 2,
["SpanCharged"] = at => 100f / 2 * 2 * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f)) / (2 + 3),
["SpanBeam"] = at => 100f * curve.Evaluate(Math.Clamp((at - 20f) / 100f, 0f, 1f))
};
foreach (var item in e.Equipment.Where(i => i.Behaviors.Any(b => b is Weapon)))
{
var live = item.Behaviors.OfType<Weapon>().Single();
var rated = item.Data.Behaviors.OfType<WeaponData>().Single();
foreach (var at in new[] { 10f, 70f, 110f, 500f })
{
Assert.Equal(expected[item.Data.Name](at), live.RangeDamagePerSecond(at), 3);
Assert.Equal(live.RangeDamagePerSecond(at), rated.RatedRangeDamagePerSecond(at), 3);
}
}
}
// A charged weapon at full charge reads the same rated figure: the charge multiplier is applied once, in the formula
// (UpdateStats lerps the shot's damage by charge, so the live reads start from the uncharged damage). At range 10 the
// curve factor is 1: 100 / 2 rounds x 2 / (2 + 3) = 20. Mutation: the live reads pass the charge-lerped Damage.
[Fact]
public void AFullyChargedWeaponReadsItsRatedFigure()
{
var e = Add(float3(0, 0, 0), new[] { "SpanCharged" });
Tick(.1f);
var item = e.Equipment.Single(i => i.Data.Name == "SpanCharged");
var live = (ChargedWeapon) item.Behaviors.Single(b => b is ChargedWeapon);
var rated = item.Data.Behaviors.OfType<WeaponData>().Single();
typeof(ChargedWeapon).GetField("_charge", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(live, 1f);
typeof(Weapon).GetMethod("UpdateStats", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(live, null);
foreach (var at in new[] { 10f, 70f, 110f })
Assert.Equal(rated.RatedRangeDamagePerSecond(at), live.RangeDamagePerSecond(at), 3);
Assert.Equal(20f, live.DamagePerSecond, 3);
Assert.Equal(20f, live.RangeDamagePerSecond(10f), 3);
}
// ---- The schematic's rows (ruling ew-claim-weapon-measure-ruled; finding schematic-unreached) ----
// Wears a gun's Range to 95% (a shorter one is under the fuse arming distance and the gun is refused) so that, once it ticks, its live range (38) is not its design's rated range (40).
private void WearRange(Entity ship, EquippedItem gun)
{
var data = (InstantWeaponData) gun.Data.Behaviors.Single(b => b is InstantWeaponData);
ship.Resolver.AttachModifier(gun, data.Range, "worn", StatModifierType.Multiplier, .95f);
}
// The fixture's guard, read once the gun has ticked: its live range is not its design's rated range.
private static void AssertWorn(EquippedItem gun) =>
Assert.Equal(38f, gun.Behaviors.OfType<Weapon>().Single().Range, 2);
// The unclaimed enemy row is the true gun, with the gun's own rated range (Gun: 40) though the gun is worn to a live
// 38. Mutation: the row's Item.Data or the true live range stands in for the claim (also when Claimed == Data).
[Fact]
public void AnEnemySchematicRowDefaultsToTheTruth()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
WearRange(e, gun);
Fire(e, gun);
AssertWorn(gun);
var row = Assert.Single(o.SchematicRows(e));
Assert.Same(gun, row.Item);
Assert.Same(gun.Data, row.Claimed);
Assert.Equal(40f, row.Range);
Assert.NotEqual(gun.Behaviors.OfType<Weapon>().Single().Range, row.Range);
}
// A weapon claimed as another weapon shows the claimed design's rated range (SpanGun: Range.Max 120), not the true
// gun's 40. Mutation: Range reads the true item, or the claimed stat's Min (50), or scales the figure.
[Fact]
public void AWeaponClaimedAsAnotherWeaponShowsTheClaimedRatedRange()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
var other = DataOf("SpanGun");
e.Claim(gun, other);
Fire(e, gun);
var row = Assert.Single(o.SchematicRows(e));
Assert.Same(gun, row.Item);
Assert.Same(other, row.Claimed);
Assert.Equal(120f, row.Range);
}
// Mutation: the rows are the book's reveals (RevealsOf) or the subject's equipment.
[Fact]
public void AWeaponClaimedAsANonWeaponHasNoSchematicRow()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
e.Claim(gun, DataOf("Eye"));
Fire(e, gun);
Assert.Empty(o.SchematicRows(e));
Assert.Single(o.RevealsOf(e));
}
// Unrevealed gear never appears on an enemy schematic: a ship that has not fired shows no row.
[Fact]
public void AnArmedShipThatNeverFiredHasNoEnemySchematicRow()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
Tick(.1f);
Assert.Empty(o.SchematicRows(e));
}
// Your own schematic is your own gear whatever you claim: the true data, the live range, weapons only.
// Mutation: own rows read ClaimOf, the rated range of the claim (SpanGun 120) or of the own design (40), or list non-weapons.
[Fact]
public void OwnSchematicRowsShowOwnTruth()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var gun = Guns(e)[0];
WearRange(e, gun);
e.Claim(gun, DataOf("SpanGun"));
Tick(.1f);
AssertWorn(gun);
var row = Assert.Single(e.SchematicRows(e));
Assert.Same(gun, row.Item);
Assert.Same(gun.Data, row.Claimed);
Assert.Equal(38f, row.Range, 2); // the live range of the worn gun, below its rated 40
Assert.Equal(gun.Behaviors.OfType<Weapon>().Single().Range, row.Range);
Assert.NotEqual(((WeaponData) gun.Data.Behaviors.Single(b => b is WeaponData)).RatedRange, row.Range);
}
// The renderer rebuilds when the key differs: a claim that changes between two acts changes the key, and an
// unchanged claim does not. Mutation: Key drops Claimed.
[Fact]
public void TheRebuildKeyChangesWhenAClaimChanges()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var gun = Guns(e)[0];
Fire(e, gun);
var before = o.SchematicRows(e).Select(r => r.Key).ToArray();
Assert.Equal(before, o.SchematicRows(e).Select(r => r.Key).ToArray());
e.Claim(gun, DataOf("SpanGun"));
e.SetSignature(new object(), SignatureChannel.LaunchFlash, 1000000f, gun);
Tick(.1f);
var after = o.SchematicRows(e).Select(r => r.Key).ToArray();
Assert.Single(after);
Assert.NotEqual(before, after);
Assert.Same(DataOf("SpanGun"), after[0].Item2);
}
// Two guns of one claimed design are told apart by the key: a row set that swaps one gun for the other keeps the count and
// the claims and changes only the item, and the renderer must still rebuild (the selection mark follows the item).
// Mutation: Key drops Item.
[Fact]
public void TheRebuildKeyChangesWhenOnlyTheItemChanges()
{
var e = Add(float3(30, 0, 40), new[] { "Gun", "Gun" });
var a = Add(float3(0, 0, 0), new[] { "Eye" });
var b = Add(float3(0, 0, 5), new[] { "Eye" });
var guns = Guns(e);
var design = DataOf("SpanGun");
e.Claim(guns[0], design);
e.Claim(guns[1], design);
a.RecordReveal(e, guns[0], SignatureChannel.LaunchFlash, e.ClaimOf(guns[0]));
b.RecordReveal(e, guns[1], SignatureChannel.LaunchFlash, e.ClaimOf(guns[1]));
var first = Assert.Single(a.SchematicRows(e));
var second = Assert.Single(b.SchematicRows(e));
Assert.Same(first.Claimed, second.Claimed);
Assert.NotEqual(first.Key, second.Key);
}
// ---- One hull class in the book ----
// The guard runs both ways: a hull class can be claimed only by the hull, so no item's reveal carries a hull class that
// disagrees with Contact.Profile. Mutation: the guard checks the hull side only.
[Fact]
public void OnlyTheHullClaimsAHullClass()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var gun = Guns(e)[0];
Assert.Throws<System.ArgumentException>(() => e.Claim(gun, e.HullData));
Assert.Same(gun.Data, e.ClaimOf(gun));
}
// ---- Gear the subject no longer carries ----
// A reveal of gear the subject does not carry follows every fitted weapon, and stale gear is ordered by lot whatever
// order the observer saw it in. Mutation: the missing index sorts first (-1), or stale ties follow the book's slot order.
[Fact]
public void RevealsOfGearNoLongerCarriedFollowFittedGear()
{
var e = Add(float3(30, 0, 40), new[] { "Gun" });
var gone = Add(float3(60, 0, 40), new[] { "Gun", "Gun" });
var o = Add(float3(0, 0, 0), new[] { "Eye" });
var fitted = Guns(e)[0];
var stale = Guns(gone);
Assert.True(stale[0].EquippableItem.Lot < stale[1].EquippableItem.Lot);
o.RecordReveal(e, stale[1], SignatureChannel.LaunchFlash, stale[1].Data);
o.RecordReveal(e, stale[0], SignatureChannel.LaunchFlash, stale[0].Data);
o.RecordReveal(e, fitted, SignatureChannel.LaunchFlash, fitted.Data);
Assert.Equal(new[] { fitted, stale[0], stale[1] }, o.RevealedWeapons(e).Select(r => r.Item).ToArray());
}
}

View file

@ -107,6 +107,14 @@ public sealed partial class ContactRevealTests : IDisposable
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
Behaviors = { Armed(new ConstantWeaponData(), 0) }
});
// Weapons authored so the rated figure is a function of every stat: a falling damage curve, a Range span, a
// MinRange above 0, burst and charge spans (a stat with no terms resolves to its Max live).
cache.Upsert(SpannedWeapon("SpanGun", Spanned(new InstantWeaponData { BurstTime = Const(0) })));
cache.Upsert(SpannedWeapon("SpanCharged", Spanned(new ChargedWeaponData
{
BurstTime = Const(0), ChargeTime = Span(1, 3), ChargeEnergy = Const(1), ChargeHeat = Const(0), ChargeFiringDamageMultiplier = 2f
})));
cache.Upsert(SpannedWeapon("SpanBeam", Spanned(new ConstantWeaponData())));
cache.Upsert(Locker("Locker", interceptFactor: null));
cache.Upsert(Locker("Hushed", interceptFactor: 4f));
cache.Upsert(new ConsumableItemData
@ -171,6 +179,29 @@ public sealed partial class ContactRevealTests : IDisposable
return weapon;
}
private static WeaponData Spanned(WeaponData weapon)
{
Armed(weapon, 5);
weapon.Damage = Span(10, 100);
weapon.MinRange = Span(0, 20);
weapon.Range = Span(50, 120);
weapon.DamageCurve = Falloff();
if (weapon is InstantWeaponData instant)
{
instant.Count = Span(1, 2);
instant.Cooldown = Span(8, 2);
}
return weapon;
}
private static WeaponItemData SpannedWeapon(string name, WeaponData weapon) => new WeaponItemData
{
Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Fuse = WeaponFuse.Proximity, BlastRadius = 30f,
Behaviors = { weapon }
};
private static GearData Suppressor(string name, float factor, float heatPerUnit)
{
var cell = new Shape(1, 1);

View file

@ -9,8 +9,8 @@ using float3 = CultMath.float3;
// staging what its brief claims fails here.
public sealed partial class RunStartTests
{
// Crossing: the demo's cast; plight and FastBlast+- fitted in separate weapon groups with the Fire Control Array
// aboard; a piloted hostile Djinni that crosses the bow inside 400 m; and an unpiloted neutral one inside plight's
// Crossing: the demo's cast; projector and Cell Blaster fitted in separate weapon groups with the Fire Control Array
// aboard; a piloted hostile Djinni that crosses the bow inside 400 m; and an unpiloted neutral one inside the Lightning Projector's
// arc, short of its Range and beyond three quarters of it.
[Fact]
public void CrossingConditions()
@ -21,12 +21,12 @@ public sealed partial class RunStartTests
Assert.Equal(4, galaxy.HomeZones.Count);
var player = staged.Player;
var plight = player.Equipment.Single(item => item.Data.Name == "plight");
var small = player.Equipment.Single(item => item.Data.Name == "FastBlast+-");
var projector = player.Equipment.Single(item => item.Data.Name == "Lightning Projector");
var small = player.Equipment.Single(item => item.Data.Name == "Cell Blaster");
Assert.Contains(player.Equipment, item => item.Data.Name == "Fire Control Array");
var groupOf = new System.Func<EquippedItem, int>(item => System.Array.FindIndex(player.WeaponGroups, group => group.items.Contains(item)));
Assert.True(groupOf(plight) >= 0 && groupOf(small) >= 0, "both guns are in a weapon group");
Assert.NotEqual(groupOf(plight), groupOf(small));
Assert.True(groupOf(projector) >= 0 && groupOf(small) >= 0, "both guns are in a weapon group");
Assert.NotEqual(groupOf(projector), groupOf(small));
Assert.Equal(2, staged.Entities.Count);
var crosser = staged.Entities[0];
@ -54,10 +54,10 @@ public sealed partial class RunStartTests
Assert.Equal(0, AgentsOf(arena, waiting));
var toWaiting = waiting.Position - player.Position;
EvSettle(player); // a weapon's Range is read once the ship's stats have settled; the geometry above is as staged
var range = plight.Behaviors.OfType<Weapon>().Single().Range;
Assert.True(FireControl.InArc(plight, toWaiting), "the waiting Djinni is inside plight's arc");
Assert.True(length(toWaiting) <= range, "the waiting Djinni is inside plight's Range");
Assert.True(length(toWaiting) > .75f * range, "the waiting Djinni is beyond three quarters of plight's Range");
var range = projector.Behaviors.OfType<Weapon>().Single().Range;
Assert.True(FireControl.InArc(projector, toWaiting), "the waiting Djinni is inside the Lightning Projector's arc");
Assert.True(length(toWaiting) <= range, "the waiting Djinni is inside the Lightning Projector's Range");
Assert.True(length(toWaiting) > .75f * range, "the waiting Djinni is beyond three quarters of the Lightning Projector's Range");
// The crossing is what the sim does with that staging, read in the player's frame: the arena's gravity and the
// crosser's pilot act from the first tick, so the t=0 facing proves nothing. Within CrossingSeconds the crosser's
@ -79,8 +79,8 @@ public sealed partial class RunStartTests
crossedMetres = length(after);
Assert.True(after.z > 0, "the crosser passes the bow line ahead of the player, not astern");
Assert.True(length(after) < CrossingMetres, "the crosser passes inside the stated distance");
Assert.True(FireControl.InArc(small, after), "FastBlast+- bears on the crosser as it crosses");
Assert.True(dot(FireControl.MountDirection(small), float3(player.Direction.x, 0, player.Direction.y)) > 0, "FastBlast+- mounts forward");
Assert.True(FireControl.InArc(small, after), "Cell Blaster bears on the crosser as it crosses");
Assert.True(dot(FireControl.MountDirection(small), float3(player.Direction.x, 0, player.Direction.y)) > 0, "Cell Blaster mounts forward");
}
}
Assert.True(swept, $"the crosser's bearing never swept across the bow within {CrossingSeconds} s");

View file

@ -23,22 +23,22 @@ public sealed partial class RunStartTests
private const float EvDt = 1f / 60f;
// The Longinus's drive train, fitted by name: its cockpit and power and the generated Longinus's thrusters (two Large Drive
// forward, two Talaria on the flanks).
// forward, two Rugged Drive on the flanks).
private static readonly (string design, int2 cell, ItemRotation rotation)[] EvLonginusDrive =
{
("Cockpit 2x2", int2(2, 6), ItemRotation.None), ("Core Power", int2(2, 4), ItemRotation.None),
("Cockpit 2x2", int2(2, 6), ItemRotation.None), ("Compact Reactor", int2(2, 4), ItemRotation.None),
("Large Drive", int2(1, 0), ItemRotation.Reversed), ("Large Drive", int2(3, 0), ItemRotation.Reversed),
("Talaria", int2(2, 14), ItemRotation.CounterClockwise), ("Talaria", int2(3, 14), ItemRotation.Clockwise),
("Rugged Drive", int2(2, 14), ItemRotation.CounterClockwise), ("Rugged Drive", int2(3, 14), ItemRotation.Clockwise),
};
// The Duel's guns on a Longinus: the drive train above, its Targeting Computer, and the Duel's guns, launchers and sensor.
private static ScenarioFit EvLonginus(ScenarioStage stage, string gun = "FastBlast+-", string second = null) => stage.Fit("Longinus",
private static ScenarioFit EvLonginus(ScenarioStage stage, string gun = "Cell Blaster", string second = null) => stage.Fit("Longinus",
EvLonginusDrive.Concat(new (string design, int2 cell, ItemRotation rotation)[]
{
("GT 3K", int2(0, 5), ItemRotation.None), ("GT 3K", int2(5, 5), ItemRotation.None), (gun, int2(1, 8), ItemRotation.None),
(second ?? gun, int2(4, 8), ItemRotation.None), ("Iapyx", int2(2, 2), ItemRotation.CounterClockwise), ("Iapyx", int2(3, 2), ItemRotation.Clockwise),
("not if i see you first", int2(3, 10), ItemRotation.None), ("PotaT+-", int2(1, 6), ItemRotation.None),
("Targeting Computer", int2(1, 7), ItemRotation.None), ("Store-All Plus", int2(2, 8), ItemRotation.None)
("Guided Missile Launcher", int2(0, 5), ItemRotation.None), ("Guided Missile Launcher", int2(5, 5), ItemRotation.None), (gun, int2(1, 8), ItemRotation.None),
(second ?? gun, int2(4, 8), ItemRotation.None), ("Radiator", int2(2, 2), ItemRotation.CounterClockwise), ("Radiator", int2(3, 2), ItemRotation.Clockwise),
("Compact Sensor", int2(3, 10), ItemRotation.None), ("Capacitor", int2(1, 6), ItemRotation.None),
("Targeting Computer", int2(1, 7), ItemRotation.None), ("Cargo Bay", int2(2, 8), ItemRotation.None)
}).ToArray());
// The catalog's two generated ships, far apart in the fixture arena, at the fixture seed.
@ -383,7 +383,7 @@ public sealed partial class RunStartTests
var settings = _items.GameplaySettings;
var thrusters = longinus.GetBehaviors<Thruster>().Where(t => t.Item.Active.Value).ToList();
var largeDrives = thrusters.Where(t => t.Item.Data.Name == "Large Drive").ToList();
var flanks = thrusters.Where(t => t.Item.Data.Name == "Talaria").ToList();
var flanks = thrusters.Where(t => t.Item.Data.Name == "Rugged Drive").ToList();
Assert.Equal(2, largeDrives.Count);
Assert.Equal(2, flanks.Count);
@ -396,7 +396,7 @@ public sealed partial class RunStartTests
var forward = largeDrives.Sum(t => t.Thrust) / mass;
Assert.InRange(envelope.Forward, forward * .99f, forward * 1.01f);
Assert.Equal(0f, envelope.Reverse);
// One Talaria pushes each way, and each has its own lot's thrust: the sides are the two flanks' accelerations.
// One Rugged Drive pushes each way, and each has its own lot's thrust: the sides are the two flanks' accelerations.
var flankAccelerations = flanks.Select(t => t.Thrust / mass).OrderBy(a => a).ToArray();
var sides = new[] { envelope.Left, envelope.Right }.OrderBy(a => a).ToArray();
Assert.InRange(sides[0], flankAccelerations[0] * .99f, flankAccelerations[0] * 1.01f);
@ -614,17 +614,17 @@ public sealed partial class RunStartTests
public Weapon Gun, Laser, GunnerLaser;
}
// The target's fit, written out: the Djinni's own hardpoints filled with the Talaria it is generated with, its
// The target's fit, written out: the Djinni's own hardpoints filled with the Rugged Drive it is generated with, its
// reactor, cockpit and radiators. What the evasion tests price is this ship's strafing, not whatever the generator
// rolls for it from the galaxy.
private static ScenarioFit EvDjinni(ScenarioStage stage) => stage.Fit("Djinni",
("Cockpit 2x2", int2(6, 3), ItemRotation.None), ("Core Power", int2(6, 11), ItemRotation.None),
("Talaria", int2(6, 0), ItemRotation.Reversed), ("Talaria", int2(5, 1), ItemRotation.Reversed), ("Talaria", int2(7, 1), ItemRotation.Reversed),
("Talaria", int2(4, 4), ItemRotation.CounterClockwise), ("Talaria", int2(4, 11), ItemRotation.CounterClockwise),
("Talaria", int2(9, 4), ItemRotation.Clockwise), ("Talaria", int2(9, 11), ItemRotation.Clockwise),
("Talaria", int2(6, 14), ItemRotation.None),
("Iapyx", int2(1, 4), ItemRotation.Reversed), ("Iapyx", int2(3, 1), ItemRotation.Reversed),
("Iapyx", int2(9, 1), ItemRotation.Reversed), ("Iapyx", int2(11, 4), ItemRotation.Reversed));
("Cockpit 2x2", int2(6, 3), ItemRotation.None), ("Compact Reactor", int2(6, 11), ItemRotation.None),
("Rugged Drive", int2(6, 0), ItemRotation.Reversed), ("Rugged Drive", int2(5, 1), ItemRotation.Reversed), ("Rugged Drive", int2(7, 1), ItemRotation.Reversed),
("Rugged Drive", int2(4, 4), ItemRotation.CounterClockwise), ("Rugged Drive", int2(4, 11), ItemRotation.CounterClockwise),
("Rugged Drive", int2(9, 4), ItemRotation.Clockwise), ("Rugged Drive", int2(9, 11), ItemRotation.Clockwise),
("Rugged Drive", int2(6, 14), ItemRotation.None),
("Radiator", int2(1, 4), ItemRotation.Reversed), ("Radiator", int2(3, 1), ItemRotation.Reversed),
("Radiator", int2(9, 1), ItemRotation.Reversed), ("Radiator", int2(11, 4), ItemRotation.Reversed));
private EvRange EvRangeLaunch()
{
@ -632,9 +632,9 @@ public sealed partial class RunStartTests
var scenario = new Scripted(false, stage =>
{
stage.Player(stage.Bare("Djinni"), float2(-50000, -50000));
gunner = stage.Place(EvLonginus(stage, "FastBlast+-", "ColdFire"), float2(0, 0), facing: float2(0, 1)) as Ship;
gunner = stage.Place(EvLonginus(stage, "Cell Blaster", "Long Laser"), float2(0, 0), facing: float2(0, 1)) as Ship;
target = stage.Place(EvDjinni(stage), float2(0, 200), facing: float2(0, 1)) as Ship;
lasing = stage.Place(EvLonginus(stage, "ColdFire"), float2(0, 400), facing: float2(0, -1)) as Ship;
lasing = stage.Place(EvLonginus(stage, "Long Laser"), float2(0, 400), facing: float2(0, -1)) as Ship;
});
var (_, arena, _, failures) = Launch(scenario, Inputs(() => GalaxySeed));
Assert.True(failures.Count == 0, string.Join("; ", failures));

View file

@ -367,8 +367,8 @@ public sealed partial class RunStartTests
{
("Point Defense Gun", 40f, 80000, 60f, WeaponModifiers.RapidFire, (1.5f, 4f), (1f, 1f), (300f, 550f), (.06f, .025f), (1f, .3f),
(1100f, 1600f), (.4f, .2f), (40f, 15f), (400f, 150f), true, 250, 3f),
("Point Defense Laser", 30f, 110000, 30f, PdDesign("Spectra").WeaponModifiers, (5f, 12f), (1f, 1f), (400f, 750f), (.2f, .1f), (.05f, 0f),
(0f, 0f), (6f, 10f), (300f, 150f), (300f, 100f), false, PdDesign("Spectra").Behaviors.OfType<InstantWeaponData>().Single().MagazineSize, PdDesign("Spectra").Behaviors.OfType<InstantWeaponData>().Single().ReloadTime),
("Point Defense Laser", 30f, 110000, 30f, PdDesign("Light Laser").WeaponModifiers, (5f, 12f), (1f, 1f), (400f, 750f), (.2f, .1f), (.05f, 0f),
(0f, 0f), (6f, 10f), (300f, 150f), (300f, 100f), false, PdDesign("Light Laser").Behaviors.OfType<InstantWeaponData>().Single().MagazineSize, PdDesign("Light Laser").Behaviors.OfType<InstantWeaponData>().Single().ReloadTime),
("Flak Gun", 120f, 180000, 80f, WeaponModifiers.Cluster, (30f, 70f), (6f, 8f), (350f, 800f), (.6f, .35f), (4f, 2f),
(900f, 1300f), (1f, .5f), (300f, 150f), (800f, 300f), true, 40, 4f),
};
@ -449,20 +449,20 @@ public sealed partial class RunStartTests
return FireControl.TrackingRate(range.Gun, range.Gunner);
}
var pd = FireControl.PMount(omega, RateOf("Point Defense Gun"));
var clearPath = FireControl.PMount(omega, RateOf("ClearPath"));
var clearPath = FireControl.PMount(omega, RateOf("Light Machine Gun"));
Assert.True(pd >= .75f, $"the PD gun follows a speed demon: {pd}");
Assert.True(clearPath <= .25f, $"the machine gun does not: {clearPath}");
}
// Data check, not a behaviour test: ruling plight-shape, plight is one wide, two high and fits a Longinus Energy hardpoint.
// Data check, not a behaviour test: ruling plight-shape, the Lightning Projector is one wide, two high and fits a Longinus Energy hardpoint.
[Fact]
public void PlightHasItsRuledShape()
{
var plight = _cache.GetAll<WeaponItemData>().Single(w => w.Name == "plight");
Assert.Equal(1, plight.Shape.Width);
Assert.Equal(2, plight.Shape.Height);
Assert.True(plight.Shape.Cells[0, 0] && plight.Shape.Cells[0, 1], "both cells are occupied");
var projector = _cache.GetAll<WeaponItemData>().Single(w => w.Name == "Lightning Projector");
Assert.Equal(1, projector.Shape.Width);
Assert.Equal(2, projector.Shape.Height);
Assert.True(projector.Shape.Cells[0, 0] && projector.Shape.Cells[0, 1], "both cells are occupied");
var longinus = _cache.GetAll<HullData>().Single(h => h.Name == "Longinus");
Assert.Contains(longinus.Hardpoints, hardpoint => hardpoint.Type == HardpointType.Energy && hardpoint.Takes(plight));
Assert.Contains(longinus.Hardpoints, hardpoint => hardpoint.Type == HardpointType.Energy && hardpoint.Takes(projector));
}
}

View file

@ -478,7 +478,7 @@ public sealed class FireAuthorityTests : IDisposable
var aimedItem = e.Target.Equipment.Single(x => x.EquippableItem == aimedGear);
e.Shooter.EntityInfoGathered[e.Target] = 1f; // fully tracked
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash, aimedItem.Data);
Assert.True(e.Shooter.TrySelectTargetItem(aimedItem));
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);

View file

@ -519,7 +519,7 @@ public sealed class FireControlCut12Tests : IDisposable
var aim = new int2(2, 2);
var e = Build(TestSettings(), SolidShape(6, 6), precision: 1000f, markerCells: new[] { aim });
var aimed = e.Markers[0];
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash, aimed.Data);
Assert.True(e.Shooter.TrySelectTargetItem(aimed));
// The aimed item is still equipped (Entity.TryUnequip refuses on an active entity, and reveal/aim
@ -546,7 +546,7 @@ public sealed class FireControlCut12Tests : IDisposable
public void AimingAtAnItemBiggerThanOneCellCentresOnItsCentroid()
{
var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: float2(1, 0), equipBigMarker: true);
e.Shooter.RecordReveal(e.Target, e.BigMarker, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, e.BigMarker, SignatureChannel.LaunchFlash, e.BigMarker.Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.BigMarker));
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
@ -584,7 +584,7 @@ public sealed class FireControlCut12Tests : IDisposable
// the bounding-box centre along the diagonal, not along either grid axis -- fully onto the lateral
// axis, instead of losing most of it to the axis a purely axis-aligned facing would leave unmeasured.
var e = Build(TestSettings(), SolidShape(9, 9), precision: 1f, targetFacing: normalize(float2(1, 1)), equipLMarker: true);
e.Shooter.RecordReveal(e.Target, e.LMarker, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, e.LMarker, SignatureChannel.LaunchFlash, e.LMarker.Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.LMarker));
var travelDirection = FireControl.TravelDirection(e.Weapon, e.Shooter, e.Target);
@ -975,7 +975,7 @@ public sealed class FireControlCut12Tests : IDisposable
var aim = new int2(1, 1);
var e = Build(settings, hullShape, precision, spread: 0, targetFacing: normalize(float2(1f, 1f)),
markerCells: new[] { aim });
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash, e.Markers[0].Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0]));
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
@ -1158,7 +1158,7 @@ public sealed class FireControlCut12Tests : IDisposable
var aim = new int2(1, 1);
var e = Build(TestSettings(), shape, precision: .3f, targetFacing: normalize(float2(.8f, .6f)), markerCells: new[] { aim });
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, e.Markers[0], SignatureChannel.LaunchFlash, e.Markers[0].Data);
Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0]));
var outcomes = FireMany(e, 1500);
@ -1192,7 +1192,7 @@ public sealed class FireControlCut12Tests : IDisposable
{
var e = Build(settings, hullShape, precision: .6f, targetFacing: facing, markerCells: new[] { aimCell });
var aimed = e.Markers[0];
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, aimed, SignatureChannel.LaunchFlash, aimed.Data);
Assert.True(e.Shooter.TrySelectTargetItem(aimed));
Assert.Same(aimed, e.Shooter.ResolvedTargetItem);

View file

@ -587,8 +587,8 @@ public sealed class FireControlCut5Tests : IDisposable
target.Zone.Admit(observer, piloted: false);
observer.SetTarget(target);
observer.RecordReveal(target, big, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, medium, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, big, SignatureChannel.LaunchFlash, big.Data);
observer.RecordReveal(target, medium, SignatureChannel.LaunchFlash, medium.Data);
Assert.True(FireControl.IsRevealed(observer, big));
Assert.True(FireControl.IsRevealed(observer, medium));
Assert.True(observer.TrySelectTargetItem(big));

View file

@ -169,7 +169,7 @@ public sealed class FireControlCut6dTests : IDisposable
if (marker != null)
{
var markerItem = target.Equipment.Single(x => x.EquippableItem == marker);
shooter.RecordReveal(target, markerItem, SignatureChannel.LaunchFlash);
shooter.RecordReveal(target, markerItem, SignatureChannel.LaunchFlash, markerItem.Data);
Assert.True(shooter.TrySelectTargetItem(markerItem));
Assert.Same(markerItem, shooter.ResolvedTargetItem);
}

View file

@ -167,7 +167,7 @@ public sealed class FireControlCut7Tests : IDisposable
// The shooter tracks the target fully and has seen the interior item act; it selects it as the aim point.
e.Shooter.EntityInfoGathered[e.Target] = 1f;
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash);
e.Shooter.RecordReveal(e.Target, aimedItem, SignatureChannel.LaunchFlash, aimedItem.Data);
Assert.True(e.Shooter.TrySelectTargetItem(aimedItem));
Assert.Same(aimedItem, e.Shooter.ResolvedTargetItem);

View file

@ -68,6 +68,13 @@ public sealed class FireControlTests : IDisposable
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new InstantWeaponData() }
});
// A second design, so a target can carry two guns whose true figures differ (claim tests).
buildCache.Upsert(new GearData
{
Name = "WeakGun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new InstantWeaponData() }
});
buildCache.FlushAsync().Wait();
}
@ -175,9 +182,10 @@ public sealed class FireControlTests : IDisposable
}
// A shooter with one in-range, in-arc gun, a detected target at `targetPosition`, and a Minion driving the shooter.
private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition, float projectileSpeed = 0f)
private (Ship shooter, Ship target, Zone zone) BuildCombatEncounter(float3 targetPosition, float projectileSpeed = 0f, bool twoTargetGuns = false)
{
var (items, shooter, weapons) = BuildShipWithHardpoints((ItemRotation.None, 0f));
var mounts = twoTargetGuns ? new[] { (ItemRotation.None, 0f), (ItemRotation.None, 0f) } : new[] { (ItemRotation.None, 0f) };
var (items, shooter, weapons) = BuildShipWithHardpoints(mounts);
var weaponData = (InstantWeaponData) weapons[0].Data.Behaviors.Single(b => b is InstantWeaponData);
weaponData.Damage = new PerformanceStat { Min = 10, Max = 10 };
weaponData.Cooldown = new PerformanceStat { Min = 1, Max = 1 };
@ -194,6 +202,20 @@ public sealed class FireControlTests : IDisposable
// gun so the target is a real, ordinarily-constructed ship rather than a workaround-shaped one.
var targetGunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Gun"));
Assert.True(target.TryEquip(new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 1 }, new int2(0, 0)));
if (twoTargetGuns)
{
// Both true guns are finite, authored figures: the strong one rates 10, the weak one 1.
weaponData.Count = new PerformanceStat { Min = 1, Max = 1 };
var weakRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("WeakGun"));
var weakData = (InstantWeaponData) items.ItemData.GetByName<GearData>("WeakGun").Behaviors.Single();
weakData.Damage = new PerformanceStat { Min = 1, Max = 1 };
weakData.Count = new PerformanceStat { Min = 1, Max = 1 };
weakData.Cooldown = new PerformanceStat { Min = 1, Max = 1 };
weakData.Range = new PerformanceStat { Min = 1000, Max = 1000 };
weakData.MinRange = new PerformanceStat { Min = 0, Max = 0 };
weakData.DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } };
Assert.True(target.TryEquip(new EquippableItem { Data = weakRef, Durability = 1, Lot = 2 }, new int2(1, 0)));
}
zone.Admit(target, piloted: false);
shooter.Position = float3.zero;
@ -249,7 +271,7 @@ public sealed class FireControlTests : IDisposable
zone.Update(1f);
Assert.Null(shooter.TargetItem.Value);
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash);
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, targetGun.Data);
zone.Update(1f);
Assert.Same(targetGun, shooter.TargetItem.Value);
@ -262,6 +284,139 @@ public sealed class FireControlTests : IDisposable
Assert.Null(shooter.TargetItem.Value);
}
// A design as an observer might be told it is: an instant weapon with rated figures, held in memory (a claim is data,
// not a catalog item). Rated damage per second at any range is damage / cooldown (one round, flat curve).
private static GearData ClaimedDesign(string name, float damage, float range = 1000f, BezierCurve curve = null) => new GearData
{
Name = name,
Behaviors =
{
new InstantWeaponData
{
Damage = new PerformanceStat { Min = 0, Max = damage }, Count = new PerformanceStat { Min = 1, Max = 1 },
Cooldown = new PerformanceStat { Min = 1, Max = 1 }, Range = new PerformanceStat { Min = 0, Max = range },
MinRange = new PerformanceStat { Min = 0, Max = 0 },
DamageCurve = curve ?? new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
}
}
};
// Ruling ew-aim: the AI classifies from its book, not the target's gear (aetheria-ew, classify-from-belief). A gun
// the target claims is not a weapon is not something Combat picks as a weapon, though the gun is truly one and
// the shooter has seen it act; clearing the claim and seeing it act again restores the choice. Mutation: Combat
// walks target.Equipment (or reveal.Item's true behaviours) instead of RevealedWeapons.
[Fact]
public void TheAIChoosesFromWhatItBelieves()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10));
var targetGun = target.Equipment.Single(x => x.Behaviors.Any(b => b is Weapon));
target.Claim(targetGun, new GearData { Name = "NotAGun" });
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, target.ClaimOf(targetGun));
zone.Update(1f);
zone.Update(1f);
Assert.Null(shooter.TargetItem.Value);
target.Claim(targetGun, null);
shooter.RecordReveal(target, targetGun, SignatureChannel.LaunchFlash, target.ClaimOf(targetGun));
zone.Update(1f);
Assert.Same(targetGun, shooter.TargetItem.Value);
}
// Ruling ew-claim-weapon-measure-ruled (rated-design): the AI ranks revealed weapons by their claimed design's
// rated figures, so the truly weaker gun claimed as the stronger design is the one chosen, and the unclaimed pair
// picks the truly stronger. Mutation: rank by the true weapon's live figure, or by the carrier's stats.
[Fact]
public void AWeakerGunClaimedAsAStrongerDesignIsChosen()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
void See()
{
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
zone.Update(1f);
zone.Update(1f);
}
See();
Assert.Same(strong, shooter.TargetItem.Value); // unclaimed: the truly stronger gun (rated 10 against 1)
target.Claim(strong, ClaimedDesign("Pea", 2f));
target.Claim(weak, ClaimedDesign("Cannon", 90f));
See();
Assert.Same(weak, shooter.TargetItem.Value);
}
// The ranking reads the claimed design at the distance to the target: a short-range design that out-damages a flat one
// up close is worthless at 10 when its Range is 12, and the weaker flat design is chosen. Mutation: Combat halves
// targetDistance (the short design then rates 6.2 at 5 and wins), or the rated read ignores the range.
[Fact]
public void TheAIRanksClaimsAtTheTargetsDistance()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
var falling = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 0, 0, 0) } };
target.Claim(strong, ClaimedDesign("Dart", 10f, range: 12f, curve: falling)); // ~.7 at 10, ~6.2 at 5
target.Claim(weak, ClaimedDesign("Slug", 5f)); // 5 at any range
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
zone.Update(1f);
zone.Update(1f);
Assert.Same(weak, shooter.TargetItem.Value);
}
// Ruling ew-claim-weapon-measure-ruled: no observer reads a target weapon's live state. Two unclaimed guns whose
// rated order (10 against 1) is the reverse of their live order (the strong one worn down to a hundredth) are
// ranked by the design, so the aim stays on the rated stronger gun. Mutation: rank an unclaimed reveal by the
// true weapon's live RangeDamagePerSecond.
[Fact]
public void TheAIRanksByTheDesignNotTheTargetsLiveGun()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var strong = target.Equipment.Single(x => x.Data.Name == "Gun");
var weak = target.Equipment.Single(x => x.Data.Name == "WeakGun");
var strongData = (InstantWeaponData) strong.Data.Behaviors.Single(b => b is InstantWeaponData);
target.Resolver.AttachModifier(strong, strongData.Damage, "worn", StatModifierType.Multiplier, .01f);
shooter.RecordReveal(target, strong, SignatureChannel.LaunchFlash, target.ClaimOf(strong));
shooter.RecordReveal(target, weak, SignatureChannel.LaunchFlash, target.ClaimOf(weak));
zone.Update(1f);
zone.Update(1f);
var liveStrong = strong.Behaviors.OfType<Weapon>().Single().RangeDamagePerSecond(10f);
var liveWeak = weak.Behaviors.OfType<Weapon>().Single().RangeDamagePerSecond(10f);
Assert.True(liveStrong < liveWeak); // the fixture: live and rated disagree
Assert.Same(strong, shooter.TargetItem.Value);
}
// Ties are explicit: two revealed weapons of equal rated figure are taken in the subject's equipment order, whatever
// order the shooter saw them in. Mutation: ties go to the last (>=), or the walk follows the book's slot order.
[Fact]
public void EqualClaimsAreTakenInTheTargetsEquipmentOrder()
{
var (shooter, target, zone) = BuildCombatEncounter(float3(0, 0, 10), twoTargetGuns: true);
var first = target.Equipment.Single(x => x.Data.Name == "Gun");
var second = target.Equipment.Single(x => x.Data.Name == "WeakGun");
Assert.True(target.Equipment.IndexOf(first) < target.Equipment.IndexOf(second));
var design = ClaimedDesign("Twin", 5f);
target.Claim(first, design);
target.Claim(second, design);
shooter.RecordReveal(target, second, SignatureChannel.LaunchFlash, target.ClaimOf(second)); // seen first
shooter.RecordReveal(target, first, SignatureChannel.LaunchFlash, target.ClaimOf(first));
zone.Update(1f);
zone.Update(1f);
Assert.Equal(new[] { first, second }, shooter.RevealedWeapons(target).Select(r => r.Item).ToArray());
Assert.Same(first, shooter.TargetItem.Value);
}
// Controls cut (helm split): Combat aims the shooter at the intercept (Aim) and commands the hull toward it
// through the one steering law (Turn); it never turns the hull by writing Aim.
[Fact]

View file

@ -0,0 +1,240 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using Xunit;
// AetherDb's generic-designs command (aetheria-release, cut generic-design-names), run on a scratch copy of the shipped
// catalog, which already carries the applied result: each case first puts a few records back to their old shape.
public sealed class GenericDesignsCommandTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "generic-designs-command-" + Guid.NewGuid().ToString("N"));
private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
public GenericDesignsCommandTests()
{
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"));
}
public void Dispose() => Directory.Delete(_root, true);
private void Edit(Action<AetherDb> edit)
{
var db = AetherDb.Open(catalogWritable: true, root: _root);
try { edit(db); }
finally { db.Cache.Dispose(); }
}
private T Read<T>(Func<AetherDb, T> read)
{
var db = AetherDb.Open(root: _root);
try { return read(db); }
finally { db.Cache.Dispose(); }
}
private static void Save(AetherDb db, EquippableItemData design) =>
db.Cache.Commit(batch => batch.Upsert(design.GetType(), design, db.Cache.RefOf(design).Key));
private static void Save(AetherDb db, FactionProductData product) =>
db.Cache.Commit(batch => batch.Upsert(typeof(FactionProductData), product, db.Cache.RefOf(product).Key));
private static EquippableItemData DesignNamed(AetherDb db, string name) =>
db.Cache.GetAll<EquippableItemData>().Single(d => d.Name == name);
private static FactionProductData ProductNamed(AetherDb db, string name) =>
db.Cache.GetAll<FactionProductData>().Single(p => p.Name == name);
[Fact]
public void An_applied_catalog_is_left_alone_by_a_dry_run_and_by_apply()
{
var before = File.ReadAllBytes(CatalogPath);
Assert.Equal(0, Program.GenericDesigns(apply: false, root: _root));
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
Assert.Equal(0, Program.GenericDesigns(apply: true, root: _root));
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
}
[Fact]
public void A_dry_run_writes_nothing_and_apply_restores_both_records()
{
var (designText, productText, brandedDesignText, rowText) = Read(db => (DesignNamed(db, "Light Machine Gun").Description,
ProductNamed(db, "Hammerfall").Description, DesignNamed(db, "Autocannon").Description, ProductNamed(db, "Skiron").Description));
Edit(db =>
{
var design = DesignNamed(db, "Light Machine Gun");
design.Name = "ClearPath";
design.Description = "Defend your precious cargo.";
Save(db, design);
var product = ProductNamed(db, "Hammerfall");
product.Name = "Autocannon";
product.Description = "";
Save(db, product);
var branded = DesignNamed(db, "Autocannon");
branded.Description = "";
Save(db, branded);
var row = ProductNamed(db, "Skiron");
row.Description = "";
Save(db, row);
});
var reverted = File.ReadAllBytes(CatalogPath);
Assert.Equal(0, Program.GenericDesigns(apply: false, root: _root));
Assert.Equal(reverted, File.ReadAllBytes(CatalogPath));
Assert.Equal(0, Program.GenericDesigns(apply: true, root: _root));
Read(db =>
{
Assert.Equal(designText, DesignNamed(db, "Light Machine Gun").Description);
Assert.DoesNotContain(db.Cache.GetAll<EquippableItemData>(), d => d.Name == "ClearPath");
Assert.Equal(productText, ProductNamed(db, "Hammerfall").Description);
Assert.Equal(brandedDesignText, DesignNamed(db, "Autocannon").Description);
Assert.Equal(rowText, ProductNamed(db, "Skiron").Description);
Assert.DoesNotContain(db.Cache.GetAll<FactionProductData>(), p => p.Name == "Autocannon" && db.Cache.Get(p.Manufacturer)?.ShortName == "Zhestokost");
return 0;
});
}
// 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_and_restores_the_rewritten_design_texts()
{
var (largeDriveText, tractorText, smokeText, tractorProductText) = Read(db => (DesignNamed(db, "Large Drive").Description, DesignNamed(db, "Tractor Beam").Description,
DesignNamed(db, "Smoke Proximity").Description, ProductNamed(db, "Space Tractor").Description));
Edit(db =>
{
var drive = DesignNamed(db, "Large Drive");
drive.Description = "Keeps you going and going and going...";
Save(db, drive);
var smoke = DesignNamed(db, "Smoke Proximity");
smoke.Description = "A Spectra that bursts by proximity, blast 8. For scenarios; no one builds it.";
Save(db, smoke);
var tractor = ProductNamed(db, "Space Tractor");
tractor.Description = "It's a tractor. In space.";
Save(db, tractor);
});
Assert.True(WidenEveryPayload() > 100, "the scratch catalog should hold many records in the widened spelling");
var before = Records();
Assert.Equal(0, Program.GenericDesigns(apply: true, root: _root));
var after = Records();
Assert.Equal(before.Count, after.Count);
var changed = before.Keys.Where(key => !before[key].Payload.AsSpan().SequenceEqual(after[key].Payload)).ToArray();
Assert.Equal(3, changed.Length);
foreach (var (key, record) in before.Where(pair => !changed.Contains(pair.Key)))
Assert.True(record.Payload.AsSpan().SequenceEqual(after[key].Payload), "a record the command did not author has changed bytes");
Read(db =>
{
Assert.Equal(largeDriveText, DesignNamed(db, "Large Drive").Description);
Assert.Equal(tractorText, DesignNamed(db, "Tractor Beam").Description);
Assert.Equal(smokeText, DesignNamed(db, "Smoke Proximity").Description);
Assert.Equal(tractorProductText, ProductNamed(db, "Space Tractor").Description);
return 0;
});
}
[Fact]
public void A_generic_name_held_by_another_design_is_refused_and_nothing_is_written()
{
Edit(db =>
{
var cargo = DesignNamed(db, "Cargo Bay");
cargo.Name = "Store-All Plus";
Save(db, cargo);
var flak = DesignNamed(db, "Flak Gun");
flak.Name = "Cargo Bay";
Save(db, flak);
});
var before = File.ReadAllBytes(CatalogPath);
Assert.Throws<InvalidOperationException>(() => Program.GenericDesigns(apply: true, root: _root));
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
}
[Fact]
public void A_brand_already_held_by_another_product_is_refused()
{
Edit(db =>
{
var product = ProductNamed(db, "Hammerfall");
product.Name = "Autocannon";
Save(db, product);
var other = ProductNamed(db, "Wren");
other.Name = "Hammerfall";
Save(db, other);
});
var before = File.ReadAllBytes(CatalogPath);
Assert.Throws<InvalidOperationException>(() => Program.GenericDesigns(apply: true, root: _root));
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
}
[Fact]
public void A_row_whose_old_and_generic_names_are_both_absent_is_refused()
{
Edit(db =>
{
var gun = DesignNamed(db, "Light Machine Gun");
gun.Name = "Neither Name";
Save(db, gun);
});
var before = File.ReadAllBytes(CatalogPath);
Assert.Throws<InvalidOperationException>(() => Program.GenericDesigns(apply: true, root: _root));
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
}
[Fact]
public void A_brand_row_whose_maker_sells_no_product_of_the_design_under_either_name_is_refused()
{
Edit(db =>
{
var product = ProductNamed(db, "Hammerfall");
product.Name = "Neither Name";
Save(db, product);
});
var before = File.ReadAllBytes(CatalogPath);
Assert.Throws<InvalidOperationException>(() => Program.GenericDesigns(apply: true, root: _root));
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
}
[Fact]
public void A_shipped_authoring_note_is_stripped_from_a_product_the_tables_do_not_name()
{
Edit(db =>
{
var product = ProductNamed(db, "LockOn");
product.Description += Program.AuthoringNote;
Save(db, product);
});
Assert.Equal(0, Program.GenericDesigns(apply: true, root: _root));
Read(db =>
{
Assert.EndsWith("target.", ProductNamed(db, "LockOn").Description);
return 0;
});
}
}

View file

@ -43,7 +43,7 @@ public sealed partial class FireControlCut124Tests
// Fires until a shot lands; returns that outcome and the absorbs the target published for that shot alone.
private static (ShotOutcome Outcome, List<AbsorbEvent> Absorbs) FireAtTheMarker(Engagement e, List<IDisposable> subs)
{
e.Shooter.RecordReveal(e.Target, e.Custom["Marker"], SignatureChannel.LaunchFlash); // the book must hold the item: aiming is earned by seeing it act
e.Shooter.RecordReveal(e.Target, e.Custom["Marker"], SignatureChannel.LaunchFlash, e.Custom["Marker"].Data); // the book must hold the item: aiming is earned by seeing it act
Assert.True(e.Shooter.TrySelectTargetItem(e.Custom["Marker"]), "fixture: the marker is aimable");
var absorbs = Collect(e.Target, subs);
ShotOutcome landed = null;

View file

@ -0,0 +1,376 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using GameCult.Caching;
using Xunit;
// The two launch suppressors the catalog sells (campaign aetheria-ew, cut ew-hide-launch-catalog; rulings
// ew-suppressor-strength-ruled, ew-suppressor-makers-ruled, ew-mild-suppressor-makers-defaulted), read from the shipped catalog and
// fitted to shipped hulls: what the designs say, that quality moves only the stat it is authored on, and what a player feels. The
// strong Launch Suppressor hides a launch from the target it is fired at and gives out after an opening volley; the mild Launch
// Baffle leaves the target its sight, hides the launch from the crowd and lasts a fight. The entity-level mechanism belongs to
// ContactRevealTests; this file owns the catalog's figures.
public sealed class LaunchSuppressorCatalogTests
{
internal const string StrongName = "Launch Suppressor";
internal const string MildName = "Launch Baffle";
// The observer's sensor, named: the reveal readings below are this design's PassiveInfo, so a catalog that gains an earlier
// sensor must not change which one the observer fits.
private const string SensorName = "Compact Sensor";
private const float Flash = 1000f;
private const float Tick = .25f; // exact in floats: four ticks are one second
private static GearData Design(CultCache cache, string name) => Assert.Single(cache.GetAll<GearData>(), g => g.Name == name);
private static LaunchSuppressorData Data(GearData design) => Assert.Single(design.Behaviors.OfType<LaunchSuppressorData>());
private static void AssertStat(PerformanceStat stat, float min, float max, string role)
{
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(1f, term.Exponent); // linear in the role's quality: the design's range is the whole of what quality changes
}
[Fact]
public void LaunchSuppressorDesignsAreAuthored()
{
using var cache = RestoredHullsTests.OpenCatalog();
var computer = Design(cache, "Targeting Computer");
var designs = new[]
{
(Design: Design(cache, StrongName), Price: 40000, Factor: (.08f, .03f), Heat: (1.4f, .8f), Rated: (310f, 330f)),
(Design: Design(cache, MildName), Price: 25000, Factor: (.4f, .2f), Heat: (.8f, .5f), Rated: (310f, 330f)),
};
foreach (var (design, price, factor, heat, rated) in designs)
{
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.Equal(60f, design.Mass);
Assert.Equal(50f, design.Durability);
Assert.Equal(price, design.Price);
Assert.False(string.IsNullOrWhiteSpace(design.Description));
Assert.Equal(new[] { "baffles", "heat sink" }, 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));
var data = Data(design);
AssertStat(data.Factor, factor.Item1, factor.Item2, "baffles");
AssertStat(data.HeatPerUnit, heat.Item1, heat.Item2, "heat sink");
AssertStat(data.RatedTemperature, rated.Item1, rated.Item2, "heat sink");
CultRecordRefs.Validate(design);
}
// Exactly these six products and these makers, each authoring both roles at its maker's own mean and deviation.
var profiles = new Dictionary<string, (float Mean, float Dev)>
{
["NiteLife"] = (.55f, .12f), ["Finch"] = (.65f, .20f), ["DME"] = (.55f, .20f), ["R&D"] = (.60f, .15f),
["Lightsail"] = (.55f, .10f), ["Alakrita"] = (.60f, .14f),
};
foreach (var (name, makers) in new[]
{
(StrongName, new[] { "DME", "Finch", "NiteLife", "R&D" }),
(MildName, new[] { "Alakrita", "Lightsail" }),
})
{
var key = cache.RefOf(Design(cache, name)).Key;
var products = cache.GetAll<FactionProductData>().Where(p => p.Design.Key.Equals(key)).ToArray();
Assert.Equal(makers, products.Select(p => cache.Get(p.Manufacturer).ShortName).OrderBy(m => m, StringComparer.Ordinal).ToArray());
Assert.Equal(products.Length, products.Select(p => p.Name).Distinct().Count());
Assert.All(products, product =>
{
var (mean, dev) = profiles[cache.Get(product.Manufacturer).ShortName];
Assert.Equal(new[] { "baffles", "heat sink" }, product.Roles.Select(r => r.Role).OrderBy(r => r, StringComparer.Ordinal).ToArray());
Assert.All(product.Roles, r =>
{
Assert.Equal(mean, r.Mean, 3);
Assert.Equal(dev, r.StandardDeviation, 3);
});
Assert.False(string.IsNullOrWhiteSpace(product.Description));
});
}
}
// A shipped hull with its drives, a reactor, a radiator and the named suppressor fitted before the ship goes live (a ship
// cannot be equipped once active), minted through the production lot path with a quality for each of the suppressor's roles.
// The recipe is RestoredHullsTests.BuildThrustedShip's; the suppressor takes the first free interior space, as generation does.
internal sealed class Rig : IDisposable
{
public readonly CultCache Cache = RestoredHullsTests.OpenCatalog();
public readonly Ship Ship;
public readonly EquippedItem Suppressor;
public readonly LaunchSuppressorData Data;
public readonly int2 Cell;
public Rig(string hullName, string designName, float baffles = .5f, float sink = .5f, string sensorName = null)
{
var hull = Cache.GetByName<HullData>(hullName);
var design = Design(Cache, designName);
Data = LaunchSuppressorCatalogTests.Data(design);
var settings = RestoredHullsTests.Settings();
var ledger = new ProvenanceLedger();
var suppressorLot = hull.Hardpoints.Count + 2;
for (var i = 1; i <= suppressorLot; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = .5f };
ledger.Lots[suppressorLot] = new Lot
{
Origin = new Attributed(), Quality = .5f,
Roles = new List<RoleFill> { new RoleFill { Role = "baffles", Quality = baffles }, new RoleFill { Role = "heat sink", Quality = sink } },
};
var items = new ItemManager(Cache, ledger, settings, _ => { });
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
Ship = new Ship(items, zone, new EquippableItem { Data = Cache.RefOf<ItemData>(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings);
var lot = 2;
var thrusters = Cache.GetAll<GearData>().Where(g => g.Hardpoint == HardpointType.Thruster).ToArray();
foreach (var hardpoint in hull.Hardpoints.Where(h => h.Type == HardpointType.Thruster))
{
var thruster = thrusters.First(hardpoint.IsFilledBy);
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(thruster), Durability = thruster.Durability, Lot = lot++ }, hardpoint.Position));
}
foreach (var type in new[] { HardpointType.Reactor, HardpointType.Radiator })
{
var hardpoint = hull.Hardpoints.First(h => h.Type == type);
var filler = Cache.GetAll<GearData>().First(hardpoint.IsFilledBy);
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(filler), Durability = filler.Durability, Lot = lot++ }, hardpoint.Position));
}
if (sensorName != null)
{
var sensor = Design(Cache, sensorName);
var hardpoint = hull.Hardpoints.First(h => h.Type == HardpointType.Sensors && h.IsFilledBy(sensor));
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(sensor), Durability = sensor.Durability, Lot = lot++ }, hardpoint.Position));
}
Assert.True(Ship.TryEquip(new EquippableItem { Data = Cache.RefOf<ItemData>(design), Durability = design.Durability, Lot = suppressorLot }));
zone.Admit(Ship, piloted: false);
Ship.Position = float3.zero;
zone.Update(0f);
Suppressor = Ship.Equipment.Single(e => e.Data.Name == designName);
Cell = Suppressor.InsetShape.Coordinates.Single();
}
public void Dispose() => Cache.Dispose();
// Write one launch flash (a direct call: no update, so nothing cools between calls) and read back what was stored.
public float Launch(float strength)
{
var source = new object();
Ship.SetSignature(source, SignatureChannel.LaunchFlash, strength, Suppressor);
return Ship.VisibilitySources[source].Strength;
}
public float Reading(PerformanceStat stat) => Suppressor.Evaluate(stat);
public float Temperature => Ship.Temperature[Cell.x, Cell.y];
// The temperature the cell reaches after n direct launches of a flash while the suppressor is working: each removed unit
// costs HeatPerUnit, spread over the cell's thermal mass. Counts the launches made while the cell is at or under its rating.
public int OpeningVolley(float strength)
{
var factor = Math.Clamp(Reading(Data.Factor), 0f, 1f);
var perCall = (strength - strength * factor) * Reading(Data.HeatPerUnit) / Ship.ThermalMass[Cell.x, Cell.y];
var rated = Reading(Data.RatedTemperature);
var count = 0;
for (double t = Temperature; t <= rated; t += perCall) count++;
return count;
}
}
// One launch of a fight: what was stored, how far the suppressor's cell rose, and the rise the authored figures predict for the
// light removed (removed x HeatPerUnit / the cell's thermal mass).
internal readonly record struct Launched(float Stored, float Rise, float Predicted);
// A fight of one launch a second with the hull ticking between them (four quarter-second updates).
internal static Launched[] Fight(string hull, string design, float strength, int launches = 60)
{
using var rig = new Rig(hull, design);
var perUnit = rig.Reading(rig.Data.HeatPerUnit);
var mass = rig.Ship.ThermalMass[rig.Cell.x, rig.Cell.y];
var fight = new Launched[launches];
for (var i = 0; i < launches; i++)
{
var before = rig.Temperature;
var stored = rig.Launch(strength);
fight[i] = new Launched(stored, rig.Temperature - before, (strength - stored) * perUnit / mass);
for (var t = 0; t < 4; t++) rig.Ship.Zone.Update(Tick);
}
return fight;
}
// The number of launches that are suppressed before the first that is not.
internal static int LeadingSuppressed(string hull, string design, float strength, int launches = 60) =>
Fight(hull, design, strength, launches).TakeWhile(l => l.Stored < strength).Count();
// A sensor ship at the origin: the observer. What it reveals is Entity.Sense's own test, a stored strength times its
// passive information at the emitter's position over the settings' threshold.
private sealed class Observer : IDisposable
{
private readonly Rig _rig;
private readonly float _threshold = RestoredHullsTests.Settings().TargetDetectionInfoThreshold;
public Observer() => _rig = new Rig("Longinus", MildName, sensorName: SensorName);
public void Dispose() => _rig.Dispose();
public bool Sees(float stored, float distance) => stored * _rig.Ship.PassiveInfo(new float2(distance, 0f), 1f) > _threshold;
}
public static IEnumerable<object[]> QualityGrid() =>
from design in new[] { StrongName, MildName }
from role in new[] { "baffles", "heat sink" }
from quality in new[] { 0f, .5f, 1f }
select new object[] { design, role, quality };
// The role under test takes the quality; the other role sits at a quality no row uses, so a stat read from the wrong role
// lands somewhere else.
[Theory]
[MemberData(nameof(QualityGrid))]
public void ShippedSuppressorQualityMovesItsStats(string designName, string role, float quality)
{
const float Decoy = .25f;
var baffles = role == "baffles" ? quality : Decoy;
var sink = role == "heat sink" ? quality : Decoy;
using var rig = new Rig("Longinus", designName, baffles, sink);
float Lerp(PerformanceStat stat, float q) => stat.Min + (stat.Max - stat.Min) * q;
var factor = rig.Reading(rig.Data.Factor);
var perUnit = rig.Reading(rig.Data.HeatPerUnit);
Assert.Equal(Lerp(rig.Data.Factor, baffles), factor, 4);
Assert.Equal(Lerp(rig.Data.HeatPerUnit, sink), perUnit, 4);
Assert.Equal(Lerp(rig.Data.RatedTemperature, sink), rig.Reading(rig.Data.RatedTemperature), 4);
var before = rig.Temperature;
var stored = rig.Launch(Flash);
Assert.Equal(Flash * factor, stored, 2);
var expected = (Flash - stored) * perUnit / rig.Ship.ThermalMass[rig.Cell.x, rig.Cell.y];
Assert.InRange(rig.Temperature - before, expected * .999f, expected * 1.001f);
}
[Theory]
[InlineData("Longinus")]
[InlineData("Djinni")]
public void StrongSuppressorHidesFromItsTargetAndStopsAfterAnOpeningVolley(string hull)
{
// From rest with no cooling: each call pays heat into one cell, so a few calls in the rating is crossed and every later
// call is bright. The count is derived from the cell's thermal mass and the authored figures; the Djinni's margin is
// under a kelvin, so it is never a literal.
using var rig = new Rig(hull, StrongName);
var expectedSuppressed = rig.OpeningVolley(Flash);
Assert.InRange(expectedSuppressed, 2, 8);
var stored = Enumerable.Range(0, 12).Select(_ => rig.Launch(Flash)).ToArray();
var hidden = rig.Reading(rig.Data.Factor) * Flash;
Assert.All(stored.Take(expectedSuppressed), s => Assert.Equal(hidden, s, 2));
Assert.All(stored.Skip(expectedSuppressed), s => Assert.Equal(Flash, s));
// With the hull ticking, one launch a second, a light flash still gets hidden for a dozen launches.
Assert.True(LeadingSuppressed(hull, StrongName, 300f) >= 12);
}
[Fact]
public void StrongSuppressorHidesALaunchFromTheObserverAtTheTarget()
{
using var rig = new Rig("Longinus", StrongName);
using var observer = new Observer();
var hidden = rig.Launch(Flash);
Assert.False(observer.Sees(hidden, 1500f)); // the shot leaves unseen
Assert.True(observer.Sees(Flash, 1500f)); // the same shot with no suppressor is seen
}
[Fact]
public void MildSuppressorLeavesTheTargetItsSightAndHidesFromTheCrowd()
{
using var mild = new Rig("Longinus", MildName);
using var strong = new Rig("Longinus", StrongName);
using var observer = new Observer();
var dimmed = mild.Launch(Flash);
Assert.Equal(300f, dimmed, 1); // mid quality: Factor .3
Assert.True(observer.Sees(dimmed, 1500f)); // the target still sees the launch
Assert.False(observer.Sees(dimmed, 6000f)); // a far observer loses it...
Assert.True(observer.Sees(Flash, 6000f)); // ...and would have seen it unsuppressed
Assert.False(observer.Sees(strong.Launch(Flash), 1500f)); // the strong design hides it from the target too
}
// What the ruled figures make of a flash at mid quality: Factor lerp(.08, .03, .5) and lerp(.4, .2, .5).
private static float MidFactor(string design) => design == StrongName ? .055f : .3f;
// The suppressor's one cell starts at rest (280 K) and a direct launch is made at each of these temperatures around the
// rating, a fraction of a kelvin either side: the rating alone decides whether the flash is hidden. No launch step is
// involved, so the line is pinned to the kelvin, not to however far a launch happens to move the cell.
[Theory]
[InlineData(StrongName, 0f, -.5f, true)]
[InlineData(StrongName, 0f, 0f, true)]
[InlineData(StrongName, 0f, .5f, false)]
[InlineData(StrongName, 1f, -.5f, true)]
[InlineData(StrongName, 1f, .5f, false)]
[InlineData(MildName, 0f, -.5f, true)]
[InlineData(MildName, 0f, .5f, false)]
[InlineData(MildName, .5f, 0f, true)]
[InlineData(MildName, 1f, -.5f, true)]
[InlineData(MildName, 1f, .5f, false)]
public void TheRatedTemperatureIsTheLineBetweenHidingAndNot(string design, float sink, float offset, bool hidden)
{
using var rig = new Rig("Longinus", design, sink: sink);
var rated = rig.Reading(rig.Data.RatedTemperature);
Assert.Equal(310f + 20f * sink, rated, 3); // the authored 310..330 on the heat sink
rig.Ship.Temperature[rig.Cell.x, rig.Cell.y] = rated + offset;
Assert.Equal(hidden ? Flash * MidFactor(design) : Flash, rig.Launch(Flash), 2);
}
// Hiding is linear in the flash at every strength, and so is the heat it costs: a rule that treats weak flashes differently
// (or scales heat with strength) moves a row. Both designs at mid quality, a first launch from rest.
public static IEnumerable<object[]> FlashGrid() =>
from design in new[] { StrongName, MildName }
from strength in new[] { 40f, 200f, 499f, 501f, 1000f, 2400f }
select new object[] { design, strength };
[Theory]
[MemberData(nameof(FlashGrid))]
public void SuppressionAndItsHeatAreLinearInTheFlash(string design, float strength)
{
using var rig = new Rig("Longinus", design);
var before = rig.Temperature;
var stored = rig.Launch(strength);
Assert.Equal(strength * MidFactor(design), stored, 2);
var expected = (strength - stored) * rig.Reading(rig.Data.HeatPerUnit) / rig.Ship.ThermalMass[rig.Cell.x, rig.Cell.y];
Assert.InRange(rig.Temperature - before, expected * .999f, expected * 1.001f);
}
// "A long fight" for the mild design is launches at one launch a second: 30 of a 300 flash (a light weapon) or 8 of a 1000
// flash (a heavy launch), on either hull (probed 36 and 33, and 10 and 10). Every one of those must store exactly the flash
// times the authored Factor and cost exactly the heat its removed light predicts, so a design that stops hiding late, or
// whose Factor or heat drifts as the cell warms, fails here. Past that the sink sits on its rating and the design flickers:
// each launch is either fully hidden or fully bright, never in between. That flicker is the ruled design at its rating.
[Theory]
[InlineData("Longinus", 300f, 30)]
[InlineData("Djinni", 300f, 30)]
[InlineData("Longinus", Flash, 8)]
[InlineData("Djinni", Flash, 8)]
public void MildSuppressorKeepsHidingThroughALongFight(string hull, float flash, int hold)
{
var fight = Fight(hull, MildName, flash);
var hidden = flash * MidFactor(MildName);
for (var i = 0; i < hold; i++)
{
Assert.True(Math.Abs(fight[i].Stored - hidden) < .01f, $"{hull}: launch {i} of {flash} stored {fight[i].Stored}, not the authored {hidden}");
Assert.InRange(fight[i].Rise, fight[i].Predicted * .999f, fight[i].Predicted * 1.001f);
}
Assert.All(fight.Skip(hold), l => Assert.True(Math.Abs(l.Stored - hidden) < .01f || l.Stored == flash, $"{hull}: a launch stored {l.Stored}, neither hidden nor bright"));
}
[Theory]
[InlineData("Longinus")]
[InlineData("Djinni")]
public void MildSuppressorSurvivesALongerFightThanStrong(string hull)
{
foreach (var flash in new[] { 300f, Flash })
{
var mild = LeadingSuppressed(hull, MildName, flash);
var strong = LeadingSuppressed(hull, StrongName, flash);
Assert.True(mild > strong, $"{hull}: mild {mild} did not outlast strong {strong} at {flash}");
}
}
}

View file

@ -0,0 +1,171 @@
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 launch-suppressor command (aetheria-ew, cut ew-hide-launch-catalog), run on a scratch copy of the shipped catalog with
// the two designs and their six products removed from it. Apply must write what the shipped catalog holds, and a catalog that is
// neither untouched nor fully authored is refused rather than completed.
public sealed class LaunchSuppressorCommandTests : IDisposable
{
private static readonly string[] DesignNames = { LaunchSuppressorCatalogTests.StrongName, LaunchSuppressorCatalogTests.MildName };
private readonly string _root = Path.Combine(Path.GetTempPath(), "launch-suppressor-command-" + Guid.NewGuid().ToString("N"));
private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc");
// The designs and products as the shipped catalog holds them, taken before they are removed from the scratch copy.
private readonly (string Name, byte[] Bytes)[] _shippedDesigns;
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 LaunchSuppressorCommandTests()
{
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 designs = cache.GetAll<GearData>().Where(g => DesignNames.Contains(g.Name)).ToArray();
Assert.Equal(DesignNames.Length, designs.Length);
_shippedDesigns = designs.Select(d => (d.Name, Pack(d))).ToArray();
var keys = designs.Select(d => cache.RefOf(d).Key).ToArray();
var products = cache.GetAll<FactionProductData>().Where(p => keys.Any(k => p.Design.Key.Equals(k))).ToArray();
_shippedProducts = products.Select(p => Describe(cache, p)).OrderBy(x => x, StringComparer.Ordinal).ToArray();
Assert.Equal(6, products.Length);
foreach (var product in products) Assert.True(cache.Remove(cache.RefOf(product).Key));
foreach (var key in keys) 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 => DesignNames.Contains(g.Name)).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_designs_once()
{
var before = File.ReadAllBytes(CatalogPath);
Assert.Empty(Read().Designs);
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: false, root: _root));
Assert.Equal(before, File.ReadAllBytes(CatalogPath));
Assert.Empty(Read().Designs);
Assert.Equal(0, Program.LaunchSuppressorCatalog(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 designs and products do not.
foreach (var (name, bytes) in _shippedDesigns)
Assert.True(bytes.AsSpan().SequenceEqual(Pack(designs.Single(d => d.Name == name))), $"{name} 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.LaunchSuppressorCatalog(apply: true, root: _root));
Assert.Equal(landed, File.ReadAllBytes(CatalogPath));
Assert.Equal(0, Program.LaunchSuppressorCatalog(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.LaunchSuppressorCatalog(apply: true, root: _root));
var after = Records();
Assert.Equal(before.Count + DesignNames.Length + _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 { OneDesignOnly, DesignWithSomeProducts, ProductsWithoutTheirDesign }
[Theory]
[InlineData(PartialState.OneDesignOnly)]
[InlineData(PartialState.DesignWithSomeProducts)]
[InlineData(PartialState.ProductsWithoutTheirDesign)]
public void Apply_refuses_a_partly_authored_catalog_and_writes_nothing(PartialState state)
{
Assert.Equal(0, Program.LaunchSuppressorCatalog(apply: true, root: _root));
switch (state)
{
case PartialState.OneDesignOnly:
Remove(p => DesignOf(p) == DesignNames[1], g => g.Name == DesignNames[1]);
break;
case PartialState.DesignWithSomeProducts:
Remove(p => p.Name == "Hush" || p.Name == "Low Profile", g => false);
break;
case PartialState.ProductsWithoutTheirDesign:
Remove(p => false, g => g.Name == DesignNames[0]);
break;
}
var partial = File.ReadAllBytes(CatalogPath);
Assert.Throws<InvalidOperationException>(() => Program.LaunchSuppressorCatalog(apply: true, root: _root));
Assert.Equal(partial, File.ReadAllBytes(CatalogPath));
}
private string DesignOf(FactionProductData p) => p.Name is "Low Profile" or "Fast Pass Dim" ? DesignNames[1] : DesignNames[0];
}

View file

@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using Xunit;
using static CultMath.math;
// The arena the operator flies to check the incoming-lock warning (map: Scenarios for operator checks, SC5 and SC14). The
// test launches the scenario the way New Game does and steps what it staged.
public sealed partial class RunStartTests
{
// Lock On: the hostile starts piloted, facing the player, beyond its own sight of the player, and the player has no
// incoming paint. Stepped at .1 s, it closes, finds the player and locks: a Track paint reaches the player bearing
// toward the hostile, and its Strength rises from nothing as the lock tightens.
[Fact]
public void LockOnSituation()
{
var (_, arena, staged, failures) = Launch(new LockOn(), Inputs(() => GalaxySeed));
Assert.True(failures.Count == 0, string.Join("; ", failures));
var player = staged.Player;
var hostile = Assert.Single(staged.Entities);
Assert.True(player.IsHostileTo(hostile) && hostile.IsHostileTo(player), "the hostile is hostile both ways");
Assert.Equal(1, AgentsOf(arena, hostile));
var toPlayer = normalize(player.Position.xz - hostile.Position.xz);
Assert.True(dot(normalize(hostile.Direction), toPlayer) >= cos(radians(5f)), "the hostile faces the player");
// The first real sensing tick: the hostile holds no track of the player, and the sim's own passive sensing, asked for
// the player's signature at the staged place, settles below the detection threshold: the staging is out of its sight.
arena.Update(.1f);
Assert.DoesNotContain(player, hostile.VisibleEnemies);
Assert.DoesNotContain(player.IncomingPaints, paint => paint.Mode == PaintMode.Track);
Assert.True(player.Visibility > 0f, "the player shows a signature to be seen by");
var threshold = player.ItemManager.GameplaySettings.TargetDetectionInfoThreshold;
var settled = hostile.PassiveInfo(player.Position.xz, player.Visibility);
Assert.True(settled <= threshold, $"the hostile would settle at {settled} on the player, above the detection threshold {threshold}: staged inside its sight");
var lockedAt = -1;
var bearingDot = 0f;
var strengths = new List<float>();
for (var tick = 0; tick < 600 && (lockedAt < 0 || tick < lockedAt + 40); tick++)
{
arena.Update(.1f);
foreach (var paint in player.IncomingPaints.Where(paint => paint.Mode == PaintMode.Track))
{
if (lockedAt < 0)
{
lockedAt = tick;
bearingDot = dot(normalize(paint.Bearing), toPlayer * -1f);
}
strengths.Add(paint.Strength);
}
}
Assert.True(lockedAt >= 0, "the hostile never locked the player within 600 ticks of .1 s");
Assert.True(bearingDot >= cos(radians(5f)), "the warning points at the hostile");
Assert.True(strengths.First() < strengths.Last(), $"the lock tightens: Strength went {strengths.First()} to {strengths.Last()}");
}
}

View file

@ -23,11 +23,11 @@ public sealed class MunitionPartsTests : IDisposable
// both ranged stats. The column names are the retired fields'.
private static readonly (string Design, float ThrustMin, float ThrustMax, float MissileVelocityMin, float MissileVelocityMax, float DodgeFrequency)[] Golden =
{
("GT 3K", 135, 325, 150, 300, 0.25f),
("LRMM72", 400, 600, 200, 300, 0.25f),
("SRMM72", 1600, 2400, 400, 600, 1f),
("pswarm", 500, 2000, 400, 600, 1f),
("scorched void policy", 500, 2000, 400, 600, 1f),
("Guided Missile Launcher", 135, 325, 150, 300, 0.25f),
("Long-Range Missile Rack", 400, 600, 200, 300, 0.25f),
("Short-Range Missile Rack", 1600, 2400, 400, 600, 1f),
("Swarm Rocket Pod", 500, 2000, 400, 600, 1f),
("Incendiary Rocket Pod", 500, 2000, 400, 600, 1f),
};
private static PerformanceStat Flat(float value) => new PerformanceStat { Min = value, Max = value };
@ -208,13 +208,13 @@ public sealed class MunitionPartsTests : IDisposable
[Fact]
public void CatalogLoadRefusesAnIncompleteOrStrayMunitionPart()
{
using (AetheriaStores.Open(Forged<WeaponItemData>(d => d.Name == "GT 3K", _ => { }), registry: TestCatalog.Registry())) { }
using (AetheriaStores.Open(Forged<WeaponItemData>(d => d.Name == "Guided Missile Launcher", _ => { }), registry: TestCatalog.Registry())) { }
foreach (var part in AllParts)
{
var path = Forged<WeaponItemData>(d => d.Name == "GT 3K", d => d.Behaviors.RemoveAll(b => b.GetType() == part));
var path = Forged<WeaponItemData>(d => d.Name == "Guided Missile Launcher", d => d.Behaviors.RemoveAll(b => b.GetType() == part));
var refusal = Assert.ThrowsAny<Exception>(() => AetheriaStores.Open(path, registry: TestCatalog.Registry()));
Assert.Contains("GT 3K", refusal.ToString());
Assert.Contains("Guided Missile Launcher", refusal.ToString());
Assert.Contains(part.Name, refusal.ToString());
}
@ -245,13 +245,13 @@ public sealed class MunitionPartsTests : IDisposable
// The Longinus's own gear: two drives, a core, a cockpit and a sensor, so the ship has thrusters, a sensor and a plume
// for any munition contribution to be seen against.
private static readonly (string Name, int X, int Y)[] ShipGear =
{ ("Large Drive", 1, 0), ("Large Drive", 3, 0), ("Core Power", 2, 4), ("Cockpit 2x2", 2, 6), ("not if i see you first", 3, 10) };
{ ("Large Drive", 1, 0), ("Large Drive", 3, 0), ("Compact Reactor", 2, 4), ("Cockpit 2x2", 2, 6), ("Compact Sensor", 3, 10) };
// A GT 3K fitted to a Longinus launcher hardpoint beside that gear, its lot's thruster role at the given quality.
// A Guided Missile Launcher fitted to a Longinus launcher hardpoint beside that gear, its lot's thruster role at the given quality.
private static EquippedItem Fit(CultCache cache, float thrusterQuality, out Ship ship)
{
var hull = cache.GetByName<HullData>("Longinus");
var gt = cache.GetByName<WeaponItemData>("GT 3K");
var gt = cache.GetByName<WeaponItemData>("Guided Missile Launcher");
var hardpoint = hull.Hardpoints.First(h => h.Type == HardpointType.Launcher && h.IsFilledBy(gt));
var settings = RestoredHullsTests.Settings();
var ledger = new ProvenanceLedger();
@ -269,7 +269,7 @@ public sealed class MunitionPartsTests : IDisposable
zone.Admit(ship, piloted: false);
// Settle at the item's optimal temperature, as ConditionRatioTests does, so heat and wear read their identity and only
// the role's quality moves a stat.
var item = ship.Equipment.Single(e => e.Data.Name == "GT 3K");
var item = ship.Equipment.Single(e => e.Data.Name == "Guided Missile Launcher");
foreach (var cell in item.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = gt.OptimalTemperature;
item.UpdatePerformance();
item.UpdatePerformance();
@ -280,7 +280,7 @@ public sealed class MunitionPartsTests : IDisposable
public void PartsReadThroughTheItem()
{
using var cache = RestoredHullsTests.OpenCatalog();
var design = cache.GetByName<WeaponItemData>("GT 3K");
var design = cache.GetByName<WeaponItemData>("Guided Missile Launcher");
var poor = Fit(cache, .2f, out var poorShip);
var good = Fit(cache, .9f, out _);
@ -324,7 +324,7 @@ public sealed class MunitionPartsTests : IDisposable
public void ShipSumsIgnoreTheFittedMunitionParts()
{
using var cache = RestoredHullsTests.OpenCatalog();
var design = cache.GetByName<WeaponItemData>("GT 3K");
var design = cache.GetByName<WeaponItemData>("Guided Missile Launcher");
var part = Part<MunitionThrusterData>(design);
var item = Fit(cache, .5f, out var ship);
for (var i = 0; i < 5; i++) ship.Update(.1f);

View file

@ -107,14 +107,13 @@ public sealed class PickupTests : IDisposable
return new Lab { Items = items, Zone = zone };
}
// A ship with a cargo bay, aimed along +x and (unless told otherwise) carrying the tractor beam.
private static Ship Spawn(Lab lab, float3 at, bool beam = true)
// A ship with `bays` cargo bays (bays are equipped before the zone admits the ship), aimed along +x and
// (unless told otherwise) carrying the tractor beam.
private static Ship Spawn(Lab lab, float3 at, bool beam = true, int bays = 1)
{
var hull = lab.Items.ItemData.RefOf<ItemData>(lab.Items.ItemData.GetByName<HullData>("Skiff"));
var ship = new Ship(lab.Items, lab.Zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = lab.Lot++ }, new EntitySettings());
var crate = lab.Make("Crate");
Assert.True(ship.TryFindSpace(crate, out var cratePos));
Assert.True(ship.TryEquip(crate, cratePos));
for (var i = 0; i < bays; i++) Equip(ship, lab.Make("Crate"));
if (beam) Equip(ship, lab.Make("Tractor"));
lab.Zone.Admit(ship, piloted: false);
ship.Position = at;
@ -404,6 +403,29 @@ public sealed class PickupTests : IDisposable
Assert.True(block.Holder == null, "block is held");
}
// Either bay may be the full one: a selection that read only the first bay, or only the last, fails one orientation.
[Theory]
[InlineData(0)]
[InlineData(1)]
public void TheBeamTakesFromWhicheverBayCanStore(int fullBay)
{
using var bag = new CompositeDisposable();
var lab = Build(Beam());
var ship = Spawn(lab, float3(0, 0, 0), bays: 2);
Assert.Equal(2, ship.CargoBays.Count);
var freeBay = 1 - fullBay;
for (var i = 0; i < 16; i++) Assert.True(ship.CargoBays[fullBay].TryStore(lab.Make("Spare")));
var ore = Drift(lab, 40, 0);
var events = Watch(ship, bag);
ship.TractorCommand = 1;
Tick(lab, 300, 1 / 60f);
Assert.Contains(events, e => e.Phase == GrabPhase.Started && e.Target.Equals(ore.Id));
Assert.Contains(events, e => e.Phase == GrabPhase.Completed);
Assert.False(lab.Zone.FloatingItems.Contains(ore), "ore is still in the zone");
Assert.Equal(16, ship.CargoBays[fullBay].Cargo.Count);
Assert.Single(ship.CargoBays[freeBay].Cargo);
}
[Fact]
public void AnItemInTheBeamsFarCornerIsCollected()
{

View file

@ -79,13 +79,13 @@ public sealed class PiratesFactionCommandTests : IDisposable
var expected = new (string Design, string Name, string Description)[]
{
("Autocannon", "🫳🔫", "Serial number filed off. Still shoots."),
("Earp", "🪦🤠", "The previous owner had no further use for it."),
("FastBlast+-", "🧾🚫", "Warranty void where prohibited, which is everywhere."),
("6k Shooter", "🎨🙈", "The logo underneath is somebody else's problem."),
("scorched void policy", "🔥", "Turns out we did start the fire after all."),
("Long Slug Rifle", "🪦🤠", "The previous owner had no further use for it."),
("Cell Blaster", "🧾🚫", "Warranty void where prohibited, which is everywhere."),
("Slug Rifle", "🎨🙈", "The logo underneath is somebody else's problem."),
("Incendiary Rocket Pod", "🔥", "Turns out we did start the fire after all."),
("Targeting Computer", "👀🎯", "Somebody else's eyes. They still work."),
("Small Drive", "🔑🏃💨", "Hotwired. Do not ask about the ignition."),
("Store-All Plus", "📦🤫", "Contents not as declared."),
("Cargo Bay", "📦🤫", "Contents not as declared."),
};
Assert.Equal(expected.Select(e => e.Name).OrderBy(n => n, StringComparer.Ordinal), products.Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal));
foreach (var (designName, name, description) in expected)

View file

@ -306,7 +306,7 @@ public sealed class PowerCurveTests : IDisposable
// --- F6 (docs/stats-and-power-cut.md, Soul pass 2026-09-19) named PumpedHeat as a Radiator request field
// --- (PowerRequest's own early-out gate reads it, the exact shape Soul reproduced against the shipped
// --- catalog's "OK Disperser": SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates) and, at the time,
// --- catalog's "Heat Pump": SOUL_RadiatorRequestDependsOnItsOwnGrantAndOscillates) and, at the time,
// --- refused a PowerSupply term there for exactly that reason. The nominal-request ruling above is what
// --- actually fixes the oscillation (the request no longer reads the live grant at all), so this shape is
// --- accepted now -- the six shipped records this ruling exists to make legal again include exactly this

View file

@ -298,7 +298,7 @@ public sealed class RestoredHullsTests
Assert.True(ship.TryEquip(gearItem, hardpoint.Position));
}
// A reactor: Victoire (one of Cut 1's own restored thrusters, docs/locomotion-cut.md's own note on it)
// A reactor: Racing Drive (one of Cut 1's own restored thrusters, docs/locomotion-cut.md's own note on it)
// carries a real EnergyUsage draw unlike its four siblings, so an unpowered ship reads as though only
// some fitting thrusters produce thrust -- an artifact of which design TryEquip's own hardpoint search
// happens to land on, not of the restored content. Every hull here also has a Reactor hardpoint; a real
@ -952,7 +952,7 @@ public sealed class RestoredHullsTests
// Every restored thruster's particle effect is Thruster 1.prefab, by the legacy record (soul-dump.txt).
var thruster1Guid = GuidOf("Assets/Content/Prefabs/Thrusters/Thruster 1.prefab");
var restoredThrusterNames = new[] { "deep space burnout", "Large Drive", "Victoire", "Small Drive", "Talaria", "RevvITup 2.0", "Medium Drive" };
var restoredThrusterNames = new[] { "Burn Drive", "Large Drive", "Racing Drive", "Small Drive", "Rugged Drive", "High-Thrust Drive", "Medium Drive" };
foreach (var name in restoredThrusterNames)
{
var design = cache.GetAll<GearData>().First(g => g.Name == name);

View file

@ -13,13 +13,13 @@ using Xunit;
// oracle, since the command produced it.
public sealed class RolesBackfillCommandTests : IDisposable
{
private static readonly string[] Flattened = { "Autocannon", "LRMM72", "SRMM72", "Mine Launcher", "Large Drive", "Small Drive" };
private static readonly string[] Flattened = { "Autocannon", "Long-Range Missile Rack", "Short-Range Missile Rack", "Mine Launcher", "Large Drive", "Small Drive" };
// Designs that shipped with non-flat stats whose Quality term named no role; the field is the stat to unname.
private static readonly (string Design, string Field)[] Unnamed =
{
("DeathCluster", "Count"), ("Flak Gun", "Count"), ("GT 3K", "MinRange"), ("plight", "DamageSpread"),
("MoveOnPro", "Modifier"), ("MoveOnPro Station Reactor", "Modifier"),
("Cluster Cannon", "Count"), ("Flak Gun", "Count"), ("Guided Missile Launcher", "MinRange"), ("Lightning Projector", "DamageSpread"),
("Muon Fusion Reactor", "Modifier"), ("Muon Fusion Station Reactor", "Modifier"),
};
private readonly string _root = Path.Combine(Path.GetTempPath(), "roles-backfill-" + Guid.NewGuid().ToString("N"));
@ -165,7 +165,7 @@ public sealed class RolesBackfillCommandTests : IDisposable
static Dictionary<string, float> Exponents(EquippableItemData d) => CatalogRoleTests.StatsOf(d)
.SelectMany(s => s.Stat.Terms.Where(t => t.Source == StatSource.Quality && !string.IsNullOrEmpty(t.Role)).Select(t => (s.Name, t.Exponent)))
.ToDictionary(x => x.Name, x => x.Exponent);
var designs = new[] { "Autocannon", "LRMM72" };
var designs = new[] { "Autocannon", "Long-Range Missile Rack" };
Dictionary<string, Dictionary<string, float>> shipped;
Dictionary<string, Dictionary<string, float>> Read(EquippableItemData[] all) => all.Where(d => designs.Contains(d.Name)).ToDictionary(d => d.Name, Exponents);
using (var catalog = RestoredHullsTests.OpenCatalog()) shipped = Read(catalog.GetAll<EquippableItemData>().ToArray());

View file

@ -445,7 +445,7 @@ public sealed partial class RunStartTests : IDisposable
}
// The sold hardpoint gear the generator gives no home today: the production rule (LoadoutGenerator.HasHome over the hulls a
// product sells). The Smoke Test Hull, which no product sells, gives ChargeBlast SG and ChargeBlast+- no home; authoring a
// product sells). The Smoke Test Hull, which no product sells, gives Charged Scatter Blaster and Charged Blaster no home; authoring a
// sold hull that takes one shows up here as a changed list.
[Fact]
public void TheSoldHardpointGearWithNoHomeToday()
@ -456,7 +456,7 @@ public sealed partial class RunStartTests : IDisposable
.Where(design => sold.Contains(_cache.RefOf(design).Key) && design.Price > 0)
.Where(design => !LoadoutGenerator.HasHome(design, hulls))
.Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToArray();
Assert.Equal(new[] { "CShot RainbowLite Lazer", "ChargeBlast SG", "ChargeBlast+-", "OK Disperser" }, homeless);
Assert.Equal(new[] { "Charged Blaster", "Charged Scatter Blaster", "Heat Pump", "Heavy Beam Laser" }, homeless);
}
// A sold weapon can be generated onto an NPC, whose combat state samples its damage at range, so every weapon a

View file

@ -537,7 +537,7 @@ public sealed partial class RunStartTests
runs.Add(Stage(Arena(false), new Scripted(false, stage =>
{
var ship = stage.Player(stage.Fit("Djinni", ("Census Fitted", int2(1, 6), ItemRotation.None), ("Store-All Plus", int2(3, 6), ItemRotation.None)), float2(0, 0));
var ship = stage.Player(stage.Fit("Djinni", ("Census Fitted", int2(1, 6), ItemRotation.None), ("Cargo Bay", int2(3, 6), ItemRotation.None)), float2(0, 0));
stage.Cargo(ship, "Census Carried");
})));
Assert.Empty(UnsoldAndUnplaced(runs));
@ -580,4 +580,19 @@ public sealed partial class RunStartTests
Assert.Equal("<Keyboard>/1", Assert.Single(settings.InputSettings.ActionBarInputs));
Assert.Equal((Quality.High, true), (settings.GraphicsSettings.NebulaQuality, settings.GraphicsSettings.ShowAsteroidsInMinimap));
}
// A scenario's Brief is player text (the menu shows it): it names gear by what the catalog sells today. A name a rename retired,
// that no design or product carries any more, would send the player looking for gear that is gone.
[Fact]
public void NoScenarioBriefNamesRetiredGear()
{
var current = _cache.GetAll<EquippableItemData>().Select(design => design.Name)
.Concat(_cache.GetAll<FactionProductData>().Select(product => product.Name)).ToHashSet(StringComparer.Ordinal);
var stale = new List<string>();
foreach (var scenario in EveryScenario)
foreach (var retired in Program.Renames.Select(row => row.Old).Where(name => !current.Contains(name)))
if (System.Text.RegularExpressions.Regex.IsMatch(scenario.Brief, @"(?<!\w)" + System.Text.RegularExpressions.Regex.Escape(retired) + @"s?(?!\w)"))
stale.Add(scenario.Name + " names " + retired);
Assert.True(stale.Count == 0, "scenario briefs naming gear the catalog no longer has: " + string.Join("; ", stale));
}
}

View file

@ -454,7 +454,7 @@ public sealed class SignatureTests : IDisposable
var view = r.Ship.VisibilitySources;
Assert.False(view is Dictionary<object, Signature>);
Assert.True(ReferenceEquals(view, r.Ship.VisibilitySources), "one view object per entity"); // not Assert.Same: its failure message formats the entity graph and aborts the test host
Assert.Throws<NotSupportedException>(() => ((IDictionary<object, Signature>) view)[r.Ship] = new Signature(SignatureChannel.Thermal, 1f, null));
Assert.Throws<NotSupportedException>(() => ((IDictionary<object, Signature>) view)[r.Ship] = new Signature(SignatureChannel.Thermal, 1f, null, null));
}
// ---- APlumeKeepsTheLargerBurn ----

View file

@ -37,7 +37,7 @@ public sealed class SmokeHullCommandTests : IDisposable
var hulls = db.Cache.GetAll<HullData>().Where(h => h.Name == HullName).ToArray();
var keys = hulls.Select(h => db.Cache.RefOf(h).Key).ToArray();
var products = db.Cache.GetAll<FactionProductData>().Count(p => keys.Any(k => p.Design.Key.Equals(k)));
var fitted = new[] { "ChargeBlast SG", "Autocannon" }
var fitted = new[] { "Charged Scatter Blaster", "Autocannon" }
.Select(name => db.Cache.GetAll<EquippableItemData>().Single(d => d.Name == name)).ToArray();
return (hulls, db.Cache.GetAll<HullData>().Single(h => h.Name == "Longinus"), fitted, products);
}

View file

@ -12,7 +12,7 @@ public sealed partial class RunStartTests
private static List<EquippedItem> WeaponMounts(Entity ship) =>
ship.Equipment.Where(item => item.Behaviors.OfType<Weapon>().Any()).ToList();
// Weapon Feel: the player flies the Smoke Test Hull with ChargeBlast SG on the 2x2 Energy hardpoint, Autocannon
// Weapon Feel: the player flies the Smoke Test Hull with Charged Scatter Blaster on the 2x2 Energy hardpoint, Autocannon
// on the 2x2 Ballistic hardpoint and a burst weapon; the bare hull ahead lies in every mount's arc.
[Fact]
public void WeaponFeelConditions()
@ -23,7 +23,7 @@ public sealed partial class RunStartTests
Assert.Equal("Smoke Test Hull", _items.GetData(player.EquippedHull.EquippableItem).Name);
EquippedItem Mount(string design) => Assert.Single(WeaponMounts(player), item => _items.GetData(item.EquippableItem).Name == design);
var charge = Mount("ChargeBlast SG");
var charge = Mount("Charged Scatter Blaster");
var autocannon = Mount("Autocannon");
Assert.Equal((HardpointType.Energy, 2, 2), (charge.Hardpoint.Type, charge.Hardpoint.Shape.Width, charge.Hardpoint.Shape.Height));
Assert.Equal((HardpointType.Ballistic, 2, 2), (autocannon.Hardpoint.Type, autocannon.Hardpoint.Shape.Width, autocannon.Hardpoint.Shape.Height));

View file

@ -43,7 +43,7 @@ public sealed class SmokeRoundsCommandTests : IDisposable
public void Dispose() => Directory.Delete(_root, true);
private (WeaponItemData[] Smoke, WeaponItemData Spectra, WeaponItemData ChargeBlast, int Products) Read()
private (WeaponItemData[] Smoke, WeaponItemData LightLaser, WeaponItemData ChargedScatterBlaster, int Products) Read()
{
var db = AetherDb.Open(root: _root);
try
@ -51,7 +51,7 @@ public sealed class SmokeRoundsCommandTests : IDisposable
var smoke = db.Cache.GetAll<WeaponItemData>().Where(w => Names.Contains(w.Name)).ToArray();
var keys = smoke.Select(w => db.Cache.RefOf(w).Key).ToArray();
var products = db.Cache.GetAll<FactionProductData>().Count(p => keys.Any(k => p.Design.Key.Equals(k)));
return (smoke, db.Cache.GetAll<WeaponItemData>().Single(w => w.Name == "Spectra"), db.Cache.GetAll<WeaponItemData>().Single(w => w.Name == "ChargeBlast SG"), products);
return (smoke, db.Cache.GetAll<WeaponItemData>().Single(w => w.Name == "Light Laser"), db.Cache.GetAll<WeaponItemData>().Single(w => w.Name == "Charged Scatter Blaster"), products);
}
finally { db.Cache.Dispose(); }
}
@ -85,7 +85,7 @@ public sealed class SmokeRoundsCommandTests : IDisposable
Assert.Equal((WeaponFuse.Delayed, 4f, 2f, 2f), (Design("Smoke Delayed").Fuse.Value, Design("Smoke Delayed").BlastRadius.Value, Data("Smoke Delayed").Penetration.Min, Data("Smoke Delayed").Penetration.Max));
Assert.Equal((WeaponFuse.Proximity, 30f, 20f, 20f), (Design("Smoke Refused").Fuse.Value, Design("Smoke Refused").BlastRadius.Value, Data("Smoke Refused").Range.Min, Data("Smoke Refused").Range.Max));
// Spectra clones are energy, 1x2, with the Spectra's own gear; the charged one is a ChargeBlast SG clone on its 2x2 energy mount.
// Light Laser clones are energy, 1x2, with the Light Laser's own gear; the charged one is a Charged Scatter Blaster clone on its 2x2 energy mount.
foreach (var name in new[] { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam" })
Assert.Equal((spectra.Hardpoint, spectra.Shape.Width, spectra.Shape.Height, spectra.Mass), (Design(name).Hardpoint, Design(name).Shape.Width, Design(name).Shape.Height, Design(name).Mass));
Assert.Equal((chargeBlast.Hardpoint, chargeBlast.Shape.Width, chargeBlast.Shape.Height), (Design("Smoke Refused Charge").Hardpoint, 2, 2));

View file

@ -34,7 +34,7 @@ public sealed partial class RunStartTests
var proximity = Carried(player, "Smoke Proximity");
var delayed = Carried(player, "Smoke Delayed");
var direct = Carried(player, "DeathCluster");
var direct = Carried(player, "Cluster Cannon");
Assert.Null(player.Target.Value.Entity);
// The bare hull is on the initial aim line, ahead, and inside every weapon mount's arc.

View file

@ -20,11 +20,11 @@ public sealed partial class RunStartTests
return (arena, staged);
}
// Fused Rounds, nothing selected, the player aiming down the bare hull ahead. The DeathCluster is the control: it fires
// Fused Rounds, nothing selected, the player aiming down the bare hull ahead. The Cluster Cannon is the control: it fires
// in this arena, so a fused weapon that does not is not a harness that cannot fire. A fused round flies slowly enough
// to be in the air when the direct weapon kills the fragile hull (check A.3) and bursts on the first hull on the aim line.
[Theory]
[InlineData("DeathCluster")]
[InlineData("Cluster Cannon")]
[InlineData("Smoke Proximity")]
[InlineData("Smoke Delayed")]
public void EveryWeaponAboardFiresInTheSteppedFusedRoundsArena(string design)
@ -42,7 +42,7 @@ public sealed partial class RunStartTests
for (var i = 0; i < 60; i++) arena.Update(SteppedDt);
Assert.True(fired.Count > 0, $"{design} fired nothing in 3 s; the ship's reactor is {(player.PowerBus.TotalGeneration > 0f ? "running" : "down")}");
if (design == "DeathCluster") return;
if (design == "Cluster Cannon") return;
var (at, burst) = committed.First();
Assert.Equal(ShotResult.Burst, burst.Result);
Assert.True(burst.HasBurstPoint);
@ -51,7 +51,7 @@ public sealed partial class RunStartTests
Assert.InRange(at + burst.ArrivalIn - fired[0], 1f, 5f); // in the air for seconds: not resolved in its firing tick, not minutes of flight
}
// Check A.3 flown: the fragile hull is selected, a Smoke Proximity round is fired at it and the DeathCluster kills it
// Check A.3 flown: the fragile hull is selected, a Smoke Proximity round is fired at it and the Cluster Cannon kills it
// before the round lands, so the round never bursts.
[Fact]
public void TheDirectWeaponKillsTheFragileHullWhileASmokeRoundIsStillInTheAir()
@ -71,7 +71,7 @@ public sealed partial class RunStartTests
});
WeaponOf(proximity).Activate();
WeaponOf(Carried(player, "DeathCluster")).Activate();
WeaponOf(Carried(player, "Cluster Cannon")).Activate();
float? deadAt = null;
for (var i = 0; i < 120 && landed == null; i++)
{

View file

@ -168,7 +168,7 @@ public sealed class TargetingSystemTests : IDisposable
Assert.False(observer.TrySelectTargetItem(weapon)); // nothing seen yet
Assert.Null(observer.TargetItem.Value);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
Assert.True(observer.TrySelectTargetItem(weapon));
Assert.Same(weapon, observer.TargetItem.Value);
Assert.Same(weapon, observer.ResolvedTargetItem);
@ -196,7 +196,7 @@ public sealed class TargetingSystemTests : IDisposable
var observer = BuildObserver(items, target.Zone);
observer.SetTarget(target);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
Assert.True(FireControl.IsRevealed(observer, weapon));
Assert.False(FireControl.IsRevealed(observer, wideItem));
@ -230,8 +230,8 @@ public sealed class TargetingSystemTests : IDisposable
var (items, target, weapon, wideItem, _, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.SetTarget(target);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, wideItem, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
observer.RecordReveal(target, wideItem, SignatureChannel.LaunchFlash, wideItem.Data);
Assert.True(observer.TrySelectTargetItem(weapon));
weapon.EquippableItem.Durability = 0f;
@ -250,7 +250,7 @@ public sealed class TargetingSystemTests : IDisposable
var (items, target, weapon, _, _, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.SetTarget(target);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
Assert.True(FireControl.IsRevealed(observer, weapon));
target.Deactivate(); // an active ship refuses to unequip
@ -269,7 +269,7 @@ public sealed class TargetingSystemTests : IDisposable
var (items, target, weapon, _, _, _) = BuildTarget(TestSettings());
var observer = BuildObserver(items, target.Zone);
observer.EntityInfoGathered[target] = .5f; // below TargetGearInfoThreshold (.9)
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash);
observer.RecordReveal(target, weapon, SignatureChannel.LaunchFlash, weapon.Data);
Assert.True(FireControl.IsRevealed(observer, weapon));
weapon.EquippableItem.Durability = 0f;

View file

@ -14,7 +14,7 @@ using float3 = CultMath.float3;
// Flight control allocates thrust (ruling flight-control-allocates-thrust): Ship.Update hands the intent and every
// thruster's live column to one ThrustAllocator, and a ship flies on what that answers. These tests fly the Duel's
// Longinus (two mismatched Large Drives forward, a Talaria on each flank) through the real Ship.Update; a test that reads
// Longinus (two mismatched Large Drives forward, a Rugged Drive on each flank) through the real Ship.Update; a test that reads
// velocity steps the arena, because the zone places the orbits that Ship.Update alone leaves stacked at the spawn.
public sealed partial class RunStartTests
{
@ -307,7 +307,7 @@ public sealed partial class RunStartTests
// Rulings evasion-is-unpredictability and turn-authority-full: the Longinus's Large Drives count toward its turn,
// because the envelope is summed over every live column. Its clockwise is the sum of the positive yaw of its columns
// at live thrust, and exceeds the Talaria's own by the drives'.
// at live thrust, and exceeds the Rugged Drive's own by the drives'.
[Fact]
public void TheEnvelopeCountsTheMains()
{
@ -317,7 +317,7 @@ public sealed partial class RunStartTests
for (var tick = 0; tick < 10; tick++) player.Update(AllocDt);
var thrusters = player.GetBehaviors<Thruster>().Where(t => t.Item.Active.Value).ToArray();
var drives = thrusters.Where(t => t.Item.Data.Name == "Large Drive").ToArray();
var flanks = thrusters.Where(t => t.Item.Data.Name == "Talaria").ToArray();
var flanks = thrusters.Where(t => t.Item.Data.Name == "Rugged Drive").ToArray();
Assert.Equal(2, drives.Length);
Assert.Equal(2, flanks.Length);
float Clockwise(IEnumerable<Thruster> of) => of.Sum(t => max(t.Column(t.Thrust).z, 0f));

View file

@ -18,13 +18,13 @@ public class ThrustAllocatorTests
static float3 Col(float starboard, float forward, float yawDeg) => new(starboard, forward, yawDeg * Deg);
// TA3, the Duel Longinus: two mismatched drives (0, 1) and two Talarias (2 clockwise, 3 counter-clockwise).
// TA3, the Duel Longinus: two mismatched drives (0, 1) and two Rugged Drives (2 clockwise, 3 counter-clockwise).
static float3[] Duel() => new[]
{
Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), Col(29.08f, 0, 166.26f), Col(-21.80f, 0, -124.63f),
};
// The clockwise Talaria lost.
// The clockwise Rugged Drive lost.
static float3[] DamagedDuel() => new[]
{
Col(0, 53.94f, 49.24f), Col(0, 61.19f, -55.86f), default, Col(-21.80f, 0, -124.63f),
@ -137,7 +137,7 @@ public class ThrustAllocatorTests
[Fact]
public void TheOnsetMovesWithTheColumns()
{
// Clockwise Talaria lost: the drive serves a clockwise turn from the first touch.
// Clockwise Rugged Drive lost: the drive serves a clockwise turn from the first touch.
var damaged = DamagedDuel();
var small = Solve(damaged, 0, 0, .01f);
Assert.True(small[0] > 0, $"drive {small[0]}");
@ -145,7 +145,7 @@ public class ThrustAllocatorTests
var full = Solve(damaged, 0, 0, 1f);
Assert.True(MathF.Abs(YawDeg(damaged, full) - 49.24f) <= 49.24f * 1e-3f, $"yaw {YawDeg(damaged, full)}");
// Clockwise Talaria halved: the onset is its new share of the clockwise extreme.
// Clockwise Rugged Drive halved: the onset is its new share of the clockwise extreme.
var halved = Duel();
halved[2] = halved[2] * .5f;
var onset = halved[2].z / Extremes(halved, 2).plus;