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/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"); 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/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/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/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/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 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")); + } } 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); } diff --git a/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs b/tests/Aetheria.Shared.Tests/DeterminismLedgerTests.cs index 6029d3647..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] @@ -255,7 +261,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/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index c0c04f89c..9319dbb1c 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 @@ -146,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] @@ -286,17 +327,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 +343,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)); } 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 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)); + } +} 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/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"); 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);