diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index ca89ac58d..1ce9ab03c 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -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(Predicate filter, bool required) where T : CraftedItemData { - var hulls = ItemManager.ItemData.GetAll().ToArray(); + var hulls = SoldHulls(ItemManager.ItemData); var candidates = ItemManager.ItemData.GetAll() .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().Select(product => items.Get(product.Design)).OfType().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))); diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index 9103f7d63..fcbd5dbe7 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -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); } } diff --git a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs index 38cbfea88..9022f53ee 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 Pursuit(), new Crossing() + new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new WeaponFeel(), new LongHaul(), new Pursuit(), new Crossing() }; } diff --git a/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs b/Assets/Scripts/ServerShared/Scenarios/WeaponFeel.cs new file mode 100644 index 000000000..95cc36123 --- /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(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); + } +} 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/GameData/Aetheria.cc b/GameData/Aetheria.cc index 28d90cb98..4452fe8c0 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:613f3742be45fd252c9c0250c642491eb8577f23a7a0cfc23bad5b329bd631d6 -size 12722563 +oid sha256:3497996d4601a12a73a32f6888d699a5685128fcab2223b9e0519f96ae2dfd6c +size 12723747 diff --git a/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs b/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs index 9d9b79056..67ae08d22 100644 --- a/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs +++ b/tests/Aetheria.Shared.Tests/PiratesRecordTests.cs @@ -29,7 +29,7 @@ public sealed partial class RunStartTests var products = PiratesProducts(); Assert.Equal(8, products.Length); - var hulls = _cache.GetAll().ToArray(); + var hulls = LoadoutGenerator.SoldHulls(_cache); foreach (var product in products) { var design = _cache.Get(product.Design) as EquippableItemData; diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index e2ac85f49..4086e4b4a 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -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().Where(h => h.HullType == HullType.Ship).OrderBy(h => h.Name, StringComparer.Ordinal).ToArray(); + var sold = cache.GetAll().Select(product => product.Design.Key).ToHashSet(); + var ships = cache.GetAll().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) { diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index f1e76f5f6..5edaa035b 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -353,7 +353,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void GenerationOffersExactlyTheSoldGearSomeHullCanFit() { - var hulls = _cache.GetAll().ToArray(); + var hulls = LoadoutGenerator.SoldHulls(_cache); var sold = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).Select(p => p.Design.Key).ToHashSet(); var generator = PreludeGenerator(); var wrong = _cache.GetAll() @@ -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(_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().ToArray(); var sold = _cache.GetAll().Select(product => product.Design.Key).ToHashSet(); + var hulls = LoadoutGenerator.SoldHulls(_cache); var homeless = _cache.GetAll() .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); } diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index 986f8658b..73097e9ab 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.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 diff --git a/tests/Aetheria.Shared.Tests/SmokeHullCommandTests.cs b/tests/Aetheria.Shared.Tests/SmokeHullCommandTests.cs new file mode 100644 index 000000000..fdf3f9526 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeHullCommandTests.cs @@ -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().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" } + .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 (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); + } +} diff --git a/tests/Aetheria.Shared.Tests/SmokeHullScenarioTests.cs b/tests/Aetheria.Shared.Tests/SmokeHullScenarioTests.cs new file mode 100644 index 000000000..7cc2ef469 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeHullScenarioTests.cs @@ -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 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]); + // 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)); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index d5b633412..b24161383 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,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().Any(h => h.Name == name)) + { + Console.WriteLine($"\"{name}\" is already authored; nothing to do."); + return 0; + } + + var longinus = db.Cache.GetAll().Single(h => h.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 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