From f44dadc5cfaafc33f6ab439339a9559219f89fc2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 03:36:57 +0200 Subject: [PATCH] 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"); + } +}