From f458206c768df6f951bd55dab879f8bb1c33e271 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 23:13:31 +0200 Subject: [PATCH] Content: a station reactor for every reactor maker Operator ruling, 2026-09-30: "Keep the variety, not every galaxy will have Zhestokost in it." A transient `AetherDb station-reactors [apply]` gives each manufacturer that makes reactors a design for Zenith's 16-cell Reactor hardpoint, scaled by cells from its largest reactor with every other value, stat term, role, temperature and sound bank its own, and a product by it: Vulcan Station Reactor (AU, from Vulcan 12 cells x1.333) charge 66.7..266.7, mass 666.7, price 1,550,000 Notorious Station Reactor (Lucent, from Notorious 9 x1.778) charge 711.1..2133.3, mass 1333.3, price 1,800,000 MoveOnPro Station Reactor (NiteLife, from MoveOnPro 4 x4) charge 100..500, mass 1200, price 3,600,000 Core Power Station Reactor (R&D, from Core Power 4 x4) charge 400, mass 200, price 1,000,000 products: Vulcan Station, Notorious Station, MoveOnPro Station, Steadfast Station The Manhattan-derived one is renamed "Manhattan Station Reactor" (product "Manhattan Station"). Reach is read through the one rule, LoadoutGenerator.IsAvailable. Test: a faction allied only to AU, Lightsail and NiteLife is offered none of Zhestokost's products and AU's, its stations carry nothing it cannot reach and nothing by Zhestokost, and they are powered. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 54 ++++++++++-- tools/AetherDb/Program.cs | 87 +++++++++++++++++++- 3 files changed, 133 insertions(+), 12 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 9fdde41a6..f247b3fd4 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a990c6e8c4b8cc90cd16b5cdb7d598e0ec5279bf0c0f2891d3918879ff4eb7d -size 12699064 +oid sha256:6ce919b3fbe9149cbbddd1cf0b9e6d93379c406770dfdd60928715ba4f564b6f +size 12702246 diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index b459c9c77..75c490e3b 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -430,18 +430,16 @@ public sealed class RunStartTests : IDisposable authored.Read("NameGeneratorSettings"), _cache, _ => { }, null, GalaxySeed); } - // Every item a station carries, equipped or stocked, with the faction that made it. - private IEnumerable<(string item, Faction maker)> Made(Entity station) => + // Every item a station carries, equipped or stocked, with the product it was made as. + private IEnumerable<(string item, FactionProductData product)> Made(Entity station) => station.Equipment.Select(item => item.EquippableItem).Cast() .Concat(station.CargoBays.SelectMany(bay => bay.Cargo.Keys).OfType()) - .Select(item => (_items.GetData(item).Name, _items.Brand(item).Maker)); + .Select(item => (_items.GetData(item).Name, _items.Brand(item).Product)); - private bool Reaches(Galaxy galaxy, Faction faction, Faction maker) => - maker != null && galaxy.ContainsFaction(_cache.RefOf(maker)) && - faction.Allegiance.Keys.Any(key => key.Key.Equals(_cache.RefOf(maker).Key)); + private string Maker(FactionProductData product) => product == null ? "nobody" : _cache.Get(product.Manufacturer)?.Name ?? "nobody"; // Every faction's stations, in a main-sector galaxy, carry a heater and a reactor, and nothing, equipped or - // stocked, from a manufacturer that faction cannot reach. + // stocked, from a manufacturer that faction cannot reach (LoadoutGenerator.IsAvailable, the one reach rule). [Fact] public void EveryFactionsStationsCarryOnlyGearItCanReachIncludingAHeaterAndAReactor() { @@ -456,13 +454,51 @@ public sealed class RunStartTests : IDisposable var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); Assert.True(station.Equipment.Count(IsHeater) == 1, $"{faction.Name} station {i}: no heater"); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"{faction.Name} station {i}: no reactor"); - var unreachable = Made(station).Where(made => !Reaches(galaxy, faction, made.maker)).ToList(); + var unreachable = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product)).ToList(); Assert.True(unreachable.Count == 0, - $"{faction.Name} station {i} carries {string.Join(", ", unreachable.Select(u => u.item + " by " + (u.maker?.Name ?? "nobody")))}"); + $"{faction.Name} station {i} carries {string.Join(", ", unreachable.Select(u => u.item + " by " + Maker(u.product)))}"); } } } + // A faction with no access to Zhestokost (its allegiance names only AU, Lightsail and NiteLife) gets + // stations with no Zhestokost gear, powered by a reactor it can reach, and an idle one of them holds its heater's + // cells above freezing over the second five of ten minutes. + [Fact] + public void AStationWithoutZhestokostAccessIsPoweredAndStaysAboveFreezing() + { + var galaxy = MainGalaxy(); + Faction Named(string shortName) => _cache.GetAll().Single(f => f.ShortName == shortName); + var coop = new Faction { Name = "Test Cooperative", ShortName = "Coop" }; + _cache.Upsert(coop); + foreach (var ally in new[] { Named("AU"), Named("Lightsail"), Named("NiteLife") }) coop.Allegiance[_cache.RefOf(ally)] = 1; + var zhestokost = Named("Zhestokost"); + var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, coop, .5f); + var products = _cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + Assert.DoesNotContain(products.Where(p => p.Manufacturer.Key.Equals(_cache.RefOf(zhestokost).Key)), generator.IsAvailable); + Assert.Contains(products.Where(p => p.Manufacturer.Key.Equals(_cache.RefOf(Named("AU")).Key)), generator.IsAvailable); + var zone = Arena(new Scenario { Ambient = false }); // no ships: nothing but the zone runs + Entity station = null; + for (var i = 0; i < 8; i++) + { + station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"station {i}: no reactor"); + var foreign = Made(station).Where(made => made.product == null || !generator.IsAvailable(made.product) || + made.product.Manufacturer.Key.Equals(_cache.RefOf(zhestokost).Key)).ToList(); + Assert.True(foreign.Count == 0, $"station {i} carries {string.Join(", ", foreign.Select(u => u.item + " by " + Maker(u.product)))}"); + } + + zone.Admit(station, piloted: false); + var heater = station.Equipment.Single(IsHeater); + var coldest = float.MaxValue; + for (var tick = 0; tick < 6000; tick++) + { + zone.Update(.1f); + if (tick >= 3000) coldest = min(coldest, heater.Temperature); + } + Assert.True(coldest > Freezing, $"the heater's cells fell to {coldest} K (station now {station.MinTemp}..{station.MaxTemp} K)"); + } + // An idle station, powered by its reactor, holds its heater's cells (where the thermostat reads) above freezing // over the second five of ten idle minutes. Unpowered, they cool from 280 K to about 247 K in that time. (The // station's border cells still sit near 265 K; how warm the whole hull should run is a tuning question.) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 034bbfd6f..1e0572798 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -36,8 +36,9 @@ public static class Program case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); + case "station-reactors": return StationReactors(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], station-reactors [apply]"); return 1; } } @@ -1471,4 +1472,88 @@ public static class Program Console.WriteLine($"\nLanded {precisionChanges.Length} Precision changes in Aetheria.cc"); return 0; } + + // Scenarios map, operator ruling 2026-09-30 on station reactors (B): "a station with no access to Zhestokost gear + // should not have Zhestokost gear". Every manufacturer that makes reactors gets its own design for Zenith's 16-cell + // Reactor hardpoint, scaled by cells from its largest reactor as the Manhattan one was: a full 4x4, charge, mass + // and price times 16 over the source's cells, every other value and stat term the source's own, sold by that + // manufacturer. The Manhattan-derived "Station Reactor" is renamed to the same pattern. + private static int StationReactors(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + var cache = db.Cache; + var reactors = cache.GetAll().Where(d => d.Hardpoint == HardpointType.Reactor).ToArray(); + var products = cache.GetAll().Where(p => p.Manufacturer.IsSet()).ToArray(); + FactionProductData ProductOf(GearData design) => products.Single(p => p.Design.Key.Equals(cache.RefOf(design).Key)); + + var existing = cache.GetAll().Single(d => d.Name == "Station Reactor"); + var existingProduct = ProductOf(existing); + var changes = new List<(Type Type, object Document, CultRecordKey Key)>(); + existing.Name = "Manhattan Station Reactor"; + existingProduct.Name = "Manhattan Station"; + changes.Add((typeof(GearData), existing, cache.RefOf(existing).Key)); + changes.Add((typeof(FactionProductData), existingProduct, cache.RefOf(existingProduct).Key)); + Console.WriteLine("Renamed: Station Reactor -> Manhattan Station Reactor, product Station Reactor -> Manhattan Station"); + + var makers = reactors.Where(r => r.Shape.Coordinates.Length < 16).GroupBy(r => ProductOf(r).Manufacturer.Key) + .Where(g => !reactors.Any(r => r.Shape.Coordinates.Length == 16 && ProductOf(r).Manufacturer.Key.Equals(g.Key))); + foreach (var group in makers.OrderBy(g => cache.Get(g.Key).Name, StringComparer.Ordinal)) + { + var source = group.OrderByDescending(r => r.Shape.Coordinates.Length).ThenByDescending(r => r.Price).First(); + var sourceProduct = ProductOf(source); + var scale = 16f / source.Shape.Coordinates.Length; + var shape = new Shape(4, 4); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var design = new GearData + { + Name = source.Name + " Station Reactor", + Description = "A reactor sized for a station's core.", + Hardpoint = HardpointType.Reactor, + Shape = shape, + Mass = source.Mass * scale, + Price = (int) Math.Round(source.Price * scale / 50000) * 50000, + SpecificHeat = source.SpecificHeat, + Conductivity = source.Conductivity, + Durability = source.Durability, + MinimumTemperature = source.MinimumTemperature, + MaximumTemperature = source.MaximumTemperature, + OptimalTemperature = source.OptimalTemperature, + PlateauWidth = source.PlateauWidth, + ThermalResilience = source.ThermalResilience, + SoundBank = source.SoundBank, + Roles = source.Roles.Select(role => new ItemRole { Name = role.Name }).ToList(), + Behaviors = MessagePack.MessagePackSerializer.Deserialize>(MessagePack.MessagePackSerializer.Serialize(source.Behaviors)), + }; + var reactor = design.Behaviors.OfType().Single(); + reactor.Charge.Min *= scale; + reactor.Charge.Max *= scale; + CultRecordRefs.Validate(design); + var key = new CultRecordKey(Guid.NewGuid().ToString("N")); + var product = new FactionProductData + { + Name = sourceProduct.Name + " Station", + Description = "", + Design = new CultRecordRef(key), + Manufacturer = sourceProduct.Manufacturer, + Roles = design.Roles.Select(role => new ProductRole { Role = role.Name }).ToList(), + }; + changes.Add((typeof(GearData), design, key)); + changes.Add((typeof(FactionProductData), product, new CultRecordKey(Guid.NewGuid().ToString("N")))); + Console.WriteLine($"{design.Name}: from {source.Name} ({source.Shape.Coordinates.Length} cells) x {scale:F3}, mass {source.Mass} -> {design.Mass}, " + + $"price {source.Price} -> {design.Price}, charge {reactor.Charge.Min / scale}..{reactor.Charge.Max / scale} -> {reactor.Charge.Min}..{reactor.Charge.Max}; " + + $"product {product.Name} by {cache.Get(product.Manufacturer).Name}"); + } + + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to land it."); + return 0; + } + cache.Commit(batch => + { + foreach (var (type, document, key) in changes) batch.Upsert(type, document, key); + }); + Console.WriteLine($"\nLanded {changes.Count} records in Aetheria.cc"); + return 0; + } }