From 93d5a0c69cfd5a2b6077c170cae068d3f88e14bc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:33:22 +0200 Subject: [PATCH 1/9] AetherDb smoke-rounds: the five product-less smoke weapons, dry run unless apply Smoke Proximity, Smoke Delayed and Smoke Refused are Spectra clones with the operator's fuse values; Smoke Refused Beam is a constant weapon and Smoke Refused Charge a charged one cloned from ChargeBlast SG, each fused with a Range below its BlastRadius. No products. SmokeRoundsCommandTests covers dry run, apply and the no-op second apply. The catalog and the arenas that carry the designs follow in the next commit. This commit builds; its command test needs no catalog change. Co-Authored-By: Claude Opus 5.5 --- .../SmokeRoundsCommandTests.cs | 91 +++++++++++++++++++ tools/AetherDb/Program.cs | 86 +++++++++++++++++- 2 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs new file mode 100644 index 000000000..716a41c39 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs @@ -0,0 +1,91 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// AetherDb's smoke-rounds command (aetheria-release, cut scenarios-smoke-rounds), run on a scratch copy of the shipped catalog +// with the five smoke weapons removed from it. +public sealed class SmokeRoundsCommandTests : IDisposable +{ + private static readonly string[] Names = { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam", "Smoke Refused Charge" }; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "smoke-rounds-command-" + Guid.NewGuid().ToString("N")); + private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + + public SmokeRoundsCommandTests() + { + 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 weapon in cache.GetAll().Where(w => Names.Contains(w.Name)).ToArray()) Assert.True(cache.Remove(cache.RefOf(weapon).Key)); + cache.FlushAsync().Wait(); + } + + public void Dispose() => Directory.Delete(_root, true); + + private (WeaponItemData[] Smoke, WeaponItemData Spectra, WeaponItemData ChargeBlast, int Products) Read() + { + var db = AetherDb.Open(root: _root); + try + { + var smoke = db.Cache.GetAll().Where(w => Names.Contains(w.Name)).ToArray(); + var keys = smoke.Select(w => db.Cache.RefOf(w).Key).ToArray(); + var products = db.Cache.GetAll().Count(p => keys.Any(k => p.Design.Key.Equals(k))); + return (smoke, db.Cache.GetAll().Single(w => w.Name == "Spectra"), db.Cache.GetAll().Single(w => w.Name == "ChargeBlast SG"), products); + } + finally { db.Cache.Dispose(); } + } + + [Fact] + public void A_dry_run_writes_nothing_and_apply_lands_five_productless_designs_once() + { + var before = File.ReadAllBytes(CatalogPath); + Assert.Empty(Read().Smoke); + + Assert.Equal(0, Program.SmokeRoundsCatalog(apply: false, root: _root)); + Assert.Equal(before, File.ReadAllBytes(CatalogPath)); + Assert.Empty(Read().Smoke); + + Assert.Equal(0, Program.SmokeRoundsCatalog(apply: true, root: _root)); + var landed = File.ReadAllBytes(CatalogPath); + Assert.NotEqual(before, landed); + var (smoke, spectra, chargeBlast, products) = Read(); + Assert.Equal(Names.OrderBy(n => n), smoke.Select(w => w.Name).OrderBy(n => n)); + Assert.Equal(0, products); + + WeaponItemData Design(string name) => smoke.Single(w => w.Name == name); + WeaponData Data(string name) => Design(name).Behaviors.OfType().Single(); + + // The operator's own values: the fuse and blast of each, Smoke Delayed's two cells of penetration, Smoke Refused's range of 20. + Assert.Equal((WeaponFuse.Proximity, 8f), (Design("Smoke Proximity").Fuse.Value, Design("Smoke Proximity").BlastRadius.Value)); + Assert.Equal((WeaponFuse.Delayed, 4f, 2f, 2f), (Design("Smoke Delayed").Fuse.Value, Design("Smoke Delayed").BlastRadius.Value, Data("Smoke Delayed").Penetration.Min, Data("Smoke Delayed").Penetration.Max)); + Assert.Equal((WeaponFuse.Proximity, 30f, 20f, 20f), (Design("Smoke Refused").Fuse.Value, Design("Smoke Refused").BlastRadius.Value, Data("Smoke Refused").Range.Min, Data("Smoke Refused").Range.Max)); + + // Spectra clones are energy, 1x2, with the Spectra's own gear; the charged one is a ChargeBlast SG clone on its 2x2 energy mount. + foreach (var name in new[] { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam" }) + Assert.Equal((spectra.Hardpoint, spectra.Shape.Width, spectra.Shape.Height, spectra.Mass), (Design(name).Hardpoint, Design(name).Shape.Width, Design(name).Shape.Height, Design(name).Mass)); + Assert.Equal((chargeBlast.Hardpoint, chargeBlast.Shape.Width, chargeBlast.Shape.Height), (Design("Smoke Refused Charge").Hardpoint, 2, 2)); + + // The beam is a constant weapon and the charge a charged one, each fused with a Range below its BlastRadius. + Assert.IsType(Data("Smoke Refused Beam")); + Assert.IsType(Data("Smoke Refused Charge")); + foreach (var name in new[] { "Smoke Refused Beam", "Smoke Refused Charge" }) + { + Assert.NotNull(Design(name).Fuse); + Assert.True(Data(name).Range.Max < Design(name).BlastRadius, $"{name}'s range is not below its blast radius"); + } + + // A second apply finds them authored and writes nothing; so does a dry run. + Assert.Equal(0, Program.SmokeRoundsCatalog(apply: true, root: _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + Assert.Equal(0, Program.SmokeRoundsCatalog(apply: false, root: _root)); + Assert.Equal(landed, File.ReadAllBytes(CatalogPath)); + Assert.Equal(5, Read().Smoke.Length); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index b24161383..572346d7c 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -43,6 +43,7 @@ public static class Program 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 "smoke-rounds": return SmokeRoundsCatalog(args.Contains("apply")); case "roles-backfill": return RolesBackfill(args.Contains("apply")); case "consumables-first-set": return ConsumablesFirstSetCatalog(args.Contains("apply")); case "pirates-faction": @@ -50,7 +51,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], smoke-hull [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], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); return 1; } } @@ -1669,6 +1670,89 @@ public static class Program return 0; } + // aetheria-release cut scenarios-smoke-rounds (docs/scenarios-cut.md 5.3): the five smoke weapons the Fused Rounds, Refused + // Rounds and AI Fused Discipline arenas fit. None has a product, so no generated ship, station or shop offers one and only a + // scenario fits them. Smoke Proximity, Smoke Delayed and Smoke Refused are Spectra clones (energy, 1x2) with the operator's + // fuse values; Smoke Refused Beam is a constant weapon and Smoke Refused Charge a charged one cloned from ChargeBlast SG, + // each fused with a Range below its BlastRadius, so every refused weapon kind has a design. The rounds that fly are slow + // (Velocity 150) so a scenario can kill the target while one is in the air. Dry run unless passed "apply". + public static int SmokeRoundsCatalog(bool apply, string root = null) + { + var db = AetherDb.Open(catalogWritable: apply, root: root); + var weapons = db.Cache.GetAll().ToArray(); + var names = new[] { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam", "Smoke Refused Charge" }; + var already = names.Count(name => weapons.Any(w => w.Name == name)); + if (already == names.Length) + { + Console.WriteLine("The smoke rounds are already authored; nothing to do."); + return 0; + } + if (already != 0) throw new InvalidOperationException("The smoke rounds are partly authored; the catalog needs a human."); + + WeaponItemData Template(string name) => + weapons.FirstOrDefault(w => w.Name == name) ?? throw new InvalidOperationException($"No weapon design named \"{name}\"."); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(WeaponItemData).Assembly); + WeaponItemData Copy(string template, string name, string description, WeaponFuse fuse, float blast) + { + var source = Template(template); + var design = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(source, options), options); + design.Name = name; + design.Description = description; + design.Fuse = fuse; + design.BlastRadius = blast; + return design; + } + static PerformanceStat Fixed(float value) => new PerformanceStat { Min = value, Max = value, Terms = new List() }; + + var proximity = Copy("Spectra", names[0], "A Spectra that bursts by proximity, blast 8. For scenarios; no one builds it.", WeaponFuse.Proximity, 8f); + var delayed = Copy("Spectra", names[1], "A Spectra that bursts after two cells of metal, blast 4. For scenarios; no one builds it.", WeaponFuse.Delayed, 4f); + var refused = Copy("Spectra", names[2], "A Spectra whose blast is wider than its reach, so it never fires. For scenarios; no one builds it.", WeaponFuse.Proximity, 30f); + var charge = Copy("ChargeBlast SG", names[4], "A ChargeBlast SG whose blast is wider than its reach, so it never charges. For scenarios; no one builds it.", WeaponFuse.Proximity, 30f); + var beam = Copy("Spectra", names[3], "A constant beam whose blast is wider than its reach, so it never starts. For scenarios; no one builds it.", WeaponFuse.Proximity, 30f); + + foreach (var design in new[] { proximity, delayed }) + design.Behaviors.OfType().Single().Velocity = Fixed(150f); + delayed.Behaviors.OfType().Single().Penetration = Fixed(2f); + refused.Behaviors.OfType().Single().Range = Fixed(20f); + charge.Behaviors.OfType().Single().Range = Fixed(20f); + + // The beam keeps the Spectra's gear (shape, hardpoint, thermal envelope, roles) and swaps its behaviour for a constant one. + var instant = beam.Behaviors.OfType().Single(); + var constant = new ConstantWeaponData + { + DamageType = instant.DamageType, Damage = instant.Damage, Penetration = instant.Penetration, DamageSpread = instant.DamageSpread, + MinRange = instant.MinRange, Range = Fixed(20f), DamageCurve = instant.DamageCurve, EffectPrefab = instant.EffectPrefab, + Energy = instant.Energy, Heat = instant.Heat, Visibility = instant.Visibility, AmmoType = instant.AmmoType, + MagazineSize = instant.MagazineSize, ReloadTime = instant.ReloadTime, Spread = instant.Spread, Velocity = instant.Velocity, + Tracking = instant.Tracking + }; + beam.Behaviors.Remove(instant); + beam.Behaviors.Add(constant); + + var designs = new[] { proximity, delayed, refused, beam, charge }; + Console.WriteLine("Adds 5 weapon designs, no products:"); + foreach (var design in designs) + { + var data = design.Behaviors.OfType().Single(); + Console.WriteLine($" {design.Name,-20} {design.Hardpoint,-8} {design.Shape.Width}x{design.Shape.Height} {data.GetType().Name,-18} fuse {design.Fuse} blast {design.BlastRadius} range {data.Range.Min}-{data.Range.Max} velocity {data.Velocity.Min}"); + } + if (!apply) + { + Console.WriteLine(" +Dry run. Pass \"apply\" to author them."); + return 0; + } + + foreach (var design in designs) CultRecordRefs.Validate(design); + db.Cache.Commit(batch => + { + foreach (var design in designs) batch.Upsert(typeof(WeaponItemData), design); + }); + Console.WriteLine($" +Authored {designs.Length} designs 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 From db5fc667c984bcc61c718afc2d903a832c14f89c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:34:54 +0200 Subject: [PATCH 2/9] AetherDb smoke-rounds: fix two newline escapes the previous commit wrote as literal line breaks The previous commit (93d5a0c6) does not build: two string literals in SmokeRoundsCatalog carry a raw line break. This commit builds. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 572346d7c..7169b3614 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -1738,8 +1738,7 @@ public static class Program } if (!apply) { - Console.WriteLine(" -Dry run. Pass \"apply\" to author them."); + Console.WriteLine("\nDry run. Pass \"apply\" to author them."); return 0; } @@ -1748,8 +1747,7 @@ Dry run. Pass \"apply\" to author them."); { foreach (var design in designs) batch.Upsert(typeof(WeaponItemData), design); }); - Console.WriteLine($" -Authored {designs.Length} designs in Aetheria.cc"); + Console.WriteLine($"\nAuthored {designs.Length} designs in Aetheria.cc"); return 0; } From f44dadc5cfaafc33f6ab439339a9559219f89fc2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:36:57 +0200 Subject: [PATCH 3/9] Fused Rounds, Refused Rounds and AI Fused Discipline arenas, and the five smoke weapons they carry The catalog gains Smoke Proximity, Smoke Delayed, Smoke Refused, Smoke Refused Beam and Smoke Refused Charge (AetherDb smoke-rounds apply), none with a product. Seeds 108, 109 and 110 stage the demo's cast with the Smoke Test Hull; SmokeRoundsScenarioTests pins their conditions and that no smoke weapon is offered. Design and arenas land together so the census never sees an uncarried design. Not yet run: the verification job. Co-Authored-By: Claude Opus 5.5 --- .../Scenarios/AiFusedDiscipline.cs | 36 ++++ .../Scenarios/AiFusedDiscipline.cs.meta | 11 ++ .../ServerShared/Scenarios/FusedRounds.cs | 51 ++++++ .../Scenarios/FusedRounds.cs.meta | 11 ++ .../ServerShared/Scenarios/RefusedRounds.cs | 40 +++++ .../Scenarios/RefusedRounds.cs.meta | 11 ++ .../ServerShared/Scenarios/Scenarios.cs | 2 +- GameData/Aetheria.cc | 4 +- .../SmokeRoundsScenarioTests.cs | 161 ++++++++++++++++++ 9 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs.meta create mode 100644 Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs create mode 100644 Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs diff --git a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs new file mode 100644 index 000000000..65f5062d5 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs @@ -0,0 +1,36 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class AiFusedDiscipline : Scenario +{ + public override string Name => "AI Fused Discipline"; + public override string Brief => "A Djinni; a piloted neutral Longinus carrying a Smoke Proximity and a piloted hostile Longinus whose only weapon is " + + "Smoke Refused. Verify: the hostile closes on you and never fires its refused weapon; the neutral never fires its " + + "fused one."; + public override uint Seed => 110; + public override bool Ambient => false; + + public static readonly CultMath.float2 Hostile = float2(0, 700), Neutral = float2(500, 300); + + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(DemoTerminus.Cast); + + public override void Stage(ScenarioStage stage) + { + ScenarioFit Armed(string weapon) => stage.Fit("Longinus", + ("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), + (weapon, 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(stage.Generated("Djinni"), float2(0, 0)); + stage.Place(Armed("Smoke Refused"), Hostile, facing: float2(0, -1), stance: ScenarioStance.Hostile, piloted: true); + stage.Place(Armed("Smoke Proximity"), Neutral, facing: float2(-1, 0), piloted: true); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs.meta b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs.meta new file mode 100644 index 000000000..5994a43a7 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/AiFusedDiscipline.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0d71dbf0ab5847a6ab58f96a50678ac7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs new file mode 100644 index 000000000..e0a8c029f --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs @@ -0,0 +1,51 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class FusedRounds : Scenario +{ + public override string Name => "Fused Rounds"; + public override string Brief => "The Smoke Test Hull with Smoke Proximity, Smoke Delayed and a DeathCluster; a hostile bare Longinus at mid range, a bare " + + "hull dead ahead, a fragile one to starboard, a close one inside the proximity blast radius, and one astern. " + + "Verify with nothing selected: aim past every hull and the round bursts at max range along the aim; aim at the hull " + + "ahead and it stops and bursts on it; fire at the fragile hull and kill it with the DeathCluster before the round " + + "lands: no burst. Select the astern hull: the round fires and bursts at its range along the arc edge. Select the " + + "close hull: the burst is pushed out to the blast radius from your centre. A Smoke Proximity burst beside the " + + "Longinus damages where the model shows the blast; a Smoke Delayed round into its nose bursts inside. The HUD " + + "reads Direct, Burst at N m or Refused for the fused weapons and a percentage for the DeathCluster, and the " + + "smoke weapons show no Manufacturer row."; + public override uint Seed => 108; + public override bool Ambient => false; + + // Where everything sits: the player is at the origin facing +z. Ahead lies on the initial aim line, inside every forward + // mount's arc. Fragile is a bare Longinus inside the DeathCluster's range; Close is inside Smoke Proximity's blast radius + // (8 m from the ship's centre) and beside the aim line, not across it; Stern is outside every mount's arc. + public static readonly CultMath.float2 Mid = float2(-200, 600), Ahead = float2(0, 300), Fragile = float2(140, 420), + Close = float2(6.4f, 4f), Stern = 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), + ("Smoke Proximity", int2(3, 8), None), + ("Smoke Delayed", int2(6, 8), None), + ("DeathCluster", int2(5, 17), 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"), Mid, stance: ScenarioStance.Hostile); + stage.Place(stage.Bare("Longinus"), Ahead); + stage.Place(stage.Bare("Longinus"), Fragile); + stage.Place(stage.Bare("Longinus"), Close); + stage.Place(stage.Bare("Longinus"), Stern); + } +} diff --git a/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs.meta b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs.meta new file mode 100644 index 000000000..0e96bab70 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/FusedRounds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4afe22415e8546849f2366c39a40fd8f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs new file mode 100644 index 000000000..5d77cb466 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs @@ -0,0 +1,40 @@ +using static CultMath.math; +using static ItemRotation; + +public sealed class RefusedRounds : Scenario +{ + public override string Name => "Refused Rounds"; + public override string Brief => "The Smoke Test Hull with Smoke Refused, Smoke Refused Beam, Smoke Refused Charge and an Autocannon; a bare Longinus " + + "dead ahead. Each smoke weapon's blast radius is wider than its range, so it can never be fired. Verify: pull each " + + "trigger and a refused shot costs no ammo, energy, heat, sound, visibility or cooldown, the beam never starts and " + + "the charged weapon never starts charging; the Autocannon fires normally."; + public override uint Seed => 109; + 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, 300); + + 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), + ("Smoke Refused", int2(3, 8), None), + ("Smoke Refused Beam", int2(6, 8), None), + ("Smoke Refused Charge", int2(3, 17), None), + ("Autocannon", int2(5, 17), 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/RefusedRounds.cs.meta b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs.meta new file mode 100644 index 000000000..45c0cde82 --- /dev/null +++ b/Assets/Scripts/ServerShared/Scenarios/RefusedRounds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c72528ae3b7144989483462543f2de88 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs b/Assets/Scripts/ServerShared/Scenarios/Scenarios.cs index 9022f53ee..5f32f2e83 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 WeaponFeel(), new LongHaul(), new Pursuit(), new Crossing() + new DjinniShakedown(), new StarvedReactor(), new Arcs(), new Duel(), new LauncherAngles(), new WeaponFeel(), new FusedRounds(), new RefusedRounds(), new AiFusedDiscipline(), new LongHaul(), new Pursuit(), new Crossing() }; } diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 4452fe8c0..3a467909f 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3497996d4601a12a73a32f6888d699a5685128fcab2223b9e0519f96ae2dfd6c -size 12723747 +oid sha256:a44ecdd4d4b470cc67534dd21234da41096ed2d246927be486d45c240164d790 +size 12728634 diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs new file mode 100644 index 000000000..bbf5385ef --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs @@ -0,0 +1,161 @@ +using System.Linq; +using Xunit; +using static CultMath.math; + +// aetheria-release, cut scenarios-smoke-rounds: what the Fused Rounds, Refused Rounds and AI Fused Discipline arenas stage, +// read from the launched run, the shipped catalog and the production fire solution (FireControl.Solve) the HUD and the +// weapons both ask. +public sealed partial class RunStartTests +{ + private static readonly string[] SmokeWeapons = { "Smoke Proximity", "Smoke Delayed", "Smoke Refused", "Smoke Refused Beam", "Smoke Refused Charge" }; + + private static EquippedItem Carried(Entity ship, string design) => Assert.Single(ship.Equipment, item => item.Data.Name == design); + + private static Weapon WeaponOf(EquippedItem mount) => mount.Behaviors.OfType().Single(); + + private static FireOutcome OutcomeOn(Entity shooter, EquippedItem mount, Entity target) => FireControl.Solve(WeaponOf(mount), mount, shooter, target).Outcome; + + // Fused Rounds: the player carries Smoke Proximity, Smoke Delayed and a direct weapon with nothing selected; a bare hull + // lies within the arc on the initial aim line; the close hull is nearer than Smoke Proximity's blast radius; the stern + // target is outside every mount's arc; the direct weapon can kill the fragile hull (damage x shots in its magazine above + // its hull). The smoke weapons are unbranded and the fused weapons read Burst where the direct one reads Direct. + [Fact] + public void FusedRoundsConditions() + { + var (_, _, staged, failures) = Launch(new FusedRounds(), Inputs(() => GalaxySeed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + var player = staged.Player; + player.GenerateWeaponGroups(); + EvSettle(player); // a weapon's Range is read once the ship's stats have settled; the geometry is as staged + Assert.Equal(5, staged.Entities.Count); + var (mid, ahead, fragile, close, stern) = (staged.Entities[0], staged.Entities[1], staged.Entities[2], staged.Entities[3], staged.Entities[4]); + Assert.Equal((FusedRounds.Mid, FusedRounds.Ahead, FusedRounds.Fragile, FusedRounds.Close, FusedRounds.Stern), + (mid.Position.xz, ahead.Position.xz, fragile.Position.xz, close.Position.xz, stern.Position.xz)); + + var proximity = Carried(player, "Smoke Proximity"); + var delayed = Carried(player, "Smoke Delayed"); + var direct = Carried(player, "DeathCluster"); + Assert.Null(player.Target.Value.Entity); + + // The bare hull is on the initial aim line, ahead, and inside every weapon mount's arc. + var aim = player.Direction; + Assert.True(abs(aim.x * ahead.Position.z - aim.y * ahead.Position.x) < 1e-3f && dot(aim, ahead.Position.xz) > 0, "the bare hull is not on the player's initial aim line"); + foreach (var mount in WeaponMounts(player)) + Assert.True(Bears(mount, player, ahead), $"{mount.Data.Name} cannot bear on the bare hull ahead"); + + // The stern target is outside every mount's arc. + foreach (var mount in WeaponMounts(player)) + Assert.False(Bears(mount, player, stern), $"{mount.Data.Name} bears on the stern hull"); + + // The close hull is nearer than Smoke Proximity's blast radius, measured from the ship's centre, and inside its arc. + Assert.NotNull(FireControl.FuseOf(proximity, out var blast)); + Assert.True(length((close.Position - player.Position).xz) < blast, "the close hull is not inside Smoke Proximity's blast radius"); + Assert.True(Bears(proximity, player, close), "Smoke Proximity cannot bear on the close hull"); + + // The fragile and hostile hulls are reachable by the fused weapons, the fragile one by the direct weapon too, and the + // direct weapon's magazine holds enough damage to kill the fragile hull. + foreach (var target in new[] { mid, fragile }) + { + Assert.True(Bears(proximity, player, target) && Bears(delayed, player, target), "a fused weapon cannot bear on a target hull"); + Assert.True(length((target.Position - player.Position).xz) <= WeaponOf(proximity).Range, "a target hull lies beyond Smoke Proximity's range"); + } + var directWeapon = WeaponOf(direct); + Assert.True(Bears(direct, player, fragile), "the direct weapon cannot bear on the fragile hull"); + Assert.True(length((fragile.Position - player.Position).xz) <= directWeapon.Range, "the fragile hull lies beyond the direct weapon's range"); + var magazine = direct.Data.Behaviors.OfType().Single().MagazineSize; + Assert.True(magazine > 0 && directWeapon.Damage * magazine > ((HullData) _items.GetData(fragile.Hull)).Durability, + "the direct weapon cannot kill the fragile hull from its magazine"); + + // Only the Longinus at mid range is hostile. + Assert.True(player.IsHostileTo(mid) && mid.IsHostileTo(player), "the mid-range hull is hostile"); + Assert.False(new[] { ahead, fragile, close, stern }.Any(entity => player.IsHostileTo(entity)), "a bare hull is hostile"); + + // What the HUD forecasts: fused weapons burst, the direct weapon resolves against its target. + Assert.Equal((FireOutcome.Burst, FireOutcome.Burst, FireOutcome.Direct), + (OutcomeOn(player, proximity, ahead), OutcomeOn(player, delayed, ahead), OutcomeOn(player, direct, ahead))); + + // The smoke weapons carry no manufacturer and no product, so the properties panel shows no Manufacturer row. + foreach (var mount in new[] { proximity, delayed }) + { + var (maker, product) = _items.Brand((CraftedItemInstance) mount.EquippableItem); + Assert.Null(maker); + Assert.Null(product); + } + } + + // Refused Rounds: Smoke Refused, Smoke Refused Beam and Smoke Refused Charge, each with a Range below its BlastRadius, and one + // working weapon. AI Fused Discipline: a piloted neutral carrying a fused weapon and a piloted hostile whose only weapon is + // refused. + [Fact] + public void RefusedAndAiConditions() + { + var (_, _, rounds, roundsFailures) = Launch(new RefusedRounds(), Inputs(() => GalaxySeed)); + Assert.True(roundsFailures.Count == 0, string.Join("; ", roundsFailures)); + var player = rounds.Player; + player.GenerateWeaponGroups(); + EvSettle(player); + var target = Assert.Single(rounds.Entities); + Assert.Equal(4, WeaponMounts(player).Count); + + foreach (var design in SmokeWeapons.Skip(2)) + { + var mount = Carried(player, design); + var weapon = WeaponOf(mount); + Assert.NotNull(FireControl.FuseOf(mount, out var blast)); + Assert.True(weapon.Range > 0f, $"{design}'s range has not settled"); + Assert.True(weapon.Range < blast, $"{design}'s range is not below its blast radius"); + Assert.True(Bears(mount, player, target), $"{design} cannot bear on the bare hull"); + Assert.Equal(FireOutcome.Refused, OutcomeOn(player, mount, target)); + } + Assert.IsType(WeaponOf(Carried(player, "Smoke Refused Beam"))); + Assert.IsType(WeaponOf(Carried(player, "Smoke Refused Charge"))); + var working = Carried(player, "Autocannon"); + Assert.True(Bears(working, player, target) && length((target.Position - player.Position).xz) <= WeaponOf(working).Range, "the working weapon cannot reach the bare hull"); + Assert.Equal(FireOutcome.Direct, OutcomeOn(player, working, target)); + + var (_, arena, ai, aiFailures) = Launch(new AiFusedDiscipline(), Inputs(() => GalaxySeed)); + Assert.True(aiFailures.Count == 0, string.Join("; ", aiFailures)); + Assert.Equal(2, ai.Entities.Count); + var (hostile, neutral) = (ai.Entities[0], ai.Entities[1]); + Assert.True(ai.Player.IsHostileTo(hostile) && hostile.IsHostileTo(ai.Player), "the refused-weapon ship is hostile"); + Assert.False(ai.Player.IsHostileTo(neutral) || neutral.IsHostileTo(ai.Player), "the fused-weapon ship is neutral"); + Assert.Equal((1, 1), (AgentsOf(arena, hostile), AgentsOf(arena, neutral))); + Assert.Equal("Djinni", _items.GetData(ai.Player.Hull).Name); + + EvSettle((Ship) hostile); + EvSettle((Ship) neutral); + var refused = Assert.Single(WeaponMounts(hostile)); + Assert.Equal("Smoke Refused", refused.Data.Name); + Assert.True(WeaponOf(refused).Range > 0f, "the hostile's refused weapon range has not settled"); + Assert.Equal(FireOutcome.Refused, OutcomeOn(hostile, refused, ai.Player)); + var fused = Assert.Single(WeaponMounts(neutral)); + Assert.Equal("Smoke Proximity", fused.Data.Name); + Assert.NotNull(FireControl.FuseOf(fused, out _)); + Assert.Equal(FireOutcome.Burst, OutcomeOn(neutral, fused, ai.Player)); + } + + // The five smoke weapons have no product, so nothing offers them: no product names one, the charged one has no home on any + // hull a product sells, and no generated ship carries one. + [Fact] + public void SmokeWeaponsAreNeverOffered() + { + var designs = _cache.GetAll().Where(design => SmokeWeapons.Contains(design.Name)).ToArray(); + Assert.Equal(SmokeWeapons.OrderBy(name => name), designs.Select(design => design.Name).OrderBy(name => name)); + var keys = designs.Select(design => _cache.RefOf(design).Key).ToArray(); + Assert.DoesNotContain(_cache.GetAll(), product => keys.Any(key => product.Design.Key.Equals(key))); + Assert.False(LoadoutGenerator.HasHome(designs.Single(design => design.Name == "Smoke Refused Charge"), LoadoutGenerator.SoldHulls(_cache))); + + var arena = Arena(false); + var generator = new LoadoutGenerator(ref _items.Random, _items, arena.Galaxy, arena.GalaxyZone, null, 2); + var carried = 0; + for (var i = 0; i < 100; i++) + { + var pack = generator.GenerateShipLoadout(); + Assert.NotNull(pack); + var ship = EntitySerializer.Unpack(_items, arena, pack); + carried += ship.Equipment.Count(); + Assert.DoesNotContain(ship.Equipment, item => SmokeWeapons.Contains(item.Data.Name)); + } + Assert.True(carried > 100, "the generated ships carried no gear to check"); + } +} From 5d5bdc25f8bc48eeb9f88ee0aaf4fe6cfa4b2487 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:40:03 +0200 Subject: [PATCH 4/9] SmokeRoundsScenarioTests: the kill check names its numbers Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs index bbf5385ef..f837e3d9f 100644 --- a/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs @@ -64,7 +64,7 @@ public sealed partial class RunStartTests Assert.True(length((fragile.Position - player.Position).xz) <= directWeapon.Range, "the fragile hull lies beyond the direct weapon's range"); var magazine = direct.Data.Behaviors.OfType().Single().MagazineSize; Assert.True(magazine > 0 && directWeapon.Damage * magazine > ((HullData) _items.GetData(fragile.Hull)).Durability, - "the direct weapon cannot kill the fragile hull from its magazine"); + $"the direct weapon cannot kill the fragile hull from its magazine ({directWeapon.Damage} x {magazine})"); // Only the Longinus at mid range is hostile. Assert.True(player.IsHostileTo(mid) && mid.IsHostileTo(player), "the mid-range hull is hostile"); From 0388e39913d8dc50a3a06b77a6aac173ffc045b2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:43:41 +0200 Subject: [PATCH 5/9] SmokeRoundsScenarioTests: the direct weapon's kill check counts a burst's rounds (Damage is per round, ammo per burst) Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs index f837e3d9f..eaf07de29 100644 --- a/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs @@ -63,8 +63,9 @@ public sealed partial class RunStartTests Assert.True(Bears(direct, player, fragile), "the direct weapon cannot bear on the fragile hull"); Assert.True(length((fragile.Position - player.Position).xz) <= directWeapon.Range, "the fragile hull lies beyond the direct weapon's range"); var magazine = direct.Data.Behaviors.OfType().Single().MagazineSize; - Assert.True(magazine > 0 && directWeapon.Damage * magazine > ((HullData) _items.GetData(fragile.Hull)).Durability, - $"the direct weapon cannot kill the fragile hull from its magazine ({directWeapon.Damage} x {magazine})"); + var burst = ((InstantWeapon) directWeapon).BurstCount; + Assert.True(magazine > 0 && directWeapon.Damage * burst * magazine > ((HullData) _items.GetData(fragile.Hull)).Durability, + $"the direct weapon cannot kill the fragile hull from its magazine ({directWeapon.Damage} x {burst} x {magazine})"); // Only the Longinus at mid range is hostile. Assert.True(player.IsHostileTo(mid) && mid.IsHostileTo(player), "the mid-range hull is hostile"); From 445bdb66f24bb30a55fbc08f74317eccae79726a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 04:00:55 +0200 Subject: [PATCH 6/9] FireControlCut124Tests: the shipped catalog is fuse-neutral for the weapons a product sells; the product-less smoke weapons are the fused designs Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index 03651b19f..a1d5445e8 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -2580,7 +2580,7 @@ public sealed partial class FireControlCut124Tests : IDisposable return cache; } - // Every WeaponItemData in the shipped catalog has a null BlastRadius and a null Fuse, and the weapon + // Every WeaponItemData a product sells in the shipped catalog has a null BlastRadius and a null Fuse, and the weapon // schema's own migration report is Exact. (Until the scenarios product-gap pass rewrote the catalog on // 2026-09-30 it was CompatibleDrift with only slot 32 defaulted: the file predated BlastRadius and Fuse.) [Fact] @@ -2593,7 +2593,9 @@ public sealed partial class FireControlCut124Tests : IDisposable var weapons = cache.GetAll().ToList(); Assert.NotEmpty(weapons); // A mine layer is the one shipped weapon that carries a blast: its radius is the mine's (MineLauncherCatalogTests). - foreach (var w in weapons.Where(w => !w.Behaviors.OfType().Any())) + // The product-less smoke weapons are the fused designs scenarios fit; only what a product sells is neutral. + var sold = cache.GetAll().Select(product => product.Design.Key).ToHashSet(); + foreach (var w in weapons.Where(w => !w.Behaviors.OfType().Any() && sold.Contains(cache.RefOf(w).Key))) { Assert.Null(w.BlastRadius); Assert.Null(w.Fuse); From b0de7c746b0a15d46c8895076321ea0f1483e4cf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 19:50:13 +0200 Subject: [PATCH 7/9] AetherDb smoke-rounds: Smoke Proximity and Smoke Delayed draw 4 energy a shot A stepped Fused Rounds arena showed no round fired from any weapon aboard, the DeathCluster included: the Smoke Test Hull's one 2x2 reactor (Core Power, charge 100) cannot carry two Spectra clones at 20-40 energy a shot, overloads, reaches 445 K within a second and goes offline. The catalog is regenerated in the next commit; this commit's command no longer matches the committed catalog, so SmokeRoundsCommandTests/stepped tests are not meaningful at this commit alone. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 7169b3614..819b9cf88 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -1710,8 +1710,15 @@ public static class Program var charge = Copy("ChargeBlast SG", names[4], "A ChargeBlast SG whose blast is wider than its reach, so it never charges. For scenarios; no one builds it.", WeaponFuse.Proximity, 30f); var beam = Copy("Spectra", names[3], "A constant beam whose blast is wider than its reach, so it never starts. For scenarios; no one builds it.", WeaponFuse.Proximity, 30f); + // The Smoke Test Hull carries one 2x2 reactor. A Spectra's 20-40 energy a shot at its cadence is more than that reactor makes, + // so two of them overload it, it overheats and shuts down within a second, and nothing aboard fires again (Fused Rounds, + // stepped, before this). The two rounds a scenario must fly draw like an Autocannon instead. foreach (var design in new[] { proximity, delayed }) - design.Behaviors.OfType().Single().Velocity = Fixed(150f); + { + var data = design.Behaviors.OfType().Single(); + data.Velocity = Fixed(150f); + data.Energy = Fixed(4f); + } delayed.Behaviors.OfType().Single().Penetration = Fixed(2f); refused.Behaviors.OfType().Single().Range = Fixed(20f); charge.Behaviors.OfType().Single().Range = Fixed(20f); From aac3e9f26032487a6e1f37d61dab1b5251a525d6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 19:53:12 +0200 Subject: [PATCH 8/9] Stepped smoke-rounds tests with controls; the catalog regenerated with the lighter Smoke Proximity and Smoke Delayed The two fused rounds draw 4 energy a shot, so the Smoke Test Hull's one reactor carries the Fused Rounds fit and the rounds fire (sha256 b60cefa2). New tests: every weapon aboard fires in the stepped Fused Rounds arena with the DeathCluster as the control, the round's flight is seconds and bursts on the first hull, the DeathCluster kills the fragile hull before the round lands (A.3), the AI holds fire against its two controls, New Game lists the three arenas, and the command's replay writes the shipped records. Not run at this commit. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 +- .../SmokeRoundsCommandTests.cs | 20 ++- .../SmokeRoundsScenarioTests.cs | 11 ++ .../SmokeRoundsSteppedTests.cs | 129 ++++++++++++++++++ 4 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 3a467909f..6c3965b19 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a44ecdd4d4b470cc67534dd21234da41096ed2d246927be486d45c240164d790 -size 12728634 +oid sha256:b60cefa2308d9794ae481232c427c9149427f950c17a09e4a13e5381ce8e0623 +size 12728590 diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs index 716a41c39..7dd04c2f1 100644 --- a/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsCommandTests.cs @@ -1,8 +1,10 @@ using System; using System.IO; +using System.Collections.Generic; using System.Linq; using GameCult.Caching; using GameCult.Caching.MessagePack; +using MessagePack; using Xunit; // AetherDb's smoke-rounds command (aetheria-release, cut scenarios-smoke-rounds), run on a scratch copy of the shipped catalog @@ -14,6 +16,13 @@ public sealed class SmokeRoundsCommandTests : IDisposable private readonly string _root = Path.Combine(Path.GetTempPath(), "smoke-rounds-command-" + Guid.NewGuid().ToString("N")); private string CatalogPath => Path.Combine(_root, "GameData", "Aetheria.cc"); + // The five designs as the shipped catalog holds them, taken before they are removed from the scratch copy: apply must write + // exactly these, so the command stays the replay of what ships. + private readonly Dictionary _shipped = new Dictionary(); + + private static byte[] Pack(WeaponItemData design) => + MessagePackSerializer.Serialize(design, CultDocumentMessagePackSerialization.OptionsFor(typeof(WeaponItemData).Assembly)); + public SmokeRoundsCommandTests() { Directory.CreateDirectory(Path.Combine(_root, "GameData")); @@ -23,7 +32,12 @@ public sealed class SmokeRoundsCommandTests : IDisposable 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 weapon in cache.GetAll().Where(w => Names.Contains(w.Name)).ToArray()) Assert.True(cache.Remove(cache.RefOf(weapon).Key)); + foreach (var weapon in cache.GetAll().Where(w => Names.Contains(w.Name)).ToArray()) + { + _shipped[weapon.Name] = Pack(weapon); + Assert.True(cache.Remove(cache.RefOf(weapon).Key)); + } + Assert.Equal(Names.OrderBy(n => n), _shipped.Keys.OrderBy(n => n)); cache.FlushAsync().Wait(); } @@ -59,6 +73,10 @@ public sealed class SmokeRoundsCommandTests : IDisposable Assert.Equal(Names.OrderBy(n => n), smoke.Select(w => w.Name).OrderBy(n => n)); Assert.Equal(0, products); + // Replaying the command on the catalog without them writes the records the shipped catalog holds (the record keys are + // CultCache's to mint and differ run to run; the designs do not). + foreach (var design in smoke) Assert.True(_shipped[design.Name].AsSpan().SequenceEqual(Pack(design)), $"{design.Name} differs from the shipped catalog's record"); + WeaponItemData Design(string name) => smoke.Single(w => w.Name == name); WeaponData Data(string name) => Design(name).Behaviors.OfType().Single(); diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs index eaf07de29..bfd412452 100644 --- a/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsScenarioTests.cs @@ -159,4 +159,15 @@ public sealed partial class RunStartTests } Assert.True(carried > 100, "the generated ships carried no gear to check"); } + + // New Game lists the three arenas. The census (EveryScenario) is built from the same listing, so only a read of the listing + // itself notices an arena dropped from it. + [Fact] + public void NewGameListsTheThreeSmokeRoundsArenas() + { + var listed = Scenarios.Tests.Select(scenario => scenario.Name).ToArray(); + Assert.Contains(new FusedRounds().Name, listed); + Assert.Contains(new RefusedRounds().Name, listed); + Assert.Contains(new AiFusedDiscipline().Name, listed); + } } diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs new file mode 100644 index 000000000..56ba8a169 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using System.Linq; +using UniRx; +using Xunit; +using static CultMath.math; + +// aetheria-release, cut scenarios-smoke-rounds: the Fused Rounds and AI Fused Discipline arenas flown, stepped through +// Zone.Update with the production fire path (Activate, the weapons' Execute, FireControl.Step), each against a control that +// shows the harness fires when the rule allows it. SmokeRoundsScenarioTests reads what the arenas stage; this reads what they do. +public sealed partial class RunStartTests +{ + private const float SteppedDt = .05f; + + private (Zone Arena, RunStart.Staged Staged) LaunchStepped(Scenario scenario) + { + var (_, arena, staged, failures) = Launch(scenario, Inputs(() => GalaxySeed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + staged.Player.GenerateWeaponGroups(); + EvSettle(staged.Player); + return (arena, staged); + } + + // Fused Rounds, nothing selected, the player aiming down the bare hull ahead. The DeathCluster is the control: it fires + // in this arena, so a fused weapon that does not is not a harness that cannot fire. A fused round flies slowly enough + // to be in the air when the direct weapon kills the fragile hull (check A.3) and bursts on the first hull on the aim line. + [Theory] + [InlineData("DeathCluster")] + [InlineData("Smoke Proximity")] + [InlineData("Smoke Delayed")] + public void EveryWeaponAboardFiresInTheSteppedFusedRoundsArena(string design) + { + var (arena, staged) = LaunchStepped(new FusedRounds()); + var player = staged.Player; + var weapon = (InstantWeapon) WeaponOf(Carried(player, design)); + player.Aim = float3(0, 0, 1); + var fired = new List(); + var committed = new List<(float At, ShotOutcome Outcome)>(); + weapon.OnFire += _ => fired.Add(arena.Time); + using var subscription = arena.ShotCommitted.Subscribe(outcome => committed.Add((arena.Time, outcome))); + + weapon.Activate(); + for (var i = 0; i < 60; i++) arena.Update(SteppedDt); + + Assert.True(fired.Count > 0, $"{design} fired nothing in 3 s; the ship's reactor is {(player.PowerBus.TotalGeneration > 0f ? "running" : "down")}"); + if (design == "DeathCluster") return; + var (at, burst) = committed.First(); + Assert.Equal(ShotResult.Burst, burst.Result); + Assert.True(burst.HasBurstPoint); + var reach = length(burst.BurstPoint - player.Position.xz); + Assert.InRange(reach, FusedRounds.Ahead.y - 30f, FusedRounds.Ahead.y); // on the first hull in the way, not at max range + Assert.InRange(at + burst.ArrivalIn - fired[0], 1f, 5f); // in the air for seconds: not resolved in its firing tick, not minutes of flight + } + + // Check A.3 flown: the fragile hull is selected, a Smoke Proximity round is fired at it and the DeathCluster kills it + // before the round lands, so the round never bursts. + [Fact] + public void TheDirectWeaponKillsTheFragileHullWhileASmokeRoundIsStillInTheAir() + { + var (arena, staged) = LaunchStepped(new FusedRounds()); + var player = staged.Player; + var fragile = staged.Entities[2]; + Assert.Equal(FusedRounds.Fragile, fragile.Position.xz); + player.SetTarget(fragile); + player.SetIff(fragile, true); + player.EntityInfoGathered[fragile] = 1f; + var proximity = Carried(player, "Smoke Proximity"); + (float At, ShotResult Result)? landed = null; + using var subscription = arena.ShotResolved.Subscribe(outcome => + { + if (landed == null && outcome.Weapon == proximity) landed = (arena.Time, outcome.Result); + }); + + WeaponOf(proximity).Activate(); + WeaponOf(Carried(player, "DeathCluster")).Activate(); + float? deadAt = null; + for (var i = 0; i < 120 && landed == null; i++) + { + arena.Update(SteppedDt); + if (deadAt == null && !arena.Entities.Contains(fragile)) deadAt = arena.Time; + } + + Assert.NotNull(deadAt); + Assert.NotNull(landed); + Assert.True(deadAt <= landed.Value.At, $"the round landed at {landed.Value.At:F2} s, before the fragile hull died at {deadAt:F2} s"); + Assert.NotEqual(ShotResult.Burst, landed.Value.Result); + } + + // AI Fused Discipline, run for two minutes, against its two controls. The hostile closes on the player and never fires its + // refused weapon; the neutral never fires its fused one. Each silence is discipline, not inability: the same hostile + // fires once its weapon's Range reaches its blast radius, and the same neutral fires once it is hostile to the player. + [Fact] + public void TheAiHoldsARefusedWeaponAndANeutralHoldsItsFusedOne() + { + (int Hostile, int Neutral, float Closest) Fly(System.Action alter = null) + { + var (arena, staged) = LaunchStepped(new AiFusedDiscipline()); + var (hostile, neutral) = (staged.Entities[0], staged.Entities[1]); + alter?.Invoke(hostile, neutral, staged.Player); + int hostileShots = 0, neutralShots = 0; + using var subscription = arena.ShotCommitted.Subscribe(outcome => + { + if (outcome.Source == hostile) hostileShots++; + else if (outcome.Source == neutral) neutralShots++; + }); + var closest = float.MaxValue; + for (var i = 0; i < 1200; i++) + { + arena.Update(.1f); + closest = min(closest, length((hostile.Position - staged.Player.Position).xz)); + } + return (hostileShots, neutralShots, closest); + } + + var shipped = Fly(); + Assert.Equal((0, 0), (shipped.Hostile, shipped.Neutral)); + Assert.True(shipped.Closest < 100f, $"the hostile never closed on the player (nearest {shipped.Closest:F0} m)"); + + // Control: the hostile's weapon given the reach to arm. The design's data is shared, so it is put back. + var refused = _cache.GetAll().Single(design => design.Name == "Smoke Refused").Behaviors.OfType().Single(); + var range = refused.Range; + refused.Range = new PerformanceStat { Min = 2000f, Max = 2000f }; + try { Assert.True(Fly().Hostile > 0, "the hostile fires nothing even when its weapon can arm: the arena cannot show it holding fire"); } + finally { refused.Range = range; } + + // Control: the neutral made hostile to the player, both ways. + Assert.True(Fly((hostile, neutral, player) => { neutral.SetIff(player, true); player.SetIff(neutral, true); }).Neutral > 0, + "the neutral fires nothing even when hostile: the arena cannot show it holding fire"); + } +} From 7be5454742c143f5926e55c813d01332cebd38d5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 20:23:52 +0200 Subject: [PATCH 9/9] AI discipline test flies half a minute: a hostile moved to 7000 m must fail it The two-minute run let a hostile placed ten times farther still arrive; the hostile closes in about 15 s from 700 m (about 50 m/s), so thirty seconds separates the arena's geometry from a mutant of it. Both controls fire within a second. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs b/tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs index 56ba8a169..fcacfd43d 100644 --- a/tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs +++ b/tests/Aetheria.Shared.Tests/SmokeRoundsSteppedTests.cs @@ -85,8 +85,8 @@ public sealed partial class RunStartTests Assert.NotEqual(ShotResult.Burst, landed.Value.Result); } - // AI Fused Discipline, run for two minutes, against its two controls. The hostile closes on the player and never fires its - // refused weapon; the neutral never fires its fused one. Each silence is discipline, not inability: the same hostile + // AI Fused Discipline, run for half a minute (the hostile closes from its 700 m in about 15 s, and either control fires within + // a second), against its two controls. The hostile closes on the player and never fires its refused weapon; the neutral never fires its fused one. Each silence is discipline, not inability: the same hostile // fires once its weapon's Range reaches its blast radius, and the same neutral fires once it is hostile to the player. [Fact] public void TheAiHoldsARefusedWeaponAndANeutralHoldsItsFusedOne() @@ -103,7 +103,7 @@ public sealed partial class RunStartTests else if (outcome.Source == neutral) neutralShots++; }); var closest = float.MaxValue; - for (var i = 0; i < 1200; i++) + for (var i = 0; i < 300; i++) { arena.Update(.1f); closest = min(closest, length((hostile.Position - staged.Player.Position).xz));