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:
commit
005e2fc57b
103 changed files with 5392 additions and 668 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)>
|
||||
|
|
|
|||
464
tests/Aetheria.Shared.Tests/ClaimTests.cs
Normal file
464
tests/Aetheria.Shared.Tests/ClaimTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
240
tests/Aetheria.Shared.Tests/GenericDesignsCommandTests.cs
Normal file
240
tests/Aetheria.Shared.Tests/GenericDesignsCommandTests.cs
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
376
tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs
Normal file
376
tests/Aetheria.Shared.Tests/LaunchSuppressorCatalogTests.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
171
tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs
Normal file
171
tests/Aetheria.Shared.Tests/LaunchSuppressorCommandTests.cs
Normal 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];
|
||||
}
|
||||
56
tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs
Normal file
56
tests/Aetheria.Shared.Tests/LockOnScenarioTests.cs
Normal 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()}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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++)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue