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