Opens of suite-honest: honest station descriptions, Arcs bay, measured crates, drag test at play ThrustScale, HUD readout decision in Shared, gated Console capture #11

Merged
nibu merged 7 commits from nibu/opens-econ into master 2026-11-11 07:16:44 +00:00
15 changed files with 265 additions and 61 deletions

View file

@ -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);

View file

@ -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);

View file

@ -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());
}
}

View file

@ -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;
}

BIN
GameData/Aetheria.cc (Stored with Git LFS)

Binary file not shown.

View file

@ -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"));
}
}

View file

@ -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<HullData>("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}");
}
}

View file

@ -13,15 +13,28 @@ internal static class ConsoleProbe
private static readonly object Gate = new object();
public static string WrittenBy(Action act)
{
Capture<int>(() => { 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<T>(Func<T> 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();
}
}
}

View file

@ -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<SimpleCommodityData>(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<ItemData>(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<ItemData>(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}"));
}
}

View file

@ -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]

View file

@ -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]

View file

@ -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>(StationKindData.KeyOf("mining")).Description);
Assert.Contains("Ammonia", cache.Get<StationKindData>(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<RecipeData>().SelectMany(r => r.Inputs.Select(i => i.Commodity.Key.Value).Append(r.Output.Key.Value)).ToHashSet();
var commodities = c.GetAll<SimpleCommodityData>().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<StationKindData>().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>(StationKindData.KeyOf(id)).Description);
}
}

View file

@ -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<StationKindData>(key) ?? throw new InvalidOperationException($"The catalog holds no station kind '{id}'.");
kind.Description = description;
author.Put(kind, key);
}
}
}

View file

@ -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<StationKindData>(key) ?? throw new InvalidOperationException($"The catalog holds no station kind '{id}'.");
kind.Description = description;
author.Put(kind, key);
}
}
}

View file

@ -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."),