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 <noreply@anthropic.com>
This commit is contained in:
soul 2026-10-22 00:17:59 +02:00
commit 2fd8cbe8e9
2 changed files with 95 additions and 1 deletions

View file

@ -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<HullData>)[]
{
("--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<VelocityLimitData>().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<VelocityLimitData>().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()
{

View file

@ -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 <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] [--not-offered] | --remove) | price <ship.cc> <price> | compose <shipped.cc> <derived.cc> <mods-dir>";
private const string Usage = "ship-authoring create <ship.cc> <stable-id> <name> [--like <shipped hull name>] | inspect <ship.cc> | centre <ship.cc> | validate <ship.cc> | product <ship.cc> <maker> (--name <name> [--description <text>] [--not-offered] | --remove) | price <ship.cc> <price> | stats <ship.cc> [--mass m] [--durability d] [--armor a] [--drag x] [--top-speed v] | compose <shipped.cc> <derived.cc> <mods-dir>";
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<string, float>(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<BehaviorData>()).OfType<VelocityLimitData>().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.