Smoke Test Hull command, Weapon Feel and Arcs on the test hull (catalog not yet applied)

AetherDb smoke-hull [apply] adds a product-less Longinus copy with a 2x2 Energy and a 2x2 Ballistic
hardpoint past the bow and a 1x1 side mount on each flank. Weapon Feel (seed 107) stages it with
ChargeBlast SG, Autocannon and FastBlast+-; Arcs flies it with a mount on each flank and a Port and
Starboard hull. This commit does not build a passing suite: the scenarios name the hull and
GameData/Aetheria.cc does not hold it until the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 21:04:09 +02:00
commit e8442328df
8 changed files with 297 additions and 12 deletions

View file

@ -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);
}
}

View file

@ -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()
};
}

View file

@ -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);
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 74c554ab5a6a4ac5ad0334a29135dc7f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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<OrbitalEntity>(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<Weapon>().Any()).ToList();
var mounts = player.Equipment.Where(item => item.Behaviors.OfType<Weapon>().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

View file

@ -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<HullData>().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<HullData>().Where(h => h.Name == HullName).ToArray();
var keys = hulls.Select(h => db.Cache.RefOf(h).Key).ToArray();
var products = db.Cache.GetAll<FactionProductData>().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<EquippableItemData>().Single(d => d.Name == name)).ToArray();
return (hulls, db.Cache.GetAll<HullData>().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);
}
}

View file

@ -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<EquippedItem> WeaponMounts(Entity ship) =>
ship.Equipment.Where(item => item.Behaviors.OfType<Weapon>().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));
}
}

View file

@ -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 <Type.Member>]... [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 <Type.Member>]... [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<HullData>().Any(hull => hull.Name == name))
{
Console.WriteLine($"\"{name}\" is already authored; nothing to do.");
return 0;
}
var longinus = db.Cache.GetAll<HullData>().Single(hull => hull.Name == "Longinus");
var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly);
var hull = MessagePackSerializer.Deserialize<HullData>(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