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