From 3fa4768d1c76353c9a2b76a4daa5d7ae9aa71b8d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:31:18 +0200 Subject: [PATCH 001/198] Cargo transfer tests: a partial commodity move duplicates the stack left behind (red), also through the buy path Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/Aetheria.Shared.Tests/CargoTransferTests.cs diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs new file mode 100644 index 000000000..ce8732fe1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// Goods moving bay to bay between ships and stations: the one conserving commit (EquippedCargoBay.TryTransferItem) that +// the docked trade, the sale and the give all go through. Fixture as StationServicesTests: authored settings, Ore. +public sealed class CargoTransferTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-cargotransfer-" + Guid.NewGuid().ToString("N")); + private readonly CultCache _cache; + private readonly ItemManager _items; + private readonly Zone _zone; + + public CargoTransferTests() + { + Directory.CreateDirectory(_root); + _cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true); + _cache.Upsert(new VerseGrammar { Revision = 1 }); + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + _cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Price = 1000, Durability = 10, Armor = 10 }); + _cache.Upsert(new HullData { Name = "Hub", HullType = HullType.Station, Shape = shape, Price = 5000, Durability = 10 }); + var bay = new Shape(4, 4); + foreach (var cell in bay.AllCoordinates) bay[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = bay, Price = 20, Durability = 4 }); + var pocket = new Shape(1, 1); + foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); + _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + _cache.FlushAsync().Wait(); + + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + _items = new ItemManager(1u, _cache, new ProvenanceLedger(), settings, _ => { }); + _zone = new Zone(_items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test Zone", Owner = null }, null); + } + + public void Dispose() + { + _cache.Dispose(); + Directory.Delete(_root, true); + } + + private EquippableItem Mint(string design) + { + var data = _cache.GetByName(design); + return new EquippableItem + { + Data = _cache.RefOf(data), + Durability = data.Durability, + Lot = _items.Lots.Add(new Lot { Design = _cache.RefOf(data), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + } + + private SimpleCommodity Commodity(string design, int quantity) => + new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity }; + + private Ship ShipWith(string bay) + { + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + Assert.True(ship.TryEquip(Mint(bay), new int2(1, 1))); + return ship; + } + + private OrbitalEntity Station() + { + var station = new OrbitalEntity(_items, _zone, Mint("Hub"), default, new EntitySettings()); + Assert.True(station.TryEquip(Mint("Crate"), new int2(1, 1))); + return station; + } + + // The units of the same design as `like` that the bay holds, over every stack. + private static int Units(EquippedCargoBay bay, SimpleCommodity like) => + bay.Cargo.Keys.OfType().Where(c => c.Data.Key.Equals(like.Data.Key)).Sum(c => c.Quantity); + + [Fact] + public void PartialCommodityTransferConserves() + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + var massBefore = from.Mass + to.Mass; + + Assert.True(from.TryTransferItem(to, ore, 4)); + Assert.True(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(2, Units(from, ore)); + Assert.Equal(8, Units(to, ore)); + Assert.Empty(from.Cargo.Keys.Intersect(to.Cargo.Keys)); + Assert.Equal(massBefore, from.Mass + to.Mass, 3); + } + + [Fact] + public void ABoughtPartOfAStationStackLeavesTheRestWithTheStation() + { + // TradeMenu.Buy delivers by this exact commit: the station's stack to the player's bay, a part at a time. + var station = Station(); + var stationBay = station.CargoBays[0]; + var player = ShipWith("Crate").CargoBays[0]; + var stock = Commodity("Ore", 10); + Assert.True(stationBay.TryStore(stock)); + var credits = 1000; + + Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, stock, 4, ref credits, () => stationBay.TryTransferItem(player, stock, 4))); + Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, stock, 4, ref credits, () => stationBay.TryTransferItem(player, stock, 4))); + + Assert.Equal(1000 - 2 * 4 * 7, credits); + Assert.Equal(2, Units(stationBay, stock)); + Assert.Equal(8, Units(player, stock)); + Assert.Empty(stationBay.Cargo.Keys.Intersect(player.Cargo.Keys)); + } + + [Fact] + public void RefusedCommodityTransferChangesNothing() + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Pocket").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Slag", 5))); + var at = from.Cargo[ore]; + var massBefore = from.Mass + to.Mass; + + Assert.False(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(10, ore.Quantity); + Assert.Equal(at, from.Cargo[ore]); + Assert.Equal(10, Units(from, ore)); + Assert.Equal(0, Units(to, ore)); + Assert.Equal(massBefore, from.Mass + to.Mass, 3); + } + + [Fact] + public void ATransferThatNeedsMoreRoomThanTheStackHasLeftChangesNothing() + { + // The receiver's only stack of Ore has room for 2 of the 4: the move is whole or nothing. + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Pocket").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Ore", 98))); + + Assert.False(from.TryTransferItem(to, ore, 4)); + + Assert.Equal(10, Units(from, ore)); + Assert.Equal(98, Units(to, ore)); + } +} From cada2fde3e09e1aca8154628093e8fd901959b8e Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:38:17 +0200 Subject: [PATCH 002/198] Transfer verb: a pilot gives cargo to a ship alongside, committed through the conserving bay move TryTransferItem(SimpleCommodity) checks the target can take the part and stores the part removed, not the stack left behind (it duplicated and lost units on every partial move, the player's commodity buy among them). TryTransferItems goes. CargoTransfer.Step owns a give's progress and end; PilotCommand.Give and StopGiving are applied by PilotApplier; TransferRate is the new tunable, DockingDistance the range. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 124 ++++++++++++++++++ .../ServerShared/CargoTransfer.cs.meta | 11 ++ Assets/Scripts/ServerShared/Entity.cs | 54 ++------ Assets/Scripts/ServerShared/Pilot.cs | 19 +++ Assets/Scripts/ServerShared/Settings.cs | 2 + Assets/Scripts/ServerShared/Zone.cs | 1 + 6 files changed, 171 insertions(+), 40 deletions(-) create mode 100644 Assets/Scripts/ServerShared/CargoTransfer.cs create mode 100644 Assets/Scripts/ServerShared/CargoTransfer.cs.meta diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs new file mode 100644 index 000000000..4026bbc7b --- /dev/null +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -0,0 +1,124 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System.Linq; +using CultMath; +using static CultMath.math; + +// Why a give ended. Notification only: nothing reads it to decide anything. +public enum GiveEnd +{ + Done, + Stopped, + OutOfRange, + ReceiverGone, + ReceiverFull, + NothingLeft, + GiverDocked +} + +// A pilot's standing offer of goods to a ship alongside. Runtime only: nothing about a give is saved. +public sealed class GiveOrder +{ + public readonly Entity Receiver; + // A commodity order is for any stack of this item's design in the giver's bays; a crafted order is for this instance. + public readonly ItemInstance Item; + // Units still to move. + public int Remaining { get; internal set; } + // Units earned and not yet moved, fractional between steps. + internal double Budget; + + internal GiveOrder(Entity receiver, ItemInstance item, int quantity) + { + Receiver = receiver; + Item = item; + Remaining = quantity; + } +} + +// The one owner of a give's progress and its end (ruling tender-transfer-verb). A ship's own pilot asks for a give through +// PilotCommand.Give and PilotApplier starts it here; nothing else moves goods out of a hold in space (ruling +// transfer-is-give-only). Every unit moves through EquippedCargoBay.TryTransferItem, the one conserving bay-to-bay commit. +public static class CargoTransfer +{ + internal static void Begin(Entity giver, GiveOrder order) + { + End(giver, GiveEnd.Stopped); + giver.Giving = order; + } + + // Ends the giver's give, if it has one, for the reason. + internal static void End(Entity giver, GiveEnd reason) + { + if (giver.Giving == null) return; + giver.Giving = null; + giver.LastGiveEnd = reason; + } + + // One step of every give in the zone: the receiver is still here and alongside, and the rate earns units, each moved + // into the first receiver bay that takes it. + public static void Step(Zone zone, float deltaTime) + { + foreach (var giver in zone.Entities.ToArray()) + { + var order = giver.Giving; + if (order == null) continue; + var settings = giver.ItemManager.GameplaySettings; + var range = settings.DockingDistance; + if (!zone.Entities.Contains(order.Receiver)) + { + End(giver, GiveEnd.ReceiverGone); + continue; + } + if (lengthsq(order.Receiver.Position.xz - giver.Position.xz) > range * range) + { + End(giver, GiveEnd.OutOfRange); + continue; + } + + order.Budget += (double) settings.TransferRate * deltaTime; + while (order.Budget >= 1 && order.Remaining > 0) + { + var source = Source(giver, order); + if (source.Bay == null) + { + End(giver, GiveEnd.NothingLeft); + break; + } + if (!Move(source.Bay, source.Item, order.Receiver)) + { + End(giver, GiveEnd.ReceiverFull); + break; + } + order.Budget -= 1; + order.Remaining--; + } + if (giver.Giving == order && order.Remaining == 0) End(giver, GiveEnd.Done); + } + } + + // Where the next unit comes from: the first stack of the order's design in the giver's bays, or the crafted instance. + private static (EquippedCargoBay Bay, ItemInstance Item) Source(Entity giver, GiveOrder order) + { + foreach (var bay in giver.CargoBays) + { + if (order.Item is CraftedItemInstance crafted) + { + if (bay.Cargo.ContainsKey(crafted)) return (bay, crafted); + } + else if (bay.ItemsOfType.TryGetValue(order.Item.Data.Key, out var stacks)) + { + var stack = stacks.OfType().FirstOrDefault(s => s.Quantity > 0); + if (stack != null) return (bay, stack); + } + } + return (null, null); + } + + // One unit of a commodity, or the crafted item, into the first receiver bay that takes it. + private static bool Move(EquippedCargoBay from, ItemInstance item, Entity receiver) => + item is SimpleCommodity commodity + ? receiver.CargoBays.Any(bay => from.TryTransferItem(bay, commodity, 1)) + : receiver.CargoBays.Any(bay => from.TryTransferItem(bay, (CraftedItemInstance) item)); +} diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs.meta b/Assets/Scripts/ServerShared/CargoTransfer.cs.meta new file mode 100644 index 000000000..70599860c --- /dev/null +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7be41c09d3a84f5e9a206d18c4f3b5a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 6b1eab8c9..00df96087 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -191,6 +191,10 @@ public abstract class Entity // The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it. public float TractorCommand { get; internal set; } + // The give the pilot has under way, and why the last one ended. CargoTransfer is the only writer of both. + public GiveOrder Giving { get; internal set; } + public GiveEnd? LastGiveEnd { get; internal set; } + // The beam's spun-up state: Pickup's, or 0 without one. public float TractorPower => GetBehavior()?.Power ?? 0f; @@ -1460,42 +1464,6 @@ public abstract class Entity } } - // Attempts to move a given number of items of the given type to the target Entity - // Returns the number of items successfully transferred - public int TryTransferItems(Entity target, CultRecordKey itemDataID, int quantity) - { - int quantityTransferred = 0; - while (quantityTransferred < quantity) - { - EquippedCargoBay originInventory = CargoBays.FirstOrDefault(c => c.ItemsOfType.ContainsKey(itemDataID)); - - if (originInventory == null) break; - - var itemInstance = originInventory.ItemsOfType[itemDataID][0]; - - if (itemInstance is SimpleCommodity simpleCommodity) - { - var targetQuantity = min(simpleCommodity.Quantity, quantity - quantityTransferred); - if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, simpleCommodity, targetQuantity))) - { - quantityTransferred += targetQuantity - simpleCommodity.Quantity; - break; - } - - quantityTransferred += targetQuantity; - } - else if (itemInstance is CraftedItemInstance craftedItemInstance) - { - if (!target.CargoBays.Any(c => originInventory.TryTransferItem(c, craftedItemInstance))) - break; - - quantityTransferred++; - } - } - - return quantityTransferred; - } - public EquippableItem TryUnequip(EquippedItem item) { // Don't allow unequipping when the entity is active @@ -1752,6 +1720,7 @@ public abstract class Entity if (bay != null) { bay.DockedShip = ship; + CargoTransfer.End(ship, GiveEnd.GiverDocked); ship.SetParent(this); Zone.Remove(ship); ship.Deactivate(); @@ -2695,12 +2664,17 @@ public class EquippedCargoBay : EquippedItem return false; } + // Whole or nothing: the target must be able to take the part before it leaves the stack. + var part = new SimpleCommodity { Data = item.Data, Quantity = min(quantity, item.Quantity), Rotation = item.Rotation }; + if (!target.TryFindSpace(part, out _)) return false; + var oldPos = Cargo[item]; var newItem = Remove(item, quantity); - - if (target.TryStore(item)) return true; - - // Failed to transfer full quantity, move the remaining items back to their old slot + + // The part removed goes across, never the stack left behind. + if (target.TryStore(newItem)) return true; + + // The target refused after all: put the part back where it came from. TryStore(newItem, oldPos); return false; } diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index 8b04637d9..be71f877d 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -56,6 +56,8 @@ public sealed class PilotCommand internal readonly List<(ActivatedBehavior Behavior, bool Pull)> Triggers = new List<(ActivatedBehavior, bool)>(); internal readonly List<(Entity Other, bool? Hostile, bool Toggle)> Iffs = new List<(Entity, bool?, bool)>(); internal TargetVerb TargetVerb; + // The step's give verb: a Give, or a stop when its Receiver is null. The last one in a step wins. + internal (Entity Receiver, ItemInstance Item, int Quantity)? GiveVerb; internal int AimCycles; internal int ShutdownOverrideToggles, HeatsinkToggles, ShieldToggles, Pings; @@ -77,6 +79,17 @@ public sealed class PilotCommand public void ToggleHeatsinks() => HeatsinkToggles++; public void ToggleShield() => ShieldToggles++; public void Ping() => Pings++; + // Offers goods from this entity's hold to a ship alongside (ruling transfer-is-give-only): the quantity of a commodity, + // or the one crafted instance. It replaces any give under way. A give of nothing, or of less than one, is ignored, and + // so is one to oneself when applied. The last give or stop in a step wins. + public void Give(Entity receiver, ItemInstance item, int quantity) + { + if (receiver == null || item == null) return; + var count = item is CraftedItemInstance ? 1 : quantity; + if (count < 1) return; + GiveVerb = (receiver, item, count); + } + public void StopGiving() => GiveVerb = (null, null, 0); // The pilot's own pending decisions, which it reads back instead of the ship (that is last step's applied state). public TargetRef? Designated => Designations.Count > 0 ? Designations[Designations.Count - 1] : (TargetRef?) null; @@ -98,6 +111,7 @@ public sealed class PilotCommand Triggers.Clear(); Iffs.Clear(); TargetVerb = TargetVerb.None; + GiveVerb = null; AimCycles = 0; ShutdownOverrideToggles = HeatsinkToggles = ShieldToggles = Pings = 0; } @@ -134,6 +148,11 @@ public static class PilotApplier for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping(); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); + if (command.GiveVerb is (Entity receiver, ItemInstance given, int quantity)) + { + if (receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); + else if (!ReferenceEquals(receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(receiver, given, quantity)); + } if (command.Aim is float3 aim) entity.Aim = aim; foreach (var (behavior, pull) in command.Triggers) if (pull) behavior.Activate(); diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 5a95f450d..b90b1252d 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -205,6 +205,8 @@ public class GameplaySettings public int WeaponGroupCount = 6; public float WarpDistance = 25; public float DockingDistance = 25; + // Units per second one pilot gives to a ship within DockingDistance; a commodity unit or one crafted item. + public float TransferRate = 2; public float ProductionPersonalityLerp = .05f; public float MessageDuration = 4f; public float TargetPersistenceDuration = 3; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index f2276f4e4..a74a8f7b9 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -385,6 +385,7 @@ public class Zone foreach (var entity in entities) entity.Sense(); _departures.Clear(); + CargoTransfer.Step(this, deltaTime); StepMines(deltaTime); StepClouds(deltaTime); StepFloatingItems(deltaTime); From 7ac8f389014864e16d873840198ca13c14a207ac Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:38:18 +0200 Subject: [PATCH 003/198] Cargo transfer tests: the give verb's rate, ends, instance choice and press order Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index ce8732fe1..eca433719 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -32,6 +32,8 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); + _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); _cache.FlushAsync().Wait(); var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); @@ -150,4 +152,284 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(10, Units(from, ore)); Assert.Equal(98, Units(to, ore)); } + + // ---- The give verb ------------------------------------------------------------------------------------------ + + private const float Dt = .1f; + + // Two ships in the zone, alongside each other, the giver holding `ore` units of Ore. + private (Ship Giver, Ship Receiver, SimpleCommodity Stack) Alongside(int ore, string receiverBay = "Crate") + { + var giver = ShipWith("Crate"); + var receiver = ShipWith(receiverBay); + var stack = Commodity("Ore", ore); + Assert.True(giver.CargoBays[0].TryStore(stack)); + giver.Position = new float3(0, 0, 0); + receiver.Position = new float3(5, 0, 0); + _zone.Admit(giver, piloted: false); + _zone.Admit(receiver, piloted: false); + return (giver, receiver, stack); + } + + private static int Held(Entity entity, SimpleCommodity like) => entity.CargoBays.Sum(bay => Units(bay, like)); + + private void Steps(int count) + { + for (var i = 0; i < count; i++) _zone.Update(Dt); + } + + [Fact] + public void GiveMovesAtTheRate() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + + for (var i = 1; i <= 10; i++) + { + Steps(1); + Assert.Equal(10, Held(giver, ore) + Held(receiver, ore)); + } + + // Rate 2 for one second: exactly two units, not one and not three. + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + Assert.NotNull(giver.Giving); + } + + [Fact] + public void GiveEndsDoneAtItsQuantity() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(3, Held(receiver, ore)); + Assert.Equal(7, Held(giver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void GiveEndsOutOfRange() + { + var (giver, receiver, ore) = Alongside(10); + var range = _items.GameplaySettings.DockingDistance; + receiver.Position = new float3(range - 1, 0, 0); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + + receiver.Position = new float3(range + 1, 0, 0); + Steps(10); + + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); + } + + [Fact] + public void GiveRangeIsMeasuredOnTheFlatPlane() + { + // The plane is xz: a ship far apart in y only is still alongside. + var (giver, receiver, ore) = Alongside(10); + receiver.Position = new float3(5, 1000, 0); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + } + + [Fact] + public void GiveEndsWhenTheReceiverLeaves() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + + _zone.Remove(receiver); + Steps(10); + + Assert.Equal(8, Held(giver, ore)); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.ReceiverGone, giver.LastGiveEnd); + } + + [Fact] + public void DockingEndsTheGiversGive() + { + var (giver, receiver, ore) = Alongside(10); + var station = new OrbitalEntity(_items, _zone, Mint("Hub"), default, new EntitySettings()); + Assert.True(station.TryEquip(Mint("Berth"), new int2(1, 1))); + giver.Command.Give(receiver, ore, 10); + Steps(10); + + Assert.NotNull(station.TryDock(giver)); + Steps(10); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.GiverDocked, giver.LastGiveEnd); + Assert.Equal(2, Held(receiver, ore)); + } + + [Fact] + public void GiveEndsOnAFullHold() + { + var (giver, receiver, ore) = Alongside(10, "Pocket"); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + giver.Command.Give(receiver, ore, 10); + + Steps(20); + + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(0, Held(receiver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + } + + [Fact] + public void GiveEndsWhenNothingIsLeft() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 50); + + Steps(100); + + Assert.Equal(10, Held(receiver, ore)); + Assert.Equal(0, Held(giver, ore)); + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + } + + [Fact] + public void AGiveDrawsOnEveryStackOfTheDesign() + { + // Two stacks in two bays: the order is for the design, not for the stack it was named with. + var (giver, receiver, ore) = Alongside(3); + Assert.True(giver.TryEquip(Mint("Crate"), new int2(3, 3))); + Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); + giver.Command.Give(receiver, ore, 7); + + Steps(60); + + Assert.Equal(7, Held(receiver, ore)); + Assert.Equal(0, Held(giver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGivenCraftedItemIsThatInstance() + { + var (giver, receiver, _) = Alongside(0); + var first = Mint("Gun"); + var second = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(first)); + Assert.True(giver.CargoBays[0].TryStore(second)); + giver.Command.Give(receiver, second, 5); + + Steps(20); + + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(second)); + Assert.False(receiver.CargoBays[0].Cargo.ContainsKey(first)); + Assert.True(giver.CargoBays[0].Cargo.ContainsKey(first)); + Assert.False(giver.CargoBays[0].Cargo.ContainsKey(second)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void TheLastGivePressInAStepWins() + { + var (giver, receiver, ore) = Alongside(10); + var third = ShipWith("Crate"); + third.Position = new float3(0, 0, 5); + _zone.Admit(third, piloted: false); + + // A give then a stop leaves nothing, and nothing ended. + giver.Command.Give(receiver, ore, 10); + giver.Command.StopGiving(); + Steps(1); + Assert.Null(giver.Giving); + Assert.Null(giver.LastGiveEnd); + + // A stop then a give leaves the give. + giver.Command.StopGiving(); + giver.Command.Give(receiver, ore, 10); + Steps(1); + Assert.Same(receiver, giver.Giving.Receiver); + + // A second give in the same step replaces the first before it starts. + giver.Command.Give(receiver, ore, 10); + giver.Command.Give(third, ore, 10); + Steps(1); + Assert.Same(third, giver.Giving.Receiver); + Assert.Equal(GiveEnd.Stopped, giver.LastGiveEnd); + + // A give in a later step replaces the one under way, which ends Stopped. + giver.Command.Give(receiver, ore, 10); + Steps(1); + Assert.Same(receiver, giver.Giving.Receiver); + } + + [Fact] + public void StoppingEndsTheGiveAndKeepsWhatMoved() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(10); + giver.Command.StopGiving(); + Steps(10); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.Stopped, giver.LastGiveEnd); + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + } + + [Fact] + public void AGiveToOneselfOrOfNothingIsIgnored() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + var order = giver.Giving; + + giver.Command.Give(giver, ore, 5); + giver.Command.Give(receiver, ore, 0); + giver.Command.Give(receiver, ore, -3); + giver.Command.Give(null, ore, 5); + giver.Command.Give(receiver, null, 5); + Steps(1); + + Assert.Same(order, giver.Giving); + Assert.Null(giver.LastGiveEnd); + } + + [Fact] + public void AGiverStaysInTheZone() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Designate(new TargetRef(receiver)); + receiver.Command.Designate(new TargetRef(giver)); + giver.Command.Give(receiver, ore, 10); + + Steps(20); + + Assert.True(_zone.Entities.Contains(giver)); + Assert.True(_zone.Entities.Contains(receiver)); + Assert.Same(receiver, giver.Target.Value.Entity); + Assert.Same(giver, receiver.Target.Value.Entity); + } + + [Fact] + public void NoOrderMovesNothing() + { + // No order, no work: a zone of ships that are not giving moves nothing. + var (giver, receiver, ore) = Alongside(10); + Steps(20); + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(0, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + } } From c1aab929b19985fc9a2035a05113bd892db5f0e9 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:42:42 +0200 Subject: [PATCH 004/198] Give verb: a stop is a verb with no receiver, and the type pattern skipped it; tests: second bay at a free cell Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Pilot.cs | 6 +++--- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/ServerShared/Pilot.cs b/Assets/Scripts/ServerShared/Pilot.cs index be71f877d..dcfa655ae 100644 --- a/Assets/Scripts/ServerShared/Pilot.cs +++ b/Assets/Scripts/ServerShared/Pilot.cs @@ -148,10 +148,10 @@ public static class PilotApplier for (var i = 0; i < command.Pings; i++) entity.Sensor?.Ping(); foreach (var hail in command.Hails) entity.SetMessage(hail); foreach (var consumable in command.Uses) entity.TryActivateConsumable(consumable); - if (command.GiveVerb is (Entity receiver, ItemInstance given, int quantity)) + if (command.GiveVerb is { } give) { - if (receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); - else if (!ReferenceEquals(receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(receiver, given, quantity)); + if (give.Receiver == null) CargoTransfer.End(entity, GiveEnd.Stopped); + else if (!ReferenceEquals(give.Receiver, entity)) CargoTransfer.Begin(entity, new GiveOrder(give.Receiver, give.Item, give.Quantity)); } if (command.Aim is float3 aim) entity.Aim = aim; foreach (var (behavior, pull) in command.Triggers) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index eca433719..5983747b4 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -308,7 +308,7 @@ public sealed class CargoTransferTests : IDisposable { // Two stacks in two bays: the order is for the design, not for the stack it was named with. var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"), new int2(3, 3))); + Assert.True(giver.TryEquip(Mint("Crate"), new int2(2, 2))); Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); giver.Command.Give(receiver, ore, 7); From 4e559d822eae1e1ac5961d12ea10bbf22510e498 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:46:35 +0200 Subject: [PATCH 005/198] Cargo transfer tests: the second bay goes where the hull has room 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 5983747b4..44190c3de 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -308,7 +308,7 @@ public sealed class CargoTransferTests : IDisposable { // Two stacks in two bays: the order is for the design, not for the stack it was named with. var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"), new int2(2, 2))); + Assert.True(giver.TryEquip(Mint("Crate"))); Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); giver.Command.Give(receiver, ore, 7); From bcfc776513f74654ba35a48093f6a26860bce745 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:50:35 +0200 Subject: [PATCH 006/198] Cargo transfer tests: two stacks of one design come from a commodity that stacks two to a stack Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 44190c3de..148cde89d 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,6 +31,7 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); @@ -163,7 +164,7 @@ public sealed class CargoTransferTests : IDisposable var giver = ShipWith("Crate"); var receiver = ShipWith(receiverBay); var stack = Commodity("Ore", ore); - Assert.True(giver.CargoBays[0].TryStore(stack)); + if (ore > 0) Assert.True(giver.CargoBays[0].TryStore(stack)); giver.Position = new float3(0, 0, 0); receiver.Position = new float3(5, 0, 0); _zone.Admit(giver, piloted: false); @@ -306,16 +307,17 @@ public sealed class CargoTransferTests : IDisposable [Fact] public void AGiveDrawsOnEveryStackOfTheDesign() { - // Two stacks in two bays: the order is for the design, not for the stack it was named with. - var (giver, receiver, ore) = Alongside(3); - Assert.True(giver.TryEquip(Mint("Crate"))); - Assert.True(giver.CargoBays[1].TryStore(Commodity("Ore", 4))); - giver.Command.Give(receiver, ore, 7); + // Grit stacks two to a stack, so three units are two stacks: the order is for the design, not the stack named. + var (giver, receiver, _) = Alongside(0); + var grit = Commodity("Grit", 3); + Assert.True(giver.CargoBays[0].TryStore(grit)); + Assert.Equal(2, giver.CargoBays[0].ItemsOfType[grit.Data.Key].Count); + giver.Command.Give(receiver, grit, 3); - Steps(60); + Steps(40); - Assert.Equal(7, Held(receiver, ore)); - Assert.Equal(0, Held(giver, ore)); + Assert.Equal(3, Held(receiver, grit)); + Assert.Equal(0, Held(giver, grit)); Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); } From bb38036e5148f2618f1c2587097d9201cbc44874 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 16:54:12 +0200 Subject: [PATCH 007/198] Cargo transfer tests: build the second stack with a second store Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/CargoTransferTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 148cde89d..d2c1db6fb 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -309,8 +309,9 @@ public sealed class CargoTransferTests : IDisposable { // Grit stacks two to a stack, so three units are two stacks: the order is for the design, not the stack named. var (giver, receiver, _) = Alongside(0); - var grit = Commodity("Grit", 3); + var grit = Commodity("Grit", 2); Assert.True(giver.CargoBays[0].TryStore(grit)); + Assert.True(giver.CargoBays[0].TryStore(Commodity("Grit", 1))); Assert.Equal(2, giver.CargoBays[0].ItemsOfType[grit.Data.Key].Count); giver.Command.Give(receiver, grit, 3); From 70aa1ed037b7002f82389104fcec9ca759037a40 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 18:22:43 +0200 Subject: [PATCH 008/198] Transfer verb: drop the redundant pre-check, let range, step length and quantity edges show in tests TryStore already takes all of a part or none, so the pre-check in TryTransferItem changed nothing observable. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 2 +- Assets/Scripts/ServerShared/Entity.cs | 6 +- .../CargoTransferTests.cs | 66 +++++++++++++++++-- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 4026bbc7b..6484bace8 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -94,7 +94,7 @@ public static class CargoTransfer order.Budget -= 1; order.Remaining--; } - if (giver.Giving == order && order.Remaining == 0) End(giver, GiveEnd.Done); + if (order.Remaining == 0) End(giver, GiveEnd.Done); } } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 00df96087..721ac31b0 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2664,17 +2664,13 @@ public class EquippedCargoBay : EquippedItem return false; } - // Whole or nothing: the target must be able to take the part before it leaves the stack. - var part = new SimpleCommodity { Data = item.Data, Quantity = min(quantity, item.Quantity), Rotation = item.Rotation }; - if (!target.TryFindSpace(part, out _)) return false; - var oldPos = Cargo[item]; var newItem = Remove(item, quantity); // The part removed goes across, never the stack left behind. if (target.TryStore(newItem)) return true; - // The target refused after all: put the part back where it came from. + // Whole or nothing: TryStore takes all of the part or none of it, so a refusal puts the part back where it came from. TryStore(newItem, oldPos); return false; } diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index d2c1db6fb..28eae6cf9 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -165,8 +165,9 @@ public sealed class CargoTransferTests : IDisposable var receiver = ShipWith(receiverBay); var stack = Commodity("Ore", ore); if (ore > 0) Assert.True(giver.CargoBays[0].TryStore(stack)); - giver.Position = new float3(0, 0, 0); - receiver.Position = new float3(5, 0, 0); + // Off the origin, so a distance taken from the wrong sum or difference of positions shows. + giver.Position = new float3(-50, 0, 10); + receiver.Position = giver.Position + new float3(5, 0, 0); _zone.Admit(giver, piloted: false); _zone.Admit(receiver, piloted: false); return (giver, receiver, stack); @@ -216,13 +217,13 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10); var range = _items.GameplaySettings.DockingDistance; - receiver.Position = new float3(range - 1, 0, 0); + receiver.Position = giver.Position + new float3(range - 1, 0, 0); giver.Command.Give(receiver, ore, 10); Steps(10); Assert.Equal(2, Held(receiver, ore)); Assert.Null(giver.LastGiveEnd); - receiver.Position = new float3(range + 1, 0, 0); + receiver.Position = giver.Position + new float3(range + 1, 0, 0); Steps(10); Assert.Equal(2, Held(receiver, ore)); @@ -230,12 +231,65 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); } + [Fact] + public void AReceiverExactlyAtTheDockingDistanceIsStillAlongside() + { + var (giver, receiver, ore) = Alongside(10); + receiver.Position = giver.Position + new float3(0, 0, _items.GameplaySettings.DockingDistance); + giver.Command.Give(receiver, ore, 10); + Steps(10); + Assert.Equal(2, Held(receiver, ore)); + Assert.Null(giver.LastGiveEnd); + } + + [Fact] + public void AStepOfExactlyOneUnitMovesOneUnit() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + _zone.Update(1f / _items.GameplaySettings.TransferRate); + Assert.Equal(1, Held(receiver, ore)); + } + + [Fact] + public void ALongStepNeverMovesMoreThanTheQuantity() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 3); + _zone.Update(5f); + Assert.Equal(3, Held(receiver, ore)); + Assert.Equal(7, Held(giver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGiveOfOneMovesOne() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 1); + Steps(20); + Assert.Equal(1, Held(receiver, ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGiveOfACraftedItemTheGiverDoesNotHoldEndsNothingLeft() + { + var (giver, receiver, _) = Alongside(0); + var gun = Mint("Gun"); + Assert.True(receiver.CargoBays[0].TryStore(gun)); + giver.Command.Give(receiver, gun, 1); + Steps(20); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); + } + [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() { // The plane is xz: a ship far apart in y only is still alongside. var (giver, receiver, ore) = Alongside(10); - receiver.Position = new float3(5, 1000, 0); + receiver.Position = giver.Position + new float3(5, 1000, 0); giver.Command.Give(receiver, ore, 10); Steps(10); Assert.Equal(2, Held(receiver, ore)); @@ -346,7 +400,7 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10); var third = ShipWith("Crate"); - third.Position = new float3(0, 0, 5); + third.Position = giver.Position + new float3(0, 0, 5); _zone.Admit(third, piloted: false); // A give then a stop leaves nothing, and nothing ended. From dadb6f327d24012c9c17e3efef93cfb4b3a9f3e7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 19:56:54 +0200 Subject: [PATCH 009/198] Cargo transfer tests: long steps after the receiver leaves or strays move nothing; a full first bay hands on to the next Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index 28eae6cf9..0926494bf 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,6 +31,9 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + var bin = new Shape(1, 1); + bin[0, 0] = true; + _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); @@ -284,6 +287,51 @@ public sealed class CargoTransferTests : IDisposable Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); } + [Fact] + public void ALongStepAfterTheReceiverLeavesMovesNothing() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + _zone.Remove(receiver); + + _zone.Update(5f); + + Assert.Equal(0, Held(receiver, ore)); + Assert.Equal(10, Held(giver, ore)); + Assert.Equal(GiveEnd.ReceiverGone, giver.LastGiveEnd); + } + + [Fact] + public void ALongStepOutOfRangeMovesNothing() + { + var (giver, receiver, ore) = Alongside(10); + giver.Command.Give(receiver, ore, 10); + Steps(1); + receiver.Position = giver.Position + new float3(0, 0, _items.GameplaySettings.DockingDistance + 1); + + _zone.Update(5f); + + Assert.Equal(0, Held(receiver, ore)); + Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); + } + + [Fact] + public void AGiveGoesToTheNextReceiverBayWhenTheFirstIsFull() + { + var (giver, receiver, ore) = Alongside(10, "Bin"); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Assert.True(receiver.TryEquip(Mint("Crate"))); + Assert.Equal(2, receiver.CargoBays.Count); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(0, Units(receiver.CargoBays[0], ore)); + Assert.Equal(3, Units(receiver.CargoBays[1], ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() { From 3f265b6c731a85df25af79068dceed18c4eb4005 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 19:59:19 +0200 Subject: [PATCH 010/198] Cargo transfer tests: fill the one-cell bay the way the other fixtures fill a shape 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 0926494bf..9153e015a 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -32,7 +32,7 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); var bin = new Shape(1, 1); - bin[0, 0] = true; + foreach (var cell in bin.AllCoordinates) bin[cell] = true; _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); From 00d34e5ab5303bb40f793789fe2866a5751db39d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:05:33 +0200 Subject: [PATCH 011/198] Cargo transfer tests: the second receiver bay is a one-cell bin 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 9153e015a..cdc356bf2 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -321,7 +321,7 @@ public sealed class CargoTransferTests : IDisposable { var (giver, receiver, ore) = Alongside(10, "Bin"); Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); - Assert.True(receiver.TryEquip(Mint("Crate"))); + Assert.True(receiver.TryEquip(Mint("Bin"))); Assert.Equal(2, receiver.CargoBays.Count); giver.Command.Give(receiver, ore, 3); From c37fbdb3b95ddca0b05570f228a4ffc7e6b192a4 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 20:11:38 +0200 Subject: [PATCH 012/198] Cargo transfer tests: drop the two-bay receiver test; the fixture hulls take one bay Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index cdc356bf2..ea92abd40 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -31,9 +31,6 @@ public sealed class CargoTransferTests : IDisposable foreach (var cell in pocket.AllCoordinates) pocket[cell] = true; _cache.Upsert(new CargoBayData { Name = "Pocket", Shape = new Shape(), InteriorShape = pocket, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); - var bin = new Shape(1, 1); - foreach (var cell in bin.AllCoordinates) bin[cell] = true; - _cache.Upsert(new CargoBayData { Name = "Bin", Shape = bin, InteriorShape = bin, Price = 5, Durability = 4 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); @@ -316,22 +313,6 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(GiveEnd.OutOfRange, giver.LastGiveEnd); } - [Fact] - public void AGiveGoesToTheNextReceiverBayWhenTheFirstIsFull() - { - var (giver, receiver, ore) = Alongside(10, "Bin"); - Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); - Assert.True(receiver.TryEquip(Mint("Bin"))); - Assert.Equal(2, receiver.CargoBays.Count); - giver.Command.Give(receiver, ore, 3); - - Steps(40); - - Assert.Equal(0, Units(receiver.CargoBays[0], ore)); - Assert.Equal(3, Units(receiver.CargoBays[1], ore)); - Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); - } - [Fact] public void GiveRangeIsMeasuredOnTheFlatPlane() { From 66671cf036f711c8c41fc0ce311ebc715bcd8a7d Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:09:30 +0200 Subject: [PATCH 013/198] Cargo tests: quantities below one, give routing and ends, purchase lots (red); soul-tv probes P4-P6, P9 adopted Co-Authored-By: Claude Sonnet 5.5 --- .../CargoTransferTests.cs | 261 +++++++++++++++++- 1 file changed, 259 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs index ea92abd40..26f2332f0 100644 --- a/tests/Aetheria.Shared.Tests/CargoTransferTests.cs +++ b/tests/Aetheria.Shared.Tests/CargoTransferTests.cs @@ -33,6 +33,12 @@ public sealed class CargoTransferTests : IDisposable _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); _cache.Upsert(new SimpleCommodityData { Name = "Grit", Shape = new Shape(), Price = 1, MaxStack = 2 }); _cache.Upsert(new SimpleCommodityData { Name = "Slag", Shape = new Shape(), Price = 1, MaxStack = 100 }); + var slot = new Shape(1, 2); + foreach (var cell in slot.AllCoordinates) slot[cell] = true; + _cache.Upsert(new CargoBayData { Name = "Slot", Shape = new Shape(), InteriorShape = slot, Price = 5, Durability = 4 }); + var rod = new Shape(2, 1); + foreach (var cell in rod.AllCoordinates) rod[cell] = true; + _cache.Upsert(new SimpleCommodityData { Name = "Rod", Shape = rod, Price = 1, MaxStack = 1 }); _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); _cache.FlushAsync().Wait(); @@ -59,8 +65,28 @@ public sealed class CargoTransferTests : IDisposable }; } - private SimpleCommodity Commodity(string design, int quantity) => - new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity }; + private SimpleCommodity Commodity(string design, int quantity, ItemRotation rotation = ItemRotation.None) => + new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName(design)), Quantity = quantity, Rotation = rotation }; + + // Bays built directly, so a ship can hold several (TryEquip is not under test). + private Ship ShipWithBays(params string[] bays) + { + var ship = new Ship(_items, _zone, Mint("Skiff"), new EntitySettings()); + for (var i = 0; i < bays.Length; i++) + ship.CargoBays.Add(new EquippedCargoBay(_items, Mint(bays[i]), new int2(1 + i, 1), ship, $"bay{i}")); + return ship; + } + + // Off the origin, so a distance taken from the wrong sum or difference of positions shows. + private void Place(Ship giver, Ship receiver) + { + giver.Position = new float3(-50, 0, 10); + receiver.Position = giver.Position + new float3(5, 0, 0); + _zone.Admit(giver, piloted: false); + _zone.Admit(receiver, piloted: false); + } + + private static int Stacks(EquippedCargoBay bay) => bay.Cargo.Keys.Count; private Ship ShipWith(string bay) { @@ -518,4 +544,235 @@ public sealed class CargoTransferTests : IDisposable Assert.Equal(0, Held(receiver, ore)); Assert.Null(giver.LastGiveEnd); } + + // ---- Quantities below one -------------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void ATransferBelowOneUnitChangesNothing(int quantity) + { + var from = ShipWith("Crate").CargoBays[0]; + var to = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(from.TryStore(ore)); + Assert.True(to.TryStore(Commodity("Ore", 3))); + var mass = (from.Mass, to.Mass); + var stacks = (Stacks(from), Stacks(to)); + + Assert.False(from.TryTransferItem(to, ore, quantity)); + + Assert.Equal(10, Units(from, ore)); + Assert.Equal(3, Units(to, ore)); + Assert.Equal(mass, (from.Mass, to.Mass)); + Assert.Equal(stacks, (Stacks(from), Stacks(to))); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void ARemovalBelowOneUnitTakesNothing(int quantity) + { + var bay = ShipWith("Crate").CargoBays[0]; + var ore = Commodity("Ore", 10); + Assert.True(bay.TryStore(ore)); + var mass = bay.Mass; + + Assert.Null(bay.Remove(ore, quantity)); + + Assert.Equal(10, Units(bay, ore)); + Assert.Equal(mass, bay.Mass); + Assert.Equal(1, Stacks(bay)); + } + + // ---- The give: where units go, and when it ends (the soul-tv probes, adopted) ---------------------------- + + // First receiver bay full, second has room: the unit goes to the second. + [Fact] + public void AGivenCommodityGoesToTheSecondBayWhenTheFirstIsFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket", "Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 3); + + Steps(40); + + Assert.Equal(3, Units(receiver.CargoBays[1], ore)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + // Two open receiver bays: one unit per move, not one per bay. + [Fact] + public void AGiveMovesOneUnitPerMoveWithTwoOpenBays() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate", "Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 10); + + _zone.Update(1f); + + Assert.Equal(2, Held(receiver, ore)); + Assert.Equal(8, Held(giver, ore)); + } + + [Fact] + public void AGivenCraftedItemGoesToTheSecondBayWhenTheFirstIsFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket", "Crate"); + var gun = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(gun)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, gun, 1); + + Steps(10); + + Assert.True(receiver.CargoBays[1].Cargo.ContainsKey(gun)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + [Fact] + public void AGivenCraftedItemMovesOnceIntoTwoOpenBays() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate", "Crate"); + var gun = Mint("Gun"); + Assert.True(giver.CargoBays[0].TryStore(gun)); + Place(giver, receiver); + giver.Command.Give(receiver, gun, 1); + + Steps(10); + + Assert.True(receiver.CargoBays[0].Cargo.ContainsKey(gun)); + Assert.Equal(GiveEnd.Done, giver.LastGiveEnd); + } + + // A refused one-unit move of a whole one-unit stack re-files it at the end of ItemsOfType, so the next Source would be + // a different stack. Rods are 2x1; the receiver's Slot (1x2) takes only the turned one. After the give has ended + // ReceiverFull nothing may move. + [Fact] + public void NothingMovesAfterAGiveEndsReceiverFull() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Slot"); + var flat = Commodity("Rod", 1); + Assert.True(giver.CargoBays[0].TryStore(flat)); + Assert.True(giver.CargoBays[0].TryStore(Commodity("Rod", 1, ItemRotation.Clockwise))); + Place(giver, receiver); + giver.Command.Give(receiver, flat, 2); + + _zone.Update(5f); + + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + Assert.Equal(0, Held(receiver, flat)); + Assert.Equal(2, Held(giver, flat)); + } + + // Refused moves do not spend the order's units. + [Fact] + public void AnEndedOrderKeepsItsRemainingUnits() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Pocket"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Assert.True(receiver.CargoBays[0].TryStore(Commodity("Slag", 5))); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 5); + _zone.Update(.01f); // applies the command, earns no unit + var order = giver.Giving; + Assert.NotNull(order); + + _zone.Update(5f); + + Assert.Equal(GiveEnd.ReceiverFull, giver.LastGiveEnd); + 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. + [Fact] + public void AnEmptyStackIsNotAUnitToGive() + { + 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); + + _zone.Update(5f); + + Assert.Equal(2, Held(receiver, grit)); + Assert.Equal(GiveEnd.NothingLeft, giver.LastGiveEnd); + } + + // Warping and dying both leave through Zone.Remove; docking has already ended the give GiverDocked. + [Fact] + public void AGiveEndsWhenItsGiverLeavesTheZone() + { + var giver = ShipWithBays("Crate"); + var receiver = ShipWithBays("Crate"); + var ore = Commodity("Ore", 10); + Assert.True(giver.CargoBays[0].TryStore(ore)); + Place(giver, receiver); + giver.Command.Give(receiver, ore, 10); + _zone.Update(.5f); + + Assert.True(_zone.Remove(giver)); + + Assert.Null(giver.Giving); + Assert.Equal(GiveEnd.GiverGone, giver.LastGiveEnd); + } + + // ---- The lot a purchase delivers --------------------------------------------------------------------------- + + [Fact] + public void APurchaseChargesForEachLotItDelivers() + { + // Grit stacks hold two. Five units are three lots, and each lot is charged for what it delivers. + var stock = Commodity("Grit", 5); + var credits = 100; + var delivered = new List(); + + var result = StationServices.TryBuyLots(_items, stock, 5, 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); + } + + [Fact] + public void ARefusedLotLeavesEarlierLotsBoughtAndChargesNothingMore() + { + var stock = Commodity("Grit", 5); + var credits = 100; + var lots = 0; + + var result = StationServices.TryBuyLots(_items, stock, 5, ref credits, lot => ++lots < 2); + + Assert.Equal(BuyResult.NoRoom, result); + Assert.Equal(100 - 2, credits); + } + + [Theory] + [InlineData(-5, 1000, 1)] + [InlineData(0, 1000, 1)] + [InlineData(4, 1000, 4)] + [InlineData(50, 1000, 10)] + [InlineData(50, 21, 3)] + [InlineData(5, 3, 1)] + public void TheBuyQuantityIsClampedToWhatIsBuyable(int typed, int credits, int expected) + { + var stock = Commodity("Ore", 10); // 7 credits each + Assert.Equal(expected, StationServices.BuyableQuantity(_items, stock, typed, credits)); + } } From f9a09e0d0a4fc1f24388c2292656c90b3fe93461 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:04 +0200 Subject: [PATCH 014/198] Cargo: TryTransferItem and Remove refuse a quantity below one and change nothing Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 721ac31b0..bc8087228 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2596,8 +2596,10 @@ public class EquippedCargoBay : EquippedItem return true; } + // The part removed: null, with the bay as it was, when the item is not here or the quantity is below one unit. public SimpleCommodity Remove(SimpleCommodity item, int quantity) { + if (quantity < 1) return null; if (!Cargo.ContainsKey(item)) { ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!"); @@ -2658,6 +2660,7 @@ public class EquippedCargoBay : EquippedItem public bool TryTransferItem(EquippedCargoBay target, SimpleCommodity item, int quantity) { + if (quantity < 1) return false; if (!Cargo.ContainsKey(item)) { ItemManager.Log("Attempted to remove item from a cargo bay that it wasn't even in! Something went wrong here!"); From f0d7c12cbdfa0bda23ad04e9f5991e68a62d16b7 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:07 +0200 Subject: [PATCH 015/198] Trade: the purchase loop moves to StationServices.TryBuyLots, charging each lot it delivers; the Buy Quantity field clamps to at least one Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/StationServices.cs | 22 +++++++++++++++++++ Assets/Scripts/UI/Menu/TradeMenu.cs | 15 +++---------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index a6cb4bfb5..433b0baf1 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -79,6 +79,28 @@ 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. + public static BuyResult TryBuyLots(ItemManager items, SimpleCommodity stock, int quantity, ref int credits, Func deliver) + { + if (quantity < 1) 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; + } + + // The quantity a Buy Quantity field may hold: what was typed, at most what the credits cover and what the station + // has, and never below one unit (a price the credits do not cover is then refused as short of credits). + public static int BuyableQuantity(ItemManager items, SimpleCommodity stock, int typed, int credits) => + Math.Max(1, Math.Min(Math.Min(typed, credits / BuyPrice(items, stock)), stock.Quantity)); + // The sale: the item leaves the bay for one of the station's cargo bays and the station pays SellPrice. False, // with the item in its bay and the credits unchanged, when no station bay can take it. public static bool TrySell(EquippedCargoBay from, Entity station, ItemInstance item, ref int credits) diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 3d448f64d..13345f88a 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -270,7 +270,7 @@ public class TradeMenu : MonoBehaviour Dialog.Title.text = $"Buying {GameManager.ItemManager.Title(i.item)}"; Dialog.AddField("Quantity", () => quantity, - q => quantity = min(min(q, GameManager.Credits / StationServices.BuyPrice(GameManager.ItemManager, s)), s.Quantity)); + q => quantity = StationServices.BuyableQuantity(GameManager.ItemManager, s, q, GameManager.Credits)); Dialog.Show(() => { Buy(s,quantity); @@ -343,17 +343,8 @@ public class TradeMenu : MonoBehaviour private void Buy(SimpleCommodity simpleCommodity, int quantity) { - var data = GameManager.ItemManager.GetData(simpleCommodity); - // Up-rounded integer division from https://stackoverflow.com/a/503201 - int lots = (quantity - 1) / data.MaxStack + 1; - int remaining = quantity; - for (int i = 0; i < lots; i++) - { - int q = min(remaining, data.MaxStack); - if (!Bought(StationServices.TryBuy(GameManager.ItemManager, simpleCommodity, q, ref GameManager.Credits, - () => Inventory.TryTransferItem(_targetCargo, simpleCommodity, quantity)))) return; - remaining -= q; - } + Bought(StationServices.TryBuyLots(GameManager.ItemManager, simpleCommodity, quantity, ref GameManager.Credits, + lot => Inventory.TryTransferItem(_targetCargo, simpleCommodity, lot))); } // Refreshes the credits after a purchase, or tells the player why the station refused it. From 4d462c771c8f7bb82bb47b1731de3e82738158cc Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:10:09 +0200 Subject: [PATCH 016/198] Give: Zone.Remove ends the leaving giver's give GiverGone Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/CargoTransfer.cs | 3 ++- Assets/Scripts/ServerShared/Zone.cs | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/CargoTransfer.cs b/Assets/Scripts/ServerShared/CargoTransfer.cs index 6484bace8..45208d948 100644 --- a/Assets/Scripts/ServerShared/CargoTransfer.cs +++ b/Assets/Scripts/ServerShared/CargoTransfer.cs @@ -15,7 +15,8 @@ public enum GiveEnd ReceiverGone, ReceiverFull, NothingLeft, - GiverDocked + GiverDocked, + GiverGone } // A pilot's standing offer of goods to a ship alongside. Runtime only: nothing about a give is saved. diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 8bc152eaa..d395f722a 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -299,11 +299,13 @@ public class Zone if (piloted) Agents.Add(CreateAgent((Ship) entity, task)); } - // The one removal from this zone (death, dock, jump): membership and its death watch leave together. False, and - // nothing changes, when the entity is not a member. The caller deactivates; Remove does not. + // The one removal from this zone (death, dock, jump): membership and its death watch leave together, and so does the + // entity's give, which ends GiverGone unless a dock already ended it. False, and nothing changes, when the entity is + // not a member. The caller deactivates; Remove does not. public bool Remove(Entity entity) { if (!_entities.Remove(entity)) return false; + CargoTransfer.End(entity, GiveEnd.GiverGone); _deathWatches.Remove(entity, out var watch); watch.Dispose(); foreach (var floating in FloatingItems) From 708fbeee5903f1c60188de71cd0342443d4b2c47 Mon Sep 17 00:00:00 2001 From: soul Date: Thu, 22 Oct 2026 21:59:06 +0200 Subject: [PATCH 017/198] 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 018/198] 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 019/198] 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 020/198] 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 021/198] 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 022/198] 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 023/198] 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 024/198] 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 025/198] 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 026/198] 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 027/198] 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 028/198] 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 029/198] 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 030/198] 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 031/198] 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 032/198] 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 033/198] 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 034/198] 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 zcmeHw36vbwb#~99(aIvgpbe?D00OB*?TeOfjYbjzBP2^QI5u8tc_lT>OpERrK*Vtf z1nl_7Sn3%Xfw95Akr)ILn;iqOVIsiBh2z9oFaxqpaDH&GgK@$k@%AgPz3;wTeUF;Gojs;*?jXyuJ?RWtb~ShV?L!x2cj&w%FXwqy z77alr8sSWvS2$iKL^f4P7Ytq$Ox5BzlV=T9BbsIMGA9bQuG%Oq5|IJt|%Ef%_zWU{GrH_CCi)H`5Hp>zK+;OL^ja`EQBEaozb26Z`_lBWw6|zG!xHVjdO)?{ zQ@v5oW&@%<_JQ6`Qcl>|u&;~~(Whh!$)#rg2@f}ng+W1&_$67FSBEr-I>;V#E*S^Jdxnzf3nBdGDfS$ z+_ksqUJB9&OZa*C@Zd9pdz<&UkUMm44Y&w^`I*Cy`bvtAvWExvw%kiW(|?AGm3Rj3 zwBGN7*ifoS!}x>OGtYd>r?UzafvyQ(uZAqu-eB@m$CmWf>P&e&RHtNORx3UA>9B;U z0w&ceO?x6-spd0Ic&v()sMhW&k5o+Bp=HPDxGIyi(DSG6FHE9GD&~%RKhV@g$>wy~ z6b#emOj$83TO=IeY+e^Qm572a@PZ<6HfQjrDO;MQ3LLRy!iT{INd9 z5C#QBKDv&+S%(JxKSehVpl~fjvra7sUov`frE&Uk0DPNw=-Ky_lt5KqpSqSHXX@6majsZ9{4|t=dn!IV*=k{W?TdfC6xcX2TGt0yX1cGc@ zBBm{@dP%eup71PZ36`vJx+p;iNU*#uT{ktwk}Z>y3ilT}FQ7nPyEY8+6NhYeHLI%Cua$q<) z5*eo zon%7lqM{SgCbFnnmTB0ErW=wb={(OvStOipDvE6hMALZSfFu#i)gN_{g0P!}EBwgD1B)c3+YE4~E~OVCyv9OSv(m*2{%5Rfd%$Nzr9PFo>o~yd)SJRx$)p z5}~)qvWda1ss^W+8fUVSXxO&R3ARL7O*VU|EXz^5mKNM}O?QX= zLp6L7Tj`6@Dv9y+#Z(#i>-B=;J(z_xXf0M~6s3W+wjtd`%Gg@Z2n~<-ahGD98(xtM_bi6G^Yq+B~Lp>n0Z-a@{ zaC($#^q|AAD)t^y6*$n>=;oE%GwI&$@PZN+{?eJ?&Q{XX1Mdq3*J zDTv`pRlptHjS|j*iq!53=QVm}4VHe3Z1GmYDQ1-4Zxn@r;&fqlTQ52g%OVv+Y)v}d zNp$Kz8vmZ;0-V@FI^C7a?2gpVP)8PVD}1+6+zJ4o0|P8oMbm7Y@ZfZ}zpso8wU1f}DYV&gzpovP zS(~i`9$Z&Z^C#R(;j_w(wg*CRR^|RgEQ0uOSAdr)r6wH+g!?UG|%9ua;l-Doi~P2q&+_qD(5ZIPG9q8f3pwRYVkuo^W4r>Q;Hv z6Yr%n$@BRZCXibzu=Yic6D)z{BwLqZkr0*FL?ZE`t{6lSG!5Qf*%Dcm2)xAdl4kNc zk!)EZECRA5-YBzyu7jQtvVkmEJhCDiqAD6PmWOzlP(-j5T{1L@w^;?Iy@ACdUK3=| zgwexFwynvqnPfrI2>WtN-!W|<`Z!4OXsbd2cs1OaTc(}`AoZRuW%zbL08#DM6<~fr zUkpK1>8NiAZU)fg3Bw8)Z=~uIfYYXH5C>MBmWD7UFfuyQ-96~5P(M+`*$iEFJP8S- zW@3-g@9*{Vw5qa`es(iMc* zV%^?;dGbrcip|2qFhHUS%|KK&TxcE-cO6xz*|=lIafhf zH9=M-f)4c>-~bAId4)*5fZEbXn{f_z4Yd8h5V|pt*cSWHwe zbNV-`&RWkKR?wW>=8nX+_7BBbfz<>_WqH_YqF`w%!j)xFlx4-1M8y#Ny#k|+`W%|i zv%Ji3EW`8LIBhvEEtkY)GAF2_sw`pIsR}BwL=mn86*0An zAnK;gN*sc*oDO--O-}N3XGYnZm&_Xc<8`<=vrPv7VyHvQqhoK@fa%9(}v1(r;C&Pn|L zi#naeiyP)RiHpDgH~mg>?O(;5Wb0GcJBd3E2~Ohs^kU-0{yrzM@6ew+iQZpa?j&d4 zaVJWyc7pLCLi>a*A%If*wisB`a%4wpVmWD)zN5#+4!rfrJ|w??Fr zscS?s49-&d&_o_>_~dY`);M_Xi>y}jg99ExL-AVU+39%guBQu(#*5VImhm6`qNa^d zBK_#3HBRCW)0<02f2~Fj~sUFAVyRu68gZA*cpQZ9!VGvwPerwiQnE7OjCD; z4mVA6{m!bd{QbSosypu3i+Sb2pgV`0#7taFob|mK?r9(HKYLH15ETKKoIgMPw@%`9 z+b$HR7r{G?x@r-QYD)%$v?OUZOLP_q3q(u6Ki22n_{}>>^Mo~>cOF4Qaf`EbLseM> z$LgY@NV=@S`)p%6YNCqxTW4+4LDQdi2FtRlU~z&DA+H&-hSfa?Y*jH?jzjFB|03g~ z(;$Wz?rv`oj6|p5o_(cs-eF6L&9a&($ucWq!-uY_JOU<>L&CPPmCf|uWW0tm-0zZH zGMqhg_p*B8m zBJrA}N**ANWISJG2vVTv65%*3^N0q|8@!?kSYQ{4p$V1}yvev}Q34j03Vrfi zQ>&{+c{B~hYsn2~-RT>52Ll>HH|7i{pZ7wi`H>QL5>MZhbP~5bCl=MHRd@c;T4&WY zPyWhT`R2ttox~{@o(1J-)}Bu~iC=&GpPj_&^hcaT&b@f*O{o6URkt{aDQ_Nf60dZ9 z6D8-qE<1^>zh3Gjr{1^5NxIb&zg+kwC-KCuP-xEILyeMO=|;(U=bYgr{_~`(oW#nv z00AGP)e}!l`4eZ=59ITl#68dd(3SoW{D{L=6am{{SqORo`XY=j4SQHM)zYv7BFIWJ zUQ!0zXcbTJ7eLb^X((RH7;@I#^M!8&ol+AtDPFF6>VhM~faa!$H~XN8?oCdxepx(s z@iOu6YrEZZpLaE^)p3_H$Oa^MbF*ACdUoy;=R0fle@OQaU9!D1ZRnlLiwTTI8|dY~ z)~Fk*>K0I7{c>Cpc%Ey0>Fy)3)epGw|1|c6VME7U=$H>33!!5%bS#CA<wSK1=~0 zrhpGqz=tUi!W0N$3WP8PLYM*}Oo0%lKnPPHgeefh6bNAo#4rV7m;y0Off%Mh3{xP6 zDGqsgqs@B;iv1a1sdm4_B_vY}hhF&V`+Bu3;+iBT<`Wh@i1 zY{+9~VB7J~BAvQBSX*Q$dNXhkDvXMZXN+xQkFp4JOB1!BMXHE=XPPWCtfi_9l9wrr z&a;GpEry+KH0I8xPqb_uhZ-ZBvcQPEt}q%>Gcd5r*-8UJ6K9x$s9*`WCh>*!@ht+5MZ?R3Vo~ zyOYe9%cE{+c)9F3IiEXoXKw9JVq(*6=y4t)I;<`+oPi$^!vQ_6a*V+vH3lzY0~lxJ zVtb+u4JXMDoVa^vkt`4s`^R`jkvN?ZC6h3SpQO)VS)Dg67DiF?Z9{P!WAmnteP?hO z*&+rNF`+=h3oOE2#1+OkNr7BE9JShx~df#Fmc%)q>2K$6BFKcFE=Dv|^c zeFV%nZ`rMTC?Q#rxjLQ7KwJFHPcRcP3rxaVj4fK27nX^KAR3Cn)>7mlU=>Z#v&LlP917^EH$Wb903Kp`frD4_Q=LuYt6O5wf5 zO`UU+@oVPo$vw1#Af&&0dmLL_yXbIF{OZSl0sWb%tcrcAswN_S3m!ZKzd|G+P)k#Z zBuf)PjsWIK=v4-`xD5fq@eKC8N{j^OJtDbm7Bxpej`r|Fx!nE8^M$=4$Oh#k|Md1$ z?m?QdQQuDD*n$gpKd>Cas~@r*k>%O zETTD`5 z>df27_;e;^h>CwJ~Hq@L)qdu7I|D06U&(H!=vHRHB-OYm%ER2rFvZH zcKYOrxq%EZNec1~D=BCEI`&GHoUo6k-A`czel;+Y7jtUUbMtuD9YcaZ@A|r8-ZUc2}s_8%<*02W7C`dA)i8^4E z=jY?$V=NaJe}5>VTwH*MtGbDZYp4Y_Pz(rV08C_#V+4Vdb?EZQQ^6MJ6{p#nVKOYT z8bO>v(1LpcgPAH>yoTK_hUp~Fdtpf+7ZU@CODg1I;=wZ^B_%EUjq`-9PB;v$!rF_nqUfBY3+CdNPgQi)7#efRa+ zGVv_XG`2D^@uIvxqD(xSGGuIJ;*xE*hGb&mjeC9{mWgxE)~d@yYJSz0iHkw+7|X=j z@<549O#JKwrd%dY-+UMN6hTKlH>ymGVqH6lH(x%ZR3?5hx!RYBmtVgaql<$D0o|E} zQy8!<8lsJf$nmNrDcBjOorV30p*M2_Jv%bJNVyL5S|~o1yYCu316#~; zk|*8qRrty^lj9Lk1;AI3Oit7}izm8_co3}QBB*F7?JFaqbc)tjQ?Gu~mrku4ZVgMP z#FPK<17Io)A0UJzB7{XY;S(?o0~#Ar<-ww{2+M#bfuqVdlq(jwV9<>K48(iDUxe5+ zx(3aOTA>@GVQSkSrcvocFQC zRD}idGdlJaF_OeW(dP}F*9t0ZG%aKLFMkn|PRYsF?h8q$#1mH@t}dMtgSk&(vLV6^ z4lN7m|71AlY!xegutg97qheLrV$TP?5+T@73hx4v>_8L6_+(THwkixuEC>pCZ^IpI zkt9HJ`r?zT4Ytis{yaR`R%bpF47S8~ZkPi}ij-9FznMr5Wb3fX(9IfAU|tKBXd%{* z1+5m+{@B<{%b1#qX-6saQ7f6V0uQr%a$+m~TX9r&}{ z{_ga3*LA|dH~IYO2+)DI1-p0^SWJctE5YDQm?LmXfDpQsi~~_J3Ac^|2l6gn1!2quAM!5@n- zHP8hPsRgf5G+r&{yGwPL_IH=UMS}#A@I_%ITEarU1`P_CEFs!hm_-^kIEx@8GWU8!gHdK8L0Uy$sLSOPyYfmZk zNiA6)`Xuq9+R!J1Sqj1dWjcD0%L>M(0=KztC?W&n7fu_b<|KT<*wo>_fd0!eaDKt7 zpo+i1vz~hsG80+uB%~##zA*wIv*3P!Jc5ga z#f*i3hV)hyI#$ONcaw9kZVuzF@Xj^0aaRQyBf#C-3ldJ^C!1faA_^04fBw9%D4g-p zdqSe{owgqi#bJnYEEY;^nEHZ%=p!UOL&9k!KtqmL4JHq0jf}g|ruQk?ull%q-hFSD z;O_G?=GC?emV>4-#@(gPmm}it3Osy_areZX{~p5K#P4oC7{cA;^yZDj;BL=NU#pF~ zD?#rV<8H@gk5?IN$%_^}93E_^tW5@kEh%Xqt&O{@Kr69Xzy%x#8a_J&g$lZlVE}<` zP9`*KF@XcRsE|gO7)dUDO9}3te0YnQuZ9hYQ{|keU5w2EZ%N=0W7-Mg1%a0>q?`k|avb`Hk zmNO^iAvdG=0Q*CMDM38$X!B82k7b75slr!Z3}8GnzklnF-tJv`4l!wW!~C4^)?NM0 zGv_bLHBFc>7^W)-7aKpi6oo6&H820bW%~B*dfffZIZ5vP=bgk?`sLh&wmsO=&>xPZ zX#~r#oaeJ_pd;1QMZZx4rrq(xY2e&<7WK8nr9DkerGzztXBy{iADNdwusM_JBJpkb z0m~M(eZon8;GLGfiKjDrnwv{WZ5ldigN4O z{U@J#+KgONOBvTfo6|;_&uRD>0MA#@o$K{Xd@H^*1kxV*(m%rf{LoE(C#_8GX>Bd# znb-)P@m_54!u-K)I|wE~rYpVypL9cywq4lfB;I=A-M-Ui&dW_`!2}IYgHQncIP%R4 z=vg9vUEiEV z%L~#s+_^yv^*GK~#be+6{K1R$?Z`ljXp8k&2*ubtXV=qCvgOE+bFHUW2otRlY*W3h z7r?9yySAqgY=a^9i}g<8sVO(~PdoXHW$4eA63l9ZkVEjDk68mB?(W3LZYaMJKVP*J zu|_AH*B?7==HiO}ce;u6aVKIN=-7wVLJ)xtaNW zH0rMhWXT6lb&@B1<{P=Gr=yd*_k;PT~i5-`qFtv}JqfYz@gcz5xIR z-<>CY&&==Jrg!OCQ1#p2y#vj6)~9caHJ^Ce2Qb)5k(F-%c7ZSt_!s3L+NfW94T9X_ z8wky50uqxg%_m{+$E>ct56rGm=J*D{7Ik_S?JXta)zrX!T9heHTzWdqVV`I&dXZohko?qFg4Zti7QPoAB&Cfr$ zLGQHV8;PFn%~0`n%4f0L`kwe-_RT$KsXG%wHee&;iq>uv?Hn1k-`U*X)W@^~9u;_QLtEIf<`- zeOaz;VYwGVXb5`o-sY*Ko*!7(-oYsNIy?x|zP8XwyzpQ9bJ&M)bnor(O4iz9jF9&l zn>OQKCW>K`W7OVaSbOJw?Ys~*cd*}Cd($!)Qfv2k?Ys^(H!arA1yDQJMD1PiC{b(Y zaP6!)H8TX=mWv&UoxcC0q&wBK+Z_njou{!JKa;+1; zTzeBp@sVqukI_dqAI;^aHsgs=A_rRv)E29@#6WD+s!f$u+af--vg(8;RE@Hqqx4-T zwB~Xxv}%;a1+VG`mrWCoVvW2B?2Y8|F4pb1&aK<#)@`BBjj*+$Sa)rqZtKy(zG>5r z#C8XHKAetQupc+VkGn0q>?cAsw@6SeYK`ONB6Z69j!h%)>7Yh%6!o?I8Wp3O3Kb{9 zkKaOQMntIQ776M`t#Q0uq)vI?v1#Nz9n=VpqP~`2qcEy-hY!<#&pcE8U*T?)GQ+iS z{Ca@{QFs`k%;27*djRjTSPt4u%}unPVo=bZCLP_gd-oo9Gjy~K=K0<3Uu~82Xv|r6 zhSqtwpjB?8U$*Ii4iRPNODTynj^0_+D(k5WwTJ3>QT76QkDcbKJu_Q!xe4`Gty8m{ z1*LRiRHd|3pQTk>sG1ffLd|>E!YG&xExKg_WmAMLccoS5Kq+mdRikVU@v0V-(lb%D zsTEa6uFuY+1VaI@xvk85;!d|u2_bhiy}_5J zg>u!31lQcc!Rh<+C<2iYUfM5G!NvA_pgWcQ_@xV6Y>(3-Z&&%DH{8O7BF8KjD&m%_ z^XDE)Mzy-b(dQP9;jdV2xN>fh;HQiw?x}ybz{M(9?c;ML%;Hw%#j(9o^$}G`axV_< z2L6l;fFRI|5L7EuFd&GsYxEE_e-scjAEL^IpeWleJ#keKkPAErs;_FD5VWv<2%=T% zgrG(BL(p0EL(tjvL(t;-An4ptK+rkIA?O%R3STk~kGp=6;}CQlf`EIYhoD+%_k9Rz zqQ3RDSERbTQ_y&$?7JyqLD7?X-q9nmnVuaKVb@N1?Od*T5*{37r;P_f&$~MF5`fU; z`m450rM5wYy{e^8T?*;i=K7o?Y$+|R%0Q;KQHvzX#uTq=0dSY0>I6z~lszI|)txSz z&Ig+&)nB!Zu9`&IW!XGP|6ETamOt2L5KNZ@>Z>}j{;I9iaEtQP(&Z0Ia4Tg~eN`t% zeQK#`OrJVA>QgJLww)M}U~fniW)6L7GhHQ$vQgEmS{R5NM$^>#tGZTol)W&eqlu^L z8yyn$Rh?Xa)v2^!>w9VwWz)z#2qFYbGo-`_JqQw280k7%lszlOK6P!7!9kg>qfMZ7 zqip#s)?GXKdb-RhP|fee=|^I36_guakqMm+>pQ4c_}JHmZK~|Xv0Dl5mQ_3UX;5w0 zqhmJ^;ci*W5o$y&60{t(M$~d{%TWve?<+UfuOD>O*tA^MUT`|1t_Z3{IEvbGgr=$$ z8MgUqg-fs1l2z?--xpSTRN72Px($1y-EF`%XWXOVeq#8G-k#e^&}c+!soFCNPD{7F zVSj?=s}w0~wB0QdkQSUC`&yM}1T9x>)NT3LKS86V4|iJ*xoc@FYy6*(U-Q__yLU@l z9-l_5Jt!(^0T28dG5k60?&&e94;2Z@4c8vp^gP``VfPu;eyX(6?w5<^?ioM%7Itto!^g?_Q;@ZjOxX#H5q zsQg$8wc6@)Myd7tv1rvg{a9N4eyrv7`>|Hk@5fqMzaNWMb^TaTvX+#39#(mNtoo|D zeysYcx_+!ERZIO?lwjA7RbN%tj}@hAsWXZ`)%9c5SJm}nMXBmJqY8d3`c&7C6(#YD zS2gc?MH3hjt{4{G>1%4C^{g6W-UTJ2xyVpwn1BGjT z;TZlxK7+#DGKWC52Fk9Lozmumma8^Oixh4QdlWS49{J^B_zQ;}(i_M9SQRX3r+g{% z%$1+gA~F1he#Ux811q9TKG$+BdN@A*3P?ef2kkK-J9ZEaNyIQc!AC*`nUUVI5&;ZkQ6X{kjCWR zjk=qZ@w?luK;E}4`)`X)kI%2nBPBE>^^4%83PDbUt@tnzvzcVNboxa^q#l@bF1}~H z^!}4`=cpA~I2w}t#s5z3+4+at@y(>|-Sp#i@il2ZLlbf3Ok^^-`{3ceN#`x8$oM8S zBn^zcC?LPhR~VtSg{g=7+< zB=VqZ#VOY!J(xNrcgn)uM@v#Pi490whA(b;DMFCxZ6iLs*+aAWIFD?05^ukFXMgj_ zahe;XGL(;v7-q1%z#t(N}NRZQ!?#}jPdd*^@p+jw#AdBkB zkM^H&Hp>+<_=R%5hz*zr_;Qt(=M70M3;hZrdopR7WhlPmPw{enbr`rlD+Y^kS3zbY)^H^({_Aa zCX=Q~ULW}ivTl9ihHvGT$nJCqVO9fXgNiTF_~|weZ0POU*|RIYHcip={2w6&(r4a& zu&=32EKklPH3YY0Zz{~p-@CPk<|s?7xOszJk6Hk2wkLnn}3{=akgYp;Q0gzu-F$m#w%gcftynw9qtYfE-itE$ZUbguY0#WLxn|@UiOn z#rT*t7 Date: Thu, 22 Oct 2026 23:34:24 +0200 Subject: [PATCH 035/198] 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 036/198] 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 037/198] 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 038/198] 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 039/198] 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 040/198] 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 041/198] 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 042/198] 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 043/198] 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 044/198] 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 045/198] 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 046/198] 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 047/198] 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 048/198] 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 049/198] 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 050/198] 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 051/198] 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 052/198] 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 053/198] 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 054/198] 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