From 1013e2a2e4450a29be11701ed615ac009772a56d Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 04:35:18 +0000 Subject: [PATCH 1/7] Test: the Economy console-never-written probes keep only the calling thread's writes The two tests capture Console.Out to prove the commands write to the writer they are given. Console is process-global, so in a full-suite run other tests' diagnostic prints landed in the capture and failed the author dry-run test. ConsoleProbe keeps only writes made on the thread running the command, and serialises probes so two cannot restore each other's writer. Co-Authored-By: Claude Sonnet 5.5 --- .../EconomyCommandTests.cs | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index c0c04f89c..ec1010812 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -5,6 +5,38 @@ using System.Linq; using GameCult.Caching; using Xunit; +// What the calling thread writes to Console while an action runs. Console is process-global and tests run in parallel, so a plain +// SetOut capture also collects other tests' diagnostic prints (the race that failed these tests in full-suite runs): the probe +// keeps only writes made on the thread that runs the action, and the gate keeps two probes from restoring each other's writer. +internal static class ConsoleProbe +{ + private static readonly object Gate = new object(); + + public static string WrittenBy(Action act) + { + 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(); + } + } + + private sealed class ThreadWriter : TextWriter + { + private readonly int _thread; + public readonly System.Text.StringBuilder Mine = new System.Text.StringBuilder(); + public ThreadWriter(int thread) => _thread = thread; + public override System.Text.Encoding Encoding => System.Text.Encoding.UTF8; + public override void Write(char value) { if (Environment.CurrentManagedThreadId == _thread) lock (Mine) Mine.Append(value); } + public override void Write(string value) { if (Environment.CurrentManagedThreadId == _thread) lock (Mine) Mine.Append(value); } + public override void Write(char[] buffer, int index, int count) { if (Environment.CurrentManagedThreadId == _thread) lock (Mine) Mine.Append(buffer, index, count); } + } +} + public sealed class EconomyCommandTests { private sealed class Lambda : EconomyBatch @@ -286,17 +318,13 @@ public sealed class EconomyCommandTests using var db = AetherDb.Open(); var expected = new StringWriter(); var expectedCode = EconomyCommand.Report(db.Cache, expected); - var console = new StringWriter(); - var previous = Console.Out; - Console.SetOut(console); var output = new StringWriter(); - int code; - try { code = EconomyCommand.Run(new[] { "report" }, output); } - finally { Console.SetOut(previous); } + int code = 0; + var console = ConsoleProbe.WrittenBy(() => code = EconomyCommand.Run(new[] { "report" }, output)); Assert.Equal(expectedCode, code); Assert.NotEmpty(expected.ToString()); Assert.Equal(expected.ToString(), output.ToString()); - Assert.Empty(console.ToString()); + Assert.Empty(console); } [Fact] @@ -306,17 +334,13 @@ public sealed class EconomyCommandTests var before = File.ReadAllBytes(path); var expected = new StringWriter(); var expectedCode = EconomyCommand.Author(path, new EconomyBatch[] { new Munitions() }, false, expected); - var console = new StringWriter(); - var previous = Console.Out; - Console.SetOut(console); var output = new StringWriter(); - int code; - try { code = EconomyCommand.Run(new[] { "author", "Munitions" }, output); } - finally { Console.SetOut(previous); } + int code = 0; + var console = ConsoleProbe.WrittenBy(() => code = EconomyCommand.Run(new[] { "author", "Munitions" }, output)); Assert.Equal(expectedCode, code); Assert.NotEmpty(expected.ToString()); Assert.Equal(expected.ToString(), output.ToString()); - Assert.Empty(console.ToString()); + Assert.Empty(console); Assert.Equal(before, File.ReadAllBytes(path)); } From ac45a5c836a6e14704d39186e4db46dcfd3c626f Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 04:35:18 +0000 Subject: [PATCH 2/7] AetherDb: mining and volatiles descriptions name Diamond and Ammonia; the ExtractionKindDescriptions batch restates them Every commodity a kind extracts (a recipe with no inputs) is named in that kind's description: checked over the shipped catalog, only these two kinds broke it. The batch applies in the catalog commit that follows; until then the catalog tests for it fail. Co-Authored-By: Claude Sonnet 5.5 --- .../StationKindDescriptionTests.cs | 37 +++++++++++++++++++ .../Economy/ExtractionKindDescriptions.cs | 24 ++++++++++++ tools/AetherDb/StationKindsCatalog.cs | 7 +++- 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/StationKindDescriptionTests.cs create mode 100644 tools/AetherDb/Economy/ExtractionKindDescriptions.cs diff --git a/tests/Aetheria.Shared.Tests/StationKindDescriptionTests.cs b/tests/Aetheria.Shared.Tests/StationKindDescriptionTests.cs new file mode 100644 index 000000000..234df007a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationKindDescriptionTests.cs @@ -0,0 +1,37 @@ +using System.Linq; +using GameCult.Caching; +using Xunit; + +// A station kind's description is what a player and a planner read of what it does; the recipes at the kind are what it does. +public sealed class StationKindDescriptionTests +{ + // Every good the shipped catalog extracts at a station kind (a commodity recipe with no inputs; a gear recipe is a design on offer, not raw) is named in that kind's description. + // Kills: a kind that gains an extraction recipe (Diamond at mining, Ammonia at volatiles) while its description still lists the old set. + [Fact] + public void EveryRawGoodAKindExtractsIsNamedInItsDescription() + { + using var db = AetherDb.Open(); + var cache = db.Cache; + var unnamed = cache.GetAll().Where(recipe => recipe.Inputs.Count == 0 && recipe.StationKind.IsSet()) + .Select(recipe => (Kind: cache.Get(recipe.StationKind), Output: cache.Get(recipe.Output))) + .Where(pair => pair.Kind != null && (pair.Output is SimpleCommodityData || pair.Output is CompoundCommodityData) && !pair.Kind.Description.Contains(pair.Output.Name)) + .Select(pair => $"{pair.Kind.Name} extracts {pair.Output.Name}, which its description omits") + .Distinct().OrderBy(line => line).ToList(); + Assert.True(unnamed.Count == 0, string.Join("\n", unnamed)); + } + + // The two kinds the rule was cut for, by name, so the rule cannot pass by the catalog having lost the recipes. + [Fact] + public void MiningExtractsDiamondAndVolatilesAmmonia() + { + using var db = AetherDb.Open(); + var cache = db.Cache; + string Extracted(string kindId) => string.Join(",", cache.GetAll() + .Where(recipe => recipe.Inputs.Count == 0 && recipe.StationKind.IsSet() && recipe.StationKind.Key.Equals(StationKindData.KeyOf(kindId))) + .Select(recipe => cache.Get(recipe.Output).Name)); + Assert.Contains("Diamond", Extracted("mining")); + Assert.Contains("Ammonia", Extracted("volatiles")); + Assert.Contains("Diamond", cache.Get(StationKindData.KeyOf("mining")).Description); + Assert.Contains("Ammonia", cache.Get(StationKindData.KeyOf("volatiles")).Description); + } +} diff --git a/tools/AetherDb/Economy/ExtractionKindDescriptions.cs b/tools/AetherDb/Economy/ExtractionKindDescriptions.cs new file mode 100644 index 000000000..b581af0d0 --- /dev/null +++ b/tools/AetherDb/Economy/ExtractionKindDescriptions.cs @@ -0,0 +1,24 @@ +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/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index a450d2ff7..81e5aee4d 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -14,8 +14,8 @@ 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) from bodies and belts; consumes tools and food."), - ("volatiles", "Volatiles Station", "Skims Hydrogen, Methane and Water from gas giants and ice; consumes tools."), + ("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."), ("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."), @@ -33,6 +33,9 @@ public static class StationKindsCatalog ("waystation", "Waystation", "Fuel, repair and medicine along a corridor."), }; + // A kind's authored description: the one text a fresh catalog gets and an economy batch restates. + public static string DescriptionOf(string id) => Table.Single(row => row.id == id).description; + public static int Run(CultCache cache, bool apply, TextWriter output) { var held = cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).ToHashSet(StringComparer.Ordinal); From 908712b2eef6e4151fcc44da8f21733697c316f8 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 04:36:56 +0000 Subject: [PATCH 3/7] HUD: a weapon says what its ammunition label shows; a beam reads its reserve alone ConstantWeapon names an AmmoType but has no magazine (a crate per AmmoInterval straight from the cargo), so the label printed its unfilled magazine ("1/N"). Weapon.AmmoReadout owns the label's text, HasMagazine says whether there is a magazine to show, and SchematicDisplay reads it. Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/ConstantWeapon.cs | 1 + .../Scripts/ServerShared/Behaviors/Weapon.cs | 6 ++++ Assets/Scripts/UI/HUD/SchematicDisplay.cs | 2 +- .../AmmunitionStockingTests.cs | 28 +++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs index b90a124da..8e44075ed 100644 --- a/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/ConstantWeapon.cs @@ -46,6 +46,7 @@ public class ConstantWeapon : Weapon, IProgressBehavior, IEventBehavior, IPowerC { get => _ammo; } + public override bool HasMagazine => false; public float Progress { diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 62e4f4b14..f4cb9bc07 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -104,6 +104,12 @@ public abstract class Weapon : ActivatedBehavior public abstract int Ammo { get; } // Rounds in the ship's cargo that reload this weapon's magazine: crates of AmmoType, 0 when the weapon draws none. public int Reserve => _data.AmmoType.IsSet() ? Entity.CountItemsInCargo(_data.AmmoType.Key) : 0; + // Whether Ammo counts a magazine. A weapon that names an AmmoType and fires from one (InstantWeapon) does; a beam (ConstantWeapon) draws one + // crate per AmmoInterval straight from the cargo and has none. + 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 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 526a83c80..ac49c1297 100644 --- a/Assets/Scripts/UI/HUD/SchematicDisplay.cs +++ b/Assets/Scripts/UI/HUD/SchematicDisplay.cs @@ -234,7 +234,7 @@ public class SchematicDisplay : MonoBehaviour 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()) - x.ListElement.AmmoLabel.text = $"{x.Weapon.Ammo}/{x.Weapon.Reserve}"; + x.ListElement.AmmoLabel.text = x.Weapon.AmmoReadout; } if (x.ListElement.RangeLabel) diff --git a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs index a5e7e53c9..2a52e1535 100644 --- a/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs +++ b/tests/Aetheria.Shared.Tests/AmmunitionStockingTests.cs @@ -166,4 +166,32 @@ public sealed partial class RunStartTests } Assert.True(fed > 0, "fixture: some new-run player ship carries an ammunition-fed weapon"); } + + // The HUD's ammunition label (Weapon.AmmoReadout): a magazine gun reads "magazine/reserve", a beam that names an AmmoType has no magazine (it + // draws a crate per interval) and reads its reserve alone, a weapon that draws nothing reads empty. Kills: a label that prints the beam's + // never-filled magazine as "0/N", or one that drops the magazine from a gun's. + [Fact] + public void TheAmmoLabelShowsAMagazineOnlyForAWeaponThatHasOne() + { + var (hull, _) = FedFit(bay: true); + var rounds = _cache.GetAll().Single(item => item.Name == "Fed Gun").Behaviors.OfType().Single().AmmoType; + _cache.Upsert(new WeaponItemData + { + Name = "Fed Beam", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + Behaviors = { new ConstantWeaponData { AmmoType = rounds } } + }); + EquippableItemData Design(string name) => _cache.GetAll().Single(item => item.Name == name); + var loadout = new Loadout { Name = "label", Hull = _cache.RefOf(_cache.GetAll().Single(h => h.Name == hull)), WeaponGroups = new int[0][] }; + foreach (var (name, x) in new[] { ("Fed Gun", 3), ("Fed Beam", 2), ("Plain Gun", 1), ("Fed Bay", 4) }) + loadout.Slots.Add(new LoadoutSlot { Position = int2(x, 3), Rotation = ItemRotation.None, Design = _cache.RefOf(Design(name)) }); + var failures = new List(); + var ship = Loadouts.Materialize(_items, null, loadout, _ => null, failures); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + + string Label(string name) => ship.Weapons.Single(weapon => _items.GetData(weapon.Item.EquippableItem).Name == name).AmmoReadout; + var reserve = LoadoutGenerator.AmmunitionCrates; + 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")); + } } From bd3563dee3b9936c965b0412a505f092b980c2cc Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 04:44:31 +0000 Subject: [PATCH 4/7] Scenarios: the Arcs turret and the Pursuit player carry the crates their guns reload from Arcs' hostile turret fits two 100-round Light Machine Guns and had no hold, so it fell silent after one magazine; it now fits a bay and carries 30 crates of Ammo (about 8 minutes of both guns firing without pause). Pursuit's authored cargo had no Mines, capping the player at one magazine of six; it now carries 10 crates (sixty-six mines). ScenarioStage.Cargo admits a commodity name as one unit, so authored cargo can state crates. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ScenarioStage.cs | 10 ++- Assets/Scripts/ServerShared/Scenarios/Arcs.cs | 9 ++- .../Scripts/ServerShared/Scenarios/Pursuit.cs | 5 +- .../ScenarioAmmunitionTests.cs | 67 +++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/ScenarioAmmunitionTests.cs diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index a813bbf49..b7a19b87f 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -168,7 +168,8 @@ public sealed class ScenarioStage } // Items in the entity's first cargo bay, one per name. A name is an equippable or consumable design (open: the stage - // chooses its product) or a product of one (pinned); a name that is neither is a failure. + // chooses its product) or a product of one (pinned), or a commodity (one unit: the crates an AmmoType weapon reloads + // from); a name that is none of these is a failure. public void Cargo(Entity entity, params string[] names) { if (entity == null) return; @@ -177,6 +178,13 @@ public sealed class ScenarioStage var bay = entity.CargoBays.FirstOrDefault(); foreach (var name in names) { + var commodity = _items.ItemData.GetAll().FirstOrDefault(data => data.Name == name); + if (commodity != null) + { + if (bay == null || !bay.TryStore(new SimpleCommodity { Data = new CultRecordRef(_items.ItemData.RefOf(commodity).Key), Quantity = 1 })) + _failures.Add($"{where}: no room for {name}"); + continue; + } var (data, product) = NamedOrProduct(name, where, found => found is EquippableItemData || found is ConsumableItemData); if (data == null) continue; var build = Loadouts.Resolve(_items, _items.ItemData.RefOf(data), diff --git a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs index f0c3f5553..fd5076075 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Arcs.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Arcs.cs @@ -1,3 +1,4 @@ +using System.Linq; using static CultMath.math; using static ItemRotation; @@ -20,6 +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); + public const int TurretAmmoCrates = 30; + public override Galaxy Generate(GalaxyStage stage) => stage.Prelude(); public override void Stage(ScenarioStage stage) @@ -49,12 +52,14 @@ public sealed class Arcs : Scenario ("Compact Reactor", int2(3, 3), None), ("Turret Control Module", int2(3, 5), None), ("not if i see you first", int2(4, 5), None), // Compact Sensor - ("Panopticon Prime", int2(3, 6), None)); // Fire Control Array + ("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)); stage.Place(stage.Bare("Longinus"), Bow); stage.Place(stage.Bare("Longinus"), Stern); - stage.Place(turret, Turret, stance: ScenarioStance.Hostile); + // 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. + 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 d6d630a88..20ad157a1 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Pursuit.cs @@ -1,3 +1,4 @@ +using System.Linq; using static CultMath.math; using static ItemRotation; @@ -7,6 +8,7 @@ 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; public override string Name => "Pursuit"; public override string Id => "pursuit"; @@ -55,7 +57,8 @@ 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)); - stage.Cargo(player, "Morning Fog", "Morning Fog", "Morning Fog", "send it", "send it"); + // 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); } } diff --git a/tests/Aetheria.Shared.Tests/ScenarioAmmunitionTests.cs b/tests/Aetheria.Shared.Tests/ScenarioAmmunitionTests.cs new file mode 100644 index 000000000..740e793a2 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/ScenarioAmmunitionTests.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Linq; +using UniRx; +using Xunit; + +// Scenarios that author a weapon's ammunition-fed crates in a hold (ruling ballistics-real-ammo): the staged hold is what the weapon reloads from, so a +// scenario whose check rests on a weapon firing past its first magazine states the crates it needs. Each test launches through the production path. +public sealed partial class RunStartTests +{ + // The Arcs turret fits two Light Machine Guns of a hundred rounds and a bay of Ammo: its hold holds what the scenario states, and across a minute + // in range of the player it fires past both first magazines, each reload taking a crate. Kills: a turret fit without a bay or with no authored + // crates (silent after one magazine), and Cargo failing to admit a commodity. + [Fact] + public void TheArcsTurretKeepsFiringPastItsFirstMagazine() + { + var (_, arena, staged, failures) = Launch(new Arcs(), Inputs(() => GalaxySeed)); + Assert.Empty(failures); + var player = staged.Player; + var turret = Assert.IsType(staged.Entities[2]); + var guns = turret.Weapons.ToList(); + Assert.Equal(2, guns.Count); + var ammo = guns[0].WeaponData.AmmoType.Key; + Assert.Equal(Arcs.TurretAmmoCrates, turret.CountItemsInCargo(ammo)); + + // The guns are Auto weapons: they fire on a hostile inside their range (400 m) with no pilot. The player starts 775 m off, so it flies up to 200 m. + arena.Update(0); + player.Position = turret.Position + new CultMath.float3(-200, 0, 0); + var shots = 0; + using var sub = arena.ShotCommitted.Subscribe(outcome => { if (ReferenceEquals(outcome.Source, turret)) shots++; }); + for (var tick = 0; tick < System.MathF.Round(60f / SimClock.GameStep); tick++) arena.Update(SimClock.GameStep); + + var firstMagazines = guns.Sum(gun => gun.WeaponData.MagazineSize); + Assert.True(shots > firstMagazines, $"the turret fired {shots} rounds, no more than its two first magazines of {firstMagazines}"); + Assert.True(turret.CountItemsInCargo(ammo) < Arcs.TurretAmmoCrates, "the turret reloaded without taking a crate"); + } + + // The Pursuit player's Mine Launcher draws its magazine of six from crates of Mines it carries beside the consumables: it lays past six, and the + // crate count falls by one per reload. Kills: authored cargo with no Mines (at most six mines), a reload that takes no crate. + [Fact] + public void ThePursuitPlayerLaysMoreThanOneMagazineOfMinesFromItsCrates() + { + var (_, arena, staged, failures) = Launch(new Pursuit(), Inputs(() => GalaxySeed)); + Assert.Empty(failures); + var player = staged.Player; + var launcher = player.Weapons.Single(weapon => weapon.WeaponData is MineLayerData); + var mines = launcher.WeaponData.AmmoType.Key; + Assert.Equal(Pursuit.MineCrates, player.CountItemsInCargo(mines)); + Assert.Equal(3, HeldOf(player, "Coolant Vent")); + + arena.Update(0); + var laid = 0; + using var sub = arena.Mines.ObserveAdd().Subscribe(_ => laid++); + var reloaded = new List(); + var last = Pursuit.MineCrates; + for (var tick = 0; tick < System.MathF.Round(120f / SimClock.GameStep) && laid <= 18; tick++) + { + launcher.Activate(); + arena.Update(SimClock.GameStep); + var crates = player.CountItemsInCargo(mines); + if (crates != last) { reloaded.Add(last - crates); last = crates; } + } + + Assert.True(laid > launcher.WeaponData.MagazineSize, $"the player laid {laid} mines, no more than one magazine of {launcher.WeaponData.MagazineSize}"); + Assert.NotEmpty(reloaded); + Assert.All(reloaded, step => Assert.Equal(1, step)); + } +} From 85f1a933d483728cbb330c25e07902116d6325a5 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:07:47 +0000 Subject: [PATCH 5/7] GetAllChildClasses: the type memo is concurrent A plain Dictionary written by parallel catalog loads corrupted itself ("Operations that change non-concurrent collections must have exclusive access") and failed unrelated tests at random, through ValidateStatModifiers. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Extensions.cs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Extensions.cs b/Assets/Scripts/ServerShared/Extensions.cs index 97433a299..6ac7b5186 100644 --- a/Assets/Scripts/ServerShared/Extensions.cs +++ b/Assets/Scripts/ServerShared/Extensions.cs @@ -32,16 +32,13 @@ public static class Extensions // Every loadable type assignable to this one. An assembly whose dependencies are missing (a Unity plugin // referencing UnityEngine outside Unity) still yields the types that do load. - private static readonly Dictionary ChildClasses = new Dictionary(); - public static Type[] GetAllChildClasses(this Type type) + // Read from parallel loaders (catalog validation, parallel tests), so the memo is concurrent. + private static readonly System.Collections.Concurrent.ConcurrentDictionary ChildClasses = new System.Collections.Concurrent.ConcurrentDictionary(); + public static Type[] GetAllChildClasses(this Type type) => ChildClasses.GetOrAdd(type, t => AppDomain.CurrentDomain.GetAssemblies().SelectMany(assembly => { - if (ChildClasses.TryGetValue(type, out var children)) return children; - return ChildClasses[type] = AppDomain.CurrentDomain.GetAssemblies().SelectMany(assembly => - { - try { return assembly.GetTypes(); } - catch (ReflectionTypeLoadException e) { return e.Types.Where(t => t != null); } - }).Where(type.IsAssignableFrom).ToArray(); - } + try { return assembly.GetTypes(); } + catch (ReflectionTypeLoadException e) { return e.Types.Where(x => x != null); } + }).Where(t.IsAssignableFrom).ToArray()); public static string SplitCamelCase(this string str) => Regex.Replace(Regex.Replace(str, @"(\P{Ll})(\P{Ll}\p{Ll})", "$1 $2"), @"(\p{Ll})(\P{Ll})", "$1 $2"); From ee49f95843aec147342d17bff758d5f3b7b41bab Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:07:47 +0000 Subject: [PATCH 6/7] AetherDb economy: Author takes the scratch root; TheTrialCopyIsRemoved audits its own, not the shared temp folder The test counted aetherdb-economy-* directories in the machine's temp folder, which every parallel test running the command also writes to. Co-Authored-By: Claude Sonnet 5.5 --- .../EconomyCommandTests.cs | 17 +++++++++++++---- tools/AetherDb/EconomyCommand.cs | 7 ++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index ec1010812..9319dbb1c 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -178,10 +178,19 @@ public sealed class EconomyCommandTests public void TheTrialCopyIsRemoved() { using var f = EconomyFixture.Healthy(); - var before = Directory.GetDirectories(Path.GetTempPath(), "aetherdb-economy-*").Length; - Author(f, false, out _, a => Good(f, a, "coal")); - Author(f, true, out _, a => Good(f, a, "tar", lore: "")); - Assert.Equal(before, Directory.GetDirectories(Path.GetTempPath(), "aetherdb-economy-*").Length); + // A private scratch root: the temp folder is shared with every parallel test that runs the command, so counting it races. + var scratchRoot = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "aetherdb-economy-audit-" + Guid.NewGuid().ToString("N"))).FullName; + try + { + foreach (var apply in new[] { false, true }) + { + Flush(f); + var batch = new Lambda(a => Good(f, a, apply ? "tar" : "coal", lore: apply ? "" : "a good")); + EconomyCommand.Author(f.CatalogPath, new EconomyBatch[] { batch }, apply, new StringWriter(), TestCatalog.Registry(), scratchRoot); + } + Assert.Empty(Directory.GetFileSystemEntries(scratchRoot)); + } + finally { Directory.Delete(scratchRoot, true); } } [Fact] diff --git a/tools/AetherDb/EconomyCommand.cs b/tools/AetherDb/EconomyCommand.cs index 244fc4279..cf536cd9e 100644 --- a/tools/AetherDb/EconomyCommand.cs +++ b/tools/AetherDb/EconomyCommand.cs @@ -107,15 +107,16 @@ public static class EconomyCommand // atomically, so a refusal leaves Aetheria.cc byte-identical. A batch is compiled code and is reviewed as code: it is trusted not // to touch the real file, and the command refuses by name if the file changed under it. Added, changed and unchanged are read by // comparing the original and the copy on disk. A shape fault the batches introduce (present after, absent before, by rule and - // record) refuses; faults the catalog already held print and do not. - public static int Author(string catalogPath, IReadOnlyList batches, bool apply, TextWriter output, CultDocumentRegistry registry = null) + // record) refuses; faults the catalog already held print and do not. The trial copy lives under scratchRoot (the temp folder unless a caller + // that audits it names a private one). + public static int Author(string catalogPath, IReadOnlyList batches, bool apply, TextWriter output, CultDocumentRegistry registry = null, string scratchRoot = null) { if (apply && new FileInfo(catalogPath).IsReadOnly) { output.WriteLine("The batches are refused: Aetheria.cc is read-only. Clear the read-only flag, or check it out, and run again. The catalog is unchanged."); return 1; } - var scratch = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "aetherdb-economy-" + Guid.NewGuid().ToString("N"))).FullName; + var scratch = Directory.CreateDirectory(Path.Combine(scratchRoot ?? Path.GetTempPath(), "aetherdb-economy-" + Guid.NewGuid().ToString("N"))).FullName; try { var trial = Path.Combine(scratch, "Aetheria.cc"); From 601ce3cc18c66392cb45ac3a3c5ea54e7e64876e Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:14:40 +0000 Subject: [PATCH 7/7] Catalog: mining names Diamond, volatiles names Ammonia (economy author ExtractionKindDescriptions) Applied through AetherDb: 0 added, 2 changed (station-kind:mining, station-kind:volatiles), 0 removed. economy report fault count unchanged at 126. GameData/Aetheria.cc sha256 956a1573fa2aa69d7f72861e10dd17d7d3255d114e878da81802869efcfe31e9 (before: 0f81d35d4cec8f57178220f20bca98ffaa6709783349efe3cc28aa64ea1f02b1). Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 553e6bcce..3bf502c21 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0f81d35d4cec8f57178220f20bca98ffaa6709783349efe3cc28aa64ea1f02b1 -size 220607 +oid sha256:956a1573fa2aa69d7f72861e10dd17d7d3255d114e878da81802869efcfe31e9 +size 220628