Merge cut-scenarios-smoke (verdict cut-scenarios-smoke.s2)

Scenarios.cs Tests list: kept WeaponFeel, Pursuit and Crossing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 23:28:05 +02:00
commit b6262467f0
13 changed files with 356 additions and 38 deletions

View file

@ -186,7 +186,7 @@ public class LoadoutGenerator
// not once a required item has fallen back to any manufacturer). The one availability rule for every selection.
private ((FactionProductData product, T design)[] available, bool preferManufacturers) AvailableProducts<T>(Predicate<T> filter, bool required) where T : CraftedItemData
{
var hulls = ItemManager.ItemData.GetAll<HullData>().ToArray();
var hulls = SoldHulls(ItemManager.ItemData);
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
.Select(product => (product, design: ItemManager.ItemData.Get(product.Design) as T))
.Where(entry =>
@ -231,6 +231,11 @@ public class LoadoutGenerator
// Hardpoint gear that no hull in the catalog can mount has no home (operator, 2026-09-30: "we'll need to author a
// bunch more hulls before all the gear variety in the game has a home"): generation never offers it, so no station
// stocks it. It gains one the moment a hull with a hardpoint that takes it exists. Tool gear goes in any interior.
// Only a hull some product sells gives gear a home: a hull no faction builds (the Smoke Test Hull) is a test fixture, so its
// hardpoints never put gear on a shelf. The one definition of the hulls HasHome considers.
public static HullData[] SoldHulls(CultCache items) =>
items.GetAll<FactionProductData>().Select(product => items.Get(product.Design)).OfType<HullData>().Distinct().ToArray();
public static bool HasHome(EquippableItemData design, HullData[] hulls) =>
design.HardpointType == HardpointType.Tool || design.HardpointType == HardpointType.Hull ||
hulls.Any(hull => hull.Hardpoints.Any(hardpoint => hardpoint.Takes(design)));

View file

@ -4,34 +4,42 @@ 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, 300),
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",
("Cockpit 2x2", int2(2, 6), None),
("Large Drive", int2(1, 0), Reversed),
var fighter = stage.Fit("Smoke Test Hull",
("Cockpit 2x2", int2(4, 6), None),
("Large Drive", int2(3, 0), Reversed),
("Talaria", int2(2, 14), CounterClockwise),
("Talaria", int2(3, 14), Clockwise),
("Core Power", int2(2, 4), None),
("GT 3K", int2(0, 5), None),
("GT 3K", int2(5, 5), None),
("FastBlast+-", int2(1, 8), None),
("FastBlast+-", int2(4, 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));
("Large Drive", int2(5, 0), Reversed),
("Talaria", int2(4, 14), CounterClockwise),
("Talaria", int2(5, 14), Clockwise),
("Core Power", int2(4, 4), None),
("GT 3K", int2(2, 5), None),
("GT 3K", int2(7, 5), None),
("FastBlast+-", int2(3, 8), None),
("FastBlast+-", int2(6, 8), None),
("Iapyx", int2(4, 2), CounterClockwise),
("Iapyx", int2(5, 2), Clockwise),
("not if i see you first", int2(5, 10), None),
("Fire Control Array", int2(3, 6), Clockwise),
("Autocannon", int2(8, 4), Clockwise),
("ChargeBlast SG", 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 Pursuit(), new Crossing()
new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new WeaponFeel(), new LongHaul(), new Pursuit(), new Crossing()
};
}

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(4, 6), None),
("Large Drive", int2(3, 0), Reversed),
("Large Drive", int2(5, 0), Reversed),
("Talaria", int2(4, 14), CounterClockwise),
("Talaria", int2(5, 14), Clockwise),
("Core Power", int2(4, 4), None),
("ChargeBlast SG", int2(3, 17), None),
("Autocannon", int2(5, 17), None),
("FastBlast+-", int2(3, 8), None),
("Iapyx", int2(4, 2), CounterClockwise),
("Iapyx", int2(5, 2), Clockwise),
("not if i see you first", int2(5, 10), None),
("Fire Control Array", int2(3, 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:

BIN
GameData/Aetheria.cc (Stored with Git LFS)

Binary file not shown.

View file

@ -29,7 +29,7 @@ public sealed partial class RunStartTests
var products = PiratesProducts();
Assert.Equal(8, products.Length);
var hulls = _cache.GetAll<HullData>().ToArray();
var hulls = LoadoutGenerator.SoldHulls(_cache);
foreach (var product in products)
{
var design = _cache.Get(product.Design) as EquippableItemData;

View file

@ -248,13 +248,14 @@ public sealed class RestoredHullsTests
}
// The dock intro dereferences the ship's VelocityLimit unguarded (ActionGameManager), so every ship hull the catalog
// ships carries exactly one, with a top speed that is a finite positive number. After the retirement
// sells carries exactly one, with a top speed that is a finite positive number. After the retirement
// (ruling retire-longinusx) the shipped ship hulls are the Longinus and the Djinni.
[Fact]
public void EveryShippedShipHullHasATopSpeed()
{
using var cache = OpenCatalog();
var ships = cache.GetAll<HullData>().Where(h => h.HullType == HullType.Ship).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray();
var sold = cache.GetAll<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
var ships = cache.GetAll<HullData>().Where(h => h.HullType == HullType.Ship && sold.Contains(cache.RefOf(h).Key)).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray();
Assert.Equal(new[] { "Djinni", "Longinus" }, ships.Select(h => h.Name));
foreach (var hull in ships)
{

View file

@ -353,7 +353,7 @@ public sealed partial class RunStartTests : IDisposable
[Fact]
public void GenerationOffersExactlyTheSoldGearSomeHullCanFit()
{
var hulls = _cache.GetAll<HullData>().ToArray();
var hulls = LoadoutGenerator.SoldHulls(_cache);
var sold = _cache.GetAll<FactionProductData>().Where(p => p.Manufacturer.IsSet()).Select(p => p.Design.Key).ToHashSet();
var generator = PreludeGenerator();
var wrong = _cache.GetAll<EquippableItemData>()
@ -366,7 +366,7 @@ public sealed partial class RunStartTests : IDisposable
"the Autocannon fits the turret's 8-cell Ballistic hardpoint, so it is offered");
}
// Gear no hull can fit is never offered; authoring a hull whose hardpoint takes it makes it offered.
// Gear no sold hull can fit is never offered; a product selling a hull whose hardpoint takes it makes it offered.
[Fact]
public void GearNoHullCanFitIsOfferedOnlyOnceAHullTakesIt()
{
@ -378,10 +378,15 @@ public sealed partial class RunStartTests : IDisposable
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
var hardpointShape = new Shape(8, 8);
foreach (var cell in hardpointShape.AllCoordinates) hardpointShape[cell] = true;
_cache.Upsert(new HullData
var bigMount = new HullData
{
Name = "Big Mount", HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 1000, Price = 1,
Hardpoints = { new HardpointData { Type = HardpointType.Ballistic, Position = int2(0, 0), Shape = hardpointShape } }
};
_cache.Upsert(bigMount);
_cache.Upsert(new FactionProductData
{
Name = "Big Mount", Design = new CultRecordRef<CraftedItemData>(_cache.RefOf(bigMount).Key), Manufacturer = _cache.RefOf(_protagonist)
});
Assert.True(Offered(generator, huge), "a hull whose hardpoint takes it gives it a home");
}
@ -439,17 +444,17 @@ public sealed partial class RunStartTests : IDisposable
Assert.True(disagreements.Count == 0, string.Join("; ", disagreements.Take(10)) + $" ({disagreements.Count} in all)");
}
// The sold hardpoint gear that no hull fits even loosely today, stated so that authoring a hull that takes one
// shows up here as a changed list.
// The sold hardpoint gear the generator gives no home today: the production rule (LoadoutGenerator.HasHome over the hulls a
// product sells). The Smoke Test Hull, which no product sells, gives ChargeBlast SG and ChargeBlast+- no home; authoring a
// sold hull that takes one shows up here as a changed list.
[Fact]
public void TheSoldHardpointGearWithNoHomeToday()
{
var hulls = _cache.GetAll<HullData>().ToArray();
var sold = _cache.GetAll<FactionProductData>().Select(product => product.Design.Key).ToHashSet();
var hulls = LoadoutGenerator.SoldHulls(_cache);
var homeless = _cache.GetAll<EquippableItemData>()
.Where(design => sold.Contains(_cache.RefOf(design).Key) && design.Price > 0)
.Where(design => design.HardpointType != HardpointType.Tool && design.HardpointType != HardpointType.Hull)
.Where(design => !hulls.Any(hull => hull.Hardpoints.Any(hardpoint => hardpoint.Takes(design))))
.Where(design => !LoadoutGenerator.HasHome(design, hulls))
.Select(design => design.Name).OrderBy(name => name, StringComparer.Ordinal).ToArray();
Assert.Equal(new[] { "CShot RainbowLite Lazer", "ChargeBlast SG", "ChargeBlast+-", "OK Disperser" }, homeless);
}

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.EquippableItem.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,93 @@
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" }
.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 (moved over by the margin the flank blocks need), 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) = (fitted[0], fitted[1]);
Assert.Equal(longinus.Hardpoints.Select(h => (h.Type, h.Position.x + 2, h.Position.y, h.Rotation)),
hull.Hardpoints.Take(longinus.Hardpoints.Count).Select(h => (h.Type, h.Position.x, h.Position.y, h.Rotation)));
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(autocannon));
Assert.Single(added, h => h.Type == HardpointType.Energy && h.Rotation == ItemRotation.CounterClockwise && h.Takes(chargeBlast));
// Each is a full 2x2 block with the default firing arc (0 uses the game setting; the side mounts bear by it, never wider)
// and the Longinus-class armour the command writes.
Assert.All(added, hardpoint =>
{
Assert.Equal((2, 2, 0f, 20f), (hardpoint.Shape.Width, hardpoint.Shape.Height, hardpoint.FiringArc, hardpoint.Armor));
Assert.Equal(4, hardpoint.Shape.Coordinates.Length);
});
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,87 @@
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]);
// The flank hulls lie abeam from the player's facing, read from the staged positions: within 5 degrees of the
// perpendicular, starboard on the right of the facing and port on the left.
var facing = player.Direction;
var right = CultMath.math.float2(facing.y, -facing.x);
float Abeam(Entity flank, CultMath.float2 side) => CultMath.math.dot(CultMath.math.normalize(flank.Position.xz - player.Position.xz), side);
var abeam = CultMath.math.cos(CultMath.math.radians(5f));
Assert.True(Abeam(starboard, right) >= abeam, "the starboard hull is not abeam to starboard");
Assert.True(Abeam(port, -right) >= abeam, "the port hull is not abeam to port");
var mounts = WeaponMounts(player);
var forward = mounts.Where(item => item.EquippableItem.Rotation == ItemRotation.None).ToList();
var starboardMounts = mounts.Where(item => item.EquippableItem.Rotation == ItemRotation.Clockwise).ToList();
var portMounts = mounts.Where(item => item.EquippableItem.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,73 @@ 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 2x2 side mount on each flank, a Clockwise Ballistic one on the starboard and a
// CounterClockwise Energy one on the port, which need two columns of margin on each side: every Longinus cell and
// hardpoint moves right by two. Their gear is unchanged. 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(h => h.Name == name))
{
Console.WriteLine($"\"{name}\" is already authored; nothing to do.");
return 0;
}
var longinus = db.Cache.GetAll<HullData>().Single(h => h.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 cells of margin on each side (every Longinus cell and hardpoint moves right by Margin), two rows of cells past
// the bow over the width of the tail's neighbours, and a 2x2 block of cells on each flank at the wide section.
const int Margin = 2;
var width = longinus.Shape.Width + 2 * Margin;
var bow = longinus.Shape.Height;
var grown = new Shape(width, bow + 2);
for (var x = 0; x < longinus.Shape.Width; x++)
for (var y = 0; y < bow; y++) grown.Cells[x + Margin, y] = longinus.Shape.Cells[x, y];
for (var x = Margin + 1; x <= Margin + 4; x++)
for (var y = bow; y < grown.Height; y++) grown.Cells[x, y] = true;
foreach (var x in new[] { 0, 1, width - 2, width - 1 })
for (var y = 4; y <= 5; y++) grown.Cells[x, y] = true;
hull.Shape = grown;
foreach (var existing in hull.Hardpoints) existing.Position = CultMath.math.int2(existing.Position.x + Margin, existing.Position.y);
static Shape Cells(int side)
{
var shape = new Shape(side, side);
for (var x = 0; x < side; x++)
for (var y = 0; y < side; y++) shape.Cells[x, y] = true;
return shape;
}
hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = CultMath.math.int2(Margin + 1, bow), Shape = Cells(2), Rotation = ItemRotation.None, Armor = 20f });
hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Ballistic, Position = CultMath.math.int2(Margin + 3, bow), Shape = Cells(2), Rotation = ItemRotation.None, Armor = 20f });
hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Ballistic, Position = CultMath.math.int2(width - 2, 4), Shape = Cells(2), Rotation = ItemRotation.Clockwise, Armor = 20f });
hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = CultMath.math.int2(0, 4), Shape = Cells(2), Rotation = ItemRotation.CounterClockwise, Armor = 20f });
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("\nDry run. Pass \"apply\" to author it.");
return 0;
}
CultRecordRefs.Validate(hull);
db.Cache.Commit(batch => batch.Upsert(typeof(HullData), hull));
Console.WriteLine($"\nAuthored \"{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