diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index 9103f7d63..c9b0d0f9a 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -4,20 +4,26 @@ using static ItemRotation; public sealed class Arcs : Scenario { public override string Name => "Arcs"; - public override string Brief => "Forward mounts, a bare hull off the bow, one off the stern, a hostile turret. Verify: the hull keeps its " + + public override string Brief => "The Smoke Test Hull with forward mounts and a side mount on each flank; a bare hull off the bow, one off " + + "the stern, one abeam to port, one abeam to starboard, and a hostile turret. Verify: the hull keeps its " + "heading while you aim at the stern hull, and the forward guns hold. Designate the bow hull and fire with the " + - "aim anywhere: the forward guns fire on it and hit. The turret tracks you all the way round."; + "aim anywhere: the forward guns fire on it and hit. The side guns fire at their beam hull and never through " + + "the hull. Designate the starboard hull, aim ahead and fire its group: it fires on the hull without the aim " + + "and the port group holds; undesignate and aim to port: the port group fires free. The turret tracks you " + + "all the way round."; public override uint Seed => 103; public override bool Ambient => false; - // Where the targets sit: the player is at the origin facing +z, and every mount fires forward. - public static readonly CultMath.float2 Bow = float2(0, 400), Stern = float2(0, -400), Turret = float2(600, 0); + // Where the targets sit: the player is at the origin facing +z. The forward mounts fire forward, the starboard mount + // (Clockwise) along +x and the port mount (CounterClockwise) along -x. + public static readonly CultMath.float2 Bow = float2(0, 400), Stern = float2(0, -400), Turret = float2(600, 0), + Port = float2(-400, 0), Starboard = float2(400, 0); public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); public override void Stage(ScenarioStage stage) { - var fighter = stage.Fit("Longinus", + var fighter = stage.Fit("Smoke Test Hull", ("Cockpit 2x2", int2(2, 6), None), ("Large Drive", int2(1, 0), Reversed), ("Large Drive", int2(3, 0), Reversed), @@ -31,7 +37,9 @@ public sealed class Arcs : Scenario ("Iapyx", int2(2, 2), CounterClockwise), ("Iapyx", int2(3, 2), Clockwise), ("not if i see you first", int2(3, 10), None), - ("Fire Control Array", int2(1, 6), Clockwise)); + ("Fire Control Array", int2(1, 6), Clockwise), + ("Point Defense Gun", int2(5, 4), Clockwise), + ("Point Defense Laser", int2(0, 4), CounterClockwise)); var turret = stage.Fit("Turret", ("ClearPath", int2(1, 2), None), ("ClearPath", int2(5, 2), None), @@ -46,5 +54,7 @@ public sealed class Arcs : Scenario stage.Place(stage.Bare("Longinus"), Bow); stage.Place(stage.Bare("Longinus"), Stern); stage.Place(turret, Turret, stance: ScenarioStance.Hostile); + stage.Place(stage.Bare("Longinus"), Port); + stage.Place(stage.Bare("Longinus"), Starboard); } } diff --git a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs index 4a43c8960..97ef49def 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs @@ -9,6 +9,6 @@ public static class Scenarios public static readonly Scenario[] Tests = { - new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new LongHaul() + new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new WeaponFeel(), new LongHaul() }; } diff --git a/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs new file mode 100644 index 000000000..9e7f8b597 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs @@ -0,0 +1,38 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class WeaponFeel : Scenario +{ + public override string Name => "Weapon Feel"; + public override string Brief => "The Smoke Test Hull with a ChargeBlast SG, an Autocannon and a FastBlast+- burst weapon; a bare Longinus dead ahead. " + + "Verify: ChargeBlast SG and Autocannon fire with effects, particles and action-bar icons; the burst weapon's " + + "cadence is as before; a fast release-and-press on the ChargeBlast SG starts no second charge."; + public override uint Seed => 107; + public override bool Ambient => false; + + // The one target, ahead of the nose: inside every forward mount's arc. + public static readonly CultMath.float2 Target = float2(0, 400); + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast); + + public override void Stage(ScenarioStage stage) + { + var shooter = stage.Fit("Smoke Test Hull", + ("Cockpit 2x2", int2(2, 6), None), + ("Large Drive", int2(1, 0), Reversed), + ("Large Drive", int2(3, 0), Reversed), + ("Talaria", int2(2, 14), CounterClockwise), + ("Talaria", int2(3, 14), Clockwise), + ("Core Power", int2(2, 4), None), + ("ChargeBlast SG", int2(1, 17), None), + ("Autocannon", int2(3, 17), None), + ("FastBlast+-", int2(1, 8), None), + ("Iapyx", int2(2, 2), CounterClockwise), + ("Iapyx", int2(3, 2), Clockwise), + ("not if i see you first", int2(3, 10), None), + ("Fire Control Array", int2(1, 6), Clockwise)); + + stage.Player(shooter, float2(0, 0), facing: float2(0, 1)); + stage.Place(stage.Bare("Longinus"), Target); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs.meta b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs.meta new file mode 100644 index 000000000..6ea88923e --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 74c554ab5a6a4ac5ad0334a29135dc7f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index 986f8658b..8241db13c 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -477,8 +477,8 @@ public sealed partial class RunStartTests } } - // Arcs' geometry: the bow hull sits inside every one of the player's mount arcs, the stern hull outside all of them, - // and the turret is hostile. + // Arcs' geometry: the bow hull sits inside every one of the player's forward mount arcs, the stern hull outside all of + // them (the side mounts are ArcsFlanks'), the turret is hostile and the bare hulls are neutral. [Fact] public void ArcsGeometry() { @@ -489,7 +489,7 @@ public sealed partial class RunStartTests var turret = Assert.IsType(staged.Entities[2]); Assert.Equal((Arcs.Bow, Arcs.Stern, Arcs.Turret), (bow.Position.xz, stern.Position.xz, turret.Position.xz)); - var mounts = player.Equipment.Where(item => item.Behaviors.OfType().Any()).ToList(); + var mounts = player.Equipment.Where(item => item.Behaviors.OfType().Any() && item.Hardpoint.Rotation == ItemRotation.None).ToList(); Assert.NotEmpty(mounts); foreach (var mount in mounts) { @@ -497,7 +497,7 @@ public sealed partial class RunStartTests Assert.False(FireControl.InArc(mount, stern.Position - player.Position), $"{mount.Data.Name} bears on the stern hull"); } Assert.True(player.IsHostileTo(turret) && turret.IsHostileTo(player), "the turret is hostile"); - Assert.False(player.IsHostileTo(bow) || player.IsHostileTo(stern), "the bare hulls are neutral"); + Assert.False(player.IsHostileTo(bow) || player.IsHostileTo(stern) || player.IsHostileTo(staged.Entities[3]) || player.IsHostileTo(staged.Entities[4]), "the bare hulls are neutral"); } // Q3 (operator, 2026-09-30): a design no product sells is a scenario test design and nothing else. A test design diff --git a/tests/Aetheria.Shared.Tests/SmokeHullCommandTests.cs b/tests/Aetheria.Shared.Tests/SmokeHullCommandTests.cs new file mode 100644 index 000000000..2badf502e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeHullCommandTests.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// AetherDb's smoke-hull command (aetheria-release, cut scenarios-smoke), run on a scratch copy of the shipped catalog +// with the Smoke Test Hull removed from it. +public sealed class SmokeHullCommandTests : IDisposable +{ + private const string HullName = "Smoke Test Hull"; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "smoke-hull-command-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + + public SmokeHullCommandTests() + { + Directory.CreateDirectory(Path.Combine(_root, "GameData")); + Directory.CreateDirectory(Path.Combine(_root, "Assets", "Resources")); + var shipped = AetherDb.FindRoot(); + File.Copy(Path.Combine(shipped, "GameData", "Aetheria.cc"), CatalogPath); + File.Copy(Path.Combine(shipped, "Assets", "Resources", "Settings.asset"), Path.Combine(_root, "Assets", "Resources", "Settings.asset")); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(CatalogPath), AetheriaStores.CatalogTypes); + foreach (var hull in cache.GetAll().Where(h => h.Name == HullName).ToArray()) Assert.True(cache.Remove(cache.RefOf(hull).Key)); + cache.FlushAsync().Wait(); + } + + public void Dispose() => Directory.Delete(_root, true); + + private (HullData[] Hulls, HullData Longinus, EquippableItemData[] Fitted, int Products) Read() + { + var db = AetherDb.Open(root: _root); + try + { + var hulls = db.Cache.GetAll().Where(h => h.Name == HullName).ToArray(); + var keys = hulls.Select(h => db.Cache.RefOf(h).Key).ToArray(); + var products = db.Cache.GetAll().Count(p => keys.Any(k => p.Design.Key.Equals(k))); + var fitted = new[] { "ChargeBlast SG", "Autocannon", "Point Defense Gun", "Point Defense Laser" } + .Select(name => db.Cache.GetAll().Single(d => d.Name == name)).ToArray(); + return (hulls, db.Cache.GetAll().Single(h => h.Name == "Longinus"), fitted, products); + } + finally { db.Cache.Dispose(); } + } + + [Fact] + public void A_dry_run_writes_nothing_and_apply_lands_one_productless_hull_once() + { + var before = File.ReadAllBytes(CatalogPath); + Assert.Empty(Read().Hulls); + + Assert.Equal(0, Program.SmokeHullCatalog(apply: false, root: _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + Assert.Empty(Read().Hulls); + + Assert.Equal(0, Program.SmokeHullCatalog(apply: true, root: _root)); + var landed = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(before, landed); + var (hulls, longinus, fitted, products) = Read(); + var hull = Assert.Single(hulls); + Assert.Equal(0, products); + + // The Longinus's own hardpoints survive, and four are added: each takes the gear the scenarios fit to it. + Assert.Equal(longinus.Hardpoints.Count + 4, hull.Hardpoints.Count); + var added = hull.Hardpoints.Skip(longinus.Hardpoints.Count).ToArray(); + var (chargeBlast, autocannon, pdGun, pdLaser) = (fitted[0], fitted[1], fitted[2], fitted[3]); + Assert.Single(added, h => h.Type == HardpointType.Energy && h.Rotation == ItemRotation.None && h.Takes(chargeBlast)); + Assert.Single(added, h => h.Type == HardpointType.Ballistic && h.Rotation == ItemRotation.None && h.Takes(autocannon)); + Assert.Single(added, h => h.Type == HardpointType.Ballistic && h.Rotation == ItemRotation.Clockwise && h.Takes(pdGun)); + Assert.Single(added, h => h.Type == HardpointType.Energy && h.Rotation == ItemRotation.CounterClockwise && h.Takes(pdLaser)); + foreach (var hardpoint in added) + foreach (var cell in hardpoint.Shape.Coordinates) + Assert.True(hull.Shape[hardpoint.Position + cell], "a new hardpoint lies outside the hull's cells"); + + // A second apply finds the hull authored and writes nothing; so does a dry run. + Assert.Equal(0, Program.SmokeHullCatalog(apply: true, root: _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + Assert.Equal(0, Program.SmokeHullCatalog(apply: false, root: _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + Assert.Single(Read().Hulls); + } +} diff --git a/tests/Aetheria.Shared.Tests/SmokeHullScenarioTests.cs b/tests/Aetheria.Shared.Tests/SmokeHullScenarioTests.cs new file mode 100644 index 000000000..f67fff0a6 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeHullScenarioTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +// aetheria-release, cut scenarios-smoke: what the Smoke Test Hull scenarios stage, read from the staged run and the +// shipped catalog's hull. +public sealed partial class RunStartTests +{ + private static bool Bears(EquippedItem mount, Entity player, Entity target) => FireControl.InArc(mount, target.Position - player.Position); + + private static List WeaponMounts(Entity ship) => + ship.Equipment.Where(item => item.Behaviors.OfType().Any()).ToList(); + + // Weapon Feel: the player flies the Smoke Test Hull with ChargeBlast SG on the 2x2 Energy hardpoint, Autocannon + // on the 2x2 Ballistic hardpoint and a burst weapon; the bare hull ahead lies in every mount's arc. + [Fact] + public void WeaponFeelConditions() + { + var staged = Stage(Arena(false), new WeaponFeel()); + var player = staged.Player; + player.GenerateWeaponGroups(); + Assert.Equal("Smoke Test Hull", _items.GetData(player.EquippedHull.EquippableItem).Name); + + EquippedItem Mount(string design) => Assert.Single(WeaponMounts(player), item => _items.GetData(item.EquippableItem).Name == design); + var charge = Mount("ChargeBlast SG"); + var autocannon = Mount("Autocannon"); + Assert.Equal((HardpointType.Energy, 2, 2), (charge.Hardpoint.Type, charge.Hardpoint.Shape.Width, charge.Hardpoint.Shape.Height)); + Assert.Equal((HardpointType.Ballistic, 2, 2), (autocannon.Hardpoint.Type, autocannon.Hardpoint.Shape.Width, autocannon.Hardpoint.Shape.Height)); + + var mounts = WeaponMounts(player); + Assert.Contains(mounts, item => ((WeaponItemData)_items.GetData(item.EquippableItem)).WeaponModifiers.HasFlag(WeaponModifiers.RapidFire)); + foreach (var mount in mounts) + { + Assert.Contains(player.WeaponGroups, group => group.items.Contains(mount)); + Assert.True(Bears(mount, player, Assert.Single(staged.Entities)), $"{_items.GetData(mount.EquippableItem).Name} cannot bear on the hull ahead"); + } + } + + // Arcs' flanks: each side mount bears on its own flank's hull and on nothing else, the forward mounts bear on the + // bow and on neither flank, and the two side mounts sit in different weapon groups. + [Fact] + public void ArcsFlanks() + { + var staged = Stage(Arena(false), new Arcs()); + var player = staged.Player; + player.GenerateWeaponGroups(); + var (bow, stern, port, starboard) = (staged.Entities[0], staged.Entities[1], staged.Entities[3], staged.Entities[4]); + Assert.Equal((Arcs.Port, Arcs.Starboard), (port.Position.xz, starboard.Position.xz)); + + var mounts = WeaponMounts(player); + var forward = mounts.Where(item => item.Hardpoint.Rotation == ItemRotation.None).ToList(); + var starboardMounts = mounts.Where(item => item.Hardpoint.Rotation == ItemRotation.Clockwise).ToList(); + var portMounts = mounts.Where(item => item.Hardpoint.Rotation == ItemRotation.CounterClockwise).ToList(); + Assert.NotEmpty(forward); + Assert.NotEmpty(starboardMounts); + Assert.NotEmpty(portMounts); + foreach (var mount in starboardMounts) + { + Assert.True(Bears(mount, player, starboard), "a starboard mount cannot bear on the starboard hull"); + Assert.False(Bears(mount, player, port) || Bears(mount, player, bow) || Bears(mount, player, stern), "a starboard mount bears off its flank"); + } + foreach (var mount in portMounts) + { + Assert.True(Bears(mount, player, port), "a port mount cannot bear on the port hull"); + Assert.False(Bears(mount, player, starboard) || Bears(mount, player, bow) || Bears(mount, player, stern), "a port mount bears off its flank"); + } + foreach (var mount in forward) + { + Assert.True(Bears(mount, player, bow), "a forward mount cannot bear on the bow hull"); + Assert.False(Bears(mount, player, port) || Bears(mount, player, starboard), "a forward mount bears on a flank"); + } + + int GroupOf(EquippedItem mount) => Array.FindIndex(player.WeaponGroups, group => group.items.Contains(mount)); + var starboardGroups = starboardMounts.Select(GroupOf).ToList(); + var portGroups = portMounts.Select(GroupOf).ToList(); + Assert.DoesNotContain(-1, starboardGroups.Concat(portGroups)); + Assert.Empty(starboardGroups.Intersect(portGroups)); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index d5b633412..9791f1d23 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -42,6 +42,7 @@ public static class Program case "field-kinds": return FieldKindsCatalog(args.Contains("apply")); case "pd-gear": return PdGearCatalog(args.Contains("apply")); case "mine-launcher": return MineLauncherCatalog(args.Contains("apply")); + case "smoke-hull": return SmokeHullCatalog(args.Contains("apply")); case "roles-backfill": return RolesBackfill(args.Contains("apply")); case "consumables-first-set": return ConsumablesFirstSetCatalog(args.Contains("apply")); case "pirates-faction": @@ -49,7 +50,7 @@ public static class Program return PiratesFaction(args.Contains("apply")); case "verse-grammar": return VerseGrammarCommand.Run(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], 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]"); return 1; } } @@ -1601,6 +1602,68 @@ public static class Program return 0; } + // aetheria-release cut scenarios-smoke (rulings weapon-feel-to-smoke-cut-test-hull, test-hull-both-flanks): the + // Smoke Test Hull, a copy of the Longinus hull that no product sells, so no generated ship, station or shop offers + // it and only a scenario fits it. It adds what no shipped ship hull has, where the Longinus runs out of cells: two + // rows of cells past the bow carrying a 2x2 Energy hardpoint (ChargeBlast SG) and a 2x2 Ballistic hardpoint + // (Autocannon), and a 1x1 side mount on each flank, a Clockwise Ballistic one on the starboard and a + // CounterClockwise Energy one on the port. The Longinus's own hardpoints are untouched. Its prefab is the + // Longinus's, which has no barrel for the new hardpoints: they fire from the hull centre. Dry run unless passed + // "apply". + public static int SmokeHullCatalog(bool apply, string root = null) + { + const string name = "Smoke Test Hull"; + var db = AetherDb.Open(catalogWritable: apply, root: root); + if (db.Cache.GetAll().Any(hull => hull.Name == name)) + { + Console.WriteLine($"\"{name}\" is already authored; nothing to do."); + return 0; + } + + var longinus = db.Cache.GetAll().Single(hull => hull.Name == "Longinus"); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var hull = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(longinus, options), options); + hull.Name = name; + hull.Description = "The Longinus with two forward 2x2 hardpoints and a side mount on each flank, for scenarios. No faction builds it."; + + // Two rows of cells past the bow, over the width of the tail's neighbours (x 1..4). + var grown = new Shape(longinus.Shape.Width, longinus.Shape.Height + 2); + for (var x = 0; x < longinus.Shape.Width; x++) + for (var y = 0; y < longinus.Shape.Height; y++) grown.Cells[x, y] = longinus.Shape.Cells[x, y]; + for (var x = 1; x <= 4; x++) + for (var y = longinus.Shape.Height; y < grown.Height; y++) grown.Cells[x, y] = true; + hull.Shape = grown; + + static Shape Cells(int width, int height) + { + var shape = new Shape(width, height); + for (var x = 0; x < width; x++) + for (var y = 0; y < height; y++) shape.Cells[x, y] = true; + return shape; + } + var bow = longinus.Shape.Height; + hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = CultMath.math.int2(1, bow), Shape = Cells(2, 2), Rotation = ItemRotation.None, Armor = 20f }); + hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Ballistic, Position = CultMath.math.int2(3, bow), Shape = Cells(2, 2), Rotation = ItemRotation.None, Armor = 20f }); + hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Ballistic, Position = CultMath.math.int2(longinus.Shape.Width - 1, 4), Shape = Cells(1, 1), Rotation = ItemRotation.Clockwise, Armor = 10f }); + hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = CultMath.math.int2(0, 4), Shape = Cells(1, 1), Rotation = ItemRotation.CounterClockwise, Armor = 10f }); + + Console.WriteLine($"Adds \"{name}\": a Longinus copy grown from {longinus.Shape.Width}x{longinus.Shape.Height} to {grown.Width}x{grown.Height}, no product."); + foreach (var hardpoint in hull.Hardpoints.Skip(longinus.Hardpoints.Count)) + Console.WriteLine($" {hardpoint.Type,-9} {hardpoint.Shape.Width}x{hardpoint.Shape.Height} at {hardpoint.Position.x},{hardpoint.Position.y} rotation {hardpoint.Rotation}"); + if (!apply) + { + Console.WriteLine(" +Dry run. Pass \"apply\" to author it."); + return 0; + } + + CultRecordRefs.Validate(hull); + db.Cache.Commit(batch => batch.Upsert(typeof(HullData), hull)); + Console.WriteLine($" +Authored \"{name}\" in Aetheria.cc"); + return 0; + } + // sim-mines-presenter: the Mine Launcher item, so a ship can be fitted with the sim's mine layer. Its numbers // are the 2021 record's (GameData/Legacy/AetherDB.2021-03-05.msgpack, recovered in 8c71a637: mass 75, price // 125000, damage 50, range 2000, energy 15, heat 500, visibility 10, magazine 6, reload 5, spread 5, velocity