From 3c1e1e7421761168a7754b203f870e33fc8e09fd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:59:39 +0200 Subject: [PATCH 1/8] Munition parts: five gear-part behaviours carry a missile type's stats; completeness gate; one-shot migrate command MunitionHullData, MunitionThrusterData, MunitionSeekerData, MunitionControlData (with Cognition) and MunitionFuelData (Behaviors union 43-47), read through MunitionParts.Of. StatValidation.ValidateMunitionParts refuses a launcher design without exactly one of each and any other design carrying one, at catalog load and Upsert. AetherDb gains munition-parts-migrate; LauncherRoles names the part's thruster stats by type and field (Range and Visibility also name the seeker's and plume's flat stats, which RolesBackfill must not range). Does not pass tests on its own: the shipped catalog is migrated in the next commit, and until then it fails the new gate at load. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 1 + .../ServerShared/Behaviors/Behaviors.cs | 5 + .../ServerShared/Behaviors/MunitionParts.cs | 125 +++++++++++ .../Behaviors/MunitionParts.cs.meta | 11 + Assets/Scripts/ServerShared/ItemData.cs | 17 ++ .../MunitionPartsTests.cs | 209 ++++++++++++++++++ tools/AetherDb/Program.cs | 79 ++++++- 7 files changed, 443 insertions(+), 4 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs create mode 100644 Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/MunitionPartsTests.cs diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 05122fc86..31da02f26 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -80,6 +80,7 @@ public static class CultRecordRefs { StatValidation.ValidateHeatResponse(data); StatValidation.ValidateStatModifiers(data.Name, data.Behaviors); + StatValidation.ValidateMunitionParts(data.Name, data.Behaviors); StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors); } if (document is ConsumableItemData consumable) diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 1a12d4282..f1903fcb1 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -184,6 +184,11 @@ public interface IPowerConsumer Union(40, typeof(MineLayerData)), Union(41, typeof(ThrottleLockData)), Union(42, typeof(VapourDumpData)), + Union(43, typeof(MunitionHullData)), + Union(44, typeof(MunitionThrusterData)), + Union(45, typeof(MunitionSeekerData)), + Union(46, typeof(MunitionControlData)), + Union(47, typeof(MunitionFuelData)), JsonObject(MemberSerialization.OptIn)] public abstract class BehaviorData { diff --git a/Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs b/Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs new file mode 100644 index 000000000..d25cd4f41 --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs @@ -0,0 +1,125 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Linq; +using MessagePack; +using Newtonsoft.Json; + +// A missile type's stats, as five dedicated gear-part behaviours on the launcher's own design, one per gear +// category the entity cut will derive them from (ruling drones-and-munitions-are-entities: "dedicated gear so a +// missile thruster is not the same as a ship thruster"). Ruling missile-stats-now-gear-later, in the operator's +// words: "For now these stats will belong to the missile, later they will be derived from gear." Each part becomes +// a derivation from (or moves onto) that munition gear when munitions are entities. +// +// Each part is its own BehaviorData because every stat walker (role validation, StatModifier targeting, AetherDb +// roles, the trade panel) reads PerformanceStat fields one level deep. The parts are inert: nothing here executes, +// and no ship-wide reader (thrust allocator, sensing, signatures) reads them, because a missile thruster is not a +// ship thruster and a seeker is not a ship sensor. MunitionParts.Of is the one read path. +public abstract class MunitionPartData : BehaviorData +{ + public override Behavior CreateInstance(EquippedItem item) => new MunitionPart(this, item); + public override Behavior CreateInstance(ConsumableItemEffect consumable) => new MunitionPart(this, consumable); +} + +// What a part instantiates to: no state and no override, so Execute stays the default true. +public sealed class MunitionPart : Behavior +{ + public MunitionPart(MunitionPartData data, EquippedItem item) : base(data, item) { } + public MunitionPart(MunitionPartData data, ConsumableItemEffect consumable) : base(data, consumable) { } +} + +public static class MunitionParts +{ + // The design's one part of that type. Catalog load and Upsert refuse a munition without exactly one of each. + public static T Of(EquippedItem item) where T : MunitionPartData => + item.Behaviors.Select(b => b.Data).OfType().Single(); +} + +// The missile hull. +[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public sealed class MunitionHullData : MunitionPartData +{ + [Inspectable, JsonProperty("durability"), Key(1), RuntimeInspectable] + public PerformanceStat Durability = new PerformanceStat(); + + // Metres. + [Inspectable, JsonProperty("radius"), Key(2)] + public PerformanceStat Radius = new PerformanceStat(); +} + +// The missile thruster. +[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public sealed class MunitionThrusterData : MunitionPartData +{ + // The main motor, m/s^2. + [Inspectable, JsonProperty("acceleration"), Key(1), RuntimeInspectable] + public PerformanceStat Acceleration = new PerformanceStat(); + + // The sideways acceleration cap, m/s^2. + [Inspectable, JsonProperty("lateral"), Key(2)] + public PerformanceStat Lateral = new PerformanceStat(); + + // m/s. + [Inspectable, JsonProperty("maxSpeed"), Key(3), RuntimeInspectable] + public PerformanceStat MaxSpeed = new PerformanceStat(); + + // The strength of this motor's DrivePlume at full throttle, as ThrusterData.Visibility. A datum only: it is + // never registered as the launching ship's signature. + [Inspectable, JsonProperty("visibility"), Key(4)] + public PerformanceStat Visibility = new PerformanceStat(); +} + +// The seeker. Passive here: it reads a target and emits nothing. +[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public sealed class MunitionSeekerData : MunitionPartData +{ + // Detection power, as SensorData.Sensitivity. + [Inspectable, JsonProperty("sensitivity"), Key(1)] + public PerformanceStat Sensitivity = new PerformanceStat(); + + // The full cone, in degrees (ruling sensor-stat-set's name and unit). + [Inspectable, JsonProperty("beamWidth"), Key(2)] + public PerformanceStat BeamWidth = new PerformanceStat(); + + // Metres. + [Inspectable, JsonProperty("range"), Key(3)] + public PerformanceStat Range = new PerformanceStat(); +} + +// Guidance and its cognition. +[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public sealed class MunitionControlData : MunitionPartData +{ + // The proportional-navigation constant. + [Inspectable, JsonProperty("navigation"), Key(1)] + public PerformanceStat Navigation = new PerformanceStat(); + + // The share of Lateral spent jinking. + [Inspectable, JsonProperty("evasion"), Key(2)] + public PerformanceStat Evasion = new PerformanceStat(); + + // Hz. + [Inspectable, JsonProperty("evasionFrequency"), Key(3), RuntimeInspectable] + public PerformanceStat EvasionFrequency = new PerformanceStat(); + + // Seconds. + [Inspectable, JsonProperty("lifetime"), Key(4)] + public PerformanceStat Lifetime = new PerformanceStat(); + + // The missile's own skill, 0 to 1 (ruling missile-cognition: "a smarter missile can better spend its delta v"). + // Four uses: guidance efficiency, when to jink against point defense, how much fuel it keeps for the terminal, + // and the price of the hit. + [Inspectable, JsonProperty("cognition"), Key(5)] + public PerformanceStat Cognition = new PerformanceStat(); +} + +// The tank. +[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public sealed class MunitionFuelData : MunitionPartData +{ + // Total manoeuvre budget, m/s. + [Inspectable, JsonProperty("deltaV"), Key(1)] + public PerformanceStat DeltaV = new PerformanceStat(); +} diff --git a/Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs.meta b/Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs.meta new file mode 100644 index 000000000..cf0a1097e --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/MunitionParts.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6d4eac117ca042aaba71d1f6491b4617 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index ef1b3f46f..da2c63b0e 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -815,6 +815,23 @@ public static class StatValidation } } + // Ruling missile-stats-now-gear-later: a munition design (a LauncherData or GuidedWeaponData behaviour) carries exactly + // one of each of the five munition parts, and any other design carries none. Runs wherever ValidateStatModifiers runs, + // so a launcher missing a part is named at catalog load and Upsert rather than at its first launch. + public static void ValidateMunitionParts(string ownerName, IEnumerable behaviors) + { + var all = (behaviors ?? Enumerable.Empty()).Where(b => b != null).ToArray(); + var isMunition = all.Any(b => b is LauncherData || b is GuidedWeaponData); + foreach (var part in new[] { typeof(MunitionHullData), typeof(MunitionThrusterData), typeof(MunitionSeekerData), typeof(MunitionControlData), typeof(MunitionFuelData) }) + { + var count = all.Count(b => b.GetType() == part); + var expected = isMunition ? 1 : 0; + if (count != expected) + throw new InvalidOperationException( + $"{ownerName}: carries {count} {part.Name}, but a {(isMunition ? "munition" : "non-munition")} design carries {expected}"); + } + } + // Cut 7 (docs/stats-and-power-cut.md): "a stat naming a role its design lacks" must be refused, not silently // fall back to the lot's own workmanship (Lot.QualityForRole already falls back for a role a lot never rolled // -- that is the intended forward-compat path for an old lot meeting a newly-authored role. This is the other diff --git a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs new file mode 100644 index 000000000..1e3de8fd2 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching; +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 = + { + ("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), + }; + + private static PerformanceStat Flat(float value) => new PerformanceStat { Min = value, Max = value }; + + private static PerformanceStat Ranged(float min, float max, string role) => new PerformanceStat + { + Min = min, Max = max, Terms = new List { new StatTerm { Source = StatSource.Quality, Exponent = 1.5f, Role = role } }, + }; + + // Every field set to a distinct value, so a field read from the wrong slot or dropped on the way is seen. + private static List Parts(int group) => new List + { + new MunitionHullData { Group = group, Durability = Flat(21), Radius = Flat(4.5f) }, + new MunitionThrusterData { Group = group, Acceleration = Ranged(300, 400, "motor"), Lateral = Ranged(150, 200, "motor"), MaxSpeed = Ranged(250, 350, "motor"), 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 { new ItemRole { Name = "motor" } } }; + design.Behaviors.Add(launcher); + design.Behaviors.AddRange(Parts(group)); + return design; + } + + private static T Part(EquippableItemData design) where T : MunitionPartData => design.Behaviors.OfType().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(design.Name); + var packed = stored.Behaviors.OfType().ToArray(); + Assert.Equal(new[] { typeof(MunitionHullData), typeof(MunitionThrusterData), typeof(MunitionSeekerData), typeof(MunitionControlData), typeof(MunitionFuelData) }, + packed.Select(p => p.GetType())); + var expected = design.Behaviors.OfType().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(name); + var thruster = Part(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)); + foreach (var stat in new[] { thruster.Acceleration, thruster.Lateral, thruster.MaxSpeed }) + { + var term = Assert.Single(stat.Terms); + Assert.Equal((StatSource.Quality, "thruster"), (term.Source, term.Role)); + } + Assert.Equal(thruster.Acceleration.Terms.Single().Exponent, thruster.Lateral.Terms.Single().Exponent); + + var evasion = Part(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() + .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().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().Single(); + var hull = Part(design); + var thruster = Part(design); + var seeker = Part(design); + var control = Part(design); + var fuel = Part(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())); + + var missing = Munition("No Tank", new LauncherData()); + missing.Behaviors.RemoveAll(b => b is MunitionFuelData); + var refusal = Assert.Throws(() => CultRecordRefs.Validate(missing)); + Assert.Contains("No Tank", refusal.Message); + Assert.Contains(nameof(MunitionFuelData), refusal.Message); + + var doubled = Munition("Two Seekers", new GuidedWeaponData()); + doubled.Behaviors.Add(new MunitionSeekerData()); + refusal = Assert.Throws(() => 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(() => CultRecordRefs.Validate(stray)); + Assert.Contains("Stray Part", refusal.Message); + Assert.Contains(nameof(MunitionHullData), refusal.Message); + } + + // A GT 3K fitted to a Longinus launcher hardpoint, its lot's thruster role at the given quality. + private static EquippedItem Fit(CultCache cache, float thrusterQuality, out Ship ship) + { + var hull = cache.GetByName("Longinus"); + var gt = cache.GetByName("GT 3K"); + 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 { new RoleFill { Role = "thruster", Quality = thrusterQuality } } }; + 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(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings); + Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf(gt), Durability = gt.Durability, Lot = 2 }, hardpoint.Position)); + return ship.Equipment.Single(e => e.Data.Name == "GT 3K"); + } + + [Fact] + public void PartsReadThroughTheItem() + { + using var cache = RestoredHullsTests.OpenCatalog(); + var design = cache.GetByName("GT 3K"); + var poor = Fit(cache, .2f, out var poorShip); + var good = Fit(cache, .9f, out _); + + var part = MunitionParts.Of(poor); + Assert.Same(Part(design), part); + Assert.Same(part, MunitionParts.Of(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.Empty(poorShip.GetBehaviors()); + Assert.DoesNotContain(poor.Behaviors, b => b is Thruster); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index b24161383..2a1e9188b 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -49,8 +49,9 @@ public static class Program Console.OutputEncoding = new System.Text.UTF8Encoding(false); return PiratesFaction(args.Contains("apply")); case "verse-grammar": return VerseGrammarCommand.Run(args.Contains("apply")); + case "munition-parts-migrate": return MunitionPartsMigrate(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply], munition-parts-migrate [apply]"); return 1; } } @@ -168,9 +169,18 @@ public static class Program ["MinRange"] = "guidance system", ["Cooldown"] = "guidance system", ["Spread"] = "guidance system", ["Visibility"] = "guidance system", ["Energy"] = "guidance system", ["Heat"] = "guidance system", - ["Thrust"] = "thruster", ["MissileVelocity"] = "thruster", ["Velocity"] = "thruster", + ["Velocity"] = "thruster", + // The munition thruster part's own stats. A part's fields are keyed by type and field (RoleKey), because Range + // and Visibility also name the seeker's and plume's flat stats, which this map must not range. + ["MunitionThrusterData.Acceleration"] = "thruster", ["MunitionThrusterData.Lateral"] = "thruster", + ["MunitionThrusterData.MaxSpeed"] = "thruster", }; + // How a role map names a behaviour's stat: a munition part's fields by type and field (several parts share a field name + // with the launcher's own stats), every other behaviour's by field. + private static string RoleKey(BehaviorData behavior, string field) => + behavior is MunitionPartData ? $"{behavior.GetType().Name}.{field}" : field; + // Warhead and dispenser: a mine layer is a launcher without guidance or thrust, so its stats split between the // charge it lays and the mechanism that lays it. ArmingDelay, FuseDelay and Lifetime are timings, not stats. private static readonly Dictionary MineLayerRoles = new Dictionary @@ -308,7 +318,7 @@ public static class Program if (stat.Min == stat.Max) continue; // flat: no observable role-vs-workmanship difference var qualityTerm = stat.Terms.FirstOrDefault(t => t.Source == StatSource.Quality); if (qualityTerm == null || !string.IsNullOrEmpty(qualityTerm.Role)) continue; - if (!map.TryGetValue(field.Name, out var role)) continue; + if (!map.TryGetValue(RoleKey(behavior, field.Name), out var role)) continue; qualityTerm.Role = role; rolesUsed.Add(role); assigned.Add((behavior.GetType().Name, field.Name, role)); @@ -1844,7 +1854,7 @@ public static class Program { var stat = field.GetValue(behavior) as PerformanceStat; if (stat == null) continue; - if (!map.TryGetValue(field.Name, out var role)) continue; + if (!map.TryGetValue(RoleKey(behavior, field.Name), out var role)) continue; stat.Terms ??= new List(); var quality = stat.Terms.FirstOrDefault(t => t.Source == StatSource.Quality); var value = stat.Min; @@ -2148,4 +2158,65 @@ public static class Program Console.WriteLine($"\nAuthored 2 designs and {products.Length} products in Aetheria.cc"); return 0; } + + // Aetheria release, cut missile-stats (ruling missile-stats-now-gear-later): a launcher design's flight stats move off + // LauncherData and GuidedWeaponData onto five munition part behaviours at the launcher's Group. One-shot: it reads the three + // fields the same commit's second half deletes, and goes with them. Copied with their terms and role: Acceleration from + // Thrust, MaxSpeed from MissileVelocity, Lateral from Thrust at half Min and Max; EvasionFrequency is DodgeFrequency, flat. + // Everything else is authored flat from the design (Sensitivity and Visibility are placeholders the records cut calibrates). + // Dry run unless passed "apply"; a design that already carries its parts is left alone. + private static int MunitionPartsMigrate(bool apply) + { + // Opened without AetherDb.Open's validation: the catalog this command repairs fails ValidateMunitionParts until it runs. + var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); + PerformanceStat Copy(PerformanceStat stat, float scale = 1f) => new PerformanceStat + { + Min = stat.Min * scale, Max = stat.Max * scale, + Terms = (stat.Terms ?? new List()).Select(t => new StatTerm { Source = t.Source, Exponent = t.Exponent, Role = t.Role }).ToList(), + }; + PerformanceStat Flat(float value) => new PerformanceStat { Min = value, Max = value, Terms = new List() }; + + var changed = new List<(EquippableItemData Document, CultRecordKey Key)>(); + Console.WriteLine($"{"design",-22} {"data",-17} {"Thrust",-12} {"MissileVelocity",-12} {"Dodge",6} {"Range",-12} parts"); + foreach (var design in cache.GetAll().OrderBy(d => d.Name, StringComparer.Ordinal)) + { + var launcher = (design.Behaviors ?? new List()).FirstOrDefault(b => b is LauncherData || b is GuidedWeaponData); + if (launcher == null) continue; + var (thrust, speed, dodge) = launcher is LauncherData l + ? (l.Thrust, l.MissileVelocity, l.DodgeFrequency) + : (((GuidedWeaponData) launcher).Thrust, ((GuidedWeaponData) launcher).MissileVelocity, ((GuidedWeaponData) launcher).DodgeFrequency); + var range = ((WeaponData) launcher).Range; + Console.WriteLine($"{design.Name,-22} {launcher.GetType().Name,-17} {thrust.Min,5:0.##}-{thrust.Max,-6:0.##} {speed.Min,5:0.##}-{speed.Max,-6:0.##} {dodge,6:0.##} {range.Min,5:0.##}-{range.Max,-6:0.##} " + + string.Join(",", design.Behaviors.Where(b => b is MunitionPartData).Select(b => b.GetType().Name))); + if (design.Behaviors.Any(b => b is MunitionPartData)) continue; + + var maxSpeed = Copy(speed); + var cognition = design.Name == "LRMM72" || design.Name == "SRMM72" ? 0.25f : 0.5f; + var group = launcher.Group; + design.Behaviors.Add(new MunitionHullData { Group = group, Durability = Flat(20f), Radius = Flat(4f) }); + design.Behaviors.Add(new MunitionThrusterData { Group = group, Acceleration = Copy(thrust), Lateral = Copy(thrust, 0.5f), MaxSpeed = maxSpeed, Visibility = Flat(10f) }); + design.Behaviors.Add(new MunitionSeekerData { Group = group, Sensitivity = Flat(0.75f), BeamWidth = Flat(60f), Range = Flat(0.3f * range.Max) }); + design.Behaviors.Add(new MunitionControlData + { + Group = group, Navigation = Flat(3f), Evasion = Flat(0.5f), EvasionFrequency = Flat(dodge), + Lifetime = Flat(1.5f * range.Max / maxSpeed.Max), Cognition = Flat(cognition), + }); + design.Behaviors.Add(new MunitionFuelData { Group = group, DeltaV = Flat(2f * maxSpeed.Max) }); + changed.Add((design, cache.RefOf(design).Key)); + } + Console.WriteLine($"\n{changed.Count} design(s) to migrate."); + if (!apply) + { + Console.WriteLine("Dry run. Pass \"apply\" to land them."); + return 0; + } + foreach (var (document, _) in changed) CultRecordRefs.Validate(document); + cache.Commit(batch => + { + foreach (var (document, key) in changed) batch.Upsert(document is WeaponItemData ? typeof(WeaponItemData) : typeof(GearData), document, key); + }); + Console.WriteLine($"Landed {changed.Count} design(s) in Aetheria.cc"); + return 0; + } } From 34ea8bfa15d7bb968c2744babdc19954cdd2423a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:59:48 +0200 Subject: [PATCH 2/8] Catalog: the five launcher designs carry their munition parts (munition-parts-migrate apply) GT 3K, LRMM72, SRMM72, pswarm, scorched void policy. Acceleration, Lateral and MaxSpeed copy Thrust and MissileVelocity with their thruster role; EvasionFrequency is DodgeFrequency; the rest are authored flat. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 4452fe8c0..c5c70cec2 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3497996d4601a12a73a32f6888d699a5685128fcab2223b9e0519f96ae2dfd6c -size 12723747 +oid sha256:1d9f6ec17f7b9d7a842db054b5a683fb7da7b065ce778dfafa6d62c0b0b4ab86 +size 12725399 From 098598974456d01f1df28f8d150a8e241f31b977 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 04:02:32 +0200 Subject: [PATCH 3/8] Retire the launcher flight fields; the presenter reads the munition parts; the migrate command goes Thrust, DodgeFrequency and MissileVelocity are gone from LauncherData (keys 29-31) and GuidedWeaponData (keys 24-26), keys reserved. GuidedProjectileManager flies the round from MunitionParts.Of: Acceleration, MaxSpeed and EvasionFrequency, evaluated through the item. munition-parts-migrate is deleted with the fields it read. The shipped catalog still carries the dead keys in this commit; the next commit re-saves it. The Unity assembly was not compiled. Co-Authored-By: Claude Opus 5.5 --- .../Weapons/GuidedProjectileManager.cs | 12 ++-- .../ServerShared/Behaviors/Launcher.cs | 18 +----- tools/AetherDb/Program.cs | 63 +------------------ 3 files changed, 9 insertions(+), 84 deletions(-) diff --git a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs index 876dda1f6..b325b4fd8 100644 --- a/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs +++ b/Assets/Scripts/Gameplay/Weapons/GuidedProjectileManager.cs @@ -44,7 +44,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.SourceEntity = source.Entity; if (target != null) p.Target = target.transform; else p.TargetPosition = () => burst; - p.Frequency = launcher.DodgeFrequency; + p.Frequency = item.Evaluate(MunitionParts.Of(item).EvasionFrequency); var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); p.StartPosition = (p.transform.position = barrel.position).ToCultMath(); @@ -53,8 +53,8 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.LiftCurve = launcher.LiftCurve.ToCurve(); p.ThrustCurve = launcher.ThrustCurve.ToCurve(); p.Velocity = barrel.forward * weapon.Velocity; - p.Thrust = item.Evaluate(launcher.Thrust); - p.TopSpeed = item.Evaluate(launcher.MissileVelocity); + p.Thrust = item.Evaluate(MunitionParts.Of(item).Acceleration); + p.TopSpeed = item.Evaluate(MunitionParts.Of(item).MaxSpeed); Bind(p, source.Entity.Zone, shotId); // A round with no target (a fused round fired at none) has no transform to hand a listener. OnFireGuided.OnNext((source.Entity, target != null ? target.transform : null, p)); @@ -65,7 +65,7 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.ShotId = shotId; p.Source = source.transform; p.SourceEntity = source.Entity; - p.Frequency = guidance.DodgeFrequency; + p.Frequency = item.Evaluate(MunitionParts.Of(item).EvasionFrequency); var hp = item.Hardpoint; var barrel = source.GetBarrel(hp); p.StartPosition = (p.transform.position = barrel.position).ToCultMath(); @@ -74,8 +74,8 @@ public class GuidedProjectileManager : InstantWeaponEffectManager p.LiftCurve = guidance.LiftCurve.ToCurve(); p.ThrustCurve = guidance.ThrustCurve.ToCurve(); p.Velocity = barrel.forward * weapon.Velocity; - p.Thrust = item.Evaluate(guidance.Thrust); - p.TopSpeed = item.Evaluate(guidance.MissileVelocity); + p.Thrust = item.Evaluate(MunitionParts.Of(item).Acceleration); + p.TopSpeed = item.Evaluate(MunitionParts.Of(item).MaxSpeed); p.TargetPosition = () => (source.Entity.Position + length(source.LookAtPoint.position.ToCultMath() - source.Entity.Position) * source.Entity.Aim).ToUnity(); Bind(p, source.Entity.Zone, shotId); } diff --git a/Assets/Scripts/ServerShared/Behaviors/Launcher.cs b/Assets/Scripts/ServerShared/Behaviors/Launcher.cs index e443bdf1d..439394df0 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Launcher.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Launcher.cs @@ -18,14 +18,7 @@ public class LauncherData : LockWeaponData [InspectableAnimationCurve, JsonProperty("liftCurve"), Key(28)] public float4[] LiftCurve; - [Inspectable, JsonProperty("thrust"), Key(29)] - public PerformanceStat Thrust = new PerformanceStat(); - - [Inspectable, JsonProperty("frequency"), Key(30)] - public float DodgeFrequency; - - [Inspectable, JsonProperty("missileSpeed"), Key(31), RuntimeInspectable] - public PerformanceStat MissileVelocity = new PerformanceStat(); + // Keys 29-31 were Thrust, DodgeFrequency and MissileVelocity, retired for the munition parts (MunitionParts.cs); do not reuse. public override Behavior CreateInstance(EquippedItem item) { @@ -45,14 +38,7 @@ public class GuidedWeaponData : InstantWeaponData [InspectableAnimationCurve, JsonProperty("liftCurve"), Key(23)] public float4[] LiftCurve; - [Inspectable, JsonProperty("thrust"), Key(24)] - public PerformanceStat Thrust = new PerformanceStat(); - - [Inspectable, JsonProperty("frequency"), Key(25)] - public float DodgeFrequency; - - [Inspectable, JsonProperty("missileSpeed"), Key(26), RuntimeInspectable] - public PerformanceStat MissileVelocity = new PerformanceStat(); + // Keys 24-26 were Thrust, DodgeFrequency and MissileVelocity, retired for the munition parts (MunitionParts.cs); do not reuse. public override Behavior CreateInstance(EquippedItem item) { diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 2a1e9188b..ee928bcae 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -49,9 +49,8 @@ public static class Program Console.OutputEncoding = new System.Text.UTF8Encoding(false); return PiratesFaction(args.Contains("apply")); case "verse-grammar": return VerseGrammarCommand.Run(args.Contains("apply")); - case "munition-parts-migrate": return MunitionPartsMigrate(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply], munition-parts-migrate [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); return 1; } } @@ -2159,64 +2158,4 @@ public static class Program return 0; } - // Aetheria release, cut missile-stats (ruling missile-stats-now-gear-later): a launcher design's flight stats move off - // LauncherData and GuidedWeaponData onto five munition part behaviours at the launcher's Group. One-shot: it reads the three - // fields the same commit's second half deletes, and goes with them. Copied with their terms and role: Acceleration from - // Thrust, MaxSpeed from MissileVelocity, Lateral from Thrust at half Min and Max; EvasionFrequency is DodgeFrequency, flat. - // Everything else is authored flat from the design (Sensitivity and Visibility are placeholders the records cut calibrates). - // Dry run unless passed "apply"; a design that already carries its parts is left alone. - private static int MunitionPartsMigrate(bool apply) - { - // Opened without AetherDb.Open's validation: the catalog this command repairs fails ValidateMunitionParts until it runs. - var cache = new CultCache(); - cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); - PerformanceStat Copy(PerformanceStat stat, float scale = 1f) => new PerformanceStat - { - Min = stat.Min * scale, Max = stat.Max * scale, - Terms = (stat.Terms ?? new List()).Select(t => new StatTerm { Source = t.Source, Exponent = t.Exponent, Role = t.Role }).ToList(), - }; - PerformanceStat Flat(float value) => new PerformanceStat { Min = value, Max = value, Terms = new List() }; - - var changed = new List<(EquippableItemData Document, CultRecordKey Key)>(); - Console.WriteLine($"{"design",-22} {"data",-17} {"Thrust",-12} {"MissileVelocity",-12} {"Dodge",6} {"Range",-12} parts"); - foreach (var design in cache.GetAll().OrderBy(d => d.Name, StringComparer.Ordinal)) - { - var launcher = (design.Behaviors ?? new List()).FirstOrDefault(b => b is LauncherData || b is GuidedWeaponData); - if (launcher == null) continue; - var (thrust, speed, dodge) = launcher is LauncherData l - ? (l.Thrust, l.MissileVelocity, l.DodgeFrequency) - : (((GuidedWeaponData) launcher).Thrust, ((GuidedWeaponData) launcher).MissileVelocity, ((GuidedWeaponData) launcher).DodgeFrequency); - var range = ((WeaponData) launcher).Range; - Console.WriteLine($"{design.Name,-22} {launcher.GetType().Name,-17} {thrust.Min,5:0.##}-{thrust.Max,-6:0.##} {speed.Min,5:0.##}-{speed.Max,-6:0.##} {dodge,6:0.##} {range.Min,5:0.##}-{range.Max,-6:0.##} " + - string.Join(",", design.Behaviors.Where(b => b is MunitionPartData).Select(b => b.GetType().Name))); - if (design.Behaviors.Any(b => b is MunitionPartData)) continue; - - var maxSpeed = Copy(speed); - var cognition = design.Name == "LRMM72" || design.Name == "SRMM72" ? 0.25f : 0.5f; - var group = launcher.Group; - design.Behaviors.Add(new MunitionHullData { Group = group, Durability = Flat(20f), Radius = Flat(4f) }); - design.Behaviors.Add(new MunitionThrusterData { Group = group, Acceleration = Copy(thrust), Lateral = Copy(thrust, 0.5f), MaxSpeed = maxSpeed, Visibility = Flat(10f) }); - design.Behaviors.Add(new MunitionSeekerData { Group = group, Sensitivity = Flat(0.75f), BeamWidth = Flat(60f), Range = Flat(0.3f * range.Max) }); - design.Behaviors.Add(new MunitionControlData - { - Group = group, Navigation = Flat(3f), Evasion = Flat(0.5f), EvasionFrequency = Flat(dodge), - Lifetime = Flat(1.5f * range.Max / maxSpeed.Max), Cognition = Flat(cognition), - }); - design.Behaviors.Add(new MunitionFuelData { Group = group, DeltaV = Flat(2f * maxSpeed.Max) }); - changed.Add((design, cache.RefOf(design).Key)); - } - Console.WriteLine($"\n{changed.Count} design(s) to migrate."); - if (!apply) - { - Console.WriteLine("Dry run. Pass \"apply\" to land them."); - return 0; - } - foreach (var (document, _) in changed) CultRecordRefs.Validate(document); - cache.Commit(batch => - { - foreach (var (document, key) in changed) batch.Upsert(document is WeaponItemData ? typeof(WeaponItemData) : typeof(GearData), document, key); - }); - Console.WriteLine($"Landed {changed.Count} design(s) in Aetheria.cc"); - return 0; - } } From d24135a075f3611d424b69aa4a105804b3880b34 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 04:03:31 +0200 Subject: [PATCH 4/8] Catalog: re-saved through AetherDb after the launcher fields were retired The five munition designs are written by the post-cut schema, so keys 29-31 and 24-26 are gone from the file. The catalog loads through AetherDb (census) and every munition design carries its five parts. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index c5c70cec2..98a2fa2c7 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d9f6ec17f7b9d7a842db054b5a683fb7da7b065ce778dfafa6d62c0b0b4ab86 -size 12725399 +oid sha256:2e2d927a7dde45181d97f07357d822c59ca3c818ad04aac3fcb088fdac9f4593 +size 12725005 From a249f86cef4cc61ea38a40bd95eedc8ee25df910 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 04:30:09 +0200 Subject: [PATCH 5/8] Tests: the lock fixtures carry munition parts; the migration and item-read tests read the stat the way the engine does LockWarningTests builds launcher designs, which the new gate (ValidateMunitionParts) refuses without parts. The golden test selects the role's Quality term among the stat's heat and durability terms, and the item-read test settles the ship at the item's optimal temperature so only role quality moves the stat. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/LockWarningTests.cs | 11 +++++-- .../MunitionPartsTests.cs | 30 ++++++++++++------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index 28b6abbc2..3b2710660 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -68,7 +68,7 @@ public sealed class LockWarningTests : IDisposable private static PerformanceStat Const(float v) => new PerformanceStat { Min = v, Max = v }; - private static GearData Launcher(string name, float lockSpeed, float sensorImpact, float lockAngle, float directionImpact) => new GearData + private static GearData Launcher(string name, float lockSpeed, float sensorImpact, float lockAngle, float directionImpact) => WithMunitionParts(new GearData { Name = name, Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, @@ -84,7 +84,14 @@ public sealed class LockWarningTests : IDisposable DirectionImpact = Const(directionImpact), Decay = Const(.1f) } } - }; + }); + + // A launcher design carries the five munition parts (StatValidation.ValidateMunitionParts); these fixtures are about the lock. + private static GearData WithMunitionParts(GearData launcher) + { + launcher.Behaviors.AddRange(MunitionPartsTests.Parts(0)); + return launcher; + } private int _nextLot = 2; diff --git a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs index 1e3de8fd2..a7da2c8c2 100644 --- a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs +++ b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs @@ -30,16 +30,18 @@ public sealed class MunitionPartsTests : IDisposable private static PerformanceStat Flat(float value) => new PerformanceStat { Min = value, Max = value }; - private static PerformanceStat Ranged(float min, float max, string role) => new PerformanceStat + private static PerformanceStat Ranged(float min, float max, string role) => role == null ? Flat(max) : new PerformanceStat { Min = min, Max = max, Terms = new List { new StatTerm { Source = StatSource.Quality, Exponent = 1.5f, Role = role } }, }; - // Every field set to a distinct value, so a field read from the wrong slot or dropped on the way is seen. - private static List Parts(int group) => new List + // 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 Parts(int group, string role = null) => new List { new MunitionHullData { Group = group, Durability = Flat(21), Radius = Flat(4.5f) }, - new MunitionThrusterData { Group = group, Acceleration = Ranged(300, 400, "motor"), Lateral = Ranged(150, 200, "motor"), MaxSpeed = Ranged(250, 350, "motor"), Visibility = Flat(11) }, + 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) }, @@ -57,7 +59,7 @@ public sealed class MunitionPartsTests : IDisposable launcher.Group = group; var design = new WeaponItemData { Name = name, Roles = new List { new ItemRole { Name = "motor" } } }; design.Behaviors.Add(launcher); - design.Behaviors.AddRange(Parts(group)); + design.Behaviors.AddRange(Parts(group, "motor")); return design; } @@ -102,12 +104,10 @@ public sealed class MunitionPartsTests : IDisposable 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 }) - { - var term = Assert.Single(stat.Terms); - Assert.Equal((StatSource.Quality, "thruster"), (term.Source, term.Role)); - } - Assert.Equal(thruster.Acceleration.Terms.Single().Exponent, thruster.Lateral.Terms.Single().Exponent); + Assert.Equal("thruster", Assert.Single(stat.Terms, t => t.Source == StatSource.Quality).Role); var evasion = Part(design).EvasionFrequency; Assert.Equal((dodge, dodge), (evasion.Min, evasion.Max)); @@ -183,7 +183,15 @@ public sealed class MunitionPartsTests : IDisposable 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(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings); Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf(gt), Durability = gt.Durability, Lot = 2 }, hardpoint.Position)); - return ship.Equipment.Single(e => e.Data.Name == "GT 3K"); + zone.Entities.Add(ship); + ship.Activate(); + // 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"); + foreach (var cell in item.InsetShape.Coordinates) ship.Temperature[cell.x, cell.y] = gt.OptimalTemperature; + item.UpdatePerformance(); + item.UpdatePerformance(); + return item; } [Fact] From b33f39447e4bdc156e41c677b91408b454fd2a3d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 19:31:52 +0200 Subject: [PATCH 6/8] Validate: a consumable is held to the munition-part gate too (any other design carries none) Builds. The tests that pin it follow in the next commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 31da02f26..558e04285 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -86,6 +86,7 @@ public static class CultRecordRefs if (document is ConsumableItemData consumable) { StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors); + StatValidation.ValidateMunitionParts(consumable.Name, consumable.Behaviors); StatValidation.ValidateRoleUsage(consumable.Name, consumable.Roles, consumable.Behaviors); } } From 1f1d0bfa3549f7141d3ada0f5e5e6f81952484a0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 19:31:53 +0200 Subject: [PATCH 7/8] Tests: the gate holds each of the five parts, catalog load and Upsert run it, consumables are refused, and the ship-sum separation is read on a ship with drives and a sensor Builds (unverified until the next ygg run). Co-Authored-By: Claude Opus 5.5 --- .../MunitionPartsTests.cs | 142 +++++++++++++++++- 1 file changed, 135 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs index a7da2c8c2..25f42f1c9 100644 --- a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs +++ b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; +using GameCult.Caching.MessagePack; +using CultMath; using Newtonsoft.Json; using Xunit; @@ -150,11 +152,18 @@ public sealed class MunitionPartsTests : IDisposable { CultRecordRefs.Validate(Munition("Complete", new LauncherData())); - var missing = Munition("No Tank", new LauncherData()); - missing.Behaviors.RemoveAll(b => b is MunitionFuelData); - var refusal = Assert.Throws(() => CultRecordRefs.Validate(missing)); - Assert.Contains("No Tank", refusal.Message); - Assert.Contains(nameof(MunitionFuelData), refusal.Message); + // 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[] { () => new LauncherData(), () => new GuidedWeaponData() }) + { + var missing = Munition("No " + part.Name, launcher()); + missing.Behaviors.RemoveAll(b => b.GetType() == part); + refusal = Assert.Throws(() => 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()); @@ -167,9 +176,78 @@ public sealed class MunitionPartsTests : IDisposable refusal = Assert.Throws(() => 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(() => CultRecordRefs.Validate(potion)); + Assert.Contains("Stray Potion", refusal.Message); + Assert.Contains(nameof(MunitionFuelData), refusal.Message); } - // A GT 3K fitted to a Longinus launcher hardpoint, its lot's thruster role at the given quality. + 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(Func pick, Action 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().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(d => d.Name == "GT 3K", _ => { }), registry: TestCatalog.Registry())) { } + + foreach (var part in AllParts) + { + var path = Forged(d => d.Name == "GT 3K", d => d.Behaviors.RemoveAll(b => b.GetType() == part)); + var refusal = Assert.ThrowsAny(() => AetheriaStores.Open(path, registry: TestCatalog.Registry())); + Assert.Contains("GT 3K", refusal.ToString()); + Assert.Contains(part.Name, refusal.ToString()); + } + + var gear = Forged(d => d.Behaviors.Any(b => b is ThrusterData), d => d.Behaviors.Add(new MunitionHullData())); + Assert.Contains(nameof(MunitionHullData), Assert.ThrowsAny(() => AetheriaStores.Open(gear, registry: TestCatalog.Registry())).ToString()); + + var consumable = Forged(_ => true, d => d.Behaviors.Add(new MunitionFuelData())); + Assert.Contains(nameof(MunitionFuelData), Assert.ThrowsAny(() => 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(() => cache.Upsert(incomplete)).Message); + Assert.DoesNotContain(cache.GetAll(), d => d.Name == "Incomplete"); + + var potion = new ConsumableItemData { Name = "Stray Potion" }; + potion.Behaviors.Add(new MunitionControlData()); + Assert.Contains(nameof(MunitionControlData), Assert.Throws(() => 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), ("Core Power", 2, 4), ("Cockpit 2x2", 2, 6), ("not if i see you first", 3, 10) }; + + // A GT 3K 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("Longinus"); @@ -182,6 +260,11 @@ public sealed class MunitionPartsTests : IDisposable 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(hull), Durability = hull.Durability, Lot = 1 }, settings.DefaultEntitySettings); + foreach (var (name, x, y) in ShipGear) + { + var gear = cache.GetByName(name); + Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf(gear), Durability = gear.Durability, Lot = 1 }, new int2(x, y)), name); + } Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf(gt), Durability = gt.Durability, Lot = 2 }, hardpoint.Position)); zone.Entities.Add(ship); ship.Activate(); @@ -211,7 +294,52 @@ public sealed class MunitionPartsTests : IDisposable 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.Empty(poorShip.GetBehaviors()); Assert.DoesNotContain(poor.Behaviors, b => b is Thruster); + Assert.Equal(ShipGear.Count(g => cache.GetByName(g.Name).Behaviors.Any(b => b is ThrusterData)), poorShip.GetBehaviors().Count()); + } + + private static string ShipSums(Ship s) + { + var e = s.Envelope; + var channels = string.Join(",", Enum.GetValues(typeof(SignatureChannel)).Cast().Select(c => $"{c}={s.SignatureOn(c):0.####}")); + var sensors = string.Join(",", s.GetBehaviors().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().Count()} sensors=[{sensors}]"; + } + + private static void ScaleParts(EquippableItemData design, float factor) + { + foreach (var part in design.Behaviors.OfType()) + 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("GT 3K"); + var part = Part(design); + var item = Fit(cache, .5f, out var ship); + var shipBefore = ShipSums(ship); + var partBefore = item.Evaluate(part.Acceleration); + Assert.NotEmpty(ship.GetBehaviors()); + Assert.NotEmpty(ship.GetBehaviors()); + + ScaleParts(design, 1000f); + try + { + var scaledItem = Fit(cache, .5f, out var scaledShip); + 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); } } } From 557edc5017a3abd5cb0f72caa4b0375698350c2d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 19:57:24 +0200 Subject: [PATCH 8/8] Tests: the ship-sum separation settles the ship and sums thruster thrust, so a munition thruster joining the ship's thrusters shows Builds. The previous commit's test survived the hand mutant munition-thruster-joins-ship-thrusters because the ship was never stepped. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/MunitionPartsTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs index 25f42f1c9..4eddd2731 100644 --- a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs +++ b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs @@ -304,7 +304,7 @@ public sealed class MunitionPartsTests : IDisposable var channels = string.Join(",", Enum.GetValues(typeof(SignatureChannel)).Cast().Select(c => $"{c}={s.SignatureOn(c):0.####}")); var sensors = string.Join(",", s.GetBehaviors().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().Count()} sensors=[{sensors}]"; + $"vis={s.Visibility:0.####} sources={s.VisibilitySources.Count} {channels} thrusters={s.GetBehaviors().Count()} thrust={s.GetBehaviors().Sum(t => t.Thrust):0.###} sensors=[{sensors}]"; } private static void ScaleParts(EquippableItemData design, float factor) @@ -328,6 +328,7 @@ public sealed class MunitionPartsTests : IDisposable var design = cache.GetByName("GT 3K"); var part = Part(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()); @@ -337,6 +338,7 @@ public sealed class MunitionPartsTests : IDisposable 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)); }