From 2fd8cbe8e9f94bdffc2e41c8e9a9b5795f27f979 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 00:17:59 +0200 Subject: [PATCH] AetherDb: ship-authoring stats sets a package hull's mass, durability, armor, drag and top speed, judged before the file opens Ruling ship-authoring-stats-command. --top-speed writes a fixed top speed (Min = Max) on the hull's one VelocityLimit and refuses a hull with none. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/ShipProductTests.cs | 58 +++++++++++++++++++ tools/AetherDb/ShipAuthoringCommands.cs | 38 +++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 27e84629f..e06143f38 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -325,6 +325,64 @@ public sealed class ShipProductTests : IDisposable RefusedAndUntouched("product", path, "ACM", "--remove"); } + // The hull as data, to compare everything stats did not name. + private static string HullJson(string path) => Newtonsoft.Json.JsonConvert.SerializeObject(ShipAuthoringStore.Load(path).Hull); + + [Fact] + public void StatsWritesOnlyTheFieldsItNamesAndARefusalLeavesTheFileUntouched() + { + Environment.CurrentDirectory = _root.Path; + var path = Package("mod.skiff", "Skiffhull", 0); + var bare = Path.Combine(ShipFixture.WritePackage(Mods, "mod.bare", "Barehull", tweak: ship => ship.Hull.Behaviors.Clear()), "ship.cc"); + void RefusedAndUntouched(string file, params string[] flags) + { + var before = File.ReadAllBytes(file); + Assert.Equal(1, Run(new[] { "stats", file }.Concat(flags).ToArray()).Code); + Assert.Equal(before, File.ReadAllBytes(file)); + } + + RefusedAndUntouched(path); + RefusedAndUntouched(path, "--speed", "5"); + RefusedAndUntouched(path, "--mass"); + RefusedAndUntouched(path, "--mass", "NaN"); + RefusedAndUntouched(path, "--mass", "Infinity"); + RefusedAndUntouched(path, "--armor", "-1"); + RefusedAndUntouched(path, "--drag", "slow"); + RefusedAndUntouched(path, "--mass", "5", "--mass", "6"); + // A valid flag beside a bad one writes nothing. + RefusedAndUntouched(path, "--mass", "5", "--durability", "-2"); + RefusedAndUntouched(bare, "--top-speed", "50"); + RefusedAndUntouched(bare, "--mass", "5", "--top-speed", "50"); + + // Each flag sets its own field and nothing else. + var expected = ShipAuthoringStore.Load(path).Hull; + foreach (var (flag, value, set) in new (string, float, Action)[] + { + ("--mass", 60000f, hull => hull.Mass = 60000f), + ("--durability", 8000f, hull => hull.Durability = 8000f), + ("--armor", 40f, hull => hull.Armor = 40f), + ("--drag", .35f, hull => hull.Drag = .35f), + ("--top-speed", 37.5f, hull => { var speed = hull.Behaviors.OfType().Single().TopSpeed; speed.Min = speed.Max = 37.5f; }), + }) + { + var (code, output, error) = Run("stats", path, flag, value.ToString("R", System.Globalization.CultureInfo.InvariantCulture)); + Assert.True(code == 0, error); + Assert.Contains(flag.Substring(2), output); + set(expected); + Assert.Equal(Newtonsoft.Json.JsonConvert.SerializeObject(expected), HullJson(path)); + } + var written = ShipAuthoringStore.Load(path).Hull; + Assert.Equal((60000f, 8000f, 40f, .35f), (written.Mass, written.Durability, written.Armor, written.Drag)); + var top = written.Behaviors.OfType().Single().TopSpeed; + Assert.Equal((37.5f, 37.5f), (top.Min, top.Max)); + + // Several at once, and a hull with no top speed takes the others. + Assert.Equal(0, Run("stats", path, "--mass", "1", "--drag", "0").Code); + Assert.Equal((1f, 0f, 8000f), (ShipAuthoringStore.Load(path).Hull.Mass, ShipAuthoringStore.Load(path).Hull.Drag, ShipAuthoringStore.Load(path).Hull.Durability)); + Assert.Equal(0, Run("stats", bare, "--armor", "12").Code); + Assert.Equal(12f, ShipAuthoringStore.Load(bare).Hull.Armor); + } + [Fact] public void ValidateJudgesProductsOnADraftAndInspectListsThem() { diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index b745e3dbf..bce7eda08 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -8,7 +8,9 @@ using System.Linq; // Uses the existing database executable; mod authoring does not earn another binary target. public static class ShipAuthoringCommands { - private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | compose "; + private const string Usage = "ship-authoring create [--like ] | inspect | centre | validate | product (--name [--description ] [--not-offered] | --remove) | price | stats [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose "; + + private static readonly string[] StatFlags = { "--mass", "--durability", "--armor", "--drag", "--top-speed" }; public static int Run(string[] args) { @@ -91,6 +93,40 @@ public static class ShipAuthoringCommands Console.WriteLine($"{ship.Id}: price {price}."); return 0; } + if (args[0] == "stats" && args.Length >= 2) + { + // Every flag is judged before the file is touched: a refusal leaves it byte-identical. + var stats = new Dictionary(StringComparer.Ordinal); + for (var i = 2; i < args.Length; i += 2) + { + if (Array.IndexOf(StatFlags, args[i]) < 0) throw new InvalidOperationException($"stats: unknown flag {args[i]}."); + if (stats.ContainsKey(args[i])) throw new InvalidOperationException($"stats: {args[i]} is given twice."); + if (i + 1 == args.Length) throw new InvalidOperationException($"stats: {args[i]} needs a value."); + if (!float.TryParse(args[i + 1], NumberStyles.Float, CultureInfo.InvariantCulture, out var value) || !float.IsFinite(value) || value < 0) + throw new InvalidOperationException($"stats: {args[i]} must be a finite, non-negative number."); + stats[args[i]] = value; + } + if (stats.Count == 0) throw new InvalidOperationException("stats: name at least one stat."); + var (hull, ship, _) = ShipAuthoringStore.Load(args[1]); + if (stats.TryGetValue("--top-speed", out var topSpeed)) + { + var limits = (hull.Behaviors ?? new List()).OfType().ToArray(); + if (limits.Length != 1) throw new InvalidOperationException($"{ship.Id}: --top-speed needs exactly one VelocityLimit on the hull; it has {limits.Length}."); + // A fixed top speed, as every shipped hull authors one (Min equal to Max); its terms are kept. + limits[0].TopSpeed.Min = limits[0].TopSpeed.Max = topSpeed; + } + if (stats.TryGetValue("--mass", out var mass)) hull.Mass = mass; + if (stats.TryGetValue("--durability", out var durability)) hull.Durability = durability; + if (stats.TryGetValue("--armor", out var armor)) hull.Armor = armor; + if (stats.TryGetValue("--drag", out var drag)) hull.Drag = drag; + using (var cache = ShipAuthoringStore.Open(args[1], writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + cache.FlushAsync().Wait(); + } + Console.WriteLine($"{ship.Id}: " + string.Join(", ", stats.Select(stat => string.Create(CultureInfo.InvariantCulture, $"{stat.Key.Substring(2)} {stat.Value:R}"))) + "."); + return 0; + } if (args[0] == "product" && args.Length >= 4) { // A trailing --not-offered takes the product off the market (ruling off-market-is-unoffered-product); --remove takes none.