From 1013e2a2e4450a29be11701ed615ac009772a56d Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 04:35:18 +0000 Subject: [PATCH 01/10] 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)); } -- 2.47.3 From ac45a5c836a6e14704d39186e4db46dcfd3c626f Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 04:35:18 +0000 Subject: [PATCH 02/10] 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); -- 2.47.3 From 908712b2eef6e4151fcc44da8f21733697c316f8 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 04:36:56 +0000 Subject: [PATCH 03/10] 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")); + } } -- 2.47.3 From bd3563dee3b9936c965b0412a505f092b980c2cc Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 04:44:31 +0000 Subject: [PATCH 04/10] 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)); + } +} -- 2.47.3 From 0e9b10c3ae9bdb26cb026bf64c8a2d8d716a5d84 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:03:57 +0000 Subject: [PATCH 05/10] Crossing: the crosser is launched at the speed it should cross at, since a hull no longer decays below top speed 845f6c3aa (aetheria-gravity:ruling:no-drag-below-top-speed) removed hull drag below a hull's top speed. The crosser was staged at the Djinni's 50 m/s cap on the premise that drag slowed it to ~25 m/s at the bow line; it now holds 50 m/s and crosses at 2.57 s, 50 m/s relative, 1.1 rad/s, outside the tuned encounter (3.6 s, 25 m/s, 0.56 rad/s) that the Brief, the pass window and CrossingHoldsAcrossTheStartWindow all describe. CrossingConditions and CrossingHoldsAcrossTheStartWindow failed on that stale tuning, not on a defect. The encounter is restaged to reproduce its tuning under the ruled physics: launched at 25 m/s from 90 m out (probed over speed x start; constant-speed passes are dx / v seconds, 45 m ahead). The pass lands at 3.63 s, 44.7 m, 24.9 m/s relative, 0.56 rad/s. The test's launch-speed assertions now pin the scenario's 25 m/s instead of "most of the cap"; the time, distance, relative-speed and angular-rate windows are unchanged. If drag below top speed returns, the crosser decays and the pass leaves the window. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Scenarios/Crossing.cs | 12 +++++++----- tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs | 10 +++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs index 61529d6d9..e77f50166 100644 --- a/Assets/Scripts/ServerShared/Scenarios/Crossing.cs +++ b/Assets/Scripts/ServerShared/Scenarios/Crossing.cs @@ -6,8 +6,8 @@ public sealed class Crossing : Scenario public override string Name => "Crossing"; public override string Id => "crossing"; public override string Brief => "A Lightning Projector at the starboard gun and a Cell Blaster at the port gun, an unpiloted Djinni coasting across the bow " + - "close ahead and an unpiloted one waiting at range. The crosser is launched to port at 50 m/s and only coasts: nothing steers it, " + - "its hull drag slows it to about 25 m/s at the bow line, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " + + "close ahead and an unpiloted one waiting at range. The crosser is launched to port at 25 m/s and only coasts: nothing steers it, " + + "a hull coasts at its launch speed below its top speed, and it does not shoot back. Make the pass coasting: give no throttle until the Djinni has crossed the bow, then fly freely " + "(thrusting from the start carries you past it). Verify: as the Djinni crosses close, FastBlast lands some and projector " + "almost none; against the Djinni at range both land. Fly it again without the Fire Control Array: " + "with it the small gun lands more."; @@ -16,13 +16,15 @@ public sealed class Crossing : Scenario // Where the Djinni sit: the player is at Origin facing +z. Origin is 250 m or more from every body, where the zone's // pull is weakest (about 1.4 u/s^2; the sum of the wells never drops to nothing, and at the old origin the player sat on the star). - // The crosser is unpiloted and placed moving at the Djinni's 50 m/s cap, so its pass is ballistic: bent by the field and slowed - // by hull drag (it reaches the bow line at about 25 m/s relative to the player, 3.6 s in), and nothing steers it. + // The crosser is unpiloted and placed moving at 25 m/s, well under the Djinni's 50 m/s top speed, so its pass is ballistic: a hull + // coasts below its top speed (aetheria-gravity:ruling:no-drag-below-top-speed) and only the field bends it, so it reaches the + // bow line at 25 m/s relative to the player, 3.6 s in, and nothing steers it. (Launched at the cap with drag slowing it to 25 m/s, + // as this scenario was first tuned, it would now hold 50 m/s and cross in 2.6 s.) // CrosserAt is the centre of the start window in which the pass holds: shifting it by 10 m along either axis keeps the pass // inside the test's time and distance windows (CrossingHoldsAcrossTheStartWindow). // The waiting Djinni sits inside the Lightning Projector's arc at 80% of its Range. public static readonly CultMath.float2 Origin = float2(724, 124); - public static readonly CultMath.float2 CrosserAt = Origin + float2(-128, 45), CrosserFacing = float2(1, 0), CrosserVelocity = float2(50, 0); + public static readonly CultMath.float2 CrosserAt = Origin + float2(-90, 45), CrosserFacing = float2(1, 0), CrosserVelocity = float2(25, 0); // The fighter is staged at this quality, so its gear and the range below do not ride the item stream. public const float StagedQuality = .6f; // The Lightning Projector's Range in metres at the staged fit: 996.99 after the three seconds the test settles for diff --git a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs index 9c827b968..efaa4998d 100644 --- a/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/CrossingScenarioTests.cs @@ -10,7 +10,7 @@ using float3 = CultMath.float3; public sealed partial class RunStartTests { // Crossing: the demo's cast; projector and Cell Blaster fitted in separate weapon groups with the Fire Control Array - // aboard; an unpiloted hostile Djinni, coasting at the Djinni's speed cap, that crosses the bow inside 400 m; and an unpiloted neutral one inside the Lightning Projector's + // aboard; an unpiloted hostile Djinni, coasting at 25 m/s, that crosses the bow inside 400 m; and an unpiloted neutral one inside the Lightning Projector's // arc, short of its Range and beyond three quarters of it. [Fact] public void CrossingConditions() @@ -43,12 +43,12 @@ public sealed partial class RunStartTests Assert.True(toCrosser.z > 0, "the crosser starts ahead"); Assert.True(abs(dot(crosser.Direction, player.Direction)) <= sin(radians(30f)) + 1e-4f, "the crosser faces within 30 degrees of perpendicular to the player's bow"); - // It is placed moving: toward the bow line at most of the Djinni's 50 m/s cap, so its run is its own and not only the + // It is placed moving: toward the bow line at the scenario's 25 m/s, so its run is its own and not only the // arena's pull on the player. var crosserVelocity = ((Ship) crosser).Velocity; Assert.True(crosserVelocity.x * toCrosser.x < 0, "the crosser starts moving toward the bow line"); - Assert.True(abs(crosserVelocity.x) >= 40f, "the crosser's speed toward the bow line is most of the Djinni's speed cap"); - Assert.True(length(crosserVelocity) >= 40f, "the crosser starts at most of the Djinni's speed cap"); + Assert.Equal(25f, abs(crosserVelocity.x), 3); + Assert.Equal(25f, length(crosserVelocity), 3); Assert.False(player.IsHostileTo(waiting) || waiting.IsHostileTo(player), "the waiting Djinni is neutral"); Assert.Equal(0, AgentsOf(arena, waiting)); @@ -71,7 +71,7 @@ public sealed partial class RunStartTests Assert.True(length(pass.Ahead) < CrossingMetres, "the crosser passes inside the stated distance"); Assert.True(FireControl.InArc(small, float3(pass.Ahead.x, 0, pass.Ahead.y)), "Cell Blaster bears on the crosser as it crosses"); Assert.True(dot(FireControl.MountDirection(small), float3(player.Direction.x, 0, player.Direction.y)) > 0, "Cell Blaster mounts forward"); - // At seed 112 with no input the pass is at about 3.6 s and 45 m; a seed, field or staging change that loses it moves these. + // At seed 112 with no input the pass is at about 3.6 s and 45 m, at 25 m/s relative (a hull under its top speed coasts, so the launch speed holds); a seed, field or staging change that loses it moves these. Assert.InRange(pass.Seconds, CrossingEarliest, CrossingLatest); Assert.InRange(length(pass.Ahead), 30f, 60f); } -- 2.47.3 From b2a2da9d2754f0ba845d699c69239f52539178cf Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:06:09 +0000 Subject: [PATCH 06/10] MiningCut3Tests: ChunkInfo's pinned bits are per host class; the Linux bits are the base's own ChunkInfoIsBitIdenticalToItsValueBeforeTheExtraction never passed on Linux: at c86f28e93 (the commit that added it) it already measures 1039274088 for the second observer where the recording says 1039274086, and base 12dfcef5, the code the recording claims to be taken from, measures the same ...088 on Linux (CultMath 0.4.1 and 0.5.0 alike). The sensor's bearing term (acos, curve) is libm arithmetic, which rounds differently on Windows, where the recording was made. It is a host-class recording read as universal, not a regression: no commit moved it. The pin is now per host class like the two-AI digest (ruling replays-per-host-class): windows-x64 keeps the original recording, linux-x64 is base 12dfcef5 run on Linux, and the current code equals it. A host with no recording fails and prints its bits. RunStartTests.HostClass is now internal, the one host-class name. Co-Authored-By: Claude Sonnet 5.5 --- .../DeterminismLedgerTests.cs | 2 +- tests/Aetheria.Shared.Tests/MiningCut3Tests.cs | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 6029d3647..aafadcbbd 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -255,7 +255,7 @@ public sealed partial class RunStartTests Assert.Equal(first.digest, second.digest); } - private static string HostClass() => + internal static string HostClass() => (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" : RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "windows" : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" : "other") diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 5ac322c01..dc6ffd628 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -308,9 +308,17 @@ public sealed partial class MiningCut3Tests : IDisposable // ChunkInfo's value is not changed by moving its arithmetic under PassiveInfo: six observers at different // places and bearings, one with a second sensor so the average is live, read the same chunk, and every value - // matches the bits recorded at base 12dfcef5, before the extraction (a different order of the multiplication - // by the chunk's brightness moves them in the last bits). - private static readonly uint[] ChunkInfoAtBase = new uint[] { 1045120030u, 1039274086u, 1030543422u, 1029120970u, 1036363351u, 1035122917u }; + // matches the bits the code before the extraction (base 12dfcef5) gives on the same host class (a different order of + // the multiplication by the chunk's brightness moves them in the last bits). The bits are per host class because + // the sensor's bearing term (acos, curve) is libm arithmetic, which rounds differently on Windows and Linux; replays + // are trusted only on the host class that recorded them (ruling replays-per-host-class). windows-x64 is the recording the + // test was written with; linux-x64 is base 12dfcef5 run on Linux, which gives 1039274088 where Windows gave 1039274086 for + // the second observer, and the current code gives the same bits as that base. + private static readonly System.Collections.Generic.Dictionary ChunkInfoAtBase = new() + { + ["windows-x64"] = new uint[] { 1045120030u, 1039274086u, 1030543422u, 1029120970u, 1036363351u, 1035122917u }, + ["linux-x64"] = new uint[] { 1045120030u, 1039274088u, 1030543422u, 1029120970u, 1036363351u, 1035122917u }, + }; [Fact] public void ChunkInfoIsBitIdenticalToItsValueBeforeTheExtraction() @@ -329,7 +337,9 @@ public sealed partial class MiningCut3Tests : IDisposable Assert.All(values, v => Assert.InRange(v, 1e-4f, .99f)); // a live, unsaturated reading at every place var actual = values.Select(Bits).ToArray(); - Assert.True(actual.SequenceEqual(ChunkInfoAtBase), "ChunkInfo bits: " + string.Join(",", actual)); + var host = RunStartTests.HostClass(); + Assert.True(ChunkInfoAtBase.TryGetValue(host, out var pinned), $"ChunkInfo bits: host class {host} has no recording; measured " + string.Join(",", actual)); + Assert.True(actual.SequenceEqual(pinned), $"ChunkInfo bits on {host}: " + string.Join(",", actual) + " pinned " + string.Join(",", pinned)); } // A chunk outside every sun's light is dark: no visibility, no info, and it cannot be picked, while a lit -- 2.47.3 From 85f1a933d483728cbb330c25e07902116d6325a5 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:07:47 +0000 Subject: [PATCH 07/10] 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"); -- 2.47.3 From ee49f95843aec147342d17bff758d5f3b7b41bab Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:07:47 +0000 Subject: [PATCH 08/10] 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"); -- 2.47.3 From 601ce3cc18c66392cb45ac3a3c5ea54e7e64876e Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:14:40 +0000 Subject: [PATCH 09/10] 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 -- 2.47.3 From 3458f0d7b3b2fe80188bb892518c2ac9e474dffa Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 11 Nov 2026 05:20:46 +0000 Subject: [PATCH 10/10] TwoAiDigestIsPinned: repin linux-x64 to 52fbb10d and name the flight-model commits that moved it Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index aafadcbbd..31b71dba0 100644 --- a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs +++ b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs @@ -48,7 +48,13 @@ public sealed partial class RunStartTests // go of a held weapon it no longer commands (target gone, untracked, another group the pick, Combat ended), where a held gun fired on // until a later step selected its group (the candidate change reverted: d9493bbe). Both together: the digest below. Releasing a beam or a // lock the same way (h7) moved nothing. - private const string TwoAiPinnedDigest = "85e85c46cfc66693c819dd294ad02f3cd0eae5486c8f152a97e2e2787f854274"; + // Repinned an eighth time, once (suite-honest), for the flight-model changes that landed after h6 and never repinned this + // entry (bisected on master, each intended): e0c1daa4e made ThrustScale default 0.1, 845f6c3aa removed drag below top speed, + // ffdba39ac made ThrustScale scale the push only, and 8c5a904cb pinned the fixtures to ThrustScale=1. Pull requests 8 and 9 + // moved the digest and returned it to 52fbb10d, which master already measured before this cut. The authored-cargo and + // ammunition-data blob of this cut moved nothing (52fbb10d measured with it merged in). Fixtures run ThrustScale=1 while play + // uses 0.1, so this digest pins the fight's logic, not the shipped feel. + private const string TwoAiPinnedDigest = "52fbb10df2559829a11f3a38aae0783f3f1858b0499f6f46794a267a747adacb"; private const float TwoAiSeconds = 60f; [Fact] -- 2.47.3