From 036f75a0d8e68f4e77d9194ae1dc2a2d56409b22 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:58:27 +0000 Subject: [PATCH 1/6] CoastingTests: a ship launched below top speed keeps it at the play ThrustScale 0.1 and at 1 Reads only the ship's velocity after the zone steps; reintroducing drag below top speed fails both cases (50 -> 18.39 in 10 s). Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CoastingTests.cs | 48 ++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/CoastingTests.cs diff --git a/tests/Aetheria.Shared.Tests/CoastingTests.cs b/tests/Aetheria.Shared.Tests/CoastingTests.cs new file mode 100644 index 000000000..56b33151e --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CoastingTests.cs @@ -0,0 +1,48 @@ +using System; +using CultMath; +using Xunit; +using static CultMath.math; +using float3 = CultMath.float3; +using Random = CultMath.Random; + +// The consumer's side of aetheria-gravity:ruling:no-drag-below-top-speed: a ship launched below its hull's top speed and +// left alone keeps that speed, at the global thrust tuning the game ships with (GameplaySettings.ThrustScale 0.1) as well as +// at the fixtures' 1. Reads only the ship's velocity after the zone steps: not Drag, DragScale or any field of the fix. +// Kills: drag reintroduced below top speed, or any deceleration whose size depends on ThrustScale. +public sealed class CoastingTests +{ + private const float PlayThrustScale = .1f; // GameplaySettings.ThrustScale's default, the value play runs at + + private static float CoastedSpeed(float thrustScale, float launchSpeed, float seconds) + { + using var cache = RestoredHullsTests.OpenCatalog(); + var hull = cache.GetByName("Longinus"); + var settings = RestoredHullsTests.Settings(); + settings.ThrustScale = thrustScale; + var items = new ItemManager(1u, cache, new ProvenanceLedger(), settings, _ => { }); + items.Random = new Random(1u); + var random = new Random(1); + var pack = new LoadoutGenerator(ref random, items, null, null, null, .5f).GenerateShipLoadout(candidate => candidate == hull); + Assert.NotNull(pack); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); + var ship = (Ship) EntitySerializer.Unpack(items, zone, pack); + zone.Admit(ship, piloted: false); + ship.Position = float3.zero; + zone.Update(0f); + + ship.MovementDirection = float2(0, 0); + ship.Velocity = float2(0, launchSpeed); + for (var i = 0; i < (int) (seconds * 60); i++) zone.Update(1f / 60f); + return length(ship.Velocity); + } + + [Theory] + [InlineData(PlayThrustScale)] + [InlineData(1f)] + public void AShipLaunchedBelowTopSpeedKeepsItsSpeed(float thrustScale) + { + const float launch = 50f; // Longinus's top speed is 100 + var speed = CoastedSpeed(thrustScale, launch, 10f); + Assert.True(speed >= launch - .01f, $"ThrustScale {thrustScale}: launched at {launch}, coasting 10 s left it at {speed}"); + } +} -- 2.47.3 From eee5f213bfa82903044cad99c31b5220c118177d Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:59:30 +0000 Subject: [PATCH 2/6] Ammo label visibility is Weapon.ShowsAmmoReadout: the HUD's three AmmoType.IsSet() decisions move into Shared and are tested Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Behaviors/Weapon.cs | 4 +++- Assets/Scripts/UI/HUD/SchematicDisplay.cs | 6 +++--- tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs | 5 +++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index f4cb9bc07..52b69d196 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -109,7 +109,9 @@ public abstract class Weapon : ActivatedBehavior public virtual bool HasMagazine => true; // What the HUD's ammunition label shows: "magazine/reserve" for a magazine weapon, the reserve alone for one without a magazine, empty when // the weapon draws no ammunition. The one rule the HUD reads, so the label cannot show a magazine a weapon does not have. - public string AmmoReadout => !_data.AmmoType.IsSet() ? "" : HasMagazine ? $"{Ammo}/{Reserve}" : Reserve.ToString(); + public string AmmoReadout => !ShowsAmmoReadout ? "" : HasMagazine ? $"{Ammo}/{Reserve}" : Reserve.ToString(); + // Whether the HUD shows the ammunition label at all (and not the infinite-ammunition icon): the weapon names an AmmoType. + public bool ShowsAmmoReadout => _data.AmmoType.IsSet(); public WeaponData WeaponData => _data; public float RangeFactor(float at) => WeaponData.RangeFactor(MinRange, EffectiveRange, Range, _data.DamageAtMaxRange, at); diff --git a/Assets/Scripts/UI/HUD/SchematicDisplay.cs b/Assets/Scripts/UI/HUD/SchematicDisplay.cs index ac49c1297..90b4271d3 100644 --- a/Assets/Scripts/UI/HUD/SchematicDisplay.cs +++ b/Assets/Scripts/UI/HUD/SchematicDisplay.cs @@ -132,8 +132,8 @@ public class SchematicDisplay : MonoBehaviour //x.ListElement.Label.text = x.Item.EquippableItem.Name; if (!_enemy) { - x.ListElement.InfiniteAmmoIcon.gameObject.SetActive(!x.Weapon.WeaponData.AmmoType.IsSet()); - x.ListElement.AmmoLabel.gameObject.SetActive(x.Weapon.WeaponData.AmmoType.IsSet()); + x.ListElement.InfiniteAmmoIcon.gameObject.SetActive(!x.Weapon.ShowsAmmoReadout); + x.ListElement.AmmoLabel.gameObject.SetActive(x.Weapon.ShowsAmmoReadout); } } } @@ -233,7 +233,7 @@ public class SchematicDisplay : MonoBehaviour if (x.Cooldown != null) x.ListElement.CooldownFill.anchorMax = new Vector2(x.Cooldown.Progress, 1); x.ListElement.DurabilityLabel.text = $"{(int)(x.Row.Item.EquippableItem.Durability / itemData.Durability * 100)}%"; - if (x.Weapon.WeaponData.AmmoType.IsSet()) + if (x.Weapon.ShowsAmmoReadout) x.ListElement.AmmoLabel.text = x.Weapon.AmmoReadout; } diff --git a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs index 2a52e1535..dfdb9d040 100644 --- a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs +++ b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs @@ -193,5 +193,10 @@ public sealed partial class RunStartTests Assert.Equal(reserve.ToString(), Label("Fed Beam")); Assert.Equal($"{ship.Weapons.Single(w => _items.GetData(w.Item.EquippableItem).Name == "Fed Gun").Ammo}/{reserve}", Label("Fed Gun")); Assert.Equal("", Label("Plain Gun")); + // The HUD shows the label (not the infinite-ammunition icon) exactly for the weapons that read something. + bool Shown(string name) => ship.Weapons.Single(weapon => _items.GetData(weapon.Item.EquippableItem).Name == name).ShowsAmmoReadout; + Assert.True(Shown("Fed Gun")); + Assert.True(Shown("Fed Beam")); + Assert.False(Shown("Plain Gun")); } } -- 2.47.3 From 58c4fa10c193e09adc18b102fe9ac5ecaa2c277b Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:59:30 +0000 Subject: [PATCH 3/6] ShipProductTests and ShipModCatalogTests capture Console through ConsoleProbe.Capture so restores cannot interleave Co-Authored-By: Claude Sonnet 5.5 --- .../EconomyCommandTests.cs | 25 ++++++++++++++----- .../ShipModCatalogTests.cs | 8 ++---- .../Aetheria.Shared.Tests/ShipProductTests.cs | 8 ++---- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index 9319dbb1c..9557f27a1 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -13,15 +13,28 @@ internal static class ConsoleProbe private static readonly object Gate = new object(); public static string WrittenBy(Action act) + { + Capture(() => { act(); return 0; }, out var output, out _); + return output; + } + + // The action's result with what it wrote to Console.Out and Console.Error on this thread. + public static T Capture(Func act, out string output, out string error) { lock (Gate) { - var previous = Console.Out; - var probe = new ThreadWriter(Environment.CurrentManagedThreadId); - Console.SetOut(probe); - try { act(); } - finally { Console.SetOut(previous); } - return probe.Mine.ToString(); + var (previousOut, previousError) = (Console.Out, Console.Error); + var (probeOut, probeError) = (new ThreadWriter(Environment.CurrentManagedThreadId), new ThreadWriter(Environment.CurrentManagedThreadId)); + Console.SetOut(probeOut); + Console.SetError(probeError); + try { return act(); } + finally + { + Console.SetOut(previousOut); + Console.SetError(previousError); + output = probeOut.Mine.ToString(); + error = probeError.Mine.ToString(); + } } } diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 3def3e6dd..cbda4266b 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -546,12 +546,8 @@ public sealed class ShipModCatalogTests : IDisposable private static (int Code, string Output, string Error) Run(params string[] args) { - var (output, error) = (new StringWriter(), new StringWriter()); - var (previousOut, previousError) = (Console.Out, Console.Error); - Console.SetOut(output); - Console.SetError(error); - try { return (ShipAuthoringCommands.Run(args), output.ToString(), error.ToString()); } - finally { Console.SetOut(previousOut); Console.SetError(previousError); } + var code = ConsoleProbe.Capture(() => ShipAuthoringCommands.Run(args), out var output, out var error); + return (code, output, error); } [Fact] diff --git a/tests/Aetheria.Shared.Tests/ShipProductTests.cs b/tests/Aetheria.Shared.Tests/ShipProductTests.cs index 343f35082..e14950b0d 100644 --- a/tests/Aetheria.Shared.Tests/ShipProductTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipProductTests.cs @@ -247,12 +247,8 @@ public sealed class ShipProductTests : IDisposable private static (int Code, string Output, string Error) Run(params string[] args) { - var (output, error) = (new StringWriter(), new StringWriter()); - var (previousOut, previousError) = (Console.Out, Console.Error); - Console.SetOut(output); - Console.SetError(error); - try { return (ShipAuthoringCommands.Run(args), output.ToString(), error.ToString()); } - finally { Console.SetOut(previousOut); Console.SetError(previousError); } + var code = ConsoleProbe.Capture(() => ShipAuthoringCommands.Run(args), out var output, out var error); + return (code, output, error); } [Fact] -- 2.47.3 From dcd27cb3b04c376131029feeca84de2d42dcb94c Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:59:44 +0000 Subject: [PATCH 4/6] Station-kind descriptions name only goods a recipe makes or takes Uranium, Hydrogen (and Bacteria, Fungi, Animal Products) were named by mining, volatiles, agriculture, habitat-support and power descriptions with no recipe behind them. Dropped the claims (smaller than authoring a power chain); batch StationKindDescriptions restates the five kinds in the catalog; test pins the rule. Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 +-- .../StationKindDescriptionTests.cs | 29 +++++++++++++++++++ .../Economy/ExtractionKindDescriptions.cs | 24 --------------- .../Economy/StationKindDescriptions.cs | 27 +++++++++++++++++ tools/AetherDb/StationKindsCatalog.cs | 10 +++---- 5 files changed, 63 insertions(+), 31 deletions(-) delete mode 100644 tools/AetherDb/Economy/ExtractionKindDescriptions.cs create mode 100644 tools/AetherDb/Economy/StationKindDescriptions.cs diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 3bf502c21..da60f2dd3 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:956a1573fa2aa69d7f72861e10dd17d7d3255d114e878da81802869efcfe31e9 -size 220628 +oid sha256:319c14830b174e4bd58fe849b1300f38d7724d91027e47a03a5c121741d38e51 +size 220537 diff --git a/tests/Aetheria.Shared.Tests/StationKindDescriptionTests.cs b/tests/Aetheria.Shared.Tests/StationKindDescriptionTests.cs index 234df007a..3e00ba91b 100644 --- a/tests/Aetheria.Shared.Tests/StationKindDescriptionTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindDescriptionTests.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Text.RegularExpressions; using GameCult.Caching; using Xunit; @@ -34,4 +35,32 @@ public sealed class StationKindDescriptionTests Assert.Contains("Diamond", cache.Get(StationKindData.KeyOf("mining")).Description); Assert.Contains("Ammonia", cache.Get(StationKindData.KeyOf("volatiles")).Description); } + + // Kills: a station-kind description naming a commodity no recipe yields or consumes (Uranium, Hydrogen, Bacteria, Fungi and + // Animal Products were named by the shipped descriptions with no recipe behind them). + [Fact] + public void EveryCommodityAStationKindNamesIsMadeOrTakenByARecipe() + { + using var db = AetherDb.Open(); + var c = db.Cache; + var used = c.GetAll().SelectMany(r => r.Inputs.Select(i => i.Commodity.Key.Value).Append(r.Output.Key.Value)).ToHashSet(); + var commodities = c.GetAll().ToArray(); + Assert.True(commodities.Length >= 20 && used.Count >= 20, $"fixture: the catalog holds its commodities and recipes ({commodities.Length}, {used.Count})"); + var kinds = c.GetAll().ToArray(); + Assert.True(kinds.Length >= 17, $"fixture: the catalog holds its station kinds ({kinds.Length})"); + var orphans = from kind in kinds + from good in commodities + where Regex.IsMatch(kind.Description ?? "", @"\b" + Regex.Escape(good.Name) + @"\b") && !used.Contains(c.RefOf(good).Key.Value) + select $"{kind.Name} names {good.Name}"; + Assert.True(!orphans.Any(), string.Join("\n", orphans)); + } + + // Kills: the fresh-catalog text and the shipped catalog saying different things about the same kind. + [Fact] + public void TheShippedDescriptionsAreTheAuthoredTable() + { + using var db = AetherDb.Open(); + foreach (var id in new[] { "mining", "volatiles", "agriculture", "habitat-support", "power" }) + Assert.Equal(StationKindsCatalog.DescriptionOf(id), db.Cache.Get(StationKindData.KeyOf(id)).Description); + } } diff --git a/tools/AetherDb/Economy/ExtractionKindDescriptions.cs b/tools/AetherDb/Economy/ExtractionKindDescriptions.cs deleted file mode 100644 index b581af0d0..000000000 --- a/tools/AetherDb/Economy/ExtractionKindDescriptions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using GameCult.Caching; - -// Economy content, batch 1c: the mining and volatiles station-kind descriptions name every good their kind now extracts. -// MunitionsFeed gave mining a Diamond recipe and volatiles an Ammonia recipe; the descriptions the station-round bench -// wrote predate them. The same text is StationKindsCatalog.Table's, so a catalog authored fresh says the same thing. -// Sets the description whatever it was, so a rerun converges. -public sealed class ExtractionKindDescriptions : EconomyBatch -{ - public override void Author(EconomyAuthor author) - { - foreach (var (id, description) in new[] - { - ("mining", StationKindsCatalog.DescriptionOf("mining")), - ("volatiles", StationKindsCatalog.DescriptionOf("volatiles")), - }) - { - var key = StationKindData.KeyOf(id); - var kind = author.Catalog.Get(key) ?? throw new InvalidOperationException($"The catalog holds no station kind '{id}'."); - kind.Description = description; - author.Put(kind, key); - } - } -} diff --git a/tools/AetherDb/Economy/StationKindDescriptions.cs b/tools/AetherDb/Economy/StationKindDescriptions.cs new file mode 100644 index 000000000..746847e2e --- /dev/null +++ b/tools/AetherDb/Economy/StationKindDescriptions.cs @@ -0,0 +1,27 @@ +using System; +using GameCult.Caching; + +// Economy content: station-kind descriptions that name only goods the recipe graph makes or takes. The station-round bench wrote +// descriptions naming Uranium, Hydrogen, Bacteria, Fungi and Animal Products, none of which any recipe yields or consumes; this +// restates the five kinds that named them (mining, volatiles, agriculture, habitat-support, power) as StationKindsCatalog.Table +// has them, so a catalog authored fresh says the same thing. Sets the description whatever it was, so a rerun converges. +public sealed class StationKindDescriptions : EconomyBatch +{ + public override void Author(EconomyAuthor author) + { + foreach (var (id, description) in new[] + { + ("mining", StationKindsCatalog.DescriptionOf("mining")), + ("volatiles", StationKindsCatalog.DescriptionOf("volatiles")), + ("agriculture", StationKindsCatalog.DescriptionOf("agriculture")), + ("habitat-support", StationKindsCatalog.DescriptionOf("habitat-support")), + ("power", StationKindsCatalog.DescriptionOf("power")), + }) + { + var key = StationKindData.KeyOf(id); + var kind = author.Catalog.Get(key) ?? throw new InvalidOperationException($"The catalog holds no station kind '{id}'."); + kind.Description = description; + author.Put(kind, key); + } + } +} diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 81e5aee4d..51d865f57 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -14,19 +14,19 @@ public static class StationKindsCatalog private static readonly (string id, string name, string description)[] Table = { - ("mining", "Mining Station", "Extracts ore (Rock, New Metals, Gold, Uranium, Potassium) and Diamond from bodies and belts; consumes tools and food."), - ("volatiles", "Volatiles Station", "Skims Hydrogen, Methane, Ammonia and Water from gas giants and ice; consumes tools."), - ("agriculture", "Agriculture Station", "Grows Organics (Algae, Fungi, Bacteria, Animal Products) from Water and Potassium; feeds residential."), + ("mining", "Mining Station", "Extracts ore (Rock, New Metals, Gold, Potassium) and Diamond from bodies and belts; consumes tools and food."), + ("volatiles", "Volatiles Station", "Skims Methane, Ammonia and Water from gas giants and ice; consumes tools."), + ("agriculture", "Agriculture Station", "Grows Algae from Water and Potassium; feeds residential."), ("processing", "Processing Station", "Refines ore and volatiles into Metals, Alloys and Compounds."), ("fabrication", "Fabrication Station", "Turns Alloys and Compounds into Manufacturing compounds, Assemblies, gear, Ammo and consumer goods."), ("shipyard", "Shipyard", "Assembles hulls from Assemblies and Alloys."), ("depot", "Depot", "Stores and dispatches stock; loads haulers and tenders."), ("trade-hub", "Trade Hub", "Exchanges every bloc's goods between haulers and takes a cut; produces nothing."), ("residential", "Residential Station", "A bloom: consumes food, water, Wearables, Consumables and Luxuries; supplies crews."), - ("habitat-support", "Habitat Support Station", "Air, water recycling, life support and medicine for nearby habitats; consumes Bacteria, Water, Compounds."), + ("habitat-support", "Habitat Support Station", "Air, water recycling, life support and medicine for nearby habitats; consumes Water and Compounds."), ("urban", "Urban Station", "Dense city and services: market, media, repair; consumes luxuries and food."), ("administrative", "Administrative Station", "Issues quotas, contracts and security; consumes luxuries."), - ("power", "Power Station", "Makes reactor fuel, reservoirs and capacitors from Uranium and Hydrogen."), + ("power", "Power Station", "Generates and distributes power for nearby stations."), ("research", "Research Station", "Certification, compute, prototypes; consumes Assemblies."), ("salvage", "Salvage Station", "Breaks wrecks into Metals and second-hand gear."), ("black-market", "Black Market", "Buys anything, fences stolen cargo."), -- 2.47.3 From 465ab506665c1d5e2b7998885202f16d1a296132 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 06:34:46 +0000 Subject: [PATCH 5/6] Arcs player gets a bay; Arcs and Pursuit crates sized from a measured run The Arcs fighter had no hold, so its Autocannon and Cell Blasters went silent after one magazine: it now fits the one free 2x2 bay. The estimated crate counts are replaced by measured draws (ten minutes of play or death by heat) plus margin, authored for every ammunition-drawing shooter; ScenarioAmmunitionRunTests drives both scenarios and fails if any shooter is stocked below its measured draw. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Scenarios/Arcs.cs | 13 ++- .../Scripts/ServerShared/Scenarios/Pursuit.cs | 13 ++- .../ScenarioAmmunitionRunTests.cs | 103 ++++++++++++++++++ 3 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ScenarioAmmunitionRunTests.cs diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index fd5076075..459ee4e8b 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -21,7 +21,10 @@ public sealed class Arcs : Scenario 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 const int TurretAmmoCrates = 30; + // Crates of ammunition each shooter's hold is stocked with: the measured draw over a run (ScenarioAmmunitionRunTests: ten minutes of play, + // or until the shooter dies of heat) plus half again and one. The turret drew 14 Ammo with the player in its range; the player drew 27 Ammo + // holding every gun down. The guided launchers fire only on a lock, which that run cannot give them, so they carry the default three crates. + public const int TurretAmmoCrates = 22, PlayerAmmoCrates = 42, PlayerMissileCrates = 3; public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); @@ -43,7 +46,8 @@ public sealed class Arcs : Scenario ("not if i see you first", int2(5, 10), None), // Compact Sensor ("Panopticon Prime", int2(3, 6), Clockwise), // Fire Control Array ("🫳🔫", int2(8, 4), Clockwise), // Autocannon - ("ChargeBlast SG", int2(0, 4), CounterClockwise)); // Charged Scatter Blaster + ("ChargeBlast SG", int2(0, 4), CounterClockwise), // Charged Scatter Blaster + ("Store-All Plus", int2(4, 8), None)); // Cargo Bay: the only 2x2 the hull has free var turret = stage.Fit("Turret", ("ClearPath", int2(1, 2), None), // Light Machine Gun ("ClearPath", int2(5, 2), None), // Light Machine Gun @@ -55,10 +59,11 @@ public sealed class Arcs : Scenario ("Panopticon Prime", int2(3, 6), None), // Fire Control Array ("Store-All Plus", int2(3, 1), None)); // Cargo Bay: the only 2x2 the turret hull has free - stage.Player(fighter, float2(0, 0), facing: float2(0, 1)); + var player = stage.Player(fighter, float2(0, 0), facing: float2(0, 1)); + stage.Cargo(player, Enumerable.Repeat("Ammo", PlayerAmmoCrates).Concat(Enumerable.Repeat("Missiles", PlayerMissileCrates)).ToArray()); stage.Place(stage.Bare("Longinus"), Bow); stage.Place(stage.Bare("Longinus"), Stern); - // The turret's guns reload from its hold, a crate of Ammo per 100-round magazine: both guns firing without pause spend about 3.5 crates a minute, so 30 outlast any session. + // The turret's guns reload from its hold, a crate of Ammo per magazine. stage.Cargo(stage.Place(turret, Turret, stance: ScenarioStance.Hostile), Enumerable.Repeat("Ammo", TurretAmmoCrates).ToArray()); stage.Place(stage.Bare("Longinus"), Port); stage.Place(stage.Bare("Longinus"), Starboard); diff --git a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs index 20ad157a1..157ea4f1f 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs @@ -8,7 +8,11 @@ public sealed class Pursuit : Scenario // cloud breaking a sensor's lock, and well outside the 40 at which AStrongCloseSensorBurnsThrough shows a close // sensor burning through. private const float Astern = 300; - public const int MineCrates = 10; + // Crates each shooter's hold is stocked with: the measured draw over a run (ScenarioAmmunitionRunTests: ten minutes of play, or until the + // shooter dies of heat) plus half again and one where the hold has room. The player laid four crates of Mines and drew + // 33 Ammo; its hold takes 54 crates after five consumables and Mines, so its Ammo stops at 39. The pursuer drew 31 Ammo and one Missiles. A launcher + // that drew under a reload carries the default three crates. + public const int MineCrates = 7, PlayerAmmoCrates = 39, PlayerMissileCrates = 3, PursuerAmmoCrates = 47, PursuerMissileCrates = 3; public override string Name => "Pursuit"; public override string Id => "pursuit"; @@ -57,8 +61,9 @@ public sealed class Pursuit : Scenario ("Store-All Plus", int2(2, 8), None)); // Cargo Bay var player = stage.Player(layer, float2(0, 0), facing: float2(0, 1)); - // The Mine Launcher reloads a magazine of six from a crate of Mines: ten crates are sixty-six mines, minutes of laying at its cooldown, more than a chase lasts. - stage.Cargo(player, new[] { "Morning Fog", "Morning Fog", "Morning Fog", "send it", "send it" }.Concat(Enumerable.Repeat("Mines", MineCrates)).ToArray()); - stage.Place(pursuer, float2(0, -Astern), facing: float2(0, 1), stance: ScenarioStance.Hostile, piloted: true); + stage.Cargo(player, new[] { "Morning Fog", "Morning Fog", "Morning Fog", "send it", "send it" } + .Concat(Enumerable.Repeat("Mines", MineCrates)).Concat(Enumerable.Repeat("Ammo", PlayerAmmoCrates)).Concat(Enumerable.Repeat("Missiles", PlayerMissileCrates)).ToArray()); + var hostile = stage.Place(pursuer, float2(0, -Astern), facing: float2(0, 1), stance: ScenarioStance.Hostile, piloted: true); + stage.Cargo(hostile, Enumerable.Repeat("Ammo", PursuerAmmoCrates).Concat(Enumerable.Repeat("Missiles", PursuerMissileCrates)).ToArray()); } } diff --git a/tests/Aetheria.Shared.Tests/ScenarioAmmunitionRunTests.cs b/tests/Aetheria.Shared.Tests/ScenarioAmmunitionRunTests.cs new file mode 100644 index 000000000..3a1921cbd --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ScenarioAmmunitionRunTests.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using Xunit; + +// How much ammunition a staged scenario really spends over a run (ruling ballistics-real-ammo): the crates a scenario authors are pinned to +// this measurement, not to an estimate. A run is RunSeconds of continuous play: the player holds every ammunition-drawing weapon down with its +// hostile-or-flank hull standing in weapon range, an AI shooter fires on its own, and every hull keeps its armour so no shooter or target leaves +// the run early. The draw is counted in crates (one reload per crate) and each drawn crate is put back, so the count is not capped by the stock. +public sealed partial class RunStartTests +{ + // Ten minutes: longer than any session the Arcs and Pursuit briefs ask a tester for. + public const float RunSeconds = 600f; + + // Crates each armed entity of the scenario draws over the run, keyed by (index in the stage's entities, -1 for the player; ammunition name), + // and the crates each held when the run began. + private (Dictionary<(int, string), int> drawn, Dictionary<(int, string), int> stocked) RunAmmunition(Scenario scenario, bool playerFires, bool standNearHostile) + { + var (_, arena, staged, failures) = Launch(scenario, Inputs(() => GalaxySeed)); + Assert.Empty(failures); + arena.Update(0); + var shooters = staged.Entities.Select((entity, index) => (entity, index)).Append((staged.Player, -1)) + .Where(s => s.Item1.Weapons.Any(weapon => weapon.WeaponData.AmmoType.IsSet())).ToList(); + var drawn = new Dictionary<(int, string), int>(); + var stocked = new Dictionary<(int, string), int>(); + var held = new Dictionary<(int, string), int>(); + string NameOf(CultRecordKey key) => _items.ItemData.Get(key).Name; + foreach (var (entity, index) in shooters) + foreach (var key in entity.Weapons.Where(weapon => weapon.WeaponData.AmmoType.IsSet()).Select(weapon => weapon.WeaponData.AmmoType.Key).Distinct()) + { + stocked[(index, NameOf(key))] = entity.CountItemsInCargo(key); + // A shooter with no crate cannot reload at all, so a run could not show what it would draw: it is given one to start. + if (stocked[(index, NameOf(key))] == 0) + entity.CargoBays.First().TryStore(new SimpleCommodity { Data = new CultRecordRef(key), Quantity = 1 }); + drawn[(index, NameOf(key))] = 0; + held[(index, NameOf(key))] = entity.CountItemsInCargo(key); + } + var everyone = staged.Entities.Append(staged.Player).ToList(); + var player = staged.Player; + var sound = everyone.SelectMany(entity => entity.Equipment).ToDictionary(item => item, item => item.EquippableItem.Durability); + var hostile = standNearHostile ? staged.Entities.First(entity => player.IsHostileTo(entity)) : null; + for (var tick = 0; tick < System.MathF.Round(RunSeconds / SimClock.GameStep); tick++) + { + foreach (var entity in everyone) + for (var x = 0; x < entity.Armor.GetLength(0); x++) + for (var y = 0; y < entity.Armor.GetLength(1); y++) + entity.Armor[x, y] = entity.MaxArmor[x, y]; + foreach (var (item, durability) in sound) item.EquippableItem.Durability = durability; + if (hostile != null) player.Position = hostile.Position + CultMath.math.float3(-200, 0, 0); + if (playerFires) + foreach (var weapon in player.Weapons.Where(weapon => weapon.WeaponData.AmmoType.IsSet())) weapon.Activate(); + arena.Update(SimClock.GameStep); + if (false) System.IO.File.AppendAllText(System.Environment.GetEnvironmentVariable("TMPDIR") + "/dbg.txt", $"{scenario.Name} t={tick*SimClock.GameStep:F0} active={player.Active} me={player.Position} " + string.Join(",", player.Weapons.Select(w => w.WeaponData.AmmoType.IsSet() ? w.Ammo + "/" + w.Reserve : "e")) + "\n"); + foreach (var (entity, index) in shooters) + foreach (var key in entity.Weapons.Where(weapon => weapon.WeaponData.AmmoType.IsSet()).Select(weapon => weapon.WeaponData.AmmoType.Key).Distinct()) + { + var slot = (index, NameOf(key)); + var spent = held[slot] - entity.CountItemsInCargo(key); + if (spent <= 0) continue; + drawn[slot] += spent; + for (var crate = 0; crate < spent; crate++) + entity.CargoBays.First().TryStore(new SimpleCommodity { Data = new CultRecordRef(key), Quantity = 1 }); + } + } + return (drawn, stocked); + } + + // The most crates each shooter draws in any of the modes, and what it was stocked with. + private List<(int shooter, string ammo, int drawn, int stocked)> WorstRun(Scenario scenario, params (bool playerFires, bool standNearHostile)[] modes) + { + var worst = new Dictionary<(int, string), (int drawn, int stocked)>(); + foreach (var (fires, near) in modes) + { + var (drawn, stocked) = RunAmmunition(scenario, fires, near); + foreach (var slot in drawn.Keys) + worst[slot] = (System.Math.Max(drawn[slot], worst.TryGetValue(slot, out var seen) ? seen.drawn : 0), stocked[slot]); + } + return worst.Select(kv => (kv.Key.Item1, kv.Key.Item2, kv.Value.drawn, kv.Value.stocked)).ToList(); + } + + // Arcs, run to its end twice: the player holding every gun down, and the player standing in the hostile turret's range while the turret + // shoots. Kills: a crate count nobody measured (the turret's 30 and the player's none were estimates), a scenario shooter stocked below + // what a run draws so its guns go silent, and a harness that measures nothing. + [Fact] + public void ArcsStocksEveryShooterForARun() + { + var run = WorstRun(new Arcs(), (true, false), (false, true)); + Assert.Contains(run, r => r.shooter == 2 && r.ammo == "Ammo" && r.drawn > 0); + Assert.Contains(run, r => r.shooter == -1 && r.ammo == "Ammo" && r.drawn > 0); + Assert.All(run, r => Assert.True(r.stocked >= r.drawn, $"Arcs shooter {r.shooter} drew {r.drawn} {r.ammo} over a run and was stocked {r.stocked}")); + } + + // Pursuit, the player holding every gun down and the pursuer shooting back, then the player idle. Kills: as above, for the Mine Launcher's + // crates and the pursuer's. + [Fact] + public void PursuitStocksEveryShooterForARun() + { + var run = WorstRun(new Pursuit(), (true, false), (false, false)); + Assert.Contains(run, r => r.shooter == -1 && r.ammo == "Mines" && r.drawn > 0); + Assert.Contains(run, r => r.shooter == 0 && r.ammo == "Ammo" && r.drawn > 0); + Assert.All(run, r => Assert.True(r.stocked >= r.drawn, $"Pursuit shooter {r.shooter} drew {r.drawn} {r.ammo} over a run and was stocked {r.stocked}")); + } +} -- 2.47.3 From f856021997d77b131db9c770ac9efde55955eb3c Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 07:16:40 +0000 Subject: [PATCH 6/6] Ammunition run: say the real window (heat death ~260 s, not ten minutes) and drop a dead debug line Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Scenarios/Arcs.cs | 4 ++-- Assets/Scripts/ServerShared/Scenarios/Pursuit.cs | 4 ++-- tests/Aetheria.Shared.Tests/ScenarioAmmunitionRunTests.cs | 3 +-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index 459ee4e8b..8846d006b 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -21,8 +21,8 @@ public sealed class Arcs : Scenario 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); - // Crates of ammunition each shooter's hold is stocked with: the measured draw over a run (ScenarioAmmunitionRunTests: ten minutes of play, - // or until the shooter dies of heat) plus half again and one. The turret drew 14 Ammo with the player in its range; the player drew 27 Ammo + // Crates of ammunition each shooter's hold is stocked with: the measured draw over a run (ScenarioAmmunitionRunTests: at most ten minutes of play, and in + // practice the ~260 s a shooter holding every gun down lasts before it dies of heat; a pilot who vents heat is unmeasured) plus half again and one. The turret drew 14 Ammo with the player in its range; the player drew 27 Ammo // holding every gun down. The guided launchers fire only on a lock, which that run cannot give them, so they carry the default three crates. public const int TurretAmmoCrates = 22, PlayerAmmoCrates = 42, PlayerMissileCrates = 3; diff --git a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs index 157ea4f1f..2e490a2a6 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs @@ -8,8 +8,8 @@ public sealed class Pursuit : Scenario // cloud breaking a sensor's lock, and well outside the 40 at which AStrongCloseSensorBurnsThrough shows a close // sensor burning through. private const float Astern = 300; - // Crates each shooter's hold is stocked with: the measured draw over a run (ScenarioAmmunitionRunTests: ten minutes of play, or until the - // shooter dies of heat) plus half again and one where the hold has room. The player laid four crates of Mines and drew + // Crates each shooter's hold is stocked with: the measured draw over a run (ScenarioAmmunitionRunTests: at most ten minutes of play, and in + // practice the ~250-270 s a shooter holding every gun down lasts before it dies of heat; a pilot who vents heat is unmeasured) plus half again and one where the hold has room. The player laid four crates of Mines and drew // 33 Ammo; its hold takes 54 crates after five consumables and Mines, so its Ammo stops at 39. The pursuer drew 31 Ammo and one Missiles. A launcher // that drew under a reload carries the default three crates. public const int MineCrates = 7, PlayerAmmoCrates = 39, PlayerMissileCrates = 3, PursuerAmmoCrates = 47, PursuerMissileCrates = 3; diff --git a/tests/Aetheria.Shared.Tests/ScenarioAmmunitionRunTests.cs b/tests/Aetheria.Shared.Tests/ScenarioAmmunitionRunTests.cs index 3a1921cbd..43dec542a 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioAmmunitionRunTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioAmmunitionRunTests.cs @@ -9,7 +9,7 @@ using Xunit; // the run early. The draw is counted in crates (one reload per crate) and each drawn crate is put back, so the count is not capped by the stock. public sealed partial class RunStartTests { - // Ten minutes: longer than any session the Arcs and Pursuit briefs ask a tester for. + // Ceiling of the window: a shooter holding every gun down dies of heat at ~250-270 s, so the draws are bounded by heat death, not by this. public const float RunSeconds = 600f; // Crates each armed entity of the scenario draws over the run, keyed by (index in the stage's entities, -1 for the player; ammunition name), @@ -50,7 +50,6 @@ public sealed partial class RunStartTests if (playerFires) foreach (var weapon in player.Weapons.Where(weapon => weapon.WeaponData.AmmoType.IsSet())) weapon.Activate(); arena.Update(SimClock.GameStep); - if (false) System.IO.File.AppendAllText(System.Environment.GetEnvironmentVariable("TMPDIR") + "/dbg.txt", $"{scenario.Name} t={tick*SimClock.GameStep:F0} active={player.Active} me={player.Position} " + string.Join(",", player.Weapons.Select(w => w.WeaponData.AmmoType.IsSet() ? w.Ammo + "/" + w.Reserve : "e")) + "\n"); foreach (var (entity, index) in shooters) foreach (var key in entity.Weapons.Where(weapon => weapon.WeaponData.AmmoType.IsSet()).Select(weapon => weapon.WeaponData.AmmoType.Key).Distinct()) { -- 2.47.3