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/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 05122fc86..558e04285 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -80,11 +80,13 @@ 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) { StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors); + StatValidation.ValidateMunitionParts(consumable.Name, consumable.Behaviors); StatValidation.ValidateRoleUsage(consumable.Name, consumable.Roles, consumable.Behaviors); } } 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/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/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/GameData/Aetheria.cc b/GameData/Aetheria.cc index 4452fe8c0..98a2fa2c7 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:2e2d927a7dde45181d97f07357d822c59ca3c818ad04aac3fcb088fdac9f4593 +size 12725005 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 new file mode 100644 index 000000000..4eddd2731 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs @@ -0,0 +1,347 @@ +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 = + { + ("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) => role == null ? Flat(max) : new PerformanceStat + { + Min = min, Max = max, Terms = new List { 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 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, 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 { new ItemRole { Name = "motor" } } }; + design.Behaviors.Add(launcher); + design.Behaviors.AddRange(Parts(group, "motor")); + 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)); + // 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(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())); + + // 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()); + 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 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); + } + + 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"); + 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); + 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(); + // 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] + 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.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()} thrust={s.GetBehaviors().Sum(t => t.Thrust):0.###} 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); + for (var i = 0; i < 5; i++) ship.Update(.1f); + 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); + 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); } + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index b24161383..ee928bcae 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -168,9 +168,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 +317,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 +1853,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 +2157,5 @@ public static class Program Console.WriteLine($"\nAuthored 2 designs and {products.Length} products in Aetheria.cc"); return 0; } + }