Aetheria/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs
soul cc0c164574 determinism-ledger: every ItemManager states its seed; scatter on the zone's dice
- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it.
- RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a
  zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed.
- Continue seeds the item stream from the saved run's seed (ContinueStep);
  a scene with no galaxy uses NoRunSeed.
- Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream,
  1-based pack ordinal), never the item stream.
- Loadout's scratch manager and AetherDb's catalog command take fixed seeds;
  every test and Editor construction gains 1u (mechanical rewrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:01:41 +02:00

346 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using CultMath;
using Newtonsoft.Json;
using Xunit;
// Aetheria release, cut missile-stats (ruling missile-stats-now-gear-later): a missile type's stats are five gear-part
// behaviours on the launcher design. The shipped catalog is the oracle for what the migration copied and authored.
public sealed class MunitionPartsTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "munition-parts-" + Guid.NewGuid().ToString("N"));
private string Catalog => Path.Combine(_root, "Aetheria.cc");
public MunitionPartsTests() => Directory.CreateDirectory(_root);
public void Dispose() => Directory.Delete(_root, true);
// The flight stats of the five designs that carried LauncherData or GuidedWeaponData at b6262467 (Thrust, MissileVelocity
// and DodgeFrequency), read from that revision's catalog before the fields were retired. All five read role thruster on
// 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 =
{
("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 };
private static PerformanceStat Ranged(float min, float max, string role) => role == null ? Flat(max) : new PerformanceStat
{
Min = min, Max = max, Terms = new List<StatTerm> { new StatTerm { Source = StatSource.Quality, Exponent = 1.5f, Role = role } },
};
// The five parts a munition design carries, every field set to a distinct value so a field read from the wrong slot or
// dropped on the way is seen. The thruster's stats vary with the given role's quality when one is named; the design
// that carries them declares it. Fixtures that only need a valid munition pass none.
internal static List<BehaviorData> Parts(int group, string role = null) => new List<BehaviorData>
{
new MunitionHullData { Group = group, Durability = Flat(21), Radius = Flat(4.5f) },
new MunitionThrusterData { Group = group, Acceleration = Ranged(300, 400, role), Lateral = Ranged(150, 200, role), MaxSpeed = Ranged(250, 350, role), Visibility = Flat(11) },
new MunitionSeekerData { Group = group, Sensitivity = Flat(.8f), BeamWidth = Flat(55), Range = Flat(900) },
new MunitionControlData { Group = group, Navigation = Flat(3.5f), Evasion = Flat(.4f), EvasionFrequency = Flat(.6f), Lifetime = Flat(12), Cognition = Flat(.35f) },
new MunitionFuelData { Group = group, DeltaV = Flat(700) },
};
private static string Describe(PerformanceStat stat) =>
$"{stat.Min}..{stat.Max} [{string.Join(",", stat.Terms.Select(t => $"{t.Source}^{t.Exponent}@{t.Role}"))}]";
private static string[] Describe(BehaviorData part) =>
part.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat))
.Select(f => $"{part.GetType().Name}.{f.Name} {Describe((PerformanceStat) f.GetValue(part))}").Prepend($"{part.GetType().Name} group {part.Group}").ToArray();
private static WeaponItemData Munition(string name, BehaviorData launcher, int group = 0)
{
launcher.Group = group;
var design = new WeaponItemData { Name = name, Roles = new List<ItemRole> { new ItemRole { Name = "motor" } } };
design.Behaviors.Add(launcher);
design.Behaviors.AddRange(Parts(group, "motor"));
return design;
}
private static T Part<T>(EquippableItemData design) where T : MunitionPartData => design.Behaviors.OfType<T>().Single();
[Fact]
public void MunitionPartsRoundTrip()
{
var designs = new[] { Munition("Round A", new LauncherData(), 2), Munition("Round B", new GuidedWeaponData(), 3) };
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
{
cache.Upsert(new VerseGrammar { Revision = 1 });
foreach (var design in designs) cache.Upsert(design);
cache.FlushAsync().Wait();
}
using var reopened = AetheriaStores.Open(Catalog);
foreach (var design in designs)
{
var stored = reopened.GetByName<WeaponItemData>(design.Name);
var packed = stored.Behaviors.OfType<MunitionPartData>().ToArray();
Assert.Equal(new[] { typeof(MunitionHullData), typeof(MunitionThrusterData), typeof(MunitionSeekerData), typeof(MunitionControlData), typeof(MunitionFuelData) },
packed.Select(p => p.GetType()));
var expected = design.Behaviors.OfType<MunitionPartData>().ToArray();
for (var i = 0; i < expected.Length; i++)
{
Assert.Equal(Describe(expected[i]), Describe(packed[i]));
var json = (MunitionPartData) JsonConvert.DeserializeObject(JsonConvert.SerializeObject(expected[i]), expected[i].GetType());
Assert.Equal(Describe(expected[i]), Describe(json));
}
}
}
[Fact]
public void MigrationCopiesTheFlightStats()
{
using var cache = RestoredHullsTests.OpenCatalog();
foreach (var (name, thrustMin, thrustMax, speedMin, speedMax, dodge) in Golden)
{
var design = cache.GetByName<WeaponItemData>(name);
var thruster = Part<MunitionThrusterData>(design);
Assert.Equal((thrustMin, thrustMax), (thruster.Acceleration.Min, thruster.Acceleration.Max));
Assert.Equal((thrustMin / 2f, thrustMax / 2f), (thruster.Lateral.Min, thruster.Lateral.Max));
Assert.Equal((speedMin, speedMax), (thruster.MaxSpeed.Min, thruster.MaxSpeed.Max));
// The terms come across whole: the role's Quality term and whatever heat or durability terms the stat carried.
Assert.Equal(Describe(thruster.Acceleration).Split('[')[1], Describe(thruster.Lateral).Split('[')[1]);
foreach (var stat in new[] { thruster.Acceleration, thruster.Lateral, thruster.MaxSpeed })
Assert.Equal("thruster", Assert.Single(stat.Terms, t => t.Source == StatSource.Quality).Role);
var evasion = Part<MunitionControlData>(design).EvasionFrequency;
Assert.Equal((dodge, dodge), (evasion.Min, evasion.Max));
Assert.Empty(evasion.Terms);
}
}
[Fact]
public void EveryLauncherCarriesAuthoredParts()
{
using var cache = RestoredHullsTests.OpenCatalog();
var munitions = cache.GetAll<EquippableItemData>()
.Where(d => d.Behaviors.Any(b => b is LauncherData || b is GuidedWeaponData)).ToArray();
Assert.Equal(Golden.Select(g => g.Design).OrderBy(n => n, StringComparer.Ordinal), munitions.Select(m => m.Name).OrderBy(n => n, StringComparer.Ordinal));
foreach (var design in munitions)
{
var parts = design.Behaviors.OfType<MunitionPartData>().Select(p => p.GetType()).OrderBy(t => t.Name, StringComparer.Ordinal).ToArray();
Assert.Equal(new[] { typeof(MunitionControlData), typeof(MunitionFuelData), typeof(MunitionHullData), typeof(MunitionSeekerData), typeof(MunitionThrusterData) }, parts);
var weapon = design.Behaviors.OfType<WeaponData>().Single();
var hull = Part<MunitionHullData>(design);
var thruster = Part<MunitionThrusterData>(design);
var seeker = Part<MunitionSeekerData>(design);
var control = Part<MunitionControlData>(design);
var fuel = Part<MunitionFuelData>(design);
foreach (var (label, stat) in new[]
{
("Durability", hull.Durability), ("Radius", hull.Radius), ("Acceleration", thruster.Acceleration), ("Lateral", thruster.Lateral),
("MaxSpeed", thruster.MaxSpeed), ("Visibility", thruster.Visibility), ("Sensitivity", seeker.Sensitivity), ("BeamWidth", seeker.BeamWidth),
("Seeker Range", seeker.Range), ("Navigation", control.Navigation), ("Lifetime", control.Lifetime), ("DeltaV", fuel.DeltaV),
})
Assert.True(stat.Min > 0f && stat.Max > 0f, $"{design.Name}: {label} is not above zero");
Assert.True(control.Cognition.Min > 0f && control.Cognition.Max <= 1f, $"{design.Name}: Cognition is not in (0, 1]");
var expected = 1.5f * weapon.Range.Max;
Assert.InRange(control.Lifetime.Max * thruster.MaxSpeed.Max, .99f * expected, 1.01f * expected);
}
}
[Fact]
public void ValidationRefusesIncompleteMunitions()
{
CultRecordRefs.Validate(Munition("Complete", new LauncherData()));
// Each of the five parts is required, on either launcher behaviour: a design missing it is refused naming the design
// and the part.
InvalidOperationException refusal;
foreach (var part in AllParts)
foreach (var launcher in new Func<BehaviorData>[] { () => new LauncherData(), () => new GuidedWeaponData() })
{
var missing = Munition("No " + part.Name, launcher());
missing.Behaviors.RemoveAll(b => b.GetType() == part);
refusal = Assert.Throws<InvalidOperationException>(() => CultRecordRefs.Validate(missing));
Assert.Contains(missing.Name, refusal.Message);
Assert.Contains(part.Name, refusal.Message);
}
var doubled = Munition("Two Seekers", new GuidedWeaponData());
doubled.Behaviors.Add(new MunitionSeekerData());
refusal = Assert.Throws<InvalidOperationException>(() => CultRecordRefs.Validate(doubled));
Assert.Contains("Two Seekers", refusal.Message);
Assert.Contains(nameof(MunitionSeekerData), refusal.Message);
var stray = new WeaponItemData { Name = "Stray Part" };
stray.Behaviors.Add(new MunitionHullData());
refusal = Assert.Throws<InvalidOperationException>(() => CultRecordRefs.Validate(stray));
Assert.Contains("Stray Part", refusal.Message);
Assert.Contains(nameof(MunitionHullData), refusal.Message);
// A consumable is a design too, and carries no munition part.
var potion = new ConsumableItemData { Name = "Stray Potion" };
potion.Behaviors.Add(new MunitionFuelData());
refusal = Assert.Throws<InvalidOperationException>(() => CultRecordRefs.Validate(potion));
Assert.Contains("Stray Potion", refusal.Message);
Assert.Contains(nameof(MunitionFuelData), refusal.Message);
}
private static readonly Type[] AllParts =
{ typeof(MunitionHullData), typeof(MunitionThrusterData), typeof(MunitionSeekerData), typeof(MunitionControlData), typeof(MunitionFuelData) };
// A catalog file holding the shipped catalog with one design edited around validation (the way a hand-edited file or a
// bare-cache tool writes), for the production load path to meet.
private string Forged<T>(Func<T, bool> pick, Action<T> edit) where T : ItemData
{
var path = Path.Combine(_root, "forged-" + Guid.NewGuid().ToString("N") + ".cc");
File.Copy(TestCatalog.Repo, path);
var cache = new CultCache(TestCatalog.Registry());
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, false), AetheriaStores.CatalogTypes);
var design = cache.GetAll<T>().First(pick);
edit(design);
var key = cache.RefOf(design).Key;
cache.Commit(batch => batch.Upsert(design.GetType(), design, key));
cache.FlushAsync().Wait();
cache.Dispose();
return path;
}
[Fact]
public void CatalogLoadRefusesAnIncompleteOrStrayMunitionPart()
{
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 == "Guided Missile Launcher", d => d.Behaviors.RemoveAll(b => b.GetType() == part));
var refusal = Assert.ThrowsAny<Exception>(() => AetheriaStores.Open(path, registry: TestCatalog.Registry()));
Assert.Contains("Guided Missile Launcher", refusal.ToString());
Assert.Contains(part.Name, refusal.ToString());
}
var gear = Forged<GearData>(d => d.Behaviors.Any(b => b is ThrusterData), d => d.Behaviors.Add(new MunitionHullData()));
Assert.Contains(nameof(MunitionHullData), Assert.ThrowsAny<Exception>(() => AetheriaStores.Open(gear, registry: TestCatalog.Registry())).ToString());
var consumable = Forged<ConsumableItemData>(_ => true, d => d.Behaviors.Add(new MunitionFuelData()));
Assert.Contains(nameof(MunitionFuelData), Assert.ThrowsAny<Exception>(() => AetheriaStores.Open(consumable, registry: TestCatalog.Registry())).ToString());
}
[Fact]
public void UpsertRefusesAnIncompleteOrStrayMunitionPart()
{
using var cache = AetheriaStores.Open(Catalog, catalogWritable: true);
cache.Upsert(new VerseGrammar { Revision = 1 });
cache.Upsert(Munition("Whole", new LauncherData()));
var incomplete = Munition("Incomplete", new GuidedWeaponData());
incomplete.Behaviors.RemoveAll(b => b is MunitionThrusterData);
Assert.Contains(nameof(MunitionThrusterData), Assert.Throws<InvalidOperationException>(() => cache.Upsert(incomplete)).Message);
Assert.DoesNotContain(cache.GetAll<WeaponItemData>(), d => d.Name == "Incomplete");
var potion = new ConsumableItemData { Name = "Stray Potion" };
potion.Behaviors.Add(new MunitionControlData());
Assert.Contains(nameof(MunitionControlData), Assert.Throws<InvalidOperationException>(() => cache.Upsert(potion)).Message);
}
// 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), ("Compact Reactor", 2, 4), ("Cockpit 2x2", 2, 6), ("Compact Sensor", 3, 10) };
// 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>("Guided Missile Launcher");
var hardpoint = hull.Hardpoints.First(h => h.Type == HardpointType.Launcher && h.IsFilledBy(gt));
var settings = RestoredHullsTests.Settings();
var ledger = new ProvenanceLedger();
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = .5f };
ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill> { new RoleFill { Role = "thruster", Quality = thrusterQuality } } };
var items = new ItemManager(1u, 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);
foreach (var (name, x, y) in ShipGear)
{
var gear = cache.GetByName<EquippableItemData>(name);
Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf<ItemData>(gear), Durability = gear.Durability, Lot = 1 }, new int2(x, y)), name);
}
Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf<ItemData>(gt), Durability = gt.Durability, Lot = 2 }, hardpoint.Position));
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 == "Guided Missile Launcher");
foreach (var cell in item.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = gt.OptimalTemperature;
item.UpdatePerformance();
item.UpdatePerformance();
return item;
}
[Fact]
public void PartsReadThroughTheItem()
{
using var cache = RestoredHullsTests.OpenCatalog();
var design = cache.GetByName<WeaponItemData>("Guided Missile Launcher");
var poor = Fit(cache, .2f, out var poorShip);
var good = Fit(cache, .9f, out _);
var part = MunitionParts.Of<MunitionThrusterData>(poor);
Assert.Same(Part<MunitionThrusterData>(design), part);
Assert.Same(part, MunitionParts.Of<MunitionThrusterData>(good));
var low = poor.Evaluate(part.Acceleration);
var high = good.Evaluate(part.Acceleration);
Assert.True(low < high, $"role quality .2 reads {low}, .9 reads {high}");
Assert.InRange(low, part.Acceleration.Min, part.Acceleration.Max);
Assert.InRange(high, part.Acceleration.Min, part.Acceleration.Max);
Assert.True(poor.Evaluate(part.MaxSpeed) < good.Evaluate(part.MaxSpeed));
Assert.DoesNotContain(poor.Behaviors, b => b is Thruster);
Assert.Equal(ShipGear.Count(g => cache.GetByName<EquippableItemData>(g.Name).Behaviors.Any(b => b is ThrusterData)), poorShip.GetBehaviors<Thruster>().Count());
}
private static string ShipSums(Ship s)
{
var e = s.Envelope;
var channels = string.Join(",", Enum.GetValues(typeof(SignatureChannel)).Cast<SignatureChannel>().Select(c => $"{c}={s.VisibilitySources.Values.Where(v => v.Channel == c).Sum(v => v.Strength):0.####}"));
var sensors = string.Join(",", s.GetBehaviors<Sensor>().Select(b => b.Data.GetType().Name));
return $"env F{e.Forward:0.###} R{e.Reverse:0.###} L{e.Left:0.###} Ri{e.Right:0.###} CW{e.Clockwise:0.###} CCW{e.CounterClockwise:0.###} " +
$"vis={s.Visibility:0.####} sources={s.VisibilitySources.Count} {channels} thrusters={s.GetBehaviors<Thruster>().Count()} thrust={s.GetBehaviors<Thruster>().Sum(t => t.Thrust):0.###} sensors=[{sensors}]";
}
private static void ScaleParts(EquippableItemData design, float factor)
{
foreach (var part in design.Behaviors.OfType<MunitionPartData>())
foreach (var field in part.GetType().GetFields().Where(f => f.FieldType == typeof(PerformanceStat)))
{
var stat = (PerformanceStat) field.GetValue(part);
stat.Min *= factor;
stat.Max *= factor;
}
}
// A munition's thruster and seeker are not the ship's: with real drives and a sensor aboard, the ship's envelope, plume,
// signatures, thrusters and sensors do not move when the fitted launcher's part stats are a thousand times larger, while
// the value the game reads from the part does.
[Fact]
public void ShipSumsIgnoreTheFittedMunitionParts()
{
using var cache = RestoredHullsTests.OpenCatalog();
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);
var shipBefore = ShipSums(ship);
var partBefore = item.Evaluate(part.Acceleration);
Assert.NotEmpty(ship.GetBehaviors<Thruster>());
Assert.NotEmpty(ship.GetBehaviors<Sensor>());
ScaleParts(design, 1000f);
try
{
var scaledItem = Fit(cache, .5f, out var scaledShip);
for (var i = 0; i < 5; i++) scaledShip.Update(.1f);
Assert.True(scaledItem.Evaluate(part.Acceleration) > partBefore * 100, "the scaled record must move the value the game reads");
Assert.Equal(shipBefore, ShipSums(scaledShip));
}
finally { ScaleParts(design, .001f); }
}
}