From 708fbeee5903f1c60188de71cd0342443d4b2c47 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:59:06 +0200 Subject: [PATCH 001/182] Cargo store tests: the bay owns stack quantity, purchases charge units moved, restore drops bad stacks (red) Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 26f2332f0..efcac768c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -775,4 +775,124 @@ public sealed class CargoTransferTests : IDisposable var stock = Commodity("Ore", 10); // 7 credits each Assert.Equal(expected, StationServices.BuyableQuantity(_items, stock, typed, credits)); } + // ---- The bay owns the quantity that may sit in it --------------------------------------------------------- + + [Theory] + [InlineData("Ore", 0)] + [InlineData("Ore", -3)] + [InlineData("Ore", int.MinValue)] + [InlineData("Grit", 3)] // MaxStack 2 + [InlineData("Ore", 101)] + public void TheBayRefusesAStackBelowOneOrAboveMaxStack(string design, int quantity) + { + var bay = ShipWith("Crate").CargoBays[0]; + var massBefore = bay.Mass; + + Assert.False(bay.TryStore(Commodity(design, quantity))); + Assert.False(bay.TryStore(Commodity(design, quantity), new int2(0, 0))); + + Assert.Empty(bay.Cargo); + Assert.Equal(massBefore, bay.Mass); + } + + [Fact] + public void ANegativeStackNeverMergesIntoAHeldOne() + { + var bay = ShipWith("Crate").CargoBays[0]; + var held = Commodity("Ore", 5); + Assert.True(bay.TryStore(held)); + var at = bay.Cargo[held]; + + Assert.False(bay.TryStore(Commodity("Ore", -3), at)); + + Assert.Equal(5, held.Quantity); + } + + [Fact] + public void AMergeThatOverflowsFillsTheHeldStackToMaxAndLeavesTheRestWithTheCaller() + { + var bay = ShipWith("Crate").CargoBays[0]; + var held = Commodity("Grit", 1); + Assert.True(bay.TryStore(held)); + var incoming = Commodity("Grit", 2); + + Assert.False(bay.TryStore(incoming, bay.Cargo[held])); + + Assert.Equal(2, held.Quantity); + Assert.Equal(1, incoming.Quantity); + } + + [Fact] + public void APurchaseAboveTheStationStockIsRefusedWithoutCharge() + { + // Real bays, Grit stacks hold two: a stock of 3 cannot be bought as 5. Before, the second lot took the 1-unit + // remainder and was charged for 2. + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Grit", 2); + Assert.True(station.TryStore(stock)); + var second = Commodity("Grit", 1); + Assert.True(station.TryStore(second, new int2(1, 0))); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + Assert.Equal(0, Units(player, stock)); + } + + [Fact] + public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() + { + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Ore", 0); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, StationServices.BuyableQuantity(_items, stock, 1, credits), ref credits, + lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + Assert.Empty(player.Cargo); + } + + [Fact] + public void ThePurchaseOfTheWholeStockChargesTheUnitsTheBayMoved() + { + var station = Station().CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Grit", 2); + Assert.True(station.TryStore(stock)); + var more = Commodity("Grit", 1); + Assert.True(station.TryStore(more, new int2(1, 0))); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => station.TryTransferItem(player, stock, lot)); + + Assert.Equal(BuyResult.Bought, result); + Assert.Equal(98, credits); + Assert.Equal(2, Units(player, stock)); + } + + [Fact] + public void ARestoredBayDropsAStackBelowOneAndLogsWhy() + { + var log = new List(); + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + var items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, log.Add); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var ship = ShipWith("Crate"); + Assert.True(ship.CargoBays[0].TryStore(Commodity("Slag", 4))); + var pack = (ShipPack) EntitySerializer.Pack(ship); + // The typed -5 purchase of old saves: a negative stack in the hold, beside a good one. + pack.CargoContents[0] = pack.CargoContents[0].Concat(new[] { (new int2(3, 3), (ItemInstance) Commodity("Ore", -5)) }).ToArray(); + + var restored = (Ship) EntitySerializer.Unpack(items, zone, pack); + + var bay = restored.CargoBays[0]; + Assert.Equal(new[] { 4 }, bay.Cargo.Keys.OfType().Select(c => c.Quantity).ToArray()); + Assert.Contains(log, l => l.Contains("-5") && l.Contains("Ore")); + } } From ffe43b41e88fe5d7100f4849498628c7132de1a6 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:01:39 +0200 Subject: [PATCH 002/182] Cargo store tests: the empty-stock purchase test reaches the charge (red) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index efcac768c..2495ca2b0 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -845,17 +845,17 @@ public sealed class CargoTransferTests : IDisposable [Fact] public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() { - var station = Station().CargoBays[0]; - var player = ShipWith("Crate").CargoBays[0]; + // An old save's zero stack, as the station's stock. The field clamps the typed quantity up to one unit. var stock = Commodity("Ore", 0); var credits = 100; + var delivered = 0; var result = StationServices.TryBuyLots(_items, stock, StationServices.BuyableQuantity(_items, stock, 1, credits), ref credits, - lot => station.TryTransferItem(player, stock, lot)); + lot => { delivered += lot; return true; }); Assert.Equal(BuyResult.NoRoom, result); Assert.Equal(100, credits); - Assert.Empty(player.Cargo); + Assert.Equal(0, delivered); } [Fact] From ddae55759dc49530b505706e86b3b9aeca45b7be Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:01:42 +0200 Subject: [PATCH 003/182] Cargo bay owns stack quantity: TryStore refuses below 1 and above MaxStack, TryBuyLots owns the stock limit, restore logs dropped stacks Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 3 +++ Assets/Scripts/ServerShared/EntitySerializer.cs | 13 +++++++++++-- Assets/Scripts/ServerShared/StationServices.cs | 9 +++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index bc8087228..f98ef8c32 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2530,9 +2530,12 @@ public class EquippedCargoBay : EquippedItem // Try to store the given commodity at the given position // If there's a stack at the given position it will be added to // Returns true only when ALL of the items are successfully stored + // The bay decides what quantity may sit in it: a stack below one unit or above its design's MaxStack is refused with + // the bay as it was, so no caller, loot, pickup or restore has to check for it. public bool TryStore(SimpleCommodity item, int2 cargoCoord) { var itemData = ItemManager.GetData(item); + if (item.Quantity < 1 || item.Quantity > itemData.MaxStack) return false; if (ItemFits(item, cargoCoord)) { foreach (var p in itemData.Shape.Coordinates) diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 7f934b937..dc5b27c90 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -47,6 +47,15 @@ public static class EntitySerializer return pack; } + // A saved stack goes back where it was. The bay refuses what cannot sit in it (a stack below one unit, as the typed + // -5 purchase of old saves left, or above MaxStack), and the refusal is logged, never silent. + private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item) + { + if (bay.TryStore(item, position)) return; + var quantity = item is SimpleCommodity commodity ? $" x{commodity.Quantity}" : ""; + itemManager.Log($"Dropped a saved item the bay refused at {position}: {itemManager.GetData(item).Name}{quantity}"); + } + public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack) { return pack switch @@ -103,12 +112,12 @@ public static class EntitySerializer for (var bayIndex = 0; bayIndex < pack.CargoContents.Length; bayIndex++) if(entity.CargoBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.CargoContents[bayIndex]) - entity.CargoBays[bayIndex].TryStore(item, position); + RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item); for (var bayIndex = 0; bayIndex < pack.DockingBayContents.Length; bayIndex++) if(entity.DockingBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.DockingBayContents[bayIndex]) - entity.DockingBays[bayIndex].TryStore(item, position); + RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item); entity.Temperature = pack.Temperature; diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index 433b0baf1..457b57a55 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,12 +79,13 @@ public static class StationServices return BuyResult.Bought; } - // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. `deliver` puts a lot of - // that many units where it goes, and each lot is charged for the units it delivered. The first lot the station - // cannot sell or the player cannot take ends the purchase, with the lots before it bought. + // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. The stock is the + // station's: a purchase of more than the stack holds, or of a stack that holds nothing, is refused before anything + // moves, so every lot is units the station has and `deliver` moves whole, and each lot is charged for the units it + // delivered. The first lot the player cannot take ends the purchase, with the lots before it bought. public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) { - if (quantity < 1) return BuyResult.NoRoom; + if (quantity < 1 || quantity > stock.Quantity) return BuyResult.NoRoom; var maxStack = items.GetData(stock).MaxStack; for (var remaining = quantity; remaining > 0;) { From fa5820f516820c0e865cbe9ec9bc9be010893c9b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:15:41 +0200 Subject: [PATCH 004/182] Cargo give: the bay no longer holds a stack below one, so the give's positive-stack filter and its zero-stack test go Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 2 +- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 45208d948..32f49b611 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -110,7 +110,7 @@ public static class CargoTransfer } else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks)) { - var stack = stacks.OfType().FirstOrDefault(s => s.Quantity > 0); + var stack = stacks.OfType().FirstOrDefault(); if (stack != null) return (bay, stack); } } diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 2495ca2b0..c6c80b398 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -697,15 +697,14 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(5, order.Remaining); } - // An empty stack in a hold is no unit to give: the give moves the real units and then has nothing left. + // A give of more than the hold has moves the real units and then has nothing left. [Fact] - public void AnEmptyStackIsNotAUnitToGive() + public void AGiveOfMoreThanTheHoldHasEndsNothingLeft() { var giver = ShipWithBays("Crate"); var receiver = ShipWithBays("Crate"); var grit = Commodity("Grit", 2); Assert.True(giver.CargoBays[0].TryStore(grit)); - Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 0))); Place(giver, receiver); giver.Command.Give(receiver, grit, 3); From f7ba043b1358a4a39672edaec4b7591cbb8db397 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:21:08 +0200 Subject: [PATCH 005/182] Cargo restore test: share the fixture's provenance ledger Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index c6c80b398..58efc2299 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -880,7 +880,7 @@ public sealed class CargoTransferTests : IDisposable { var log = new List(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); - var items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, log.Add); + var items = new ItemManager(1u, _cache, _items.Lots, settings, log.Add); var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); var ship = ShipWith("Crate"); Assert.True(ship.CargoBays[0].TryStore(Commodity("Slag", 4))); From fd50f02f1b6224759ff56c30b9f07f943debae8e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:29:24 +0200 Subject: [PATCH 006/182] Cargo purchase tests: a purchase of no units is refused, not bought Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 58efc2299..25245aa90 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -841,6 +841,20 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(0, Units(player, stock)); } + [Theory] + [InlineData(-5)] + [InlineData(0)] + public void APurchaseOfNoUnitsIsRefusedNotBought(int quantity) + { + var stock = Commodity("Ore", 10); + var credits = 100; + + var result = StationServices.TryBuyLots(_items, stock, quantity, ref credits, lot => true); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100, credits); + } + [Fact] public void APurchaseOfAnEmptyStationStackChargesNothingAndDeliversNothing() { From d66b2a2c3c2717c321070edf6b7af28e5dcdecc4 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:49:22 +0200 Subject: [PATCH 007/182] Cargo restore tests: an oversize saved stack splits into MaxStack stacks and drops only what fits nowhere (red) Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 25245aa90..f65606916 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -908,4 +908,59 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(new[] { 4 }, bay.Cargo.Keys.OfType().Select(c => c.Quantity).ToArray()); Assert.Contains(log, l => l.Contains("-5") && l.Contains("Ore")); } + + // ---- Saves from before the bay capped a stack ------------------------------------------------------------- + + // A pre-f0d7c12c purchase above MaxStack left a whole oversize stack in the hold. The save holds it as one stack. + private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int fillerPerBay) + { + var log = new List(); + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + var items = new ItemManager(1u, _cache, _items.Lots, settings, log.Add); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + for (var i = 0; i < bays.Length; i++) Assert.True(ship.TryEquip(Mint(bays[i]), new int2(1 + 2 * i, 1))); + var pack = (ShipPack) EntitySerializer.Pack(ship); + // Bay 0 holds the oversize stack at (3,3) and `fillerPerBay` full Slag stacks in the other cells; later bays only filler. + for (var b = 0; b < bays.Length; b++) + { + var contents = new List<(int2, ItemInstance)>(); + if (b == 0) contents.Add((new int2(3, 3), Commodity("Ore", oversize))); + for (var n = 0; n < fillerPerBay; n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); + pack.CargoContents[b] = contents.ToArray(); + } + return ((Ship) EntitySerializer.Unpack(items, zone, pack), log); + } + + [Fact] + public void ARestoredOversizeStackSplitsIntoMaxStackStacksInTheSameBay() + { + var (restored, log) = RestoreWithOversize(new[] { "Crate" }, 250, 0); + + var ore = restored.CargoBays[0].Cargo.Keys.OfType().Select(c => c.Quantity).OrderByDescending(q => q).ToArray(); + Assert.Equal(new[] { 100, 100, 50 }, ore); + Assert.DoesNotContain(log, l => l.Contains("Dropped")); + } + + [Fact] + public void ARestoredOversizeStackSpillsToAnotherBayWhenItsOwnIsFull() + { + // Bay 0 has 15 cells of filler and the oversize stack's own cell: only 100 fits there. + var (restored, log) = RestoreWithOversize(new[] { "Crate", "Crate" }, 250, 15); + + Assert.Equal(new[] { 100, 100, 50 }, restored.CargoBays.SelectMany(b => b.Cargo.Keys.OfType()) + .Where(c => _cache.GetByName("Ore").Name == _items.GetData(c).Name).Select(c => c.Quantity).OrderByDescending(q => q).ToArray()); + Assert.Equal(1, restored.CargoBays[0].Cargo.Keys.OfType().Count(c => _items.GetData(c).Name == "Ore")); + Assert.DoesNotContain(log, l => l.Contains("Dropped")); + } + + [Fact] + public void ARestoredOversizeStackDropsOnlyWhatFitsNowhereAndLogsIt() + { + var (restored, log) = RestoreWithOversize(new[] { "Crate" }, 250, 15); + + Assert.Equal(new[] { 100 }, restored.CargoBays[0].Cargo.Keys.OfType() + .Where(c => _items.GetData(c).Name == "Ore").Select(c => c.Quantity).ToArray()); + Assert.Contains(log, l => l.Contains("Ore") && l.Contains("150")); + } } From b027b36ab46f6d2ca7aecdc7968df43f36cf577b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:50:23 +0200 Subject: [PATCH 008/182] Cargo restore: a saved stack above MaxStack splits into MaxStack stacks, the same bay first, then the others; only what fits nowhere is dropped, logged Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/EntitySerializer.cs | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index dc5b27c90..5b4fb7f3f 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -47,15 +47,51 @@ public static class EntitySerializer return pack; } - // A saved stack goes back where it was. The bay refuses what cannot sit in it (a stack below one unit, as the typed - // -5 purchase of old saves left, or above MaxStack), and the refusal is logged, never silent. - private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item) + // A saved stack goes back where it was. The bay refuses what cannot sit in it, and the refusal is logged, never + // silent: a stack below one unit (the typed -5 purchase of old saves) is dropped. A stack above its design's MaxStack + // (an ordinary purchase before the bay capped stacks, or a catalog MaxStack since lowered) is a legitimate save: its + // first MaxStack units go back where it was and the rest is returned to be placed by SpillOversize once every stack + // has its own cell, so a spill cannot take a cell a later stack was saved in. + private static void RestoreStack(ItemManager itemManager, EquippedCargoBay bay, int2 position, ItemInstance item, + List<(EquippedCargoBay home, SimpleCommodity rest)> spill) { + if (item is SimpleCommodity commodity && commodity.Quantity > itemManager.GetData(commodity).MaxStack + && itemManager.GetData(commodity).MaxStack >= 1) + { + var maxStack = itemManager.GetData(commodity).MaxStack; + var first = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = maxStack }; + var rest = new SimpleCommodity { Data = commodity.Data, Rotation = commodity.Rotation, Quantity = commodity.Quantity - maxStack }; + if (!bay.TryStore(first, position)) rest.Quantity += first.Quantity; + spill.Add((bay, rest)); + return; + } if (bay.TryStore(item, position)) return; - var quantity = item is SimpleCommodity commodity ? $" x{commodity.Quantity}" : ""; + var quantity = item is SimpleCommodity c ? $" x{c.Quantity}" : ""; itemManager.Log($"Dropped a saved item the bay refused at {position}: {itemManager.GetData(item).Name}{quantity}"); } + // Places the units an oversize saved stack left over, in stacks of at most MaxStack: the stack's own bay first, then + // the entity's other bays. Only the units that fit nowhere are dropped, logged with their design and quantity. + private static void SpillOversize(ItemManager itemManager, IReadOnlyList bays, + List<(EquippedCargoBay home, SimpleCommodity rest)> spill) + { + foreach (var (home, rest) in spill) + { + var maxStack = itemManager.GetData(rest).MaxStack; + var remaining = rest.Quantity; + while (remaining > 0) + { + var chunk = new SimpleCommodity { Data = rest.Data, Rotation = rest.Rotation, Quantity = Math.Min(remaining, maxStack) }; + var placed = home.TryStore(chunk) || bays.Where(b => b != home).Any(b => b.TryStore(chunk)); + // A refused store may still have topped up a stack; what is left in the chunk found no room. + remaining -= Math.Min(remaining, maxStack) - (placed ? 0 : chunk.Quantity); + if (placed) continue; + itemManager.Log($"Dropped saved units no bay has room for: {itemManager.GetData(rest).Name} x{remaining}"); + break; + } + } + } + public static Entity Unpack(ItemManager itemManager, Zone zone, EntityPack pack) { return pack switch @@ -109,15 +145,18 @@ public static class EntitySerializer entity.DockingBays[i].DockedShip = entity.Children[pack.DockingBayAssignments[i]] as Ship; } + var spill = new List<(EquippedCargoBay home, SimpleCommodity rest)>(); for (var bayIndex = 0; bayIndex < pack.CargoContents.Length; bayIndex++) if(entity.CargoBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.CargoContents[bayIndex]) - RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item); + RestoreStack(itemManager, entity.CargoBays[bayIndex], position, item, spill); for (var bayIndex = 0; bayIndex < pack.DockingBayContents.Length; bayIndex++) if(entity.DockingBays.Count >= bayIndex + 1) foreach (var (position, item) in pack.DockingBayContents[bayIndex]) - RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item); + RestoreStack(itemManager, entity.DockingBays[bayIndex], position, item, spill); + + SpillOversize(itemManager, entity.CargoBays.Cast().Concat(entity.DockingBays).ToList(), spill); entity.Temperature = pack.Temperature; From 9958fe55ab64c45632e216e7261155a58c0abc13 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:50:24 +0200 Subject: [PATCH 009/182] Cargo purchase: a bay stack is one lot, so the lot loop is deleted; stock above MaxStack from outside a bay is refused up front Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/StationServices.cs | 20 ++++++------------ .../CargoTransferTests.cs | 21 ++++++++++--------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index 457b57a55..153fc595d 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,22 +79,14 @@ public static class StationServices return BuyResult.Bought; } - // The purchase of `quantity` units of a station stack, one lot of at most MaxStack at a time. The stock is the - // station's: a purchase of more than the stack holds, or of a stack that holds nothing, is refused before anything - // moves, so every lot is units the station has and `deliver` moves whole, and each lot is charged for the units it - // delivered. The first lot the player cannot take ends the purchase, with the lots before it bought. + // The purchase of `quantity` units of a station stack, delivered as one lot. A bay holds no stack above its + // design's MaxStack, so a stack the station holds is a lot already; a stack above it comes from outside a bay and is + // refused, as is a purchase of more than the stack holds or of a stack that holds nothing. All of that is refused + // before anything moves, so `deliver` moves units the station has and the charge is for the units it delivered. public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) { - if (quantity < 1 || quantity > stock.Quantity) return BuyResult.NoRoom; - var maxStack = items.GetData(stock).MaxStack; - for (var remaining = quantity; remaining > 0;) - { - var lot = Math.Min(remaining, maxStack); - var result = TryBuy(items, stock, lot, ref credits, () => deliver(lot)); - if (result != BuyResult.Bought) return result; - remaining -= lot; - } - return BuyResult.Bought; + if (quantity < 1 || quantity > stock.Quantity || stock.Quantity > items.GetData(stock).MaxStack) return BuyResult.NoRoom; + return TryBuy(items, stock, quantity, ref credits, () => deliver(quantity)); } // The quantity a Buy Quantity field may hold: what was typed, at most what the credits cover and what the station diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index f65606916..a08df266c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -735,31 +735,32 @@ public sealed class CargoTransferTests : IDisposable // ---- The lot a purchase delivers --------------------------------------------------------------------------- [Fact] - public void APurchaseChargesForEachLotItDelivers() + public void APurchaseOfAStackIsOneLotChargedForWhatItDelivers() { - // Grit stacks hold two. Five units are three lots, and each lot is charged for what it delivers. - var stock = Commodity("Grit", 5); + // Grit stacks hold two: a station stack of two is one lot. + var stock = Commodity("Grit", 2); var credits = 100; var delivered = new List(); - var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => { delivered.Add(lot); return true; }); + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => { delivered.Add(lot); return true; }); Assert.Equal(BuyResult.Bought, result); - Assert.Equal(new[] { 2, 2, 1 }, delivered); - Assert.Equal(100 - 5, credits); + Assert.Equal(new[] { 2 }, delivered); + Assert.Equal(100 - 2, credits); } [Fact] - public void ARefusedLotLeavesEarlierLotsBoughtAndChargesNothingMore() + public void AStackAboveMaxStackFromOutsideABayIsRefusedBeforeAnythingMoves() { var stock = Commodity("Grit", 5); var credits = 100; - var lots = 0; + var delivered = 0; - var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => ++lots < 2); + var result = StationServices.TryBuyLots(_items, stock, 2, ref credits, lot => { delivered++; return true; }); Assert.Equal(BuyResult.NoRoom, result); - Assert.Equal(100 - 2, credits); + Assert.Equal(0, delivered); + Assert.Equal(100, credits); } [Theory] From c986adcf20479284cdb0d376f09b3266931d14c8 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:54:52 +0200 Subject: [PATCH 010/182] Cargo restore tests: only the oversize stack's own bay is full, so the spill has another bay to go to Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index a08df266c..d057f004c 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -913,7 +913,7 @@ public sealed class CargoTransferTests : IDisposable // ---- Saves from before the bay capped a stack ------------------------------------------------------------- // A pre-f0d7c12c purchase above MaxStack left a whole oversize stack in the hold. The save holds it as one stack. - private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int fillerPerBay) + private (Ship restored, List log) RestoreWithOversize(string[] bays, int oversize, int filler) { var log = new List(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); @@ -922,12 +922,12 @@ public sealed class CargoTransferTests : IDisposable var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); for (var i = 0; i < bays.Length; i++) Assert.True(ship.TryEquip(Mint(bays[i]), new int2(1 + 2 * i, 1))); var pack = (ShipPack) EntitySerializer.Pack(ship); - // Bay 0 holds the oversize stack at (3,3) and `fillerPerBay` full Slag stacks in the other cells; later bays only filler. + // Bay 0 holds the oversize stack at (3,3) and `filler` full Slag stacks in the other cells; later bays are empty. for (var b = 0; b < bays.Length; b++) { var contents = new List<(int2, ItemInstance)>(); if (b == 0) contents.Add((new int2(3, 3), Commodity("Ore", oversize))); - for (var n = 0; n < fillerPerBay; n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); + for (var n = 0; n < (b == 0 ? filler : 0); n++) contents.Add((new int2(n % 4, n / 4), Commodity("Slag", 100))); pack.CargoContents[b] = contents.ToArray(); } return ((Ship) EntitySerializer.Unpack(items, zone, pack), log); From 8b0b50382a56a86becf6b350821f83f56f2ac281 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:17 +0200 Subject: [PATCH 011/182] WeaponData: drop the dead [Union] list; BehaviorData's union owns behavior arms Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Behaviors/Weapon.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 12166d46c..cb42d6bd4 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -11,12 +11,7 @@ using Newtonsoft.Json; using CultMath; using static CultMath.math; -[Inspectable, - Union(0, typeof(InstantWeaponData)), - Union(1, typeof(LauncherData)), - Union(2, typeof(ConstantWeaponData)), - Union(3, typeof(ChargedWeaponData)), - RuntimeInspectable] +[Inspectable, RuntimeInspectable] public abstract class WeaponData : BehaviorData { [Inspectable, JsonProperty("damageType"), Key(1), RuntimeInspectable] From 1dce24a226277d8570a3a40eb17d282fd04c746d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:23 +0200 Subject: [PATCH 012/182] Pin CultLib 2.0.0 (42dce361) and CultMath 0.5.0 (7a8933b0): manifest tags and build revisions Co-Authored-By: Claude Sonnet 5.5 --- Directory.Build.props | 8 ++++---- Packages/manifest.json | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 190e89780..e17a8e5d5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,12 +2,12 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - - 45c2f4006f5f7dab357426cabd3197e8d0a17f66 + + 42dce3611a81adc5c5fac54cadbaa5637d6022d3 $(CULTMATH_ROOT) $(CultLibRoot) - - 9c1b06cd703bfa44352050e819b0d0659cf5978a + + 7a8933b085468e1fc6f9d3bce4a85e7d101aaca3 C:\Program Files\Git\cmd\git.exe git diff --git a/Packages/manifest.json b/Packages/manifest.json index 2dd2a3bb3..d4dafcbf2 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -52,8 +52,8 @@ "com.unity.modules.vr": "1.0.0", "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", - "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", - "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.1" + "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0" } } From bd0293d6836e43f4a26a7d9c6021b0901a401ead Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:27 +0200 Subject: [PATCH 013/182] BehaviorData: LockWeaponData is arm 52; union-closure and null-gear tests Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/Behaviors.cs | 1 + .../CultLibAdoptionTests.cs | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index 7150e47a1..6b899ee35 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -198,6 +198,7 @@ public interface IPowerConsumer Union(49, typeof(LaunchSuppressorData)), Union(50, typeof(ListenerData)), Union(51, typeof(ScannerData)), + Union(52, typeof(LockWeaponData)), JsonObject(MemberSerialization.OptIn)] public abstract class BehaviorData { diff --git a/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs b/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs new file mode 100644 index 000000000..bfe379d36 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CultLibAdoptionTests.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; + +// CultLib 2.0 refuses a union subtype no arm names (it used to write nil). These keep that refusal a build-time failure. +public sealed class CultLibAdoptionTests +{ + [Fact] + public void EveryConcreteSubtypeOfAUnionBaseIsAnArmOfIt() + { + var types = typeof(BehaviorData).Assembly.GetTypes(); + var missing = new System.Collections.Generic.List(); + foreach (var root in types) + { + var arms = root.GetCustomAttributes(false).Select(u => u.SubType).ToHashSet(); + if (arms.Count == 0) continue; + foreach (var subtype in types.Where(t => t != root && !t.IsAbstract && !t.IsInterface && root.IsAssignableFrom(t))) + if (!arms.Contains(subtype)) missing.Add($"{subtype.Name} is not an arm of {root.Name}"); + } + Assert.Empty(missing); + } + + [Fact] + public void NoShippedGearHoldsANullBehavior() + { + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + var gear = cache.GetAll().ToArray(); + Assert.NotEmpty(gear); + var bad = gear.Where(g => g.Behaviors != null && g.Behaviors.Any(b => b == null)).Select(g => g.Name).ToArray(); + Assert.Empty(bad); + } +} From fb8f2ea2fb7211563b3e491afb69ba32199782e5 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:32 +0200 Subject: [PATCH 014/182] ShipAuthoring: Load follows the pull's InvalidDataException, RefuseLegacyEmbeddedHull the nil-stamp NotSupportedException Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 4 ++-- .../ShipAuthoringTests.cs | 23 ++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index eafbacd2d..2f6db9e5c 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -136,7 +136,7 @@ public static class ShipAuthoringStore { RefuseLegacyEmbeddedHull(path); try { return LoadRecords(path); } - catch (MessagePackSerializationException error) + catch (Exception error) when (error is InvalidDataException or MessagePackSerializationException) { throw new InvalidOperationException($"{path}: a record does not decode as its schema: {error.Message}", error); } @@ -152,7 +152,7 @@ public static class ShipAuthoringStore if (bytes.Length == 0) return; CultPersistedStoreSnapshot snapshot; try { snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); } - catch (MessagePackSerializationException error) + catch (Exception error) when (error is MessagePackSerializationException or NotSupportedException) { throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error); } diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index eced4c6f7..35a346fe0 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -173,7 +173,28 @@ public sealed class ShipAuthoringTests RewritePayload(path, "aetheria.hulldata", slots => ((object[])((object[])slots[5])[0])[2] = new object[] { 5, false, true, false }); var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); Assert.Contains(path, error.Message); - Assert.IsAssignableFrom(error.InnerException); + // The pull names the record key and schema; the MessagePack failure is its cause. + var pull = Assert.IsType(error.InnerException); + Assert.Contains("aetheria.hulldata", pull.Message); + Assert.Contains(ShipModCatalog.HullKey("mod.skiff").ToString(), pull.Message); + Assert.IsAssignableFrom(pull.InnerException); + } + + // A store header with a nil format stamp is refused by the runtime with NotSupportedException; Load names the file. + [Fact] + public void LoadNamesTheFileWhoseFormatStampIsNil() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "nilstamp.cc"); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot + { + FormatVersion = null, + SchemaCatalog = Array.Empty(), + Records = Array.Empty(), + })); + var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); + Assert.Contains(path, error.Message); + Assert.IsType(error.InnerException); } // Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again. From fbf88e85c514daaeba06b1b8f18abe704810b3ef Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:23:37 +0200 Subject: [PATCH 015/182] ship_cc.read owns its unreadable-path refusal, naming no path Co-Authored-By: Claude Sonnet 5.5 --- tools/blender/aetheria_ships/ship_cc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index dd3ca78be..b7dca307c 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -119,7 +119,10 @@ class ShipFile(NamedTuple): def read(path: str, cultlib_packages: str) -> ShipFile: cultcache, msgpack = _libraries(cultlib_packages) store = cultcache.SingleFileMessagePackBackingStore(path) - envelopes = store.pull_all() + try: + envelopes = store.pull_all() + except OSError as error: + raise ValueError(f"The ship file cannot be read: {error.strerror}") from None ships = [envelope for envelope in envelopes if envelope.type == SCHEMA] hulls = [envelope for envelope in envelopes if envelope.type == HULL_SCHEMA] # Before S1 the hull lived inside the ship record. Saving such a file would preserve the stale hull under a ship that From e518a0b3d161a05029f86d6973d1186d6f9afead Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:37:40 +0200 Subject: [PATCH 016/182] packages-lock: regenerated by Unity at the 2.0.0 / 0.5.0 tags Co-Authored-By: Claude Sonnet 5.5 --- Packages/packages-lock.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 5d26bd587..7345ecc47 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -256,27 +256,29 @@ "url": "https://packages.unity.com" }, "org.gamecult.caching.unity": { - "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v1.4.0", + "version": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", "depth": 0, "source": "git", "dependencies": { - "org.gamecult.cultlib": "1.0.60" + "org.gamecult.cultlib": "2.0.0" }, - "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" + "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.60", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", "depth": 0, "source": "git", - "dependencies": {}, - "hash": "45c2f4006f5f7dab357426cabd3197e8d0a17f66" + "dependencies": { + "org.gamecult.cultmath": "0.5.0" + }, + "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultmath": { - "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.4.1", + "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0", "depth": 0, "source": "git", "dependencies": {}, - "hash": "9c1b06cd703bfa44352050e819b0d0659cf5978a" + "hash": "7a8933b085468e1fc6f9d3bce4a85e7d101aaca3" }, "com.unity.modules.accessibility": { "version": "1.0.0", From f448957f3ad1d1de2c9a96477acacb53047683da Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:39:29 +0200 Subject: [PATCH 017/182] Brush: name AnimatedSimplexBrush and RadialWaveBrush as arms; pull-failure test asserts the record key Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Environment.cs | 2 ++ tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/Environment.cs b/Assets/Scripts/ServerShared/Environment.cs index 737033e4e..9d4994fa3 100644 --- a/Assets/Scripts/ServerShared/Environment.cs +++ b/Assets/Scripts/ServerShared/Environment.cs @@ -72,6 +72,8 @@ public class NoiseSettings [Union(0, typeof(PowerBrush)), Union(1, typeof(SimplexBrush)), + Union(2, typeof(AnimatedSimplexBrush)), + Union(3, typeof(RadialWaveBrush)), Serializable, MessagePackObject, JsonObject(MemberSerialization.OptIn)] public abstract class Brush { diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index 35a346fe0..cb4d12d5f 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -175,8 +175,8 @@ public sealed class ShipAuthoringTests Assert.Contains(path, error.Message); // The pull names the record key and schema; the MessagePack failure is its cause. var pull = Assert.IsType(error.InnerException); - Assert.Contains("aetheria.hulldata", pull.Message); - Assert.Contains(ShipModCatalog.HullKey("mod.skiff").ToString(), pull.Message); + Assert.Contains("Record '" + ShipModCatalog.HullKey("mod.skiff") + "'", pull.Message); + Assert.Contains("schema", pull.Message); Assert.IsAssignableFrom(pull.InnerException); } From da5ba653cb78cb0ba2029ba83b924db7b455bf60 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:34:08 +0200 Subject: [PATCH 018/182] Tests (red at 42dce361): a CultLib 1.0.60 run.cc opens; Load names a ship file of a newer store format tests/vectors/cultlib-1.0.60/run.cc is the run store Soul wrote with the base build (cl2-old-run-save-refused). Both new tests fail at the 2.0.0 pin; the first needs 2.0.1, the second needs ShipAuthoringStore.Load. Co-Authored-By: Claude Sonnet 5.5 --- .../RunSaveCompatibilityTests.cs | 33 ++++++++++++++++++ .../ShipAuthoringTests.cs | 19 ++++++++++ tests/vectors/cultlib-1.0.60/run.cc | Bin 0 -> 53949 bytes 3 files changed, 52 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs create mode 100644 tests/vectors/cultlib-1.0.60/run.cc diff --git a/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs b/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs new file mode 100644 index 000000000..8b8c98217 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunSaveCompatibilityTests.cs @@ -0,0 +1,33 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// tests/vectors/cultlib-1.0.60/run.cc is a run.cc written by Aetheria on CultLib 1.0.60 (64 zones, factions, one entity). +// A player's save outlives the runtime that wrote it, so the current runtime must open it as compatible drift. +public sealed class RunSaveCompatibilityTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-runcompat-" + Guid.NewGuid().ToString("N")); + + public RunSaveCompatibilityTests() => Directory.CreateDirectory(_root); + + public void Dispose() => Directory.Delete(_root, true); + + [Fact] + public void ARunWrittenByCultLib1060OpensWithEveryZoneAndFactionResolving() + { + var run = Path.Combine(_root, "run.cc"); + File.Copy(Path.Combine(RunStartTests.FindRepoRoot(), "tests", "vectors", "cultlib-1.0.60", "run.cc"), run); + using var cache = new CultCache(TestCatalog.Registry()); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(TestCatalog.Repo, true), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(run), AetheriaStores.RunTypes); + var saved = cache.GetGlobal(); + Assert.NotNull(saved); + Assert.NotEmpty(saved.Zones); + Assert.NotEmpty(saved.Factions); + Assert.All(saved.Zones, zone => Assert.NotNull(cache.Get(zone))); + Assert.All(saved.Factions, faction => Assert.NotNull(cache.Get(faction))); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs index cb4d12d5f..850e83dfd 100644 --- a/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipAuthoringTests.cs @@ -197,6 +197,25 @@ public sealed class ShipAuthoringTests Assert.IsType(error.InnerException); } + // A store format this runtime does not know is refused on read with NotSupportedException from the attach; Load names the file. + [Fact] + public async Task LoadNamesTheFileWhoseStoreFormatIsNewer() + { + using var directory = new TempDirectory(); + var path = Path.Combine(directory.Path, "newer.cc"); + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + ShipAuthoringStore.Write(cache, Fixture().Hull, Fixture().Visual); + await cache.FlushAsync(); + } + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + snapshot.FormatVersion = "cultcache.store.v9"; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var error = Assert.Throws(() => ShipAuthoringStore.Load(path)); + Assert.Contains(path, error.Message); + Assert.IsType(error.InnerException); + } + // Decodes one record's payload of the named schema to its slot array, lets the edit change it, and stores it again. private static void RewritePayload(string path, string schemaName, Action edit) { diff --git a/tests/vectors/cultlib-1.0.60/run.cc b/tests/vectors/cultlib-1.0.60/run.cc new file mode 100644 index 0000000000000000000000000000000000000000..fc2a4e76e119257e70ccafe82b3916de79159aef GIT binary patch literal 53949 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 Date: Thu, 22 Oct 2026 23:34:24 +0200 Subject: [PATCH 019/182] Pins: cultlib-unity-v2.0.1 at b8fc5eb0 (1.0.x saves with ref lists open as compatible drift) packages-lock.json follows from the Unity resolve. caching-unity stays at v2.0.0; cultmath at 7a8933b0. Co-Authored-By: Claude Sonnet 5.5 --- Directory.Build.props | 4 ++-- Packages/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index e17a8e5d5..05d5e4d22 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,8 +2,8 @@ $(CULTLIB_ROOT) $(MSBuildThisFileDirectory)..\CultLib - - 42dce3611a81adc5c5fac54cadbaa5637d6022d3 + + b8fc5eb07720a187a0421424ac2cb3aa2966ca1d $(CULTMATH_ROOT) $(CultLibRoot) diff --git a/Packages/manifest.json b/Packages/manifest.json index d4dafcbf2..7693bd94f 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -53,7 +53,7 @@ "com.unity.modules.wind": "1.0.0", "com.unity.modules.xr": "1.0.0", "org.gamecult.caching.unity": "https://github.com/GameCult/CultLib.git?path=/src/GameCult.Unity/Assets/Caching#caching-unity-v2.0.0", - "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.1", "org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0" } } From 7886f0a231890ab3f69932feec1e8d3a97e17926 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 23:34:26 +0200 Subject: [PATCH 020/182] ShipAuthoringStore.Load names the file when its store format is newer than this runtime reads Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/ShipAuthoring.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 2f6db9e5c..3962fa468 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -140,6 +140,10 @@ public static class ShipAuthoringStore { throw new InvalidOperationException($"{path}: a record does not decode as its schema: {error.Message}", error); } + catch (NotSupportedException error) + { + throw new InvalidOperationException($"{path}: not a readable ship file: {error.Message}", error); + } } // Key 1 of ShipAuthoring held the whole hull before S1. The type no longer has that member, so a deserialized record From fba7bd1b4763e08e145a0a21b99eed4720143cb7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:24:10 +0200 Subject: [PATCH 021/182] Station kinds: StationKindData catalog list, HullData.Kind, placed station Kind; GenerateStationLoadout takes a kind (the kindless draw is deleted) Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 3 +- .../Scripts/ServerShared/EntitySerializer.cs | 7 +- Assets/Scripts/ServerShared/ItemData.cs | 6 + .../Scripts/ServerShared/LoadoutGenerator.cs | 10 +- Assets/Scripts/ServerShared/OrbitalEntity.cs | 2 + Assets/Scripts/ServerShared/StationKinds.cs | 128 ++++++ .../Scripts/ServerShared/StationKinds.cs.meta | 11 + Assets/Scripts/ServerShared/ZoneGenerator.cs | 6 +- .../ConsumableSupplyTests.cs | 17 +- .../HardpointOwnershipTests.cs | 2 +- .../PickupGenerationTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunGoalTests.cs | 2 +- tests/Aetheria.Shared.Tests/RunStartTests.cs | 10 +- .../Aetheria.Shared.Tests/StationKindTests.cs | 378 ++++++++++++++++++ .../StationKindsCatalogTests.cs | 53 +++ tools/AetherDb/Program.cs | 5 +- tools/AetherDb/StationKindsCatalog.cs | 82 ++++ 17 files changed, 700 insertions(+), 24 deletions(-) create mode 100644 Assets/Scripts/ServerShared/StationKinds.cs create mode 100644 Assets/Scripts/ServerShared/StationKinds.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/StationKindTests.cs create mode 100644 tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs create mode 100644 tools/AetherDb/StationKindsCatalog.cs diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 525894ac9..47128ef6c 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -7,7 +7,7 @@ using GameCult.Caching.MessagePack; // Aetheria's stores: one home store per type, composed here and nowhere else. public static class AetheriaStores { - public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb) }; + public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb), typeof(StationKindData) }; public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) }; public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; @@ -42,6 +42,7 @@ public static class AetheriaStores foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); VerseGrammarValidation.ValidateAll(cache.GetAll()); ValidateBins(cache); + StationKinds.Validate(cache); return cache; } catch diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 5b4fb7f3f..7f0f04ebd 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -16,7 +16,8 @@ public static class EntitySerializer { Orbit = new CultRecordRef(orbital.OrbitData), SecurityLevel = orbital.SecurityLevel, - SecurityRadius = orbital.SecurityRadius + SecurityRadius = orbital.SecurityRadius, + Kind = orbital.Kind }; else if (entity is Ship ship) pack = new ShipPack @@ -120,6 +121,7 @@ public static class EntitySerializer Restore(itemManager, zone, pack, entity); entity.SecurityLevel = pack.SecurityLevel; entity.SecurityRadius = pack.SecurityRadius; + entity.Kind = pack.Kind; if (pack.Story >= 0) entity.Story = zone.GalaxyZone.Locations[pack.Story]; return entity; } @@ -214,6 +216,7 @@ public class OrbitalEntityPack : EntityPack [Key(18)] public int Story = -1; [Key(19)] public SecurityLevel SecurityLevel; [Key(20)] public float SecurityRadius; + [Key(22)] public CultRecordRef Kind; } [MessagePackObject, @@ -237,7 +240,7 @@ public abstract class EntityPack [Key(14)] public EntitySettings Settings; [Key(15)] public CultRecordRef Faction; [Key(16)] public int[][] WeaponGroups; - // Keys 17-20 belong to the subclasses; the base's next free key is 21. + // Keys 17-20 belong to the subclasses; the base's next free key is 23 (Boss is 21, the orbital pack's Kind is 22). [Key(21)] public bool Boss; private int _price; diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 170c2421d..d9e972a85 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -549,6 +549,12 @@ public class HullData : EquippableItemData [Inspectable, JsonProperty("visual"), Key(32)] public CultRecordRef Visual; + // What a station hull is built to be (StationKinds): set on every Station hull and on no other. The hull names its kind + // and the kind names no hull, so a package can add a faction's model of a kind without editing any kind record. + // Key 33 is the next free key. + [Inspectable, JsonProperty("kind"), Key(33)] + public CultRecordRef Kind; + // The farthest corner of any occupied hull cell from the centre of mass the schematic frame uses, in world // units: a true bound on how far the hull reaches from its position (the half-diagonal of the bounding box is not, // as FireControl.Detonate's comment records). A cell at (x, y) spans x +/- .5 and y +/- .5. diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index b75f0d30a..ce8569965 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -74,9 +74,12 @@ public class LoadoutGenerator return EntitySerializer.Pack(entity) as OrbitalEntityPack; } - public OrbitalEntityPack GenerateStationLoadout() + // A station of one kind: the hull is drawn among the station hulls the kind calls for in this faction's hands + // (StationKinds.HullKindFor), so a faction with no model of the kind builds its stand-in's hull and keeps the kind. + public OrbitalEntityPack GenerateStationLoadout(CultRecordRef kind) { - var (hullProduct, hullData) = RandomHull(HullType.Station); + var hullKind = StationKinds.HullKindFor(ItemManager.ItemData, Faction, kind); + var (hullProduct, hullData) = RandomHull(HullType.Station, hull => hull.Kind.Key.Equals(hullKind.Key)); if(hullData==null) { ItemManager.Log("Unable to generate station loadout: no compatible hull found!"); @@ -85,6 +88,9 @@ public class LoadoutGenerator var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem; var entity = new OrbitalEntity(ItemManager, null, hull, default, ItemManager.GameplaySettings.DefaultEntitySettings); entity.Faction = Faction; + entity.Kind = kind; + // A stand-in hull keeps its own model but not its own name: the station is what its kind says it is. + if (!hullData.Kind.Key.Equals(kind.Key) && ItemManager.ItemData.Get(kind) is { } kindData) entity.Name = kindData.Name; // Hardpoint gear goes in first so the docking bay can only take space that gear left free EquipHardpoints(entity); diff --git a/Assets/Scripts/ServerShared/OrbitalEntity.cs b/Assets/Scripts/ServerShared/OrbitalEntity.cs index a2c511ba7..be1bd893c 100644 --- a/Assets/Scripts/ServerShared/OrbitalEntity.cs +++ b/Assets/Scripts/ServerShared/OrbitalEntity.cs @@ -17,6 +17,8 @@ public class OrbitalEntity : Entity public float SecurityRadius; public LocationStory Story; public bool CanTow; + // What this station is for (StationKinds): its own fact, which under a stand-in hull differs from the hull's kind. + public CultRecordRef Kind; public OrbitalEntity(ItemManager itemManager, Zone zone, EquippableItem hull, CultRecordKey orbit, EntitySettings settings) : base(itemManager, zone, hull, settings) { diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs new file mode 100644 index 000000000..18c200092 --- /dev/null +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching; +using MessagePack; +using Newtonsoft.Json; + +// What a station is for: one authored catalog record per kind (mining, trade-hub, shipyard...). The catalog's list of +// these records is the one list of station kinds. A kind names no hull: the hull names its kind (HullData.Kind), and +// which hull a faction builds for a kind is derived from the products that faction makes (StationKinds), so a package can +// add a faction's model of a kind without editing any kind record. Records are keyed station-kind:. +[CultDocument("aetheria.stationkinddata", "1"), MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class StationKindData +{ + [CultName, JsonProperty("name"), Key(0)] + public string Name = ""; + + [JsonProperty("description"), Key(1)] + public string Description = ""; + + // A faction with no model of this kind builds the stand-in's hull and the station stays this kind. Ignored once the + // faction offers a model of this kind. + [JsonProperty("standIns"), Key(2)] + public List StandIns = new List(); + + public static CultRecordKey KeyOf(string id) => new CultRecordKey("station-kind:" + id); +} + +[MessagePackObject, JsonObject(MemberSerialization.OptIn)] +public class StationKindStandIn +{ + [JsonProperty("faction"), Key(0)] + public CultRecordRef Faction; + + // The kind whose model this faction builds in place of the owning kind's. + [JsonProperty("kind"), Key(1)] + public CultRecordRef Kind; +} + +// The one owner of which kinds a faction builds, which kind a station gets, and which hull kind stands in. +public static class StationKinds +{ + // The kinds this faction can place: those of the station hulls its products offer, and those that stand in for it. + // Ordered by key so a choice never depends on store order. Empty for no faction. + public static CultRecordRef[] KindsOf(CultCache catalog, Faction faction) + { + if (faction == null) return Array.Empty>(); + var kinds = new HashSet(OfferedStationHulls(catalog, faction).Select(hull => hull.Kind.Key.Value), StringComparer.Ordinal); + foreach (var kind in catalog.GetAll()) + if (kind.StandIns.Any(standIn => catalog.Get(standIn.Faction) == faction)) + kinds.Add(catalog.RefOf(kind).Key.Value); + return Ordered(kinds); + } + + // A station's kind: a stable pick among the faction's kinds by the station's site key. It draws nothing from any random + // stream, so adding the kind to generation moves no other roll. A faction with no kinds falls back to any kind some + // offered station hull carries, logged, and the hull draw then falls back to any maker. Unset when the catalog has none. + public static CultRecordRef Choose(CultCache catalog, Faction faction, CultRecordKey siteKey, Action log = null) + { + var pool = KindsOf(catalog, faction); + if (pool.Length == 0) + { + pool = Ordered(new HashSet( + catalog.GetAll().Where(product => LoadoutGenerator.IsOffered(catalog, product)) + .Select(product => catalog.Get(product.Design) as HullData) + .Where(hull => hull != null && hull.HullType == HullType.Station && hull.Kind.IsSet()) + .Select(hull => hull.Kind.Key.Value), StringComparer.Ordinal)); + if (pool.Length == 0) return default; + log?.Invoke($"{faction?.Name ?? "This galaxy"} builds no station kind; placing a station of any kind some maker offers."); + } + return pool[(int) (siteKey.Value.StableHash() % (uint) pool.Length)]; + } + + // The kind of hull a faction builds for a station kind: the kind itself when the faction offers a model of it (a model + // wins over a stale stand-in), else the kind its stand-in names, else the kind itself (the hull draw then falls back to + // any maker). + public static CultRecordRef HullKindFor(CultCache catalog, Faction faction, CultRecordRef kind) + { + if (!kind.IsSet() || faction == null) return kind; + if (OfferedStationHulls(catalog, faction).Any(hull => hull.Kind.Key.Equals(kind.Key))) return kind; + var standIn = catalog.Get(kind)?.StandIns.FirstOrDefault(entry => catalog.Get(entry.Faction) == faction); + return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; + } + + // Open refuses a malformed kind catalog, naming the record and field. Every Station hull names a kind the catalog holds + // and no other hull names one; every stand-in names a faction and a kind the catalog holds, is never its own kind, and + // does not stand on another stand-in for the same faction (one hop). + public static void Validate(CultCache catalog) + { + foreach (var hull in catalog.GetAll()) + { + if (hull.HullType == HullType.Station) + { + if (!hull.Kind.IsSet()) + throw new InvalidOperationException($"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."); + if (catalog.Get(hull.Kind) == null) + throw new InvalidOperationException($"Station hull '{hull.Name}' Kind names no station kind this catalog holds."); + } + else if (hull.Kind.IsSet()) + throw new InvalidOperationException($"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."); + } + foreach (var kind in catalog.GetAll()) + { + var self = catalog.RefOf(kind).Key; + for (var i = 0; i < kind.StandIns.Count; i++) + { + var entry = kind.StandIns[i]; + var at = $"Station kind '{kind.Name}' StandIns[{i}]"; + var faction = catalog.Get(entry.Faction); + if (faction == null) throw new InvalidOperationException($"{at}.Faction names no faction this catalog holds."); + var other = catalog.Get(entry.Kind); + if (other == null) throw new InvalidOperationException($"{at}.Kind names no station kind this catalog holds."); + if (entry.Kind.Key.Equals(self)) throw new InvalidOperationException($"{at}.Kind is the kind itself; a kind cannot stand in for itself."); + if (other.StandIns.Any(next => catalog.Get(next.Faction) == faction)) + throw new InvalidOperationException($"{at}.Kind stands in for the same faction itself; a stand-in is one hop."); + } + } + } + + private static IEnumerable OfferedStationHulls(CultCache catalog, Faction faction) => + catalog.GetAll() + .Where(product => LoadoutGenerator.IsOffered(catalog, product) && catalog.Get(product.Manufacturer) == faction) + .Select(product => catalog.Get(product.Design) as HullData) + .Where(hull => hull != null && hull.HullType == HullType.Station && hull.Kind.IsSet()); + + private static CultRecordRef[] Ordered(IEnumerable keys) => + keys.OrderBy(key => key, StringComparer.Ordinal).Select(key => new CultRecordRef(new CultRecordKey(key))).ToArray(); +} diff --git a/Assets/Scripts/ServerShared/StationKinds.cs.meta b/Assets/Scripts/ServerShared/StationKinds.cs.meta new file mode 100644 index 000000000..8e29291ad --- /dev/null +++ b/Assets/Scripts/ServerShared/StationKinds.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b3e1a94c2d84f6e9a05d1c8e4f27b63 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index dfe89917c..c5c653bac 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -419,7 +419,8 @@ public static class ZoneGenerator if (i >= selectedStationOrbits.Length) { itemManager.Log($"Story station for {story.Faction?.Name} has no rail clear of the bodies; its story has nowhere to live!"); continue; } var orbit = selectedStationOrbits[i]; var lagrangeOrbit = CreateLagrangeOrbit(orbit); - var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout(); + var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout( + StationKinds.Choose(cache, story.Faction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); if (station == null) { itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!"); @@ -440,7 +441,8 @@ public static class ZoneGenerator var security = (SecurityLevel) ((int) ((1f - pow(random.NextFloat(), factionPresence / 2f)) * 3f)); var lagrangeOrbit = CreateLagrangeOrbit(orbit); - var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout(); + var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout( + StationKinds.Choose(cache, nearestFaction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); if (station == null) { if (isTutorialEntrance) diff --git a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs index b02dffea7..9878d95c6 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs @@ -60,6 +60,7 @@ public sealed class ConsumableSupplyTests : IDisposable public ItemManager Items; public Galaxy Galaxy; public Faction Maker; + public CultRecordRef Kind; public LoadoutGenerator Generator(ref Random random) => new LoadoutGenerator(ref random, Items, Galaxy, null, Maker, .5f); } @@ -84,8 +85,9 @@ public sealed class ConsumableSupplyTests : IDisposable maker = seed.Upsert(new Faction { Name = "Maker", ShortName = "MKR", Allegiance = { { ally, 1f } } }); // Interior for the crate, the capacitor and the tractor beam every generated ship carries. + var hub = new CultRecordRef(seed.Upsert(new StationKindData { Name = "Hub" }).Key); var skiff = seed.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(5, 3), Price = 100 }); - var platform = seed.Upsert(new HullData { Name = "Platform", HullType = HullType.Station, Shape = Solid(5, 5), Price = 100 }); + var platform = seed.Upsert(new HullData { Name = "Platform", HullType = HullType.Station, Kind = hub, Shape = Solid(5, 5), Price = 100 }); // A hold with no cell cannot take a consumable. var crate = seed.Upsert(new CargoBayData { @@ -107,7 +109,7 @@ public sealed class ConsumableSupplyTests : IDisposable if (holds != Holds.Pinned) { sold.Add(("Cutter", seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = Solid(5, 3), Price = 100 }).Key)); - sold.Add(("Outpost", seed.Upsert(new HullData { Name = "Outpost", HullType = HullType.Station, Shape = Solid(4, 4), Price = 100 }).Key)); + sold.Add(("Outpost", seed.Upsert(new HullData { Name = "Outpost", HullType = HullType.Station, Kind = hub, Shape = Solid(4, 4), Price = 100 }).Key)); sold.Add(("Plate", seed.Upsert(new GearData { Name = "Plate", Hardpoint = HardpointType.Tool, Shape = Solid(2, 2), Price = 1 }).Key)); } @@ -155,7 +157,8 @@ public sealed class ConsumableSupplyTests : IDisposable Cache = cache, Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), _ => { }), Galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }), - Maker = cache.GetByName("Maker") + Maker = cache.GetByName("Maker"), + Kind = new CultRecordRef(cache.GetAll().Select(k => cache.RefOf(k).Key).Single()) }; } @@ -193,7 +196,7 @@ public sealed class ConsumableSupplyTests : IDisposable for (var i = 0; i < Seeds; i++) { var random = new Random(SeedOf(i)); - var pack = rig.Generator(ref random).GenerateStationLoadout(); + var pack = rig.Generator(ref random).GenerateStationLoadout(rig.Kind); Assert.NotEmpty(pack.CargoContents.SelectMany(bay => bay)); // the gear stock is read from the same pack var held = Consumables(pack); var hull = rig.Items.GetData(pack.Hull).Name; @@ -255,7 +258,7 @@ public sealed class ConsumableSupplyTests : IDisposable for (var i = 0; i < Seeds; i++) { var random = new Random(SeedOf(i)); - if (Consumables(rig.Generator(ref random).GenerateStationLoadout()).Length > 0) stocked++; + if (Consumables(rig.Generator(ref random).GenerateStationLoadout(rig.Kind)).Length > 0) stocked++; } AssertWithinThreeSigma(stocked, Seeds, StationChance, "stations stocking a consumable past a refusing first bay"); } @@ -289,7 +292,7 @@ public sealed class ConsumableSupplyTests : IDisposable items.Random = new Random(SeedOf(i)); var random = new Random(SeedOf(i)); var generator = new LoadoutGenerator(ref random, items, galaxy, galaxy.Entrance, null, .5f); - if (Consumables(generator.GenerateStationLoadout()).Length > 0) stations++; + if (Consumables(generator.GenerateStationLoadout(TestStationKinds.TradeHub)).Length > 0) stations++; if (Consumables(generator.GenerateShipLoadout()).Length > 0) ships++; } AssertWithinThreeSigma(stations, Seeds, StationChance, "shipped stations stocking a consumable"); @@ -327,7 +330,7 @@ public sealed class ConsumableSupplyTests : IDisposable rig.Items.Random = new Random(99); var random = new Random(5); var generator = rig.Generator(ref random); - generator.GenerateStationLoadout(); + generator.GenerateStationLoadout(rig.Kind); for (var i = 0; i < 3; i++) generator.GenerateShipLoadout(); return Enumerable.Range(0, 4).Select(_ => generator.Random.NextFloat()) .Concat(Enumerable.Range(0, 4).Select(_ => rig.Items.Random.NextFloat())).ToArray(); diff --git a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs index 2650b8009..2b8fa2270 100644 --- a/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs +++ b/tests/Aetheria.Shared.Tests/HardpointOwnershipTests.cs @@ -264,7 +264,7 @@ public sealed partial class RunStartTests var shipHulls = LoadoutGenerator.SoldHulls(_cache).Where(h => h.HullType == HullType.Ship).Select(h => h.Name).ToList(); var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); var packs = shipHulls.SelectMany(name => Enumerable.Range(0, 2).Select(_ => generator.GenerateShipLoadout(h => h.Name == name))) - .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout())) + .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateStationLoadout(TestStationKinds.TradeHub))) .Concat(Enumerable.Range(0, 4).Select(_ => generator.GenerateTurretLoadout())) .ToList(); var readers = 0; diff --git a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs index fefdaeb81..ce4666496 100644 --- a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs @@ -37,7 +37,7 @@ public sealed partial class RunStartTests // Stations and turrets share the interior outfitting and get none. for (var i = 0; i < 4; i++) { - Assert.Equal(0, PickupGear(generator.GenerateStationLoadout())); + Assert.Equal(0, PickupGear(generator.GenerateStationLoadout(TestStationKinds.TradeHub))); Assert.Equal(0, PickupGear(generator.GenerateTurretLoadout())); } Assert.All(main.arena.Pack.Entities.Where(pack => !(pack is ShipPack)), pack => Assert.Equal(0, PickupGear(pack))); diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index 8626148a5..c10feb803 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -318,7 +318,7 @@ public sealed partial class RunStartTests var ship = generator.GenerateShipLoadout(); Assert.NotNull(ship); Assert.NotEqual(hull.Name, HullName(items, ship)); - var station = generator.GenerateStationLoadout(); + var station = generator.GenerateStationLoadout(TestStationKinds.TradeHub); Assert.DoesNotContain(EntitySerializer.Items(station), item => items.GetData(item).Name == hull.Name); Assert.Null(generator.ChooseProduct(hull)); Assert.Null(generator.GenerateShipLoadout(candidate => candidate == hull)); diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index b3c502a6b..69b040d4e 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -107,7 +107,7 @@ public sealed partial class RunStartTests : IDisposable var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); for (var i = 0; i < 8; i++) { - var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.True(station.Equipment.Count(IsHeater) == 1, $"station {i} carries {station.Equipment.Count(IsHeater)} heaters"); Assert.True(station.Equipment.Any(item => item.Behaviors.OfType().Any()), $"station {i} has no reactor"); } @@ -168,7 +168,7 @@ public sealed partial class RunStartTests : IDisposable var generator = new LoadoutGenerator(ref _items.Random, _items, galaxy, galaxy.Entrance, faction, .5f); for (var i = 0; i < 3; i++) { - var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + var station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); 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 => made.product == null || !generator.IsAvailable(made.product)).ToList(); @@ -219,7 +219,7 @@ public sealed partial class RunStartTests : IDisposable Entity station = null; for (var i = 0; i < 8; i++) { - station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout()); + station = EntitySerializer.Unpack(_items, zone, generator.GenerateStationLoadout(TestStationKinds.TradeHub)); 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(); @@ -313,7 +313,7 @@ public sealed partial class RunStartTests : IDisposable .OrderBy(design => design.Shape.Coordinates.Length).ThenBy(design => design.Name, StringComparer.Ordinal).First(); var shape = new Shape(bay.Shape.Width + 2, bay.Shape.Height + 2); foreach (var cell in shape.AllCoordinates) shape[cell] = true; - var cramped = new HullData { Name = "Cramped Station", HullType = HullType.Station, Shape = shape, Durability = 100, Mass = 1000, Price = 1 }; + var cramped = new HullData { Name = "Cramped Station", HullType = HullType.Station, Kind = TestStationKinds.TradeHub, Shape = shape, Durability = 100, Mass = 1000, Price = 1 }; _cache.Upsert(cramped); _cache.Upsert(new FactionProductData { @@ -323,7 +323,7 @@ public sealed partial class RunStartTests : IDisposable hull.Price = 0; // generation offers no zero-price design, so the cramped hull is the only station var generator = new LoadoutGenerator(ref _items.Random, _items, _galaxy, _galaxy.Entrance, _protagonist, .5f); - var failure = Assert.Throws(() => generator.GenerateStationLoadout()); + var failure = Assert.Throws(() => generator.GenerateStationLoadout(TestStationKinds.TradeHub)); Assert.Contains("heater", failure.Message); } diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs new file mode 100644 index 000000000..0bce4474a --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using Xunit; +using Random = CultMath.Random; + +// Test-side handle on the shipped catalog's trade-hub kind, the one Zenith carries (ruling zenith-is-a-trade-hub). +internal static class TestStationKinds +{ + public static readonly CultRecordRef TradeHub = new CultRecordRef(StationKindData.KeyOf("trade-hub")); +} + +// Station kinds (aetheria-living-world, cut station-kind-datum): the one list of kinds in the catalog, a hull's kind, a +// placed station's kind, and the stand-in rule for a faction with no model of a kind. Worlds here are the composed shipped +// catalog copied to a temp directory, with Zenith taken off the market so the only station hulls are the ones a test adds. +public sealed class StationKindTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-stationkinds-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + private int _worlds; + + public StationKindTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + foreach (var cache in _caches) cache.Dispose(); + Directory.Delete(_root, true); + } + + private static CultRecordRef Kind(string id) => new CultRecordRef(StationKindData.KeyOf(id)); + + private sealed class World + { + public CultCache Cache; + public ItemManager Items; + public List Log = new List(); + public Faction Zh, Au, Lucent; + public HullData Zenith; + private FactionProductData _zenithProduct; + private int _added; + + public static World Make(StationKindTests host, bool zenithOffered) + { + var dir = Path.Combine(host._root, "world" + host._worlds++); + Directory.CreateDirectory(dir); + File.Copy(TestCatalog.Repo, Path.Combine(dir, "Aetheria.cc")); + var cache = new CultCache(TestCatalog.Registry()); + host._caches.Add(cache); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "Aetheria.cc")), AetheriaStores.CatalogTypes); + var world = new World { Cache = cache }; + world.Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), world.Log.Add); + world.Zh = cache.GetAll().Single(f => f.Name == "Zhestokost"); + world.Au = cache.GetAll().Single(f => f.ShortName == "AU"); + world.Lucent = cache.GetAll().Single(f => f.ShortName == "Lucent"); + world.Zenith = cache.GetAll().Single(h => h.Name == "Zenith"); + world._zenithProduct = cache.GetAll().Single(p => cache.Get(p.Design) == world.Zenith); + if (!zenithOffered) + { + world._zenithProduct.NotOffered = true; + cache.Upsert(world._zenithProduct); + } + return world; + } + + // A station hull of the given kind that this faction sells: a copy of Zenith's body, so it takes the same docking bay + // and heater, with its own name and kind. + public HullData Sell(Faction maker, string kindId, string name = null) + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var hull = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(Zenith, options), options); + hull.Name = name ?? $"{maker.ShortName} {kindId} {_added++}"; + hull.Kind = Kind(kindId); + var hullRef = Cache.Upsert(hull); + var product = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(_zenithProduct, options), options); + product.Name = hull.Name; + product.Design = new CultRecordRef(hullRef.Key); + product.Manufacturer = Cache.RefOf(maker); + product.NotOffered = false; + Cache.Upsert(product); + return hull; + } + + public void StandIn(string kindId, Faction faction, string asKindId) + { + var kind = Cache.Get(Kind(kindId)); + kind.StandIns.Add(new StationKindStandIn { Faction = Cache.RefOf(faction), Kind = Kind(asKindId) }); + Cache.Upsert(kind); + } + + public LoadoutGenerator Generator(Faction faction, uint seed = 1) + { + var random = new Random(seed); + Items.Random = new Random(seed); + return new LoadoutGenerator(ref random, Items, null, null, faction, .5f); + } + + public string HullName(OrbitalEntityPack pack) => Items.GetData(pack.Hull).Name; + public HullData HullOf(OrbitalEntityPack pack) => (HullData) Items.GetData(pack.Hull); + } + + // ---- Open ---- + + private CultCache SeedAndReopen(Action seed, out string path) + { + path = Path.Combine(_root, "open" + _worlds++ + ".cc"); + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed(cache); + cache.FlushAsync().Wait(); + } + return null; + } + + private string OpenRefusal(Action seed) + { + SeedAndReopen(seed, out var path); + var failure = Assert.Throws(() => AetheriaStores.Open(path)); + return failure.Message; + } + + private static HullData Hull(HullType type, CultRecordRef kind = default) => + new HullData { Name = "Probe " + type, HullType = type, Shape = new Shape(2, 2), Price = 1, Kind = kind }; + + [Fact] + public void OpenRefusesKindlessStationHull() + { + Assert.Contains("has no Kind", OpenRefusal(cache => cache.Upsert(Hull(HullType.Station)))); + Assert.Contains("names no station kind", OpenRefusal(cache => cache.Upsert(Hull(HullType.Station, Kind("nowhere"))))); + var shipWithKind = OpenRefusal(cache => + { + var kind = new CultRecordRef(cache.Upsert(new StationKindData { Name = "Hub" }).Key); + cache.Upsert(Hull(HullType.Ship, kind)); + }); + Assert.Contains("only a station hull has one", shipWithKind); + var turretWithKind = OpenRefusal(cache => + { + var kind = new CultRecordRef(cache.Upsert(new StationKindData { Name = "Hub" }).Key); + cache.Upsert(Hull(HullType.Turret, kind)); + }); + Assert.Contains("only a station hull has one", turretWithKind); + } + + [Fact] + public void OpenRefusesBadStandIn() + { + CultRecordRef Mint(CultCache cache, string name, Action shape = null) + { + var kind = new StationKindData { Name = name }; + shape?.Invoke(kind); + return new CultRecordRef(cache.Upsert(kind).Key); + } + + Assert.Contains("kind itself", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + var kind = new StationKindData { Name = "Self" }; + var handle = cache.Upsert(kind); + kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = new CultRecordRef(handle.Key) }); + cache.Upsert(kind); + })); + Assert.Contains("StandIns[0].Faction names no faction", OpenRefusal(cache => + { + var other = Mint(cache, "Other"); + Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = new CultRecordRef(new CultRecordKey("nobody")), Kind = other })); + })); + Assert.Contains("StandIns[0].Kind names no station kind", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = Kind("nowhere") })); + })); + Assert.Contains("one hop", OpenRefusal(cache => + { + CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = third })); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = second })); + })); + } + + // ---- generation ---- + + [Fact] + public void StationsArePlacedByKind() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "mining"); + world.Sell(world.Au, "shipyard"); + world.Sell(world.Au, "mining"); + + var kinds = StationKinds.KindsOf(world.Cache, world.Au).Select(kind => kind.Key.Value).ToArray(); + Assert.Equal(new[] { StationKindData.KeyOf("mining").Value, StationKindData.KeyOf("shipyard").Value }, kinds); + + var seen = new HashSet(); + for (var site = 0; site < 40; site++) + { + var kind = StationKinds.Choose(world.Cache, world.Au, new CultRecordKey("site-" + site)); + Assert.Contains(kind.Key.Value, kinds); + seen.Add(kind.Key.Value); + var pack = world.Generator(world.Au, (uint) site + 1).GenerateStationLoadout(kind); + Assert.NotNull(pack); + Assert.Equal(kind.Key, pack.Kind.Key); + Assert.Equal(kind.Key, world.HullOf(pack).Kind.Key); + } + Assert.Equal(kinds.Length, seen.Count); + } + + [Fact] + public void StandInUsesFactionModelAndKeepsName() + { + var world = World.Make(this, zenithOffered: false); + var hub = world.Sell(world.Zh, "trade-hub", "Zh Hub"); + world.StandIn("mining", world.Zh, "trade-hub"); + var mining = world.Cache.Get(Kind("mining")); + + Assert.Contains(Kind("mining").Key.Value, StationKinds.KindsOf(world.Cache, world.Zh).Select(kind => kind.Key.Value)); + Assert.Equal(Kind("trade-hub").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("mining")).Key); + for (uint seed = 1; seed <= 10; seed++) + { + var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("mining")); + Assert.Equal("Zh Hub", world.HullName(pack)); + Assert.Equal(Kind("mining").Key, pack.Kind.Key); + Assert.Equal(mining.Name, pack.Name); + } + var own = world.Generator(world.Zh).GenerateStationLoadout(Kind("trade-hub")); + Assert.Equal(hub.Name, own.Name); + } + + [Fact] + public void ModelWinsOverStaleStandIn() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Zh, "trade-hub", "Zh Hub"); + world.Sell(world.Zh, "mining", "Zh Mine"); + world.StandIn("mining", world.Zh, "trade-hub"); + + Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, world.Zh, Kind("mining")).Key); + for (uint seed = 1; seed <= 10; seed++) + { + var pack = world.Generator(world.Zh, seed).GenerateStationLoadout(Kind("mining")); + Assert.Equal("Zh Mine", world.HullName(pack)); + Assert.Equal("Zh Mine", pack.Name); + } + } + + [Fact] + public void FactionWithoutModelsFallsBack() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "shipyard"); + Assert.Empty(StationKinds.KindsOf(world.Cache, world.Lucent)); + + var logged = new List(); + var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); + Assert.Equal(Kind("shipyard").Key, kind.Key); + Assert.Single(logged); + + var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); + Assert.NotNull(pack); + Assert.Equal(kind.Key, pack.Kind.Key); + Assert.Contains(world.Log, line => line.Contains("any manufacturer")); + } + + [Fact] + public void KindChoiceLeavesRandomStream() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "mining"); + world.Sell(world.Au, "shipyard"); + world.Sell(world.Au, "depot"); + var keys = Enumerable.Range(0, 30).Select(i => new CultRecordKey("orbit-" + i)).ToArray(); + var first = keys.Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).ToArray(); + Assert.Equal(first, keys.Reverse().Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).Reverse().ToArray()); + Assert.True(first.Distinct().Count() > 1, "the stable hash spreads sites over the faction's kinds"); + + // Generating a zone with one station kind in the catalog and with several draws the same orbits and turrets. + (float, float)[] Orbits(Action stock) + { + var w = World.Make(this, zenithOffered: true); + stock?.Invoke(w); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var dir = Path.Combine(_root, "galaxy" + _worlds++); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(dir, "Narrative")), _ => { }, null, 3); + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); + Assert.NotEmpty(pack.Entities.OfType()); + return pack.Orbits.Select(orbit => w.Cache.Get(orbit)).Select(orbit => (orbit.Distance, orbit.Phase)).ToArray(); + } + var one = Orbits(null); + var several = Orbits(w => { w.Sell(w.Au, "mining"); w.Sell(w.Zh, "shipyard"); w.Sell(w.Lucent, "depot"); }); + Assert.Equal(one, several); + } + + [Fact] + public void GeneratedZonesPlaceStationsByKind() + { + var w = World.Make(this, zenithOffered: true); + w.Sell(w.Au, "mining"); + w.Sell(w.Au, "shipyard"); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "zones", "Narrative")), _ => { }, null, 5); + var stations = 0; + foreach (var galaxyZone in galaxy.Zones.Take(8)) + { + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxyZone, galaxyZone == galaxy.Entrance && galaxy.IsPrelude, true); + foreach (var station in pack.Entities.OfType().Where(e => w.HullOf(e).HullType == HullType.Station)) + { + stations++; + Assert.True(station.Kind.IsSet(), "a placed station has a kind"); + Assert.Equal(station.Kind.Key, w.HullOf(station).Kind.Key); + var faction = w.Cache.Get(station.Faction); + var kinds = StationKinds.KindsOf(w.Cache, faction); + if (kinds.Length > 0) Assert.Contains(station.Kind.Key.Value, kinds.Select(kind => kind.Key.Value)); + } + } + Assert.True(stations > 0, "the sample generated stations"); + } + + [Fact] + public void KindSurvivesSave() + { + var world = World.Make(this, zenithOffered: false); + world.Sell(world.Au, "research"); + var pack = world.Generator(world.Au).GenerateStationLoadout(Kind("research")); + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(HullData).Assembly); + var bytes = MessagePackSerializer.Serialize(pack, options); + var restored = Assert.IsType(MessagePackSerializer.Deserialize(bytes, options)); + Assert.Equal(Kind("research").Key, restored.Kind.Key); + + var entity = Assert.IsType(EntitySerializer.Unpack(world.Items, null, restored)); + Assert.Equal(Kind("research").Key, entity.Kind.Key); + Assert.Equal(Kind("research").Key, ((OrbitalEntityPack) EntitySerializer.Pack(entity)).Kind.Key); + } + + // ---- the shipped catalog ---- + + [Fact] + public void ShippedCatalogHoldsOneKindList() + { + using var cache = AetheriaStores.Open(Path.Combine(RunStartTests.FindRepoRoot(), "GameData", "Aetheria.cc")); + var ids = new[] + { + "mining", "volatiles", "agriculture", "processing", "fabrication", "shipyard", "depot", "trade-hub", "residential", + "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" + }; + Assert.Equal(ids.Select(id => StationKindData.KeyOf(id).Value).OrderBy(key => key, StringComparer.Ordinal), + cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).OrderBy(key => key, StringComparer.Ordinal)); + Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); + } + + [Fact] + public void ShippedStandInsAreLive() + { + using var cache = AetheriaStores.Open(TestCatalog.Repo); + foreach (var kind in cache.GetAll()) + foreach (var standIn in kind.StandIns) + { + var faction = cache.Get(standIn.Faction); + var models = StationKinds.KindsOf(cache, faction); + var own = cache.GetAll().Where(p => cache.Get(p.Manufacturer) == faction && LoadoutGenerator.IsOffered(cache, p)) + .Select(p => cache.Get(p.Design) as HullData).Where(h => h != null && h.HullType == HullType.Station).Select(h => h.Kind.Key).ToArray(); + Assert.DoesNotContain(cache.RefOf(kind).Key, own); + Assert.Contains(standIn.Kind.Key, own); + Assert.NotEmpty(models); + } + } +} diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs new file mode 100644 index 000000000..0cd7b4d39 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using Xunit; + +public sealed class StationKindsCatalogTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-stationkindscmd-" + Guid.NewGuid().ToString("N")); + + public StationKindsCatalogTests() => Directory.CreateDirectory(_root); + + public void Dispose() => Directory.Delete(_root, true); + + [Fact] + public void DryRunWritesNothingApplyIsIdempotent() + { + var path = Path.Combine(_root, "Aetheria.cc"); + using (var seed = AetheriaStores.Open(path, catalogWritable: true)) + { + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed.FlushAsync().Wait(); + } + var before = File.ReadAllBytes(path); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + Assert.Equal(0, StationKindsCatalog.Run(cache, false, new StringWriter())); + cache.FlushAsync().Wait(); + Assert.Empty(cache.GetAll()); + } + Assert.Equal(before, File.ReadAllBytes(path)); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + Assert.Equal(0, StationKindsCatalog.Run(cache, true, new StringWriter())); + cache.FlushAsync().Wait(); + Assert.Equal(17, cache.GetAll().Count()); + } + var applied = File.ReadAllBytes(path); + + using (var cache = AetheriaStores.Open(path, catalogWritable: true)) + { + var output = new StringWriter(); + StationKindsCatalog.Run(cache, true, output); + cache.FlushAsync().Wait(); + Assert.Contains("Nothing to author", output.ToString()); + Assert.Equal(17, cache.GetAll().Count()); + } + Assert.Equal(applied, File.ReadAllBytes(path)); + } +} diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 3e8b37b85..9cfd26f8c 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -61,9 +61,10 @@ public static class Program case "place-names": Console.OutputEncoding = new System.Text.UTF8Encoding(false); return PlaceNamesCommand.Run(args.Skip(1).ToArray()); + case "station-kinds": return StationKindsCatalog.Run(StationKindsCatalog.OpenCatalog(args.Contains("apply")), args.Contains("apply"), Console.Out); case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); 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], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [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], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [apply]"); return 1; } } @@ -824,7 +825,7 @@ public static class Program { HullType.Ship => generator.GenerateShipLoadout(candidate => candidate == hull), HullType.Turret => generator.GenerateTurretLoadout(), - _ => generator.GenerateStationLoadout() + _ => generator.GenerateStationLoadout(hull.Kind) }; outcome = pack == null ? "NO LOADOUT (nothing suitable found)" : "ok"; if (pack == null) failures++; diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs new file mode 100644 index 000000000..1a50d9f10 --- /dev/null +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +// Authors the catalog's one list of station kinds (aetheria-living-world, cut station-kind-datum) and gives Zenith, the +// one station hull in the repo, its ruled kind (ruling zenith-is-a-trade-hub: provisional, one edit here to change). +// The 17 kinds are the station-round bench's economic roles; descriptions are its produces/consumes column. A kind that +// already has a record is kept as it is and Zenith keeps a kind it already has, so a rerun that finds nothing to author +// writes nothing. Dry run unless apply. +public static class StationKindsCatalog +{ + public const string ZenithKind = "trade-hub"; + + 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."), + ("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."), + ("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."), + ("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."), + ("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."), + ("waystation", "Waystation", "Fuel, repair and medicine along a corridor."), + }; + + // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no + // kind, which StationKinds.Validate refuses, and this command is what gives it one. + public static CultCache OpenCatalog(bool apply) + { + var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); + return cache; + } + + public static int Run(CultCache cache, bool apply, TextWriter output) + { + var held = cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).ToHashSet(StringComparer.Ordinal); + var missing = Table.Where(row => !held.Contains(StationKindData.KeyOf(row.id).Value)).ToArray(); + foreach (var row in Table) + output.WriteLine($"{row.id,-16} {(missing.Any(m => m.id == row.id) ? "authors" : "kept")}"); + + var zenith = cache.GetAll().Where(h => h.HullType == HullType.Station && h.Name == "Zenith").SingleOrDefault(); + var setZenith = zenith != null && !zenith.Kind.IsSet(); + output.WriteLine(zenith == null ? "Zenith: no such hull, kind not set" + : setZenith ? $"Zenith: kind {ZenithKind}" : "Zenith: already has a kind, kept"); + + if (!apply) + { + output.WriteLine("\nDry run. Pass \"apply\" to author them."); + return 0; + } + if (missing.Length == 0 && !setZenith) + { + output.WriteLine("Nothing to author."); + return 0; + } + + cache.Commit(batch => + { + foreach (var (id, name, description) in missing) + batch.Upsert(typeof(StationKindData), new StationKindData { Name = name, Description = description }, StationKindData.KeyOf(id)); + if (setZenith) + { + zenith.Kind = new CultRecordRef(StationKindData.KeyOf(ZenithKind)); + batch.Upsert(typeof(HullData), zenith, cache.RefOf(zenith).Key); + } + }); + output.WriteLine($"\nAuthored {missing.Length} station kinds{(setZenith ? " and Zenith's kind" : "")} in Aetheria.cc"); + return 0; + } +} From a7364ed13e9842acc0d5c45e709f39d8ead4f932 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:24:21 +0200 Subject: [PATCH 022/182] GameData: 17 station kinds and Zenith as a trade-hub (station-kinds apply) Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index a2ddaad5d..3adacaa37 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c327ef1bbabbd34ddb7b9f643f5db8cc54aa5730f232bd42a1fa5f2b03973e89 -size 189753 +oid sha256:06724d46e8fe2bcf3f03515a4923337cbe30e19dff11b962b81287a56cfe4e17 +size 192474 From 353ca96b5ee66ce03807806dd450bec7c9931f66 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:30:15 +0200 Subject: [PATCH 023/182] StationKindTests: the any-maker log is the hull draw's, not Choose's --- tests/Aetheria.Shared.Tests/StationKindTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 0bce4474a..8ef8513b8 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -264,7 +264,6 @@ public sealed class StationKindTests : IDisposable var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); Assert.NotNull(pack); Assert.Equal(kind.Key, pack.Kind.Key); - Assert.Contains(world.Log, line => line.Contains("any manufacturer")); } [Fact] From ac4b771f5e18cfcd860a4b3cff3133a700c30ae2 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:37:19 +0200 Subject: [PATCH 024/182] StationKindTests: worlds carry the run store zone generation writes orbits to --- tests/Aetheria.Shared.Tests/StationKindTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 8ef8513b8..7c0b24cac 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -52,6 +52,7 @@ public sealed class StationKindTests : IDisposable var cache = new CultCache(TestCatalog.Registry()); host._caches.Add(cache); cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "Aetheria.cc")), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(dir, "run.cc")), AetheriaStores.RunTypes); var world = new World { Cache = cache }; world.Items = new ItemManager(1u, cache, new ProvenanceLedger(), RunSaveTests.TestSettings(), world.Log.Add); world.Zh = cache.GetAll().Single(f => f.Name == "Zhestokost"); From 0a468f24a86804ee0c1b9869039b3cb93190713a Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:38:26 +0200 Subject: [PATCH 025/182] Station kinds: tests pin the log text, a null faction, and one-hop stand-ins; kind strings default to null --- Assets/Scripts/ServerShared/StationKinds.cs | 6 ++-- .../Aetheria.Shared.Tests/StationKindTests.cs | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs index 18c200092..9e946b295 100644 --- a/Assets/Scripts/ServerShared/StationKinds.cs +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -13,10 +13,10 @@ using Newtonsoft.Json; public class StationKindData { [CultName, JsonProperty("name"), Key(0)] - public string Name = ""; + public string Name; [JsonProperty("description"), Key(1)] - public string Description = ""; + public string Description; // A faction with no model of this kind builds the stand-in's hull and the station stays this kind. Ignored once the // faction offers a model of this kind. @@ -76,7 +76,7 @@ public static class StationKinds // any maker). public static CultRecordRef HullKindFor(CultCache catalog, Faction faction, CultRecordRef kind) { - if (!kind.IsSet() || faction == null) return kind; + if (!kind.IsSet()) return kind; if (OfferedStationHulls(catalog, faction).Any(hull => hull.Kind.Key.Equals(kind.Key))) return kind; var standIn = catalog.Get(kind)?.StandIns.FirstOrDefault(entry => catalog.Get(entry.Faction) == faction); return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 7c0b24cac..d3752b0cd 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -176,6 +176,29 @@ public sealed class StationKindTests : IDisposable CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); Mint(cache, "Lonely", kind => kind.StandIns.Add(new StationKindStandIn { Faction = faction, Kind = Kind("nowhere") })); })); + // A stand-in whose kind stands in for another faction only is one hop for this faction: Open accepts it. + SeedAndReopen(cache => + { + CultRecordRef mine = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + CultRecordRef other = cache.Upsert(new Faction { Name = "G", ShortName = "G" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => kind.StandIns.Add(new StationKindStandIn { Faction = other, Kind = third })); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = second })); + }, out var accepted); + AetheriaStores.Open(accepted).Dispose(); + // Any one of the stand-in kind's entries for the same faction makes a second hop. + Assert.Contains("one hop", OpenRefusal(cache => + { + CultRecordRef mine = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); + CultRecordRef other = cache.Upsert(new Faction { Name = "G", ShortName = "G" }); + var third = Mint(cache, "Third"); + var second = Mint(cache, "Second", kind => + { + kind.StandIns.Add(new StationKindStandIn { Faction = other, Kind = third }); + kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = third }); + }); + Mint(cache, "First", kind => kind.StandIns.Add(new StationKindStandIn { Faction = mine, Kind = second })); + })); Assert.Contains("one hop", OpenRefusal(cache => { CultRecordRef faction = cache.Upsert(new Faction { Name = "F", ShortName = "F" }); @@ -261,6 +284,13 @@ public sealed class StationKindTests : IDisposable var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); Assert.Equal(Kind("shipyard").Key, kind.Key); Assert.Single(logged); + Assert.Contains(world.Lucent.Name, logged[0]); + var anonymous = new List(); + StationKinds.Choose(world.Cache, null, new CultRecordKey("site"), anonymous.Add); + Assert.Contains("This galaxy", Assert.Single(anonymous)); + world.StandIn("mining", world.Au, "shipyard"); + Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, null, Kind("mining")).Key); + Assert.False(StationKinds.HullKindFor(world.Cache, world.Au, default).IsSet()); var pack = world.Generator(world.Lucent).GenerateStationLoadout(kind); Assert.NotNull(pack); From ea3e983aa22a8012c591684d238b570d98f40a7b Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 22:15:38 +0200 Subject: [PATCH 026/182] StationKindsCatalog: OpenCatalog takes a path; tests pin Zenith's kind, the ids and the command's report --- .../StationKindsCatalogTests.cs | 61 ++++++++++++++++++- tools/AetherDb/StationKindsCatalog.cs | 4 +- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs index 0cd7b4d39..1aa661e5e 100644 --- a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -12,6 +12,51 @@ public sealed class StationKindsCatalogTests : IDisposable public void Dispose() => Directory.Delete(_root, true); + private static readonly string[] Ids = + { + "mining", "volatiles", "agriculture", "processing", "fabrication", "shipyard", "depot", "trade-hub", "residential", + "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" + }; + + // The catalog this command first runs on holds a station hull with no kind, which Open refuses, so the command opens + // the catalog itself: Zenith gets the ruled kind, a kind it already has is kept. + [Fact] + public void ZenithGetsTheRuledKindAndAKindedOneIsKept() + { + var path = Path.Combine(_root, "Zenith.cc"); + using (var seed = AetheriaStores.Open(path, catalogWritable: true)) + { + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + seed.Upsert(new HullData { Name = "Zenith", HullType = HullType.Station, Shape = new Shape(2, 2), Price = 1 }); + seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = new Shape(2, 2), Price = 1 }); + seed.FlushAsync().Wait(); + } + Assert.Throws(() => AetheriaStores.Open(path)); + + var dry = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(false, path)) StationKindsCatalog.Run(cache, false, dry); + Assert.Contains("Zenith: kind trade-hub", dry.ToString()); + + var applied = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(true, path)) + { + StationKindsCatalog.Run(cache, true, applied); + cache.FlushAsync().Wait(); + } + Assert.Contains("and Zenith's kind", applied.ToString()); + using (var cache = AetheriaStores.Open(path)) + { + Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); + Assert.False(cache.GetAll().Single(h => h.Name == "Cutter").Kind.IsSet()); + } + + var again = new StringWriter(); + using (var cache = StationKindsCatalog.OpenCatalog(true, path)) StationKindsCatalog.Run(cache, true, again); + Assert.Contains("Zenith: already has a kind, kept", again.ToString()); + Assert.Contains("Nothing to author", again.ToString()); + } + [Fact] public void DryRunWritesNothingApplyIsIdempotent() { @@ -26,17 +71,26 @@ public sealed class StationKindsCatalogTests : IDisposable using (var cache = AetheriaStores.Open(path, catalogWritable: true)) { - Assert.Equal(0, StationKindsCatalog.Run(cache, false, new StringWriter())); + var dry = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.Run(cache, false, dry)); cache.FlushAsync().Wait(); + Assert.Equal(17, dry.ToString().Split((char) 10).Count(line => line.TrimEnd().EndsWith("authors"))); + Assert.Contains("Dry run", dry.ToString()); + Assert.Contains("Zenith: no such hull", dry.ToString()); Assert.Empty(cache.GetAll()); } Assert.Equal(before, File.ReadAllBytes(path)); using (var cache = AetheriaStores.Open(path, catalogWritable: true)) { - Assert.Equal(0, StationKindsCatalog.Run(cache, true, new StringWriter())); + var output = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.Run(cache, true, output)); cache.FlushAsync().Wait(); - Assert.Equal(17, cache.GetAll().Count()); + Assert.Contains("Authored 17 station kinds in", output.ToString()); + var kinds = cache.GetAll().ToArray(); + Assert.Equal(Ids.Select(id => StationKindData.KeyOf(id).Value).OrderBy(key => key, StringComparer.Ordinal), + kinds.Select(kind => cache.RefOf(kind).Key.Value).OrderBy(key => key, StringComparer.Ordinal)); + Assert.All(kinds, kind => { Assert.False(string.IsNullOrWhiteSpace(kind.Name)); Assert.False(string.IsNullOrWhiteSpace(kind.Description)); }); } var applied = File.ReadAllBytes(path); @@ -46,6 +100,7 @@ public sealed class StationKindsCatalogTests : IDisposable StationKindsCatalog.Run(cache, true, output); cache.FlushAsync().Wait(); Assert.Contains("Nothing to author", output.ToString()); + Assert.Equal(17, output.ToString().Split((char) 10).Count(line => line.TrimEnd().EndsWith("kept"))); Assert.Equal(17, cache.GetAll().Count()); } Assert.Equal(applied, File.ReadAllBytes(path)); diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 1a50d9f10..0ee6b1c6c 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -36,10 +36,10 @@ public static class StationKindsCatalog // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no // kind, which StationKinds.Validate refuses, and this command is what gives it one. - public static CultCache OpenCatalog(bool apply) + public static CultCache OpenCatalog(bool apply, string catalogPath = null) { var cache = new CultCache(); - cache.AddBackingStore(new SingleFileMessagePackBackingStore(Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath ?? Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); return cache; } From d55ac309059e047c04be37cf98ef109d8283448a Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:18:20 +0200 Subject: [PATCH 027/182] Station kinds tests: a bad package kind is excluded not fatal, same seed gives the same station and belt kinds, the stand-in check can fail (red) Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 43 ++++++++++++++ .../ShipModCatalogTests.cs | 30 ++++++++++ .../Aetheria.Shared.Tests/StationKindTests.cs | 56 ++++++++++++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 3e68b4fb0..6e1da6b0f 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -833,6 +833,49 @@ public sealed partial class MiningCut3Tests : IDisposable Assert.True(belts > 0, "no seed generated a belt; the generation path went unexercised"); } + // The kind of a belt is a function of the zone's seed, not of the key the cache mints for it: the same seed generated + // again, through the real key minting, gives the same belts the same kinds. + [Fact] + public void SameSeedGivesTheSameBeltKinds() + { + string[] Generate(uint seed, int run) + { + var dir = Path.Combine(_root, $"same{seed}-{run}"); + Directory.CreateDirectory(dir); + var catalog = Path.Combine(dir, "Aetheria.cc"); + File.Copy(TestCatalog.Repo, catalog); + using var cache = OpenShippedCatalogCopy(catalog, Path.Combine(dir, "run.cc")); + foreach (var existing in cache.GetAll().ToArray()) + { + existing.GenerationWeight = 0f; + cache.Upsert(existing); + } + cache.Upsert(Kind("Gen A", 1f, 1f)); + cache.Upsert(Kind("Gen B", 1f, 2f)); + cache.Upsert(Kind("Gen C", 1f, 4f)); + Galaxy galaxy; + try + { + galaxy = new Galaxy(RestoredHullsTests.TutorialGalaxySettings(), RestoredHullsTests.TutorialBackgroundSettings(), + cache, new PlayerSettings(), new DirectoryInfo(dir), _ => { }, null, seed); + } + catch (Exception) { return null; } + var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); + return galaxy.Zones.Take(6).SelectMany(zone => + ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance) + .Planets.Select(body => cache.Get(body)).OfType().Select(belt => belt.Kind.Key.Value)) + .ToArray(); + } + for (uint seed = 1; seed <= 25; seed++) + { + var first = Generate(seed, 0); + if (first == null || first.Length < 4) continue; + for (var run = 1; run < 4; run++) Assert.Equal(first, Generate(seed, run)); + return; + } + Assert.Fail("no seed generated enough belts; the generation path went unexercised"); + } + // ==== Mining Cut 3 fix batch (Soul's findings) ==== // Q12 A ("launchers cannot mine") in the reach rule: reach counts only weapons that can mine (Weapon.CanMine). diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 8d05b9ba8..717108d8d 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -49,6 +49,36 @@ public sealed class ShipModCatalogTests : IDisposable AssertOnlyTheDerivedFile(); } + // A package's station kind is checked per package: a bad one is left out, named with its reason, and the derived catalog + // still opens. The shipped catalog's own check is unchanged. + [Fact] + public void ComposeExcludesPackagesWithBadStationKinds() + { + using (var cache = AetheriaStores.Open(Shipped, catalogWritable: true)) + { + cache.Upsert(new StationKindData { Name = "Hub" }); + cache.FlushAsync().Wait(); + } + string kindKey; + using (var cache = AetheriaStores.Open(Shipped)) kindKey = cache.RefOf(cache.GetAll().Single()).Key.Value; + var held = new CultRecordRef(new CultRecordKey(kindKey)); + WritePackage("mod.kindless", "Kindless", tweak: p => p.Hull.HullType = HullType.Station); + WritePackage("mod.unknown", "Unknown", tweak: p => { p.Hull.HullType = HullType.Station; p.Hull.Kind = new CultRecordRef(new CultRecordKey("station-kind:nowhere")); }); + WritePackage("mod.shipkind", "ShipKind", tweak: p => p.Hull.Kind = held); + WritePackage("mod.good", "GoodStation", tweak: p => { p.Hull.HullType = HullType.Station; p.Hull.Kind = held; }); + + var composition = ShipModCatalog.Compose(Shipped, Derived, Mods); + + Assert.Equal(new[] { "mod.good" }, composition.Included); + Assert.Equal(new[] { "mod.kindless", "mod.shipkind", "mod.unknown" }, composition.Excluded.Select(e => e.Package).OrderBy(n => n, StringComparer.Ordinal)); + Assert.Contains("no Kind", composition.Excluded.Single(e => e.Package == "mod.kindless").Reason); + Assert.Contains("names no station kind", composition.Excluded.Single(e => e.Package == "mod.unknown").Reason); + Assert.Contains("only a station hull has one", composition.Excluded.Single(e => e.Package == "mod.shipkind").Reason); + using var derived = AetheriaStores.Open(Derived); + Assert.Contains("GoodStation", derived.GetAll().Select(hull => hull.Name)); + Assert.DoesNotContain("Kindless", derived.GetAll().Select(hull => hull.Name)); + } + // Correction 2 of the S0 map: the derived catalog held each mod hull twice, once as a HullData and once inside the // ship's authoring record. The hull is now one record, and the visual holds no hull. [Fact] diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index d3752b0cd..9947251b3 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -329,6 +329,29 @@ public sealed class StationKindTests : IDisposable Assert.Equal(one, several); } + // A station's kind is a function of the zone's seed and the orbit's place in generation, not of a key the cache mints: + // the same zone generated again, through the real key minting, gives the same stations the same kinds in the same order. + [Fact] + public void SameSeedGivesTheSameStationKinds() + { + string[] Kinds() + { + var w = World.Make(this, zenithOffered: true); + foreach (var faction in new[] { w.Au, w.Zh, w.Lucent }) + foreach (var kind in new[] { "mining", "shipyard", "depot", "power" }) w.Sell(faction, kind); + var authored = AuthoredSettings.Load(RunStartTests.FindRepoRoot()); + var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), + authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), + Directory.CreateDirectory(Path.Combine(_root, "galaxy" + _worlds++, "Narrative")), _ => { }, null, 3); + var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); + return pack.Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value).ToArray(); + } + var first = Kinds(); + Assert.True(first.Length >= 3, "the sample placed stations"); + for (var run = 1; run < 6; run++) Assert.Equal(first, Kinds()); + } + [Fact] public void GeneratedZonesPlaceStationsByKind() { @@ -389,10 +412,8 @@ public sealed class StationKindTests : IDisposable Assert.Equal(StationKindData.KeyOf("trade-hub").Value, cache.GetAll().Single(h => h.Name == "Zenith").Kind.Key.Value); } - [Fact] - public void ShippedStandInsAreLive() + private static void AssertStandInsLive(CultCache cache) { - using var cache = AetheriaStores.Open(TestCatalog.Repo); foreach (var kind in cache.GetAll()) foreach (var standIn in kind.StandIns) { @@ -405,4 +426,33 @@ public sealed class StationKindTests : IDisposable Assert.NotEmpty(models); } } + + [Fact] + public void ShippedStandInsAreLive() + { + using var cache = AetheriaStores.Open(TestCatalog.Repo); + AssertStandInsLive(cache); + } + + // The retirement check is not vacuous: given a stand-in it passes when the faction offers the kind it stands in with and + // not the owning kind, and fails when the faction has gained its own model of the owning kind or has no model to use. + [Fact] + public void StandInRetirementCheckCanFail() + { + var live = World.Make(this, zenithOffered: false); + live.Sell(live.Au, "mining"); + live.StandIn("shipyard", live.Au, "mining"); + Assert.Single(live.Cache.GetAll().SelectMany(kind => kind.StandIns)); + AssertStandInsLive(live.Cache); + + var retired = World.Make(this, zenithOffered: false); + retired.Sell(retired.Au, "mining"); + retired.Sell(retired.Au, "shipyard"); + retired.StandIn("shipyard", retired.Au, "mining"); + Assert.ThrowsAny(() => AssertStandInsLive(retired.Cache)); + + var hollow = World.Make(this, zenithOffered: false); + hollow.StandIn("shipyard", hollow.Au, "mining"); + Assert.ThrowsAny(() => AssertStandInsLive(hollow.Cache)); + } } From 17e2da00928bbabc6ceca6b7e4ad26aa2d2c750f Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:18:21 +0200 Subject: [PATCH 028/182] AetherDb tests: a kindless station hull does not lock the tool out; station-kinds sets a hull's kind (red) Co-Authored-By: Claude Sonnet 5.5 --- .../StationKindsCatalogTests.cs | 79 +++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs index 1aa661e5e..517a6ddc6 100644 --- a/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindsCatalogTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; @@ -18,8 +19,8 @@ public sealed class StationKindsCatalogTests : IDisposable "habitat-support", "urban", "administrative", "power", "research", "salvage", "black-market", "waystation" }; - // The catalog this command first runs on holds a station hull with no kind, which Open refuses, so the command opens - // the catalog itself: Zenith gets the ruled kind, a kind it already has is kept. + // The catalog this command first runs on holds a station hull with no kind. The game's Open refuses it; the tool's Open + // names the hull and goes on, so the command can give it one: Zenith gets the ruled kind, a kind it already has is kept. [Fact] public void ZenithGetsTheRuledKindAndAKindedOneIsKept() { @@ -34,12 +35,14 @@ public sealed class StationKindsCatalogTests : IDisposable } Assert.Throws(() => AetheriaStores.Open(path)); + var named = new List(); var dry = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(false, path)) StationKindsCatalog.Run(cache, false, dry); + using (var cache = AetheriaStores.Open(path, kindlessStation: named.Add)) StationKindsCatalog.Run(cache, false, dry); + Assert.Contains("Zenith", Assert.Single(named)); Assert.Contains("Zenith: kind trade-hub", dry.ToString()); var applied = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(true, path)) + using (var cache = AetheriaStores.Open(path, catalogWritable: true, kindlessStation: _ => { })) { StationKindsCatalog.Run(cache, true, applied); cache.FlushAsync().Wait(); @@ -52,11 +55,77 @@ public sealed class StationKindsCatalogTests : IDisposable } var again = new StringWriter(); - using (var cache = StationKindsCatalog.OpenCatalog(true, path)) StationKindsCatalog.Run(cache, true, again); + using (var cache = AetheriaStores.Open(path, catalogWritable: true, kindlessStation: _ => { })) StationKindsCatalog.Run(cache, true, again); Assert.Contains("Zenith: already has a kind, kept", again.ToString()); Assert.Contains("Nothing to author", again.ToString()); } + private string SeedRoot(params string[] kindlessStations) + { + var root = Path.Combine(_root, "root" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(root, "GameData")); + using var seed = AetheriaStores.Open(Path.Combine(root, "GameData", "Aetheria.cc"), catalogWritable: true); + seed.Upsert(new VerseGrammar { Revision = 1 }); + seed.Upsert(new InputLayout { Rows = new InputLayoutRow[] { new InputLayoutRowSpacer { Height = 1 } } }); + foreach (var name in kindlessStations) seed.Upsert(new HullData { Name = name, HullType = HullType.Station, Shape = new Shape(2, 2), Price = 1 }); + seed.Upsert(new HullData { Name = "Cutter", HullType = HullType.Ship, Shape = new Shape(2, 2), Price = 1 }); + seed.FlushAsync().Wait(); + return root; + } + + // One kindless station hull does not lock the owner out of the catalog: the tool opens it, names the hull, and the + // command that sets kinds is reachable. The game's own Open still refuses until every station hull has a kind. + [Fact] + public void AKindlessStationHullDoesNotLockTheToolOut() + { + var root = SeedRoot("Zenith", "Second Station"); + var catalog = Path.Combine(root, "GameData", "Aetheria.cc"); + Assert.Throws(() => AetheriaStores.Open(catalog)); + + var warned = new List(); + var applied = new StringWriter(); + using (var db = AetherDb.Open(catalogWritable: true, root: root, warn: warned.Add)) + { + Assert.Contains(warned, line => line.Contains("Zenith")); + Assert.Contains(warned, line => line.Contains("Second Station")); + Assert.Equal(0, StationKindsCatalog.Run(db.Cache, true, applied)); + Assert.Contains("Second Station", applied.ToString()); + db.Cache.FlushAsync().Wait(); + } + Assert.Throws(() => AetheriaStores.Open(catalog)); + + using (var db = AetherDb.Open(catalogWritable: true, root: root, warn: _ => { })) + { + var dry = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.SetHullKind(db.Cache, "Second Station", "mining", false, dry)); + Assert.Contains("Dry run", dry.ToString()); + Assert.False(db.Cache.GetAll().Single(h => h.Name == "Second Station").Kind.IsSet()); + var set = new StringWriter(); + Assert.Equal(0, StationKindsCatalog.SetHullKind(db.Cache, "Second Station", "mining", true, set)); + db.Cache.FlushAsync().Wait(); + } + using var strict = AetheriaStores.Open(catalog); + Assert.Equal(StationKindData.KeyOf("mining").Value, strict.GetAll().Single(h => h.Name == "Second Station").Kind.Key.Value); + } + + [Fact] + public void SetHullKindRefusesWhatIsNotAStationHullOrAHeldKind() + { + var root = SeedRoot("Zenith"); + using var db = AetherDb.Open(catalogWritable: true, root: root, warn: _ => { }); + StationKindsCatalog.Run(db.Cache, true, new StringWriter()); + string Refusal(string hull, string kind) + { + var output = new StringWriter(); + Assert.Equal(1, StationKindsCatalog.SetHullKind(db.Cache, hull, kind, true, output)); + return output.ToString(); + } + Assert.Contains("no station kind", Refusal("Zenith", "nowhere")); + Assert.Contains("no station hull", Refusal("Cutter", "mining")); + Assert.Contains("no station hull", Refusal("Missing", "mining")); + Assert.False(db.Cache.GetAll().Single(h => h.Name == "Cutter").Kind.IsSet()); + } + [Fact] public void DryRunWritesNothingApplyIsIdempotent() { From 37a8110907a6f76c0dfbefed4a6b4774b7523372 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:21:02 +0200 Subject: [PATCH 029/182] Station and belt kinds are seeded: the pick hashes the zone seed and the place in generation order, not a minted key Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/StationKinds.cs | 41 ++++++++++++------- Assets/Scripts/ServerShared/Zone.cs | 2 +- Assets/Scripts/ServerShared/ZoneData.cs | 13 +++--- Assets/Scripts/ServerShared/ZoneGenerator.cs | 12 +++--- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 18 ++++---- .../Aetheria.Shared.Tests/StationKindTests.cs | 8 ++-- 6 files changed, 54 insertions(+), 40 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationKinds.cs b/Assets/Scripts/ServerShared/StationKinds.cs index 9e946b295..4d7c54ffe 100644 --- a/Assets/Scripts/ServerShared/StationKinds.cs +++ b/Assets/Scripts/ServerShared/StationKinds.cs @@ -52,10 +52,11 @@ public static class StationKinds return Ordered(kinds); } - // A station's kind: a stable pick among the faction's kinds by the station's site key. It draws nothing from any random + // A station's kind: a stable pick among the faction's kinds by the station's site, which generation names by the zone's + // seed and the station's place in its orbit order (never by a key the cache mints). It draws nothing from any random // stream, so adding the kind to generation moves no other roll. A faction with no kinds falls back to any kind some // offered station hull carries, logged, and the hull draw then falls back to any maker. Unset when the catalog has none. - public static CultRecordRef Choose(CultCache catalog, Faction faction, CultRecordKey siteKey, Action log = null) + public static CultRecordRef Choose(CultCache catalog, Faction faction, string site, Action log = null) { var pool = KindsOf(catalog, faction); if (pool.Length == 0) @@ -68,7 +69,7 @@ public static class StationKinds if (pool.Length == 0) return default; log?.Invoke($"{faction?.Name ?? "This galaxy"} builds no station kind; placing a station of any kind some maker offers."); } - return pool[(int) (siteKey.Value.StableHash() % (uint) pool.Length)]; + return pool[(int) (site.StableHash() % (uint) pool.Length)]; } // The kind of hull a faction builds for a station kind: the kind itself when the faction offers a model of it (a model @@ -82,22 +83,34 @@ public static class StationKinds return standIn != null && standIn.Kind.IsSet() ? standIn.Kind : kind; } + // What is wrong with a hull's kind, or null: a Station hull names a kind the catalog holds, and no other hull names one. + // The one rule, applied to the catalog on Open and to each mod package on Compose. + public static string HullKindFault(CultCache catalog, HullData hull) + { + if (hull.HullType == HullType.Station) + { + if (!hull.Kind.IsSet()) + return $"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."; + if (catalog.Get(hull.Kind) == null) + return $"Station hull '{hull.Name}' Kind names no station kind this catalog holds."; + } + else if (hull.Kind.IsSet()) + return $"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."; + return null; + } + // Open refuses a malformed kind catalog, naming the record and field. Every Station hull names a kind the catalog holds // and no other hull names one; every stand-in names a faction and a kind the catalog holds, is never its own kind, and - // does not stand on another stand-in for the same faction (one hop). - public static void Validate(CultCache catalog) + // does not stand on another stand-in for the same faction (one hop). A caller that gives kindlessStation is the owner + // of the catalog being repaired: a Station hull with no Kind is reported to it by name instead of refused. + public static void Validate(CultCache catalog, Action kindlessStation = null) { foreach (var hull in catalog.GetAll()) { - if (hull.HullType == HullType.Station) - { - if (!hull.Kind.IsSet()) - throw new InvalidOperationException($"Station hull '{hull.Name}' has no Kind; a station hull names the station kind it is built to be."); - if (catalog.Get(hull.Kind) == null) - throw new InvalidOperationException($"Station hull '{hull.Name}' Kind names no station kind this catalog holds."); - } - else if (hull.Kind.IsSet()) - throw new InvalidOperationException($"Hull '{hull.Name}' is a {hull.HullType} and names a Kind; only a station hull has one."); + var fault = HullKindFault(catalog, hull); + if (fault == null) continue; + if (kindlessStation != null && hull.HullType == HullType.Station && !hull.Kind.IsSet()) kindlessStation(fault); + else throw new InvalidOperationException(fault); } foreach (var kind in catalog.GetAll()) { diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index d395f722a..ca6764626 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -196,7 +196,7 @@ public class Zone case AsteroidBeltData belt: // Mining Cut 3 (Q16 B): a belt from before field kinds existed gets its kind here, once, by // the rule generation uses, and keeps it. - FieldKinds.Ensure(body.Key, belt, cache); + FieldKinds.Ensure(body.Key.Value, belt, cache); AsteroidBelts[body.Key] = new AsteroidBelt(belt); break; case SunData sun: diff --git a/Assets/Scripts/ServerShared/ZoneData.cs b/Assets/Scripts/ServerShared/ZoneData.cs index 646347af6..a80278566 100644 --- a/Assets/Scripts/ServerShared/ZoneData.cs +++ b/Assets/Scripts/ServerShared/ZoneData.cs @@ -134,10 +134,11 @@ public class FieldKindData // Mining Cut 3 (Q16 B): the one owner of which kind a belt is. public static class FieldKinds { - // A weighted pick among the catalog's kinds, by a stable hash of the belt's key: the same belt and the same - // catalog always give the same kind. Kinds are ordered by key so the pick does not depend on store order. + // A weighted pick among the catalog's kinds, by a stable hash of the belt's site: the same site and the same + // catalog always give the same kind. Generation names a belt by the zone's seed and the belt's place in generation + // order (never by the key the cache mints, which differs every run); a belt saved without a kind is named by its key. Kinds are ordered by key so the pick does not depend on store order. // Unset when the catalog has no kind with positive weight. - public static CultRecordRef Assign(CultRecordKey field, CultCache catalog) + public static CultRecordRef Assign(string site, CultCache catalog) { var kinds = catalog.GetAll() .Where(kind => kind.GenerationWeight > 0f) @@ -146,7 +147,7 @@ public static class FieldKinds .ToArray(); if (kinds.Length == 0) return default; - var pick = field.Value.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight); + var pick = site.StableHash() / 4294967296.0 * kinds.Sum(kind => (double) kind.Weight); foreach (var kind in kinds) { if (pick < kind.Weight) return new CultRecordRef(kind.Key); @@ -157,10 +158,10 @@ public static class FieldKinds // Gives a belt with no kind its kind and saves it; a belt that has one keeps it, whatever the catalog now says. // Generation (ZoneGenerator) and first load (Zone) both call this, so there is one assignment rule. - public static void Ensure(CultRecordKey field, AsteroidBeltData belt, CultCache cache) + public static void Ensure(string site, AsteroidBeltData belt, CultCache cache) { if (belt.Kind.IsSet()) return; - belt.Kind = Assign(field, cache); + belt.Kind = Assign(site, cache); if (belt.Kind.IsSet()) cache.Upsert(belt); } } diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c5c653bac..426e8b92e 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -119,7 +119,7 @@ public static class ZoneGenerator pack.Orbits = planets.Select(planet => cache.RefOf(orbitMap[planet])).ToList(); var beltOf = new Dictionary(); - pack.Planets = planets.Where(p=>!p.Empty).Select(planet => + pack.Planets = planets.Where(p=>!p.Empty).Select((planet, index) => { BodyType bodyType = planet.Belt ? BodyType.Asteroid : planet.Mass > zoneSettings.SunMass ? BodyType.Sun : @@ -213,7 +213,7 @@ public static class ZoneGenerator var bodyKey = cache.Upsert(planetData).Key; planetData.Name = bodyKey.Value.Substring(0, 8); // Mining Cut 3 (Q16 B): a belt's field kind is chosen here, at generation, and saved with it. - if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure(bodyKey, generatedBelt, cache); + if (planetData is AsteroidBeltData generatedBelt) FieldKinds.Ensure($"{seed}:belt:{index}", generatedBelt, cache); return cache.Upsert(planetData); }).ToList(); @@ -239,7 +239,7 @@ public static class ZoneGenerator BodyData swarmBody = swarm; var swarmKey = cache.Upsert(swarmBody).Key; swarm.Name = swarmKey.Value.Substring(0, 8); - FieldKinds.Ensure(swarmKey, swarm, cache); + FieldKinds.Ensure($"{seed}:swarm:{swarmHosts.Count}", swarm, cache); pack.Planets.Add(cache.Upsert(swarmBody)); } @@ -268,7 +268,7 @@ public static class ZoneGenerator BodyData lanedBody = laned; var lanedKey = cache.Upsert(lanedBody).Key; laned.Name = lanedKey.Value.Substring(0, 8); - FieldKinds.Ensure(lanedKey, laned, cache); + FieldKinds.Ensure($"{seed}:lane:{shaped.Count}", laned, cache); pack.Planets.Add(cache.Upsert(lanedBody)); } @@ -420,7 +420,7 @@ public static class ZoneGenerator var orbit = selectedStationOrbits[i]; var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout( - StationKinds.Choose(cache, story.Faction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); + StationKinds.Choose(cache, story.Faction, $"{seed}:station:{i}", itemManager.Log)); if (station == null) { itemManager.Log($"Story station for {story.Faction?.Name} could not be generated; its story has nowhere to live!"); @@ -442,7 +442,7 @@ public static class ZoneGenerator var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(nearestFaction).GenerateStationLoadout( - StationKinds.Choose(cache, nearestFaction, cache.RefOf(lagrangeOrbit).Key, itemManager.Log)); + StationKinds.Choose(cache, nearestFaction, $"{seed}:station:{i}", itemManager.Log)); if (station == null) { if (isTutorialEntrance) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index 6e1da6b0f..d6de2de59 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -638,7 +638,7 @@ public sealed partial class MiningCut3Tests : IDisposable var counts = new Dictionary { ["Light"] = 0, ["Never"] = 0, ["Middle"] = 0, ["Heavy"] = 0 }; for (var i = 0; i < 2000; i++) { - var key = new CultRecordKey($"belt-{i}"); + var key = $"belt-{i}"; var picked = FieldKinds.Assign(key, s.Cache); Assert.True(picked.Key.Equals(FieldKinds.Assign(key, s.Cache).Key)); counts[s.Cache.Get(picked).Name]++; @@ -649,7 +649,7 @@ public sealed partial class MiningCut3Tests : IDisposable Assert.InRange(counts["Heavy"] / 2000f, 3f / 6 - .04f, 3f / 6 + .04f); var empty = BuildScene(new[] { never }); - Assert.False(FieldKinds.Assign(new CultRecordKey("belt-0"), empty.Cache).IsSet()); + Assert.False(FieldKinds.Assign("belt-0", empty.Cache).IsSet()); } // Q16 B, first load: a belt saved before field kinds existed gets its kind when a zone first loads it, by the @@ -664,7 +664,7 @@ public sealed partial class MiningCut3Tests : IDisposable var beltKey = s.Belts[0]; var belt = (AsteroidBeltData) s.Zone.Planets[beltKey]; Assert.True(belt.Kind.IsSet()); - Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey, s.Cache).Key)); + Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey.Value, s.Cache).Key)); var assigned = belt.Kind.Key; // Saved: a fresh open of the same stores reads the kind back. @@ -678,7 +678,7 @@ public sealed partial class MiningCut3Tests : IDisposable // Kept: a new, overwhelmingly weighted kind now wins the pick for this belt, yet loading it again leaves it. reopened.Upsert(Kind("Overwhelming", 9f, 1e6f)); - Assert.False(FieldKinds.Assign(beltKey, reopened).Key.Equals(assigned)); + Assert.False(FieldKinds.Assign(beltKey.Value, reopened).Key.Equals(assigned)); var items = new ItemManager(1u, reopened, new ProvenanceLedger(), GameSettings(), _ => { }); var reloaded = new Zone(items, PlanetSettings(), s.Pack, new GalaxyZone { Name = "MiningCut3Reload", Owner = null }, null); Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned)); @@ -786,11 +786,11 @@ public sealed partial class MiningCut3Tests : IDisposable return cache; } - // Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign on - // the belt's own key. Runs against a scratch copy of the shipped catalog with three kinds of different + // Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign. Runs against + // a scratch copy of the shipped catalog with three kinds of different // weights, so a pick by any other rule would disagree on some belt. [Fact] - public void GenerationChoosesTheKindByTheSameFunction() + public void GenerationAssignsEveryBeltItsKind() { var shipped = TestCatalog.Repo; var belts = 0; @@ -826,7 +826,7 @@ public sealed partial class MiningCut3Tests : IDisposable if (!(cache.Get(body) is AsteroidBeltData belt)) continue; belts++; Assert.True(belt.Kind.IsSet()); - Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(body.Key, cache).Key)); + Assert.StartsWith("Gen ", cache.Get(belt.Kind).Name); } } } @@ -863,7 +863,7 @@ public sealed partial class MiningCut3Tests : IDisposable var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { }); return galaxy.Zones.Take(6).SelectMany(zone => ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance) - .Planets.Select(body => cache.Get(body)).OfType().Select(belt => belt.Kind.Key.Value)) + .Planets.Select(body => cache.Get(body)).OfType().Select(belt => cache.Get(belt.Kind).Name)) .ToArray(); } for (uint seed = 1; seed <= 25; seed++) diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 9947251b3..12d1c0e50 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -224,7 +224,7 @@ public sealed class StationKindTests : IDisposable var seen = new HashSet(); for (var site = 0; site < 40; site++) { - var kind = StationKinds.Choose(world.Cache, world.Au, new CultRecordKey("site-" + site)); + var kind = StationKinds.Choose(world.Cache, world.Au, "site-" + site); Assert.Contains(kind.Key.Value, kinds); seen.Add(kind.Key.Value); var pack = world.Generator(world.Au, (uint) site + 1).GenerateStationLoadout(kind); @@ -281,12 +281,12 @@ public sealed class StationKindTests : IDisposable Assert.Empty(StationKinds.KindsOf(world.Cache, world.Lucent)); var logged = new List(); - var kind = StationKinds.Choose(world.Cache, world.Lucent, new CultRecordKey("site"), logged.Add); + var kind = StationKinds.Choose(world.Cache, world.Lucent, "site", logged.Add); Assert.Equal(Kind("shipyard").Key, kind.Key); Assert.Single(logged); Assert.Contains(world.Lucent.Name, logged[0]); var anonymous = new List(); - StationKinds.Choose(world.Cache, null, new CultRecordKey("site"), anonymous.Add); + StationKinds.Choose(world.Cache, null, "site", anonymous.Add); Assert.Contains("This galaxy", Assert.Single(anonymous)); world.StandIn("mining", world.Au, "shipyard"); Assert.Equal(Kind("mining").Key, StationKinds.HullKindFor(world.Cache, null, Kind("mining")).Key); @@ -304,7 +304,7 @@ public sealed class StationKindTests : IDisposable world.Sell(world.Au, "mining"); world.Sell(world.Au, "shipyard"); world.Sell(world.Au, "depot"); - var keys = Enumerable.Range(0, 30).Select(i => new CultRecordKey("orbit-" + i)).ToArray(); + var keys = Enumerable.Range(0, 30).Select(i => "orbit-" + i).ToArray(); var first = keys.Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).ToArray(); Assert.Equal(first, keys.Reverse().Select(key => StationKinds.Choose(world.Cache, world.Au, key).Key.Value).Reverse().ToArray()); Assert.True(first.Distinct().Count() > 1, "the stable hash spreads sites over the faction's kinds"); From d2f4b2c3473835a249739db8eade784968e372e1 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:21:04 +0200 Subject: [PATCH 030/182] A bad package station kind excludes the package; AetherDb opens a catalog with a kindless station hull, names it, and station-kinds hull sets it; the raw opener is gone Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/AetheriaStores.cs | 6 ++- Assets/Scripts/ServerShared/ShipModCatalog.cs | 1 + tools/AetherDb/AetherDb.cs | 12 +++--- tools/AetherDb/Program.cs | 17 +++++++- tools/AetherDb/StationKindsCatalog.cs | 42 ++++++++++++++----- 5 files changed, 59 insertions(+), 19 deletions(-) diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index 47128ef6c..9992c293f 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -14,10 +14,12 @@ public static class AetheriaStores // Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given. // Throws when a [CultGlobal] type routed to the catalog has no record. The one exemption is a writable catalog with // no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens. + // A catalog's station hull with no Kind is refused, unless the caller gives kindlessStation: the tool that repairs a catalog + // is told the hull's name and goes on. // The registry is the process-wide one unless the caller scopes it (the test host does, so its own document types never // become catalog globals); the global check above is judged against whichever registry opened the cache. public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false, - CultDocumentRegistry registry = null) + CultDocumentRegistry registry = null, Action kindlessStation = null) { // A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here. if (!catalogWritable && !File.Exists(catalogPath)) @@ -42,7 +44,7 @@ public static class AetheriaStores foreach (var data in cache.GetAll()) CultRecordRefs.Validate(data); VerseGrammarValidation.ValidateAll(cache.GetAll()); ValidateBins(cache); - StationKinds.Validate(cache); + StationKinds.Validate(cache, kindlessStation); return cache; } catch diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 4f1908466..93b820513 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -108,6 +108,7 @@ public static class ShipModCatalog Exclude(directory, $"hull name '{hull.Name}' collides with an existing catalog item."); if (shipIds.Contains(ship.Id)) Exclude(directory, $"ship ID collides with an existing catalog ship authoring record."); + if (StationKinds.HullKindFault(cache, hull) is { } kindFault) Exclude(directory, kindFault); foreach (var product in package.Products) { if (!factionKeys.Contains(product.Manufacturer.Key)) diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index d5b4ffdb3..027a6453a 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -3,25 +3,27 @@ using System.IO; using GameCult.Caching; // Opens GameData/Aetheria.cc through AetheriaStores, the game's own store composition. The catalog attaches read-only -// unless a command writes it, so a command that only reads cannot write. +// unless a command writes it, so a command that only reads cannot write. A station hull with no kind does not lock the +// catalog: it is named to warn (stderr by default) and the commands that repair it stay reachable. public sealed class AetherDb { public string Root { get; } public CultCache Cache { get; } - private AetherDb(string root, bool catalogWritable, bool withRun) + private AetherDb(string root, bool catalogWritable, bool withRun, Action warn) { Root = root; var gameData = Path.Combine(root, "GameData"); Cache = AetheriaStores.Open( Path.Combine(gameData, "Aetheria.cc"), runPath: withRun ? Path.Combine(gameData, "run.cc") : null, - catalogWritable: catalogWritable); + catalogWritable: catalogWritable, + kindlessStation: warn ?? Console.Error.WriteLine); } // With no root given, walks up from the working directory to the game folder: the first folder whose GameData holds Aetheria.cc - public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null) => - new AetherDb(root ?? FindRoot(), catalogWritable, withRun); + public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null, Action warn = null) => + new AetherDb(root ?? FindRoot(), catalogWritable, withRun, warn); public static string FindRoot() { diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 9cfd26f8c..0ab9787b4 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -61,10 +61,23 @@ public static class Program case "place-names": Console.OutputEncoding = new System.Text.UTF8Encoding(false); return PlaceNamesCommand.Run(args.Skip(1).ToArray()); - case "station-kinds": return StationKindsCatalog.Run(StationKindsCatalog.OpenCatalog(args.Contains("apply")), args.Contains("apply"), Console.Out); + case "station-kinds": + { + var apply = args.Contains("apply"); + using var db = AetherDb.Open(catalogWritable: apply); + int code; + if (args.Length > 1 && args[1] == "hull") + { + if (args.Length != (apply ? 5 : 4)) { Console.WriteLine("station-kinds hull \"\" [apply]"); return 1; } + code = StationKindsCatalog.SetHullKind(db.Cache, args[2], args[3], apply, Console.Out); + } + else code = StationKindsCatalog.Run(db.Cache, apply, Console.Out); + if (apply) db.Cache.FlushAsync().GetAwaiter().GetResult(); + return code; + } case "doctrine-catalog": return DoctrineCatalog.Run(AetherDb.Open(catalogWritable: args.Contains("apply")).Cache, args.Contains("apply"), Console.Out); 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], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [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], range-falloff [apply] [--effective ] [--end ], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], launch-suppressor [apply], pirates-faction [apply], verse-grammar [apply], generic-designs [apply], sensor-stat-set [apply], inspection-scanner [apply], minor-power-factions [apply], place-names [apply], product-lines weapons|minor-powers|gear|tiers [apply], doctrine-catalog [apply], assembly-from-maker [apply], station-kinds [hull \"\" ] [apply]"); return 1; } } diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 0ee6b1c6c..6743ccc50 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -2,7 +2,6 @@ using System; using System.IO; using System.Linq; using GameCult.Caching; -using GameCult.Caching.MessagePack; // Authors the catalog's one list of station kinds (aetheria-living-world, cut station-kind-datum) and gives Zenith, the // one station hull in the repo, its ruled kind (ruling zenith-is-a-trade-hub: provisional, one edit here to change). @@ -34,15 +33,6 @@ public static class StationKindsCatalog ("waystation", "Waystation", "Fuel, repair and medicine along a corridor."), }; - // The catalog opened without AetheriaStores.Open's checks: the catalog this command first runs on holds a station hull with no - // kind, which StationKinds.Validate refuses, and this command is what gives it one. - public static CultCache OpenCatalog(bool apply, string catalogPath = null) - { - var cache = new CultCache(); - cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath ?? Path.Combine(AetherDb.FindRoot(), "GameData", "Aetheria.cc"), !apply), AetheriaStores.CatalogTypes); - return cache; - } - public static int Run(CultCache cache, bool apply, TextWriter output) { var held = cache.GetAll().Select(kind => cache.RefOf(kind).Key.Value).ToHashSet(StringComparer.Ordinal); @@ -55,6 +45,9 @@ public static class StationKindsCatalog output.WriteLine(zenith == null ? "Zenith: no such hull, kind not set" : setZenith ? $"Zenith: kind {ZenithKind}" : "Zenith: already has a kind, kept"); + foreach (var hull in cache.GetAll().Where(h => h.HullType == HullType.Station && !h.Kind.IsSet() && h != zenith).OrderBy(h => h.Name, StringComparer.Ordinal)) + output.WriteLine($"Station hull '{hull.Name}' has no kind; set one with: station-kinds hull \"{hull.Name}\" apply"); + if (!apply) { output.WriteLine("\nDry run. Pass \"apply\" to author them."); @@ -79,4 +72,33 @@ public static class StationKindsCatalog output.WriteLine($"\nAuthored {missing.Length} station kinds{(setZenith ? " and Zenith's kind" : "")} in Aetheria.cc"); return 0; } + + // Gives a station hull its kind: the way to repair a hull the catalog holds without one. Dry run unless apply. + public static int SetHullKind(CultCache cache, string hullName, string kindId, bool apply, TextWriter output) + { + var hull = cache.GetAll().FirstOrDefault(h => h.HullType == HullType.Station && h.Name == hullName); + if (hull == null) + { + output.WriteLine($"The catalog holds no station hull named '{hullName}'."); + return 1; + } + var kind = new CultRecordRef(StationKindData.KeyOf(kindId)); + if (cache.Get(kind) == null) + { + output.WriteLine($"The catalog holds no station kind '{kindId}'."); + return 1; + } + output.WriteLine($"{hull.Name}: kind {kindId}"); + if (!apply) + { + output.WriteLine(" +Dry run. Pass \"apply\" to set it."); + return 0; + } + hull.Kind = kind; + cache.Upsert(hull); + output.WriteLine($" +Set {hull.Name}'s kind in Aetheria.cc"); + return 0; + } } From b87873415836e069801241a269901332ce4dec28 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:23:59 +0200 Subject: [PATCH 031/182] station-kinds hull: the dry-run and applied lines are one line each Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/StationKindsCatalog.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/AetherDb/StationKindsCatalog.cs b/tools/AetherDb/StationKindsCatalog.cs index 6743ccc50..a450d2ff7 100644 --- a/tools/AetherDb/StationKindsCatalog.cs +++ b/tools/AetherDb/StationKindsCatalog.cs @@ -91,14 +91,12 @@ public static class StationKindsCatalog output.WriteLine($"{hull.Name}: kind {kindId}"); if (!apply) { - output.WriteLine(" -Dry run. Pass \"apply\" to set it."); + output.WriteLine("\nDry run. Pass \"apply\" to set it."); return 0; } hull.Kind = kind; cache.Upsert(hull); - output.WriteLine($" -Set {hull.Name}'s kind in Aetheria.cc"); + output.WriteLine($"\nSet {hull.Name}'s kind in Aetheria.cc"); return 0; } } From 4918fcd993e9f1bd15a13b4a5ab4b529e3e2d304 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:26:00 +0200 Subject: [PATCH 032/182] AetherDb is disposable: it owns the cache it opened Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/AetherDb.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index 027a6453a..c65002d11 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -5,11 +5,13 @@ using GameCult.Caching; // Opens GameData/Aetheria.cc through AetheriaStores, the game's own store composition. The catalog attaches read-only // unless a command writes it, so a command that only reads cannot write. A station hull with no kind does not lock the // catalog: it is named to warn (stderr by default) and the commands that repair it stay reachable. -public sealed class AetherDb +public sealed class AetherDb : IDisposable { public string Root { get; } public CultCache Cache { get; } + public void Dispose() => Cache.Dispose(); + private AetherDb(string root, bool catalogWritable, bool withRun, Action warn) { Root = root; From d9bc46535566fa3b702ae8ca7d21287d7a4209ec Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:26:14 +0200 Subject: [PATCH 033/182] Packages: lock regenerated by Unity at cultlib-unity-v2.0.1 (b8fc5eb0) Co-Authored-By: Claude Opus 5.5 --- Packages/packages-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 7345ecc47..919cf94d1 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -265,13 +265,13 @@ "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" }, "org.gamecult.cultlib": { - "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0", + "version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.1", "depth": 0, "source": "git", "dependencies": { "org.gamecult.cultmath": "0.5.0" }, - "hash": "42dce3611a81adc5c5fac54cadbaa5637d6022d3" + "hash": "b8fc5eb07720a187a0421424ac2cb3aa2966ca1d" }, "org.gamecult.cultmath": { "version": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0", From b1ca2ead9027cd00557170da4fecea2eb5571b83 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:32:34 +0200 Subject: [PATCH 034/182] Same-seed tests: authored kind keys are fixed; the station sample spans eight zones Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/MiningCut3Tests.cs | 7 ++++--- tests/Aetheria.Shared.Tests/StationKindTests.cs | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index d6de2de59..a92f69a0c 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -850,9 +850,10 @@ public sealed partial class MiningCut3Tests : IDisposable existing.GenerationWeight = 0f; cache.Upsert(existing); } - cache.Upsert(Kind("Gen A", 1f, 1f)); - cache.Upsert(Kind("Gen B", 1f, 2f)); - cache.Upsert(Kind("Gen C", 1f, 4f)); + // Authored kinds have fixed keys; the pick orders kinds by key, so the fixture's keys are fixed too. + cache.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a")); + cache.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b")); + cache.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c")); Galaxy galaxy; try { diff --git a/tests/Aetheria.Shared.Tests/StationKindTests.cs b/tests/Aetheria.Shared.Tests/StationKindTests.cs index 12d1c0e50..47154c1c7 100644 --- a/tests/Aetheria.Shared.Tests/StationKindTests.cs +++ b/tests/Aetheria.Shared.Tests/StationKindTests.cs @@ -343,9 +343,9 @@ public sealed class StationKindTests : IDisposable var galaxy = new Galaxy(authored.Read("TutorialGenerationSettings"), authored.Read("TutorialBackgroundSettings"), w.Cache, new PlayerSettings(), Directory.CreateDirectory(Path.Combine(_root, "galaxy" + _worlds++, "Narrative")), _ => { }, null, 3); - var pack = ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), - authored.Read("PlanetSettings"), galaxy, galaxy.Entrance, galaxy.IsPrelude, true); - return pack.Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value).ToArray(); + return galaxy.Zones.Take(8).SelectMany(zone => ZoneGenerator.GenerateZone(w.Items, authored.Read("ZoneSettings"), + authored.Read("PlanetSettings"), galaxy, zone, zone == galaxy.Entrance && galaxy.IsPrelude, true) + .Entities.OfType().Where(e => e.Kind.IsSet()).Select(e => e.Kind.Key.Value)).ToArray(); } var first = Kinds(); Assert.True(first.Length >= 3, "the sample placed stations"); From 02d60691dcd604584ffa1b79367dc7c8a9041078 Mon Sep 17 00:00:00 2001 From: soul Date: Fri, 23 Oct 2026 01:37:40 +0200 Subject: [PATCH 035/182] Same-seed belt test: fixed kind keys through a batch Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/MiningCut3Tests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index a92f69a0c..484116eb3 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -851,9 +851,9 @@ public sealed partial class MiningCut3Tests : IDisposable cache.Upsert(existing); } // Authored kinds have fixed keys; the pick orders kinds by key, so the fixture's keys are fixed too. - cache.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a")); - cache.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b")); - cache.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c")); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen A", 1f, 1f), new CultRecordKey("field-kind:gen-a"))); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen B", 1f, 2f), new CultRecordKey("field-kind:gen-b"))); + cache.Commit(batch => batch.Upsert(typeof(FieldKindData), Kind("Gen C", 1f, 4f), new CultRecordKey("field-kind:gen-c"))); Galaxy galaxy; try { From 7f1337167dd505bd103565d4f541fc5c6807ed12 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:32:11 +0200 Subject: [PATCH 036/182] Loadouts.Commit stops refusing package designs (ruling catalog-is-a-package-stack) The catalog is a stack of packages, the shipped content included, so a Loadout may name any design the stack holds. The mod-design refusal (from 54917bb3, backed by no ruling) is deleted, and its test now pins that a package design commits under its key. The IsModKey comment no longer claims the shipped catalog never references a mod. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 7 +++---- Assets/Scripts/ServerShared/ShipModCatalog.cs | 3 ++- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 20 +++++++++---------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index d7fec7f03..15e832a58 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -99,14 +99,13 @@ public static class Loadouts // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it // after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the // file; a cache that already has the catalog open sees the loadout only once it reloads. - // Throws and writes nothing when the loadout names a mod design (hull or slot), when the file is missing (capture never creates a catalog), when a loadout of this name + // The catalog is a stack of packages, the shipped content included (ruling catalog-is-a-package-stack), so a loadout may + // name any design the stack holds; one whose package is later removed fails closed where it is built, with its reason logged. + // Throws and writes nothing when the file is missing (capture never creates a catalog), when a loadout of this name // exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then. public static bool Commit(string catalogPath, Loadout loadout, bool replace) { - var modDesigns = new[] { loadout.Hull.Key }.Concat(loadout.Slots.Select(slot => slot.Design.Key)).Where(ShipModCatalog.IsModKey).Select(key => key.Value).Distinct().ToArray(); - if (modDesigns.Length > 0) - throw new InvalidOperationException($"Loadout '{loadout.Name}' names mod designs ({string.Join(", ", modDesigns)}); the shipped catalog never references a mod, so a mod ship cannot be captured as a loadout."); if (!File.Exists(catalogPath)) throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 4f1908466..9e89411b8 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -25,7 +25,8 @@ public static class ShipModCatalog public static CultRecordKey ProductKey(string id, CultRecordKey maker) => new CultRecordKey("mod-product:" + id + ":" + maker.Value); - // A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled. + // A key a package owns. Any record may reference one (ruling catalog-is-a-package-stack); one whose package is removed + // fails closed where it is read. public static bool IsModKey(CultRecordKey key) => key.IsSet() && (key.Value.StartsWith("mod-hull:", StringComparison.Ordinal) || key.Value.StartsWith("mod-ship:", StringComparison.Ordinal) || key.Value.StartsWith("mod-product:", StringComparison.Ordinal)); diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 7d3316302..c073bf37e 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -122,27 +122,25 @@ public sealed class LoadoutTests : IDisposable Assert.True(!File.Exists(Player) || !SchemaNames(Player).Contains("aetheria.loadout")); } - // The shipped catalog never references a mod: a mod may be uninstalled, and a preset naming one would dangle. The - // refusal is at the only writer, so no caller (the editor command included) can get one into the file. + // The catalog is a stack of packages (ruling catalog-is-a-package-stack): a preset may name a package's design, hull or + // slot, and the only writer commits it under the package's key. Whether the design resolves is decided where the preset is + // built, not here. [Theory] [InlineData("hull", "mod-hull:mod.skiff")] [InlineData("slot", "mod-hull:mod.skiff")] [InlineData("slot", "mod-ship:mod.skiff")] - public void APresetNamingAModDesignIsRefusedAndNothingIsWritten(string where, string key) + public void APresetNamingAPackageDesignIsCommitted(string where, string key) { - var mod = new CultRecordRef(new CultRecordKey(key)); using var cache = Open(); var loadout = HandBuilt(cache); if (where == "hull") loadout.Hull = new CultRecordRef(new CultRecordKey(key)); - else loadout.Slots[1].Design = mod; - var before = Hash(Catalog); + else loadout.Slots[1].Design = new CultRecordRef(new CultRecordKey(key)); - var error = Assert.Throws(() => Loadouts.Commit(Catalog, loadout, replace: false)); + Assert.True(Loadouts.Commit(Catalog, loadout, replace: false)); - Assert.Contains("names mod designs", error.Message); - Assert.Contains(key, error.Message); - Assert.Equal(before, Hash(Catalog)); - Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false)); + using var reopened = Open(); + var written = Assert.Single(reopened.GetAll()); + Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value); } // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. From 80283d18a9dd1cacd70eaf70f78b42105f6efc46 Mon Sep 17 00:00:00 2001 From: soul Date: Wed, 21 Oct 2026 20:36:14 +0200 Subject: [PATCH 037/182] Loadouts.Commit takes bossOf: the Loadout and Faction.BossLoadout land in one batch With bossOf set, the same conditional batch Expects the faction unchanged and Upserts it with BossLoadout = KeyOf(Name), so both land or neither does. The batch lives in an internal overload over a caller's cache; tests pin that a faction changed on disk after the read refuses both, and that a missing boss faction writes nothing. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Loadout.cs | 34 ++++++++--- tests/Aetheria.Shared.Tests/LoadoutTests.cs | 67 +++++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 15e832a58..b8b73a0ab 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -96,19 +96,28 @@ public static class Loadouts _ => "" }; - // The only writer of Loadout records. It opens its own short-lived cache over the catalog file, writable, and disposes it - // after the one commit. No other cache is touched, so no catalog instance play holds (or has mutated) can reach the - // file; a cache that already has the catalog open sees the loadout only once it reloads. - // The catalog is a stack of packages, the shipped content included (ruling catalog-is-a-package-stack), so a loadout may - // name any design the stack holds; one whose package is later removed fails closed where it is built, with its reason logged. - // Throws and writes nothing when the file is missing (capture never creates a catalog), when a loadout of this name - // exists under another key, or when one exists under KeyOf(Name) and replace is not set. The commit is conditional - // on the record at KeyOf(Name), so a loadout changed on disk since this open is not clobbered: returns false then. - public static bool Commit(string catalogPath, Loadout loadout, bool replace) + // The only writer of Loadout records, and of the Faction.BossLoadout that names one. It opens its own short-lived cache + // over the catalog file, writable, and disposes it after the one commit. No other cache is touched, so no catalog + // instance play holds (or has mutated) can reach the file; a cache that already has the catalog open sees the loadout + // only once it reloads. The catalog is a stack of packages, the shipped content included (ruling + // catalog-is-a-package-stack), so a loadout may name any design the stack holds; one whose package is later removed fails + // closed where it is built, with its reason logged. With bossOf set, the same batch makes the loadout that faction's boss + // (Faction.BossLoadout), so the loadout and the reference land together or not at all. + // Throws and writes nothing when the file is missing (capture never creates a catalog), when bossOf names no faction in + // it, when a loadout of this name exists under another key, or when one exists under KeyOf(Name) and replace is not set. + // The commit is conditional on the record at KeyOf(Name) and on the faction, so a record changed on disk since this open + // is not clobbered: returns false then, and neither is written. + public static bool Commit(string catalogPath, Loadout loadout, bool replace, CultRecordRef bossOf = default) { if (!File.Exists(catalogPath)) throw new InvalidOperationException($"Catalog {catalogPath} does not exist; a loadout commit never creates one."); using var cache = AetheriaStores.Open(catalogPath, catalogWritable: true); + return Commit(cache, loadout, replace, bossOf); + } + + // The one batch Commit(path, ...) makes, over a writable catalog cache the caller opened. + internal static bool Commit(CultCache cache, Loadout loadout, bool replace, CultRecordRef bossOf) + { var key = KeyOf(loadout.Name); var namesake = cache.GetAll().FirstOrDefault(other => other.Name == loadout.Name && !cache.RefOf(other).Key.Equals(key)); if (namesake != null) @@ -116,10 +125,17 @@ public static class Loadouts var existing = cache.Get(key); if (existing != null && !replace) throw new InvalidOperationException($"Loadout '{loadout.Name}' already exists; replace it explicitly."); + var boss = bossOf.IsSet() + ? cache.Get(bossOf) ?? throw new InvalidOperationException($"Loadout '{loadout.Name}': the boss faction is not in the catalog.") + : null; return cache.Commit(batch => { batch.Expect(key, existing); batch.Upsert(typeof(Loadout), loadout, key); + if (boss == null) return; + batch.Expect(bossOf.Key, boss); + boss.BossLoadout = new CultRecordRef(key); + batch.Upsert(boss.GetType(), boss, bossOf.Key); }); } diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index c073bf37e..6f55baa76 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -143,6 +143,73 @@ public sealed class LoadoutTests : IDisposable Assert.Equal(key, (where == "hull" ? written.Hull.Key : written.Slots[1].Design.Key).Value); } + // With bossOf, one commit writes the loadout and makes it that faction's boss: Faction.BossLoadout names its key. + [Fact] + public void ABossCommitWritesTheLoadoutAndTheFactionsBossReference() + { + using (var cache = Open()) + Assert.True(Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, cache.RefOf(cache.GetByName("Maker")))); + + using (var cache = Open()) + { + var loadout = Assert.Single(cache.GetAll()); + Assert.Equal(Loadouts.KeyOf("Skiff build"), cache.RefOf(loadout).Key); + Assert.Same(loadout, Loadouts.BossOf(cache, cache.GetByName("Maker"), out var refusal)); + Assert.Equal(BossRefusal.None, refusal); + } + } + + // The loadout and the boss reference are one batch: when the faction changed on disk after the commit's cache read it, + // the commit returns false and neither lands, though the loadout alone would have; the other writer's change survives. + [Fact] + public void ABossCommitOverAFactionChangedOnDiskWritesNeitherTheLoadoutNorTheReference() + { + Loadout loadout; + CultRecordRef maker; + using (var read = Open()) + { + loadout = HandBuilt(read); + maker = read.RefOf(read.GetByName("Maker")); + } + + using (var stale = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + Assert.NotNull(stale.Get(maker)); + using (var other = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + var fresh = other.Get(maker); + Assert.True(other.Commit(batch => + { + batch.Expect(maker.Key, fresh); + fresh.ShortName = "NEW"; + batch.Upsert(typeof(Faction), fresh, maker.Key); + })); + } + + Assert.False(Loadouts.Commit(stale, loadout, replace: false, maker)); + } + + using var after = Open(); + Assert.Empty(after.GetAll()); + var faction = after.Get(maker); + Assert.Equal("NEW", faction.ShortName); + Assert.False(faction.BossLoadout.IsSet()); + } + + // A boss faction the catalog does not hold is refused before anything is written. + [Fact] + public void ABossCommitNamingNoFactionIsRefusedAndNothingIsWritten() + { + var before = Hash(Catalog); + using (var cache = Open()) + { + var error = Assert.Throws(() => + Loadouts.Commit(Catalog, HandBuilt(cache), replace: false, new CultRecordRef(new CultRecordKey("faction:absent")))); + Assert.Contains("boss faction is not in the catalog", error.Message); + } + Assert.Equal(before, Hash(Catalog)); + } + // Recapturing a name refuses without replace and leaves the file untouched; with replace it replaces, never duplicates. [Fact] public void SameNameCaptureReplacesOnlyWhenAsked() From 3bfac250279141aef87cf56129f84c9d368d3bbb Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:02:16 +0200 Subject: [PATCH 038/182] New Game is a two-pane scenario picker: names by group, selected brief/seed/ambient, one Launch, arrows and Enter MainMenu.AddScenarios and the per-scenario launching buttons are deleted; the seven Clear+Title pairs collapse into Next. The main menu EventSystem no longer deselects on background click so the arrows keep working. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scenes/Main Menu.unity | 2 +- Assets/Scripts/UI/MainMenu.cs | 68 +++++++-------- Assets/Scripts/UI/ScenarioPicker.cs | 130 ++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 38 deletions(-) create mode 100644 Assets/Scripts/UI/ScenarioPicker.cs diff --git a/Assets/Scenes/Main Menu.unity b/Assets/Scenes/Main Menu.unity index 4fafe8336..c1f111f09 100644 --- a/Assets/Scenes/Main Menu.unity +++ b/Assets/Scenes/Main Menu.unity @@ -525,7 +525,7 @@ MonoBehaviour: type: 3} m_TrackedDeviceOrientationAction: {fileID: 1025543830046995696, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} - m_DeselectOnBackgroundClick: 1 + m_DeselectOnBackgroundClick: 0 m_PointerBehavior: 0 --- !u!114 &553528108 MonoBehaviour: diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index e72ce5263..401f4b2af 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -23,7 +23,7 @@ public class MainMenu : MonoBehaviour public float FadeAlphaExponent = 2; public float FadePositionExponent = 2; - private (PropertiesPanel panel, CanvasGroup group) _currentMenu, _nextMenu; + private (PropertiesPanel panel, CanvasGroup group, PropertiesPanel detail) _currentMenu, _nextMenu; private bool _fadeFromRight; private float _fadeLerp; private bool _fading; @@ -38,10 +38,14 @@ public class MainMenu : MonoBehaviour _panelPosition = PanelPrototype.transform.position; var panel1 = PanelPrototype.Instantiate(); - _currentMenu = (panel1, panel1.GetComponent()); + var detail1 = PanelPrototype.Instantiate(); + ScenarioPicker.Dock(detail1, panel1); + _currentMenu = (panel1, panel1.GetComponent(), detail1); var panel2 = PanelPrototype.Instantiate(); - _nextMenu = (panel2, panel2.GetComponent()); + var detail2 = PanelPrototype.Instantiate(); + ScenarioPicker.Dock(detail2, panel2); + _nextMenu = (panel2, panel2.GetComponent(), detail2); _currentMenu.panel.gameObject.SetActive(false); //_saveDirectory = ActionGameManager.GameDataDirectory.CreateSubdirectory("Saves"); @@ -74,7 +78,7 @@ public class MainMenu : MonoBehaviour } } - private string TitleSubtitle(string title, string subtitle) => $"{title}\n{subtitle}"; + public static string TitleSubtitle(string title, string subtitle) => $"{title}\n{subtitle}"; private void Fade(bool fromRight) { @@ -87,10 +91,18 @@ public class MainMenu : MonoBehaviour private static bool _exclusionsShown; - private void ShowMain() + // Starts the next screen on an empty panel with a title and its scenario detail pane hidden and empty. + private void Next(string title) { _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("aetheria", "terminus"); + _nextMenu.panel.Title.text = title; + _nextMenu.detail.Clear(); + _nextMenu.detail.gameObject.SetActive(false); + } + + private void ShowMain() + { + Next(TitleSubtitle("aetheria", "terminus")); if (ActionGameManager.CurrentGalaxy == null) { // No run is live: at the title, after death, or after a new run failed to start. A saved run exists only as @@ -131,31 +143,18 @@ public class MainMenu : MonoBehaviour // to set up a run and the test arenas are listed only in editor and development builds. private void ShowScenarios() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("new game", "scenarios"); - AddScenarios(Scenarios.Modes); + Next(TitleSubtitle("new game", "scenarios")); + var groups = new List<(string label, Scenario[] scenarios)> { ("modes", Scenarios.Modes) }; if (Debug.isDebugBuild) { - _nextMenu.panel.AddSection("development"); - AddScenarios(Scenarios.Development); - _nextMenu.panel.AddSection("test arenas"); - AddScenarios(Scenarios.Tests); + groups.Add(("development", Scenarios.Development)); + groups.Add(("test arenas", Scenarios.Tests)); } - _nextMenu.panel.AddButton("Back", - () => - { - ShowMain(); - Fade(false); - }); - } - - private void AddScenarios(IEnumerable scenarios) - { - foreach (var scenario in scenarios) + ScenarioPicker.Show(_nextMenu.panel, _nextMenu.detail, groups, Launch, () => { - _nextMenu.panel.AddButton(scenario.Name, () => Launch(scenario)); - _nextMenu.panel.AddProperty(() => scenario.Brief); - } + ShowMain(); + Fade(false); + }); } // The only way into a new run: generates the scenario's galaxy off the main thread and enters the game, where @@ -263,8 +262,7 @@ public class MainMenu : MonoBehaviour private void ShowSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = "settings"; + Next("settings"); _nextMenu.panel.AddButton("Gameplay", () => { @@ -299,8 +297,7 @@ public class MainMenu : MonoBehaviour private void ShowGameplaySettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("gameplay", "settings"); + Next(TitleSubtitle("gameplay", "settings")); _nextMenu.panel.AddField("Name", () => ActionGameManager.PlayerSettings.Name, s => ActionGameManager.PlayerSettings.Name = s); @@ -322,8 +319,7 @@ public class MainMenu : MonoBehaviour private void ShowGraphicsSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("graphics", "settings"); + Next(TitleSubtitle("graphics", "settings")); _nextMenu.panel.AddField("Nebula Quality", () => (int)ActionGameManager.PlayerSettings.GraphicsSettings.NebulaQuality, i => @@ -347,8 +343,7 @@ public class MainMenu : MonoBehaviour private void ShowInputSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("input", "settings"); + Next(TitleSubtitle("input", "settings")); _nextMenu.panel.AddButton("Back", () => { @@ -359,8 +354,7 @@ public class MainMenu : MonoBehaviour private void ShowAudioSettings() { - _nextMenu.panel.Clear(); - _nextMenu.panel.Title.text = TitleSubtitle("audio", "settings"); + Next(TitleSubtitle("audio", "settings")); _nextMenu.panel.AddButton("Back", () => { diff --git a/Assets/Scripts/UI/ScenarioPicker.cs b/Assets/Scripts/UI/ScenarioPicker.cs new file mode 100644 index 000000000..4d6ab393e --- /dev/null +++ b/Assets/Scripts/UI/ScenarioPicker.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using UniRx; +using UniRx.Triggers; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +// The New Game screen: scenario names on the left pane by group, the selected scenario's brief, seed and ambient on a +// docked right pane, one Launch. This class owns which scenario is selected (one index into the rows it built); +// everything the screen shows about a scenario, and what Launch and Enter launch, reads that index. Keys arrive only as +// Input System UI events on the focused Launch button, so Enter and a mouse click are the same Button.onClick. +public sealed class ScenarioPicker +{ + private readonly List _scenarios = new List(); + private readonly List _groupLabels = new List(); + private readonly List