From 39b6d28f3a3212d9e728ad06e603bb479f79df84 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 19:30:31 +0200 Subject: [PATCH 01/16] StationServices: one engine-free owner for sell price, repair cost and the sale and repair commits Settings gain SellFraction, RepairFraction and StartingCredits. A sale pays the price scaled by condition and moves the item into a station bay or refuses with everything unchanged; a repair restores every durability and charges the wear, or refuses when short. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Settings.cs | 5 + .../Scripts/ServerShared/StationServices.cs | 82 ++++++++ .../StationServicesTests.cs | 178 ++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 Assets/Scripts/ServerShared/StationServices.cs create mode 100644 tests/Aetheria.Shared.Tests/StationServicesTests.cs diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 67de2f0aa..cc7de7652 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -161,6 +161,11 @@ public class GameplaySettings public EntitySettings DefaultEntitySettings; public RarityTier[] Tiers; public ExponentialLerp QualityPriceModifier; + // What a station pays for goods, as a fraction of their price, scaled further by the item's condition. + public float SellFraction = .5f; + // What a station charges to restore worn gear, as a fraction of the price of the wear. + public float RepairFraction = .25f; + public int StartingCredits = 15000000; public float DurabilityQualityExponent = 2; public float DurabilityQualityMin = 2; public float DurabilityQualityMax = .25f; diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs new file mode 100644 index 000000000..b6b363dde --- /dev/null +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +// What a station pays for goods and charges to mend gear. The one owner of the sell price, the repair cost, and the +// two commits that move goods or restore durability against the run's credits; TradeMenu shows these numbers and +// decides none of them. It holds no Unity type. Credits are the caller's: a refusal leaves them, the cargo and the +// durabilities exactly as they were. +public static class StationServices +{ + // What the station pays for the item: its price, scaled by SellFraction and by its condition. A commodity has no + // wear, so it sells at the fraction alone; a simple commodity's price is per unit. + public static int SellPrice(ItemManager items, ItemInstance item) + { + var fraction = (double) items.GameplaySettings.SellFraction; + switch (item) + { + case CraftedItemInstance crafted: + return (int) Math.Floor(items.GetPrice(crafted) * fraction * Condition(items, crafted)); + case SimpleCommodity commodity: + return (int) Math.Floor((long) items.GetData(commodity).Price * commodity.Quantity * fraction); + default: + throw new ArgumentException($"{item?.GetType().Name ?? "null"} has no sell price.", nameof(item)); + } + } + + // What the station charges to restore the entity's hull and every item it carries to design durability: each + // piece's share of its price in proportion to its wear, scaled by RepairFraction, rounded up. + public static int RepairCost(ItemManager items, Entity entity) + { + long total = 0; + foreach (var gear in Gear(entity)) total += RepairShare(items, gear); + return (int) Math.Min(total, int.MaxValue); + } + + // 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) + { + var price = SellPrice(station.ItemManager, item); + var moved = item is SimpleCommodity commodity + ? station.CargoBays.Any(bay => from.TryTransferItem(bay, commodity, commodity.Quantity)) + : station.CargoBays.Any(bay => from.TryTransferItem(bay, (CraftedItemInstance) item)); + if (!moved) return false; + credits += price; + return true; + } + + // The repair: every durability goes back to design and the station charges RepairCost. False, with nothing + // restored, when the credits fall short. + public static bool TryRepair(Entity entity, ref int credits) + { + var items = entity.ItemManager; + var cost = RepairCost(items, entity); + if (cost > credits) return false; + foreach (var gear in Gear(entity)) + if (items.GetData(gear) is EquippableItemData design && design.Durability > 0) + gear.Durability = design.Durability; + credits -= cost; + return true; + } + + // Equipment holds the hull itself, so the hull is in the sum once. + private static IEnumerable Gear(Entity entity) => + entity.Equipment.Select(equipped => equipped.EquippableItem) + .Concat(entity.CargoBays.Select(bay => bay.EquippableItem)) + .Concat(entity.DockingBays.Select(bay => bay.EquippableItem)); + + // Instance durability over the design's, for gear that wears; everything else is as good as new. + private static double Condition(ItemManager items, CraftedItemInstance item) + { + if (!(item is EquippableItem gear) || !(items.GetData(gear) is EquippableItemData design) || design.Durability <= 0) + return 1; + return Math.Max(0, Math.Min(1, gear.Durability / (double) design.Durability)); + } + + private static long RepairShare(ItemManager items, EquippableItem gear) + { + var worn = 1 - Condition(items, gear); + return (long) Math.Ceiling(worn * items.GetPrice(gear) * items.GameplaySettings.RepairFraction); + } +} diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs new file mode 100644 index 000000000..1685af834 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using Xunit; + +// What a station pays and charges, and the commits that move goods and credits. The price rules are pinned against the +// authored gameplay settings, so the defaults a Settings.asset without the new fields keeps are the ones tested. +public sealed class StationServicesTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-stationservices-" + Guid.NewGuid().ToString("N")); + private readonly CultCache _cache; + private readonly ItemManager _items; + private readonly Zone _zone; + + public StationServicesTests() + { + Directory.CreateDirectory(_root); + _cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true); + _cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + var shape = new Shape(3, 3); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var hardpoint = new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() }; + _cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Price = 1000, Durability = 10, Hardpoints = { hardpoint } }); + _cache.Upsert(new HullData { Name = "Hub", HullType = HullType.Station, Shape = shape, Price = 5000, Durability = 10 }); + _cache.Upsert(new HullData { Name = "Bare Hub", HullType = HullType.Station, Shape = shape, Price = 5000, Durability = 10 }); + _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); + _cache.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 90, Durability = 0 }); + 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 }); + _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); + _cache.FlushAsync().Wait(); + + var settings = AuthoredSettings.Load(RestoredHullsTests.FindRepoRoot()).Read("GameplaySettings"); + _items = new ItemManager(_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, float? durability = null) + { + var data = _cache.GetByName(design); + return new EquippableItem + { + Data = _cache.RefOf(data), + Durability = durability ?? data.Durability, + Lot = _items.Lots.Add(new Lot { Design = _cache.RefOf(data), Origin = new Attributed(), Quality = .5f, Roles = new List() }) + }; + } + + private SimpleCommodity Ore(int quantity) => new SimpleCommodity { Data = _cache.RefOf(_cache.GetByName("Ore")), Quantity = quantity }; + + // A ship with a cargo bay. + private Ship Ship(float? hullDurability = null) + { + var ship = new Ship(_items, _zone, Mint("Skiff", hullDurability), new EntitySettings()); + Assert.True(ship.TryEquip(Mint("Crate"), new int2(1, 1))); + return ship; + } + + // A station whose one cargo bay can take the goods, or one with no cargo bay at all. + private OrbitalEntity Station(bool withBay = true) + { + var station = new OrbitalEntity(_items, _zone, Mint(withBay ? "Hub" : "Bare Hub"), default, new EntitySettings()); + if (withBay) Assert.True(station.TryEquip(Mint("Crate"), new int2(1, 1))); + return station; + } + + [Fact] + public void SettingsDefaultsAreHalfAndAQuarter() + { + Assert.Equal(.5f, _items.GameplaySettings.SellFraction); + Assert.Equal(.25f, _items.GameplaySettings.RepairFraction); + Assert.Equal(15000000, _items.GameplaySettings.StartingCredits); + } + + [Fact] + public void SellPrice_scales_with_condition() + { + var price = _items.GetPrice(Mint("Gun")); + Assert.True(price > 100, "the fixture's gun must have a price large enough to show a half"); + Assert.Equal((int) Math.Floor(price * .5), StationServices.SellPrice(_items, Mint("Gun"))); + Assert.Equal((int) Math.Floor(price * .25), StationServices.SellPrice(_items, Mint("Gun", 4))); + Assert.Equal(0, StationServices.SellPrice(_items, Mint("Gun", 0))); + + // A design with no durability does not wear, so it sells as new however its instance reads. + Assert.Equal((int) Math.Floor(_items.GetPrice(Mint("Lamp")) * .5), StationServices.SellPrice(_items, Mint("Lamp", 0))); + // Past design durability is no better than new. + Assert.Equal(StationServices.SellPrice(_items, Mint("Gun")), StationServices.SellPrice(_items, Mint("Gun", 80))); + // A commodity has no wear: its price per unit times the quantity, at the fraction. + Assert.Equal(35, StationServices.SellPrice(_items, Ore(10))); + Assert.Equal(0, StationServices.SellPrice(_items, Ore(1))); + } + + [Fact] + public void TrySell_moves_and_pays_or_refuses() + { + var ship = Ship(); + var from = ship.CargoBays.Single(); + var gun = Mint("Gun", 4); + var ore = Ore(10); + Assert.True(from.TryStore(gun)); + Assert.True(from.TryStore(ore)); + var station = Station(); + var credits = 1000; + + Assert.True(StationServices.TrySell(from, station, gun, ref credits)); + Assert.Equal(1000 + StationServices.SellPrice(_items, Mint("Gun", 4)), credits); + Assert.DoesNotContain(gun, from.Cargo.Keys); + Assert.Contains(gun, station.CargoBays.Single().Cargo.Keys); + + credits = 1000; + Assert.True(StationServices.TrySell(from, station, ore, ref credits)); + Assert.Equal(1035, credits); + Assert.Empty(from.Cargo.Keys.OfType()); + Assert.Equal(10, station.CargoBays.Single().Cargo.Keys.OfType().Sum(c => c.Quantity)); + + // A station with nowhere to put it takes nothing and pays nothing. + var other = Mint("Gun"); + Assert.True(from.TryStore(other)); + var packed = from.Cargo.Count; + credits = 1000; + Assert.False(StationServices.TrySell(from, Station(withBay: false), other, ref credits)); + Assert.Equal(1000, credits); + Assert.Equal(packed, from.Cargo.Count); + Assert.Contains(other, from.Cargo.Keys); + } + + [Fact] + public void Repair_restores_and_charges() + { + var whole = Ship(); + Assert.True(whole.TryEquip(Mint("Gun"))); + Assert.Equal(0, StationServices.RepairCost(_items, whole)); + var credits = 50; + Assert.True(StationServices.TryRepair(whole, ref credits)); + Assert.Equal(50, credits); + + var ship = Ship(hullDurability: 5); + Assert.True(ship.TryEquip(Mint("Gun", 2))); + var cost = StationServices.RepairCost(_items, ship); + var hull = (int) Math.Ceiling(.5 * _items.GetPrice(ship.Hull) * .25); + var gun = (int) Math.Ceiling(.75 * _items.GetPrice(Mint("Gun")) * .25); + Assert.Equal(hull + gun, cost); + + var shortOfCost = cost - 1; + Assert.False(StationServices.TryRepair(ship, ref shortOfCost)); + Assert.Equal(cost - 1, shortOfCost); + Assert.Equal(5, ship.Hull.Durability); + Assert.Equal(2, ship.Equipment.Single(e => e.EquippableItem != ship.Hull).EquippableItem.Durability); + + var exact = cost; + Assert.True(StationServices.TryRepair(ship, ref exact)); + Assert.Equal(0, exact); + Assert.Equal(10, ship.Hull.Durability); + Assert.Equal(8, ship.Equipment.Single(e => e.EquippableItem != ship.Hull).EquippableItem.Durability); + Assert.Equal(0, StationServices.RepairCost(_items, ship)); + } + + [Fact] + public void Repair_counts_cargo_bays_as_gear() + { + var ship = Ship(); + ship.CargoBays.Single().EquippableItem.Durability = 1; + Assert.True(StationServices.RepairCost(_items, ship) > 0, "a worn cargo bay costs to mend"); + var credits = 1000000; + Assert.True(StationServices.TryRepair(ship, ref credits)); + Assert.Equal(4, ship.CargoBays.Single().EquippableItem.Durability); + } +} From 9651f62ad4b6c4c8632a80ac37e5ca4acb8f6127 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 19:30:32 +0200 Subject: [PATCH 02/16] The run's credits are saved and seeded: SavedGame key 13, ScenarioStage.Credits, Staged.Credits RunSave.Capture takes the credits; a scenario may set the starting amount, defaulting to the gameplay settings'. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/RunStart.cs | 3 ++- Assets/Scripts/ServerShared/SavedGame.cs | 8 +++++-- Assets/Scripts/ServerShared/ScenarioStage.cs | 4 ++++ tests/Aetheria.Shared.Tests/RunSaveTests.cs | 22 +++++++++++++++++++- tests/Aetheria.Shared.Tests/ScenarioTests.cs | 19 +++++++++++++++-- 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index 4397f4575..a8e0f6134 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -12,6 +12,7 @@ public static class RunStart public sealed class Staged { public Ship Player; + public int Credits; // the run's starting credits, as the scenario left them public List Entities; // what the scenario placed besides the player, in its order } @@ -54,7 +55,7 @@ public static class RunStart player.SetIff(placed.Entity, hostile); arena.Admit(placed.Entity, placed.Piloted); } - return new Staged { Player = player, Entities = stage.Placed.Select(placed => placed.Entity).ToList() }; + return new Staged { Player = player, Credits = stage.Credits, Entities = stage.Placed.Select(placed => placed.Entity).ToList() }; } // A new run's item draws (each lot's quality, each generated fit) come from its galaxy's seed, not the process clock, diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 87bbeda60..ba819e6ec 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -47,6 +47,9 @@ public class SavedGame [Key(12)] public FactionRelationship[] Relationships; + + [Key(13)] + public int Credits; } // The run's lifecycle over the run store. Only Commit creates SavedGame, SavedZone and ProvenanceLedger records; @@ -55,7 +58,7 @@ public static class RunSave { // The live run as plain documents. Writes nothing. public static (SavedGame Game, SavedZone[] Zones) Capture(CultCache cache, Galaxy galaxy, Zone currentZone, - Entity currentEntity, SavedActionBarBinding[] actionBar) + Entity currentEntity, SavedActionBarBinding[] actionBar, int credits) { var factions = galaxy.HomeZones.Keys.ToArray(); var game = new SavedGame @@ -75,7 +78,8 @@ public static class RunSave Entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance), Exit = Array.IndexOf(galaxy.Zones, galaxy.Exit), IsTutorial = galaxy.IsPrelude, - ActionBarBindings = actionBar + ActionBarBindings = actionBar, + Credits = credits }; // A zone generated earlier but not loaded this session has no live Contents; its packed contents carry over. diff --git a/Assets/Scripts/ServerShared/ScenarioStage.cs b/Assets/Scripts/ServerShared/ScenarioStage.cs index feac93e66..2fa28dc1f 100644 --- a/Assets/Scripts/ServerShared/ScenarioStage.cs +++ b/Assets/Scripts/ServerShared/ScenarioStage.cs @@ -24,6 +24,7 @@ public sealed class ScenarioStage Arena = arena; StartingHull = startingHull; TutorialGenerationSettings = tutorialGenerationSettings; + Credits = items.GameplaySettings.StartingCredits; // A fit names designs, not manufacturers, so what the galaxy offers decides which product builds each one. _available = new LoadoutGenerator(ref items.Random, items, arena.Galaxy, arena.GalaxyZone, null, 2).IsAvailable; } @@ -33,6 +34,9 @@ public sealed class ScenarioStage public string StartingHull { get; } public TutorialGenerationSettings TutorialGenerationSettings { get; } + // What the run starts with: the gameplay settings' starting credits unless the scenario sets another amount. + public int Credits { get; set; } + internal IReadOnlyList Failures => _failures; internal bool PlayerPlaced { get; private set; } internal Ship PlayerShip { get; private set; } diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index c8630cdfd..1f60fb4fe 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -91,7 +91,7 @@ public sealed class RunSaveTests : IDisposable var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); - var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0]); + var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0], 0); RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); } @@ -104,6 +104,26 @@ public sealed class RunSaveTests : IDisposable } } + // The run's credits ride the save: what Capture is given is what a later Continue reads from the run store. + [Fact] + public void CreditsSurviveContinue() + { + using (var cache = Open()) + RunSave.Commit(cache, Game(cache), Zones(2, StageZoneContents(cache), 0), new ProvenanceLedger()); + + using (var cache = Open()) + { + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + var itemManager = new ItemManager(cache, new ProvenanceLedger(), TestSettings(), _ => { }); + galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); + var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, new SavedActionBarBinding[0], 4242); + RunSave.Commit(cache, game, zones, RunSave.Lots(cache)); + } + + using (var cache = Open()) + Assert.Equal(4242, cache.GetGlobal().Credits); + } + [Fact] public void ClearRemovesEveryRunRecord() { diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index ddb75aa8e..44d64db50 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -108,6 +108,21 @@ public sealed partial class RunStartTests } } + // A run starts with the gameplay settings' starting credits unless its scenario sets another amount. + [Fact] + public void StagedCreditsAreTheSettingsUnlessTheScenarioSets() + { + var plain = Stage(Arena(true), new Scripted(true, stage => stage.Player(stage.Bare("Djinni"), float2(0, 0)))); + Assert.Equal(_items.GameplaySettings.StartingCredits, plain.Credits); + + var poor = Stage(Arena(true), new Scripted(true, stage => + { + stage.Credits = 1234; + stage.Player(stage.Bare("Djinni"), float2(0, 0)); + })); + Assert.Equal(1234, poor.Credits); + } + // The two game modes are what New Game did: the tutorial a prelude galaxy and a starting-hull ship of the // protagonist faction at the entrance's origin; the main galaxy a main galaxy and a starting-hull ship there. Both // arenas keep their generated ships. @@ -533,10 +548,10 @@ public sealed partial class RunStartTests public void SaveReadsPrelude() { var arena = Arena(true); - var (prelude, _) = RunSave.Capture(_cache, _galaxy, arena, null, new SavedActionBarBinding[0]); + var (prelude, _) = RunSave.Capture(_cache, _galaxy, arena, null, new SavedActionBarBinding[0], 0); Assert.True(prelude.IsTutorial); - var (main, _) = RunSave.Capture(_cache, MainSectorGalaxy(), arena, null, new SavedActionBarBinding[0]); + var (main, _) = RunSave.Capture(_cache, MainSectorGalaxy(), arena, null, new SavedActionBarBinding[0], 0); Assert.False(main.IsTutorial); } From c204397cb06720fc39bd5cbf7fe8d1ab500ee129 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 19:30:34 +0200 Subject: [PATCH 03/16] TradeMenu sells and repairs through StationServices; credits seed from the stage or the save The hardcoded credits initializer and its stale scene serialization go. Buy charges the hull price it checked. Sell is a mode of the trade list over the selected bay; Repair is a foldout option for the docked ship. Co-Authored-By: Claude Opus 5.5 --- Assets/Scenes/ARPG.unity | 1 - Assets/Scripts/Gameplay/ActionGameManager.cs | 7 ++- Assets/Scripts/UI/Menu/TradeMenu.cs | 50 ++++++++++++++++++-- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index ad5c3d536..d735258a9 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -16674,7 +16674,6 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: Settings: {fileID: 11400000, guid: cd2344e2001a37544aab0960c164d4ff, type: 2} - Credits: 100000000 TargetSpottedBlinkFrequency: 20 TargetSpottedBlinkOffset: -0.25 DeathPostTransitionTime: 1 diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index c8f0bb6e2..d65ce15c0 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -111,7 +111,8 @@ public class ActionGameManager : MonoBehaviour public GameSettings Settings; //public string StarterShipTemplate = "Longinus"; - public int Credits = 15000000; + // The run's credits: seeded from the scenario stage on a new game and from SavedGame on Continue, written by SaveRun. + [NonSerialized] public int Credits; public float TargetSpottedBlinkFrequency = 20; public float TargetSpottedBlinkOffset = -.25f; @@ -252,7 +253,7 @@ public class ActionGameManager : MonoBehaviour if (CurrentGalaxy != null) { var (game, zones) = RunSave.Capture(CultCache, CurrentGalaxy, Zone, DockedEntity ?? CurrentEntity, - _actionBarSlots.Select(s => s.Save()).ToArray()); + _actionBarSlots.Select(s => s.Save()).ToArray(), Credits); RunSave.Commit(CultCache, game, zones, ItemManager.Lots); } } @@ -774,6 +775,7 @@ public class ActionGameManager : MonoBehaviour else { var saved = CultCache.GetGlobal(); + Credits = saved.Credits; foreach(var group in CurrentGalaxy.DiscoveredZones .GroupBy(dz=>dz.Distance[CurrentGalaxy.Entrance])) SectorMap.QueueZoneReveal(group); @@ -829,6 +831,7 @@ public class ActionGameManager : MonoBehaviour MainMenu.Refuse($"{scenario.Name} could not start", string.Join("\n", failures)); return; } + Credits = staged.Credits; SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance)); BindToEntity(staged.Player); } diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index c4fe6737a..6d76a50dd 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -25,6 +25,8 @@ public class TradeMenu : MonoBehaviour public TextMeshProUGUI CreditsLabel; private EquippedCargoBay _targetCargo; + // False lists the station's wares to buy; true lists the selected bay's goods to sell. + private bool _selling; private (ItemFilter filter, HardpointType type) _hardpointFilter; private (ItemFilter filter, SimpleCommodityCategory type) _commodityFilter; private (ItemFilter filter, CompoundCommodityCategory type) _compoundCommodityFilter; @@ -36,6 +38,7 @@ public class TradeMenu : MonoBehaviour { if (GameManager.DockedEntity == null) return; _targetCargo = GameManager.DockingBay; + _selling = false; TargetCargoLabel.text = "Docking Bay"; Properties.GameManager = GameManager; UpdateCreditsLabel(); @@ -220,7 +223,7 @@ public class TradeMenu : MonoBehaviour x => () => $"{x.data.Shape.Width}x{x.data.Shape.Height}", data => data.Shape.Width*data.Shape.Height)); - var items = Inventory.Cargo.Keys + var items = (_selling ? _targetCargo : Inventory).Cargo.Keys .Select(ii=>(ii, GameManager.ItemManager.GetData(ii))); if (MinimumSizeFilter.gameObject.activeSelf) @@ -315,6 +318,7 @@ public class TradeMenu : MonoBehaviour OnClick = () => Properties.Inspect(i.item), OnDoubleClick = () => { + if (_selling) return; switch (i.item) { case CraftedItemInstance c: @@ -329,7 +333,13 @@ public class TradeMenu : MonoBehaviour }, OnRightClick = () => { - if (i.item is SimpleCommodity s) + if (_selling) + { + ContextMenu.Clear(); + ContextMenu.AddOption($"Sell ({StationServices.SellPrice(GameManager.ItemManager, i.item):N0})", () => Sell(i.item)); + ContextMenu.Show(); + } + else if (i.item is SimpleCommodity s) { ContextMenu.Clear(); ContextMenu.AddOption("Buy Quantity", @@ -363,6 +373,33 @@ public class TradeMenu : MonoBehaviour } } + private void Sell(ItemInstance item) + { + if (StationServices.TrySell(_targetCargo, GameManager.DockedEntity, item, ref GameManager.Credits)) + { + UpdateCreditsLabel(); + Populate(); + return; + } + Dialog.Clear(); + Dialog.Title.text = "Unable to sell: Station Cargo Full!"; + Dialog.Show(); + Dialog.MoveToCursor(); + } + + private void Repair() + { + if (StationServices.TryRepair(GameManager.CurrentEntity, ref GameManager.Credits)) + { + UpdateCreditsLabel(); + return; + } + Dialog.Clear(); + Dialog.Title.text = "Unable to repair: Insufficient Credits!"; + Dialog.Show(); + Dialog.MoveToCursor(); + } + private void Buy(CraftedItemInstance item) { var data = GameManager.ItemManager.GetData(item); @@ -375,7 +412,7 @@ public class TradeMenu : MonoBehaviour GameManager.CommissionShip(item as EquippableItem); - GameManager.Credits -= data.Price; + GameManager.Credits -= price; UpdateCreditsLabel(); } else if (Inventory.TryTransferItem(_targetCargo, item)) @@ -468,6 +505,13 @@ public class TradeMenu : MonoBehaviour } } } + ContextMenu.AddOption(_selling ? "Buy Goods" : "Sell Goods", () => + { + _selling = !_selling; + Populate(); + }); + var repairCost = StationServices.RepairCost(GameManager.ItemManager, GameManager.CurrentEntity); + if (repairCost > 0) ContextMenu.AddOption($"Repair ({repairCost:N0})", Repair); ContextMenu.Show(); }); } From 9308f71b78af3d6b5f0c358a9c8f8a918ec7ced3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 19:43:44 +0200 Subject: [PATCH 04/16] StationServices tests: a single unit of seven-credit ore sells for three, rounded down Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/StationServicesTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index 1685af834..b8120feb4 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -95,9 +95,9 @@ public sealed class StationServicesTests : IDisposable Assert.Equal((int) Math.Floor(_items.GetPrice(Mint("Lamp")) * .5), StationServices.SellPrice(_items, Mint("Lamp", 0))); // Past design durability is no better than new. Assert.Equal(StationServices.SellPrice(_items, Mint("Gun")), StationServices.SellPrice(_items, Mint("Gun", 80))); - // A commodity has no wear: its price per unit times the quantity, at the fraction. + // A commodity has no wear: its price per unit times the quantity, at the fraction, rounded down. Assert.Equal(35, StationServices.SellPrice(_items, Ore(10))); - Assert.Equal(0, StationServices.SellPrice(_items, Ore(1))); + Assert.Equal(3, StationServices.SellPrice(_items, Ore(1))); } [Fact] From b8175416470dbf4e482aede08bb482cbc2b6ad7d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 19:55:01 +0200 Subject: [PATCH 05/16] StationServices.cs.meta: the Unity import record for the new script Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/StationServices.cs.meta | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 Assets/Scripts/ServerShared/StationServices.cs.meta diff --git a/Assets/Scripts/ServerShared/StationServices.cs.meta b/Assets/Scripts/ServerShared/StationServices.cs.meta new file mode 100644 index 000000000..c184b64d8 --- /dev/null +++ b/Assets/Scripts/ServerShared/StationServices.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: efc90d4ec994710468cd5df6ed2c2739 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From c82b28851aec92d9eb990aa6ed0a00c09806808a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 22:49:57 +0200 Subject: [PATCH 06/16] StationServices tests: a full first station bay does not refuse a sale; worn docking bays are mended Kills the two survivors Stryker left in StationServices.cs: Any() to All() over the station's bays, and Concat() to Except() over the docking bays. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/StationServicesTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index b8120feb4..f74214264 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -31,6 +31,7 @@ public sealed class StationServicesTests : IDisposable 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 }); + _cache.Upsert(new DockingBayData { Name = "Berth", Shape = new Shape(), InteriorShape = bay, MaxSize = new int2(2, 2), Price = 60, Durability = 6 }); _cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = new Shape(), Price = 7, MaxStack = 100 }); _cache.FlushAsync().Wait(); From 1baa89645dbd1c15a70c86c60c1b86fce85e8cbe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 22:50:58 +0200 Subject: [PATCH 07/16] StationServices tests: a full first station bay does not refuse a sale; worn docking bays are mended Kills the two survivors Stryker left in StationServices.cs: Any() to All() over the station's bays, and Concat() to Except() over the docking bays. Co-Authored-By: Claude Opus 5.5 --- .../StationServicesTests.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index f74214264..31ca52b32 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -135,6 +135,29 @@ public sealed class StationServicesTests : IDisposable Assert.Contains(other, from.Cargo.Keys); } + // A station's first bay being full is no reason to refuse: the goods go to whichever bay takes them. + [Fact] + public void TrySell_uses_any_station_bay_that_takes_the_goods() + { + var ship = Ship(); + var from = ship.CargoBays.Single(); + var gun = Mint("Gun"); + var ore = Ore(10); + Assert.True(from.TryStore(gun)); + Assert.True(from.TryStore(ore)); + var station = Station(); + Assert.True(station.TryEquip(Mint("Crate"), new int2(2, 2))); + var full = station.CargoBays[0]; + var open = station.CargoBays[1]; + while (full.TryStore(Mint("Gun"))) { } + + var credits = 0; + Assert.True(StationServices.TrySell(from, station, gun, ref credits)); + Assert.True(StationServices.TrySell(from, station, ore, ref credits)); + Assert.Contains(gun, open.Cargo.Keys); + Assert.Contains(ore, open.Cargo.Keys); + } + [Fact] public void Repair_restores_and_charges() { @@ -166,6 +189,19 @@ public sealed class StationServicesTests : IDisposable Assert.Equal(0, StationServices.RepairCost(_items, ship)); } + [Fact] + public void Repair_counts_docking_bays_as_gear() + { + var ship = Ship(); + Assert.True(ship.TryEquip(Mint("Berth"), new int2(2, 2))); + var berth = ship.DockingBays.Single().EquippableItem; + berth.Durability = 3; + Assert.True(StationServices.RepairCost(_items, ship) > 0, "a worn docking bay costs to mend"); + var credits = 1000000; + Assert.True(StationServices.TryRepair(ship, ref credits)); + Assert.Equal(6, berth.Durability); + } + [Fact] public void Repair_counts_cargo_bays_as_gear() { From 864bb0c6a4879c774cecf180b1d2b61d374eb3c2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 22:53:10 +0200 Subject: [PATCH 08/16] StationServices tests: hulls five cells square, so a second bay has an interior cell to sit in Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/StationServicesTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index 31ca52b32..b91e10018 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -20,7 +20,7 @@ public sealed class StationServicesTests : IDisposable Directory.CreateDirectory(_root); _cache = AetheriaStores.Open(Path.Combine(_root, "Aetheria.cc"), catalogWritable: true); _cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); - var shape = new Shape(3, 3); + var shape = new Shape(5, 5); foreach (var cell in shape.AllCoordinates) shape[cell] = true; var hardpoint = new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() }; _cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Price = 1000, Durability = 10, Hardpoints = { hardpoint } }); From 1a42ac7758cc290bbcca5a466ef3c84c511f9280 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 22:56:05 +0200 Subject: [PATCH 09/16] StationServices.BuyPrice: the one number the station charges, which the buy path shows, checks and charges Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/StationServices.cs | 15 +++++++++++++++ .../Aetheria.Shared.Tests/StationServicesTests.cs | 14 ++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index b6b363dde..6250c8f74 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -8,6 +8,21 @@ using System.Linq; // durabilities exactly as they were. public static class StationServices { + // What the station charges for the item, or for `quantity` units of a commodity (a crafted item is one unit). The + // number TradeMenu shows, checks against the run's credits and charges. + public static int BuyPrice(ItemManager items, ItemInstance item, int quantity = 1) + { + switch (item) + { + case CraftedItemInstance crafted: + return items.GetPrice(crafted); + case SimpleCommodity commodity: + return (int) Math.Min((long) items.GetData(commodity).Price * quantity, int.MaxValue); + default: + throw new ArgumentException($"{item?.GetType().Name ?? "null"} has no buy price.", nameof(item)); + } + } + // What the station pays for the item: its price, scaled by SellFraction and by its condition. A commodity has no // wear, so it sells at the fraction alone; a simple commodity's price is per unit. public static int SellPrice(ItemManager items, ItemInstance item) diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index b91e10018..84d1b154a 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -83,6 +83,20 @@ public sealed class StationServicesTests : IDisposable Assert.Equal(15000000, _items.GameplaySettings.StartingCredits); } + [Fact] + public void BuyPrice_is_the_price_and_the_sale_never_beats_it() + { + Assert.Equal(_items.GetPrice(Mint("Gun")), StationServices.BuyPrice(_items, Mint("Gun"))); + // A worn gun costs what a new one does to buy, and pays less to sell. + Assert.Equal(StationServices.BuyPrice(_items, Mint("Gun")), StationServices.BuyPrice(_items, Mint("Gun", 4))); + // A commodity is priced per unit, times the units asked for. + Assert.Equal(7, StationServices.BuyPrice(_items, Ore(10))); + Assert.Equal(70, StationServices.BuyPrice(_items, Ore(1), 10)); + Assert.Equal(int.MaxValue, StationServices.BuyPrice(_items, Ore(1), int.MaxValue)); + Assert.True(StationServices.SellPrice(_items, Mint("Gun")) < StationServices.BuyPrice(_items, Mint("Gun"))); + Assert.Throws(() => StationServices.BuyPrice(_items, null)); + } + [Fact] public void SellPrice_scales_with_condition() { From 787715ad4a3cea417e05bcbf69de9f17c4565daf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 6 Oct 2026 22:56:06 +0200 Subject: [PATCH 10/16] TradeMenu prices every buy through StationServices.BuyPrice; the list column, the quantity cap, the check and the charge agree Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/UI/Menu/TradeMenu.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 6d76a50dd..cbe15bc51 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -217,7 +217,7 @@ public class TradeMenu : MonoBehaviour x => () => ActionGameManager.PlayerSettings.Format(x.data.Mass), data => data.Mass)); columns.Add(("Price", 1, - x => () => (x.item is CraftedItemInstance craftedItemInstance ? GameManager.ItemManager.GetPrice(craftedItemInstance) : x.data.Price).ToString("N0"), + x => () => StationServices.BuyPrice(GameManager.ItemManager, x.item).ToString("N0"), data => data.Price)); columns.Add(("Size", 1, x => () => $"{x.data.Shape.Width}x{x.data.Shape.Height}", @@ -350,7 +350,7 @@ public class TradeMenu : MonoBehaviour Dialog.Title.text = $"Buying {i.data.Name}"; Dialog.AddField("Quantity", () => quantity, - q => quantity = min(min(q, GameManager.Credits / i.data.Price), s.Quantity)); + q => quantity = min(min(q, GameManager.Credits / StationServices.BuyPrice(GameManager.ItemManager, s)), s.Quantity)); Dialog.Show(() => { Buy(s,quantity); @@ -403,7 +403,7 @@ public class TradeMenu : MonoBehaviour private void Buy(CraftedItemInstance item) { var data = GameManager.ItemManager.GetData(item); - var price = GameManager.ItemManager.GetPrice(item); + var price = StationServices.BuyPrice(GameManager.ItemManager, item); if (price < GameManager.Credits) { if (data is HullData hullData) @@ -448,11 +448,12 @@ public class TradeMenu : MonoBehaviour for (int i = 0; i < lots; i++) { int q = min(remaining, data.MaxStack); - if (q * data.Price < GameManager.Credits) + var cost = StationServices.BuyPrice(GameManager.ItemManager, simpleCommodity, q); + if (cost < GameManager.Credits) { if (Inventory.TryTransferItem(_targetCargo, simpleCommodity, quantity)) { - GameManager.Credits -= q * data.Price; + GameManager.Credits -= cost; UpdateCreditsLabel(); remaining -= q; } From 0857dbe15cb284ce7b4f5cdd87be29c02c216422 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 00:47:07 +0200 Subject: [PATCH 11/16] StationServices: repair mends armour, never costs less than the sell value it restores, and owns the buy commit RepairCost adds the worn share of hull armour priced as that share of the hull's price, and TryRepair restores it. The per-unit-of-wear charge is the larger of RepairFraction and SellFraction. TryBuy checks credits with the same boundary as TryRepair and charges after delivery. A 100-unit ore sale pins the commodity sell scale. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/StationServices.cs | 50 ++++++++++-- .../StationServicesTests.cs | 80 ++++++++++++++++++- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index 6250c8f74..ef1e791d8 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -39,15 +39,46 @@ public static class StationServices } } - // What the station charges to restore the entity's hull and every item it carries to design durability: each - // piece's share of its price in proportion to its wear, scaled by RepairFraction, rounded up. + // What the station charges to restore the entity's hull armour, hull and every item it carries to design: each + // piece's share of its price in proportion to its wear, scaled by the repair fraction, rounded up. Armour has no + // price of its own, so the worn share of the hull's total armour is charged as that share of the hull's price. public static int RepairCost(ItemManager items, Entity entity) { - long total = 0; - foreach (var gear in Gear(entity)) total += RepairShare(items, gear); + var fraction = RepairFraction(items); + long total = (long) Math.Ceiling(ArmourWear(entity) * items.GetPrice(entity.Hull) * fraction); + foreach (var gear in Gear(entity)) total += RepairShare(items, gear, fraction); return (int) Math.Min(total, int.MaxValue); } + // What the station charges for each unit of wear it mends: RepairFraction, but never less than SellFraction, so + // mending an item before selling it never pays more than the repair cost. + private static double RepairFraction(ItemManager items) => + Math.Max(items.GameplaySettings.RepairFraction, items.GameplaySettings.SellFraction); + + // The worn share of the entity's armour, 0 when it is whole or has none. + private static double ArmourWear(Entity entity) + { + double max = 0, worn = 0; + for (var x = 0; x < entity.MaxArmor.GetLength(0); x++) + for (var y = 0; y < entity.MaxArmor.GetLength(1); y++) + { + max += entity.MaxArmor[x, y]; + worn += Math.Max(0, entity.MaxArmor[x, y] - entity.Armor[x, y]); + } + return max > 0 ? worn / max : 0; + } + + // The purchase: the station charges BuyPrice, and `deliver` puts the goods where they go. Credits are checked + // and charged here, at the same boundary as TryRepair: a price equal to the credits held is affordable. + public static BuyResult TryBuy(ItemManager items, ItemInstance item, int quantity, ref int credits, Func deliver) + { + var price = BuyPrice(items, item, quantity); + if (price > credits) return BuyResult.ShortOfCredits; + if (!deliver()) return BuyResult.NoRoom; + credits -= price; + return BuyResult.Bought; + } + // 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) @@ -61,8 +92,8 @@ public static class StationServices return true; } - // The repair: every durability goes back to design and the station charges RepairCost. False, with nothing - // restored, when the credits fall short. + // The repair: armour and every durability go back to design and the station charges RepairCost. False, with + // nothing restored, when the credits fall short. public static bool TryRepair(Entity entity, ref int credits) { var items = entity.ItemManager; @@ -71,6 +102,7 @@ public static class StationServices foreach (var gear in Gear(entity)) if (items.GetData(gear) is EquippableItemData design && design.Durability > 0) gear.Durability = design.Durability; + Array.Copy(entity.MaxArmor, entity.Armor, entity.MaxArmor.Length); credits -= cost; return true; } @@ -89,9 +121,11 @@ public static class StationServices return Math.Max(0, Math.Min(1, gear.Durability / (double) design.Durability)); } - private static long RepairShare(ItemManager items, EquippableItem gear) + private static long RepairShare(ItemManager items, EquippableItem gear, double fraction) { var worn = 1 - Condition(items, gear); - return (long) Math.Ceiling(worn * items.GetPrice(gear) * items.GameplaySettings.RepairFraction); + return (long) Math.Ceiling(worn * items.GetPrice(gear) * fraction); } } + +public enum BuyResult { Bought, ShortOfCredits, NoRoom } diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index 84d1b154a..45af797f2 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -23,7 +23,7 @@ public sealed class StationServicesTests : IDisposable var shape = new Shape(5, 5); foreach (var cell in shape.AllCoordinates) shape[cell] = true; var hardpoint = new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() }; - _cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Price = 1000, Durability = 10, Hardpoints = { hardpoint } }); + _cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Price = 1000, Durability = 10, Armor = 10, Hardpoints = { hardpoint } }); _cache.Upsert(new HullData { Name = "Hub", HullType = HullType.Station, Shape = shape, Price = 5000, Durability = 10 }); _cache.Upsert(new HullData { Name = "Bare Hub", HullType = HullType.Station, Shape = shape, Price = 5000, Durability = 10 }); _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); @@ -113,6 +113,8 @@ public sealed class StationServicesTests : IDisposable // A commodity has no wear: its price per unit times the quantity, at the fraction, rounded down. Assert.Equal(35, StationServices.SellPrice(_items, Ore(10))); Assert.Equal(3, StationServices.SellPrice(_items, Ore(1))); + // A stack large enough that a scaled price shows: 7 * 100 * .5 is exactly 350, and a 2% larger fraction pays 357. + Assert.Equal(350, StationServices.SellPrice(_items, Ore(100))); } [Fact] @@ -185,8 +187,8 @@ public sealed class StationServicesTests : IDisposable var ship = Ship(hullDurability: 5); Assert.True(ship.TryEquip(Mint("Gun", 2))); var cost = StationServices.RepairCost(_items, ship); - var hull = (int) Math.Ceiling(.5 * _items.GetPrice(ship.Hull) * .25); - var gun = (int) Math.Ceiling(.75 * _items.GetPrice(Mint("Gun")) * .25); + var hull = (int) Math.Ceiling(.5 * _items.GetPrice(ship.Hull) * .5); + var gun = (int) Math.Ceiling(.75 * _items.GetPrice(Mint("Gun")) * .5); Assert.Equal(hull + gun, cost); var shortOfCost = cost - 1; @@ -226,4 +228,76 @@ public sealed class StationServicesTests : IDisposable Assert.True(StationServices.TryRepair(ship, ref credits)); Assert.Equal(4, ship.CargoBays.Single().EquippableItem.Durability); } + + [Fact] + public void Repair_restores_armour_at_a_price() + { + var ship = Ship(); + Assert.Equal(0, StationServices.RepairCost(_items, ship)); + ship.Armor[2, 2] = 0; + var cells = ship.MaxArmor.Length; + var share = 10.0 / (10.0 * cells); + var cost = StationServices.RepairCost(_items, ship); + Assert.Equal((int) Math.Ceiling(share * _items.GetPrice(ship.Hull) * .5), cost); + Assert.True(cost > 0, "armour-only wear offers a repair"); + + var short1 = cost - 1; + Assert.False(StationServices.TryRepair(ship, ref short1)); + Assert.Equal(0, ship.Armor[2, 2]); + + var exact = cost; + Assert.True(StationServices.TryRepair(ship, ref exact)); + Assert.Equal(0, exact); + Assert.Equal(10, ship.Armor[2, 2]); + Assert.Equal(0, StationServices.RepairCost(_items, ship)); + } + + [Theory] + [InlineData(0.1f)] + [InlineData(0.25f)] + [InlineData(0.5f)] + [InlineData(0.9f)] + public void Repairing_then_selling_never_nets_credits(float repairFraction) + { + _items.GameplaySettings.RepairFraction = repairFraction; + for (var durability = 0; durability < 8; durability++) + { + var ship = Ship(); + var gun = Mint("Gun", durability); + Assert.True(ship.TryEquip(gun)); + var sellWorn = StationServices.SellPrice(_items, gun); + var cost = StationServices.RepairCost(_items, ship); + var credits = cost; + Assert.True(StationServices.TryRepair(ship, ref credits)); + Assert.Equal(0, credits); + var sellFixed = StationServices.SellPrice(_items, gun); + Assert.True(sellFixed - cost <= sellWorn, $"durability {durability}: repair {cost} adds {sellFixed - sellWorn}"); + } + } + + [Fact] + public void Buy_is_affordable_at_exactly_the_credits_held() + { + var gun = Mint("Gun"); + var price = StationServices.BuyPrice(_items, gun); + var delivered = 0; + var credits = price - 1; + Assert.Equal(BuyResult.ShortOfCredits, StationServices.TryBuy(_items, gun, 1, ref credits, () => { delivered++; return true; })); + Assert.Equal(price - 1, credits); + Assert.Equal(0, delivered); + + credits = price; + Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, gun, 1, ref credits, () => { delivered++; return true; })); + Assert.Equal(0, credits); + Assert.Equal(1, delivered); + + credits = price + 5; + Assert.Equal(BuyResult.NoRoom, StationServices.TryBuy(_items, gun, 1, ref credits, () => false)); + Assert.Equal(price + 5, credits); + + var ore = Ore(10); + credits = 70; + Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, ore, 10, ref credits, () => true)); + Assert.Equal(0, credits); + } } From 68d80968e8038dcb81f72f2dea21aab0267ee158 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 00:47:08 +0200 Subject: [PATCH 12/16] TradeMenu: the Price column shows and sorts by StationServices, and buys go through TryBuy The column's sort key was the raw design price; it is now the shown price, the sell price while selling. Buy affordability and the credit charge move to StationServices.TryBuy, so a purchase costing exactly the credits held succeeds. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/UI/Menu/TradeMenu.cs | 119 ++++++++++++---------------- 1 file changed, 49 insertions(+), 70 deletions(-) diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index cbe15bc51..5c3245907 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -188,13 +188,13 @@ public class TradeMenu : MonoBehaviour void Populate() { - var columns = new List<(string name, int size, Func<(ItemInstance item, ItemData data), Func> output, Func sortKey)>(); + var columns = new List<(string name, int size, Func<(ItemInstance item, ItemData data), Func> output, Func<(ItemInstance item, ItemData data), IComparable> sortKey)>(); columns.Add(("Name", 3, x => () => x.item is CraftedItemInstance craftedItemInstance ? $"{x.data.Name}" : x.data.Name, - data => data.Name)); + x => x.data.Name)); if(_hardpointFilter.filter==null) columns.Add(("Type", 2, x => () => @@ -204,8 +204,9 @@ public class TradeMenu : MonoBehaviour if(x.data is EquippableItemData e) return Enum.GetName(typeof(HardpointType), e.HardpointType); return "None"; }, - data => + x => { + var data = x.data; if (data is SimpleCommodityData s) return (int) s.Category; var offset = Enum.GetValues(typeof(SimpleCommodityCategory)).Length; if(data is CompoundCommodityData c) return (int) c.Category + offset; @@ -215,13 +216,13 @@ public class TradeMenu : MonoBehaviour })); columns.Add(("Mass", 1, x => () => ActionGameManager.PlayerSettings.Format(x.data.Mass), - data => data.Mass)); + x => x.data.Mass)); columns.Add(("Price", 1, - x => () => StationServices.BuyPrice(GameManager.ItemManager, x.item).ToString("N0"), - data => data.Price)); + x => () => ShownPrice(x.item).ToString("N0"), + x => ShownPrice(x.item))); columns.Add(("Size", 1, x => () => $"{x.data.Shape.Width}x{x.data.Shape.Height}", - data => data.Shape.Width*data.Shape.Height)); + x => x.data.Shape.Width*x.data.Shape.Height)); var items = (_selling ? _targetCargo : Inventory).Cargo.Keys .Select(ii=>(ii, GameManager.ItemManager.GetData(ii))); @@ -257,8 +258,9 @@ public class TradeMenu : MonoBehaviour { var behavior = ((EquippableItemData) x.data).Behaviors.FirstOrDefault(b => type.IsInstanceOfType(b)); return () => ActionGameManager.PlayerSettings.Format((float) field.GetValue(behavior)); - }, data => + }, x => { + var data = x.data; var behavior = ((EquippableItemData) data).Behaviors.FirstOrDefault(b => type.IsInstanceOfType(b)); return (float) field.GetValue(behavior); })); @@ -267,8 +269,9 @@ public class TradeMenu : MonoBehaviour { var behavior = ((EquippableItemData) x.data).Behaviors.FirstOrDefault(b => type.IsInstanceOfType(b)); return () => ((int) field.GetValue(behavior)).ToString(); - }, data => + }, x => { + var data = x.data; var behavior = ((EquippableItemData) data).Behaviors.FirstOrDefault(b => type.IsInstanceOfType(b)); return (int) field.GetValue(behavior); })); @@ -278,8 +281,9 @@ public class TradeMenu : MonoBehaviour { var behavior = ((EquippableItemData) x.data).Behaviors.FirstOrDefault(b => type.IsInstanceOfType(b)); return () => ActionGameManager.PlayerSettings.Format(((PerformanceStat) field.GetValue(behavior)).Max); - }, data => + }, x => { + var data = x.data; var behavior = ((EquippableItemData) data).Behaviors.FirstOrDefault(b => type.IsInstanceOfType(b)); return ((PerformanceStat) field.GetValue(behavior)).Max; })); @@ -296,8 +300,9 @@ public class TradeMenu : MonoBehaviour return (_targetCargo.ItemsOfType.ContainsKey(GameManager.ItemManager.ItemData.RefOf(x.data).Key) ? _targetCargo.ItemsOfType[GameManager.ItemManager.ItemData.RefOf(x.data).Key].Cast().Sum(s=>s.Quantity) : 0).ToString(); return (_targetCargo.ItemsOfType.ContainsKey(GameManager.ItemManager.ItemData.RefOf(x.data).Key) ? _targetCargo.ItemsOfType[GameManager.ItemManager.ItemData.RefOf(x.data).Key].Count : 0).ToString(); }, - data => + x => { + var data = x.data; if (data is HullData) return GameManager.DockedEntity.Children.Count(s => s.Hull.Data.Key.Equals(GameManager.ItemManager.ItemData.RefOf(data).Key) && s is Ship {IsPlayerShip: true}); if(data is SimpleCommodityData) @@ -313,7 +318,7 @@ public class TradeMenu : MonoBehaviour Columns = columns.Select(x => new SpreadsheetEntryColumn { Output = x.output(i), - SortKey = x.sortKey(i.data) + SortKey = x.sortKey(i) }).ToArray(), OnClick = () => Properties.Inspect(i.item), OnDoubleClick = () => @@ -365,6 +370,11 @@ public class TradeMenu : MonoBehaviour })); } + // The price the list shows and sorts by: what the station pays for the player's goods, what it charges otherwise. + private int ShownPrice(ItemInstance item) => _selling + ? StationServices.SellPrice(GameManager.ItemManager, item) + : StationServices.BuyPrice(GameManager.ItemManager, item); + private void UpdateCreditsLabel() { if (CreditsLabel != null) @@ -403,40 +413,17 @@ public class TradeMenu : MonoBehaviour private void Buy(CraftedItemInstance item) { var data = GameManager.ItemManager.GetData(item); - var price = StationServices.BuyPrice(GameManager.ItemManager, item); - if (price < GameManager.Credits) + if (data is HullData hullData && hullData.HullType != HullType.Ship) + throw new ArgumentException("Attempted to buy non-ship hull from station, WTF are you doing?!"); + Bought(StationServices.TryBuy(GameManager.ItemManager, item, 1, ref GameManager.Credits, () => { - if (data is HullData hullData) + if (data is HullData) { - if (hullData.HullType != HullType.Ship) throw new ArgumentException("Attempted to buy non-ship hull from station, WTF are you doing?!"); - GameManager.CommissionShip(item as EquippableItem); - - GameManager.Credits -= price; - UpdateCreditsLabel(); + return true; } - else if (Inventory.TryTransferItem(_targetCargo, item)) - { - GameManager.Credits -= price; - UpdateCreditsLabel(); - } - else - { - Dialog.Clear(); - Dialog.Title.text = "Unable to buy: Insufficient Cargo Space!"; - Dialog.Show(); - Dialog.MoveToCursor(); - return; - } - } - else - { - Dialog.Clear(); - Dialog.Title.text = "Unable to buy: Insufficient Credits!"; - Dialog.Show(); - Dialog.MoveToCursor(); - return; - } + return Inventory.TryTransferItem(_targetCargo, item); + })); } private void Buy(SimpleCommodity simpleCommodity, int quantity) @@ -448,37 +435,29 @@ public class TradeMenu : MonoBehaviour for (int i = 0; i < lots; i++) { int q = min(remaining, data.MaxStack); - var cost = StationServices.BuyPrice(GameManager.ItemManager, simpleCommodity, q); - if (cost < GameManager.Credits) - { - if (Inventory.TryTransferItem(_targetCargo, simpleCommodity, quantity)) - { - GameManager.Credits -= cost; - UpdateCreditsLabel(); - remaining -= q; - } - else - { - Dialog.Clear(); - Dialog.Title.text = "Unable to buy: Insufficient Cargo Space!"; - Dialog.Show(); - Dialog.MoveToCursor(); - return; - } - } - else - { - Dialog.Clear(); - Dialog.Title.text = "Unable to buy: Insufficient Credits!"; - Dialog.Show(); - Dialog.MoveToCursor(); - return; - } - - + if (!Bought(StationServices.TryBuy(GameManager.ItemManager, simpleCommodity, q, ref GameManager.Credits, + () => Inventory.TryTransferItem(_targetCargo, simpleCommodity, quantity)))) return; + remaining -= q; } } + // Refreshes the credits after a purchase, or tells the player why the station refused it. + private bool Bought(BuyResult result) + { + if (result == BuyResult.Bought) + { + UpdateCreditsLabel(); + return true; + } + Dialog.Clear(); + Dialog.Title.text = result == BuyResult.NoRoom + ? "Unable to buy: Insufficient Cargo Space!" + : "Unable to buy: Insufficient Credits!"; + Dialog.Show(); + Dialog.MoveToCursor(); + return false; + } + void Start() { FoldoutButton.onClick.AddListener(() => From 73dd96be003246cf2ec364ad312c34f284148fe7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 02:08:37 +0200 Subject: [PATCH 13/16] StationServices: a repair share is never below the sell value it adds, rounded in the sell price's own function Selling floors price * fraction * condition in doubles while repair ceilinged a separate expression, so the two disagreed by one credit for some prices (GetPrice 360 at durability 7 of 20). SellValue is now the one sell rounding, and RepairShare charges at least SellValue(whole) - SellValue(worn), so repair-never-profits holds exactly. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/StationServices.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index ef1e791d8..a6cb4bfb5 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -31,7 +31,7 @@ public static class StationServices switch (item) { case CraftedItemInstance crafted: - return (int) Math.Floor(items.GetPrice(crafted) * fraction * Condition(items, crafted)); + return SellValue(items.GetPrice(crafted), fraction, Condition(items, crafted)); case SimpleCommodity commodity: return (int) Math.Floor((long) items.GetData(commodity).Price * commodity.Quantity * fraction); default: @@ -121,10 +121,20 @@ public static class StationServices return Math.Max(0, Math.Min(1, gear.Durability / (double) design.Durability)); } + // The sell value of a price at a condition, rounded down. The one place the sell rounding lives: RepairShare + // prices the sell value a repair adds from the same function, so the two cannot round apart. + private static int SellValue(int price, double fraction, double condition) => + (int) Math.Floor(price * fraction * condition); + + // The share of the repair charge for one piece of gear: its wear at the repair fraction, rounded up, and never + // less than the sell value the repair adds, so mending before selling never nets credits. private static long RepairShare(ItemManager items, EquippableItem gear, double fraction) { - var worn = 1 - Condition(items, gear); - return (long) Math.Ceiling(worn * items.GetPrice(gear) * fraction); + var price = items.GetPrice(gear); + var condition = Condition(items, gear); + var sellFraction = (double) items.GameplaySettings.SellFraction; + var added = SellValue(price, sellFraction, 1) - SellValue(price, sellFraction, condition); + return Math.Max(added, (long) Math.Ceiling((1 - condition) * price * fraction)); } } From 2ac420edec7893090706eba5d0abccf7a5e92e33 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 02:08:39 +0200 Subject: [PATCH 14/16] StationServicesTests: pin the repair-sell seam and the armour and gear repair prices A price and durability sweep (1..3000 by 20, three fraction pairs) and the 360 at 7 of 20 case assert repairing then selling never nets a credit. Armour tests wear several cells, partial and full strip, different cell values, and check every cell is restored and the cost is the worn share of the hull price. A gear test pins the repair share at odd and even prices. Co-Authored-By: Claude Opus 5.5 --- .../StationServicesTests.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index 45af797f2..97bdbfb8b 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -27,6 +27,7 @@ public sealed class StationServicesTests : IDisposable _cache.Upsert(new HullData { Name = "Hub", HullType = HullType.Station, Shape = shape, Price = 5000, Durability = 10 }); _cache.Upsert(new HullData { Name = "Bare Hub", HullType = HullType.Station, Shape = shape, Price = 5000, Durability = 10 }); _cache.Upsert(new GearData { Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 400, Durability = 8 }); + _cache.Upsert(new GearData { Name = "Sweep", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 1, Durability = 20 }); _cache.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 90, Durability = 0 }); var bay = new Shape(4, 4); foreach (var cell in bay.AllCoordinates) bay[cell] = true; @@ -275,6 +276,128 @@ public sealed class StationServicesTests : IDisposable } } + // Mending an item and then selling it never nets even one credit over selling it worn, whatever the price, the + // wear and the fractions. Prices 1..3000 (the sell and repair roundings disagree for some of them in double + // arithmetic) at every durability of a design of 20, at the shipped fractions and two other pairs. + [Theory] + [InlineData(.5f, .25f)] + [InlineData(.3f, .1f)] + [InlineData(.7f, .2f)] + public void Repairing_then_selling_never_nets_a_credit_at_any_price_and_wear(float sell, float repair) + { + _items.GameplaySettings.SellFraction = sell; + _items.GameplaySettings.RepairFraction = repair; + var ship = Ship(); + var design = _cache.GetByName("Sweep"); + var gear = Mint("Sweep"); + Assert.True(ship.TryEquip(gear)); + for (var price = 1; price <= 3000; price++) + { + design.Price = price; + for (var durability = 0; durability < 20; durability++) + { + gear.Durability = durability; + var sellWorn = StationServices.SellPrice(_items, gear); + var cost = StationServices.RepairCost(_items, ship); + var credits = cost; + Assert.True(StationServices.TryRepair(ship, ref credits)); + var sellFixed = StationServices.SellPrice(_items, gear); + Assert.True(sellFixed - cost <= sellWorn, + $"price {_items.GetPrice(gear)} durability {durability} of 20 at {sell}/{repair}: sell worn {sellWorn}, repair {cost}, sell fixed {sellFixed}"); + } + } + } + + // The case that nets a credit in plain double arithmetic: price 360 at durability 7 of 20 sells worn for 62 but + // 180 whole, and a repair rounded up from 65 percent of 180 is 117. + [Fact] + public void Repairing_a_360_item_at_durability_7_of_20_costs_at_least_what_it_adds() + { + var design = _cache.GetByName("Sweep"); + var gear = Mint("Sweep"); + var ship = Ship(); + Assert.True(ship.TryEquip(gear)); + design.Price = Enumerable.Range(1, 3000).First(p => { design.Price = p; return _items.GetPrice(gear) == 360; }); + Assert.Equal(360, _items.GetPrice(gear)); + gear.Durability = 7; + Assert.Equal(62, StationServices.SellPrice(_items, gear)); + var cost = StationServices.RepairCost(_items, ship); + Assert.True(cost >= 180 - 62, $"repair costs {cost}, the repair adds 118"); + var credits = cost; + Assert.True(StationServices.TryRepair(ship, ref credits)); + Assert.Equal(180, StationServices.SellPrice(_items, gear)); + } + + // A ship whose armour cells hold different values, wear spread over several cells including the last. Returns the + // total armour and the worn amount. + private static (double max, double worn) Wound(Ship ship, Func armourLeft) + { + double max = 0, worn = 0; + for (var x = 0; x < ship.MaxArmor.GetLength(0); x++) + for (var y = 0; y < ship.MaxArmor.GetLength(1); y++) + { + ship.MaxArmor[x, y] = 4 + (x + 2 * y) % 5; + ship.Armor[x, y] = Math.Min(ship.MaxArmor[x, y], armourLeft(x, y)); + max += ship.MaxArmor[x, y]; + worn += ship.MaxArmor[x, y] - ship.Armor[x, y]; + } + return (max, worn); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Armour_repair_prices_the_worn_share_of_the_hull_and_restores_every_cell(int pattern) + { + var ship = Ship(); + // 0: every cell stripped. 1: a few cells stripped, one of them the last. 2: every cell a little worn. + var (max, worn) = Wound(ship, (x, y) => pattern switch + { + 0 => 0, + 1 => x + y >= 7 || (x == 0 && y == 0) || (x == 2 && y == 3) ? 0 : 99, + _ => 3 + }); + Assert.True(worn > 0 && worn <= max); + var hullPrice = _items.GetPrice(ship.Hull); + var expected = (int) Math.Ceiling(worn / max * hullPrice * .5); + var cost = StationServices.RepairCost(_items, ship); + Assert.Equal(expected, cost); + if (pattern == 0) Assert.Equal((int) Math.Ceiling(hullPrice * .5), cost); + + var credits = cost; + Assert.True(StationServices.TryRepair(ship, ref credits)); + Assert.Equal(0, credits); + for (var x = 0; x < ship.MaxArmor.GetLength(0); x++) + for (var y = 0; y < ship.MaxArmor.GetLength(1); y++) + Assert.Equal(ship.MaxArmor[x, y], ship.Armor[x, y]); + Assert.Equal(0, StationServices.RepairCost(_items, ship)); + } + + // Gear alone is charged its wear at the repair fraction, rounded up, never below the sell value mending adds: + // odd and even prices at several wears, so a price off by one shows in the rounding. + [Fact] + public void Gear_repair_is_priced_from_the_gear_price_at_every_wear() + { + var ship = Ship(); + var design = _cache.GetByName("Sweep"); + var gear = Mint("Sweep"); + Assert.True(ship.TryEquip(gear)); + foreach (var dataPrice in new[] { 3, 11, 37, 101, 250, 777, 1001 }) + { + design.Price = dataPrice; + var price = _items.GetPrice(gear); + foreach (var durability in new[] { 0, 1, 5, 10, 19 }) + { + gear.Durability = durability; + var wear = 1 - durability / 20.0; + var added = (int) Math.Floor(price * .5) - (int) Math.Floor(price * .5 * durability / 20.0); + var expected = Math.Max(added, (int) Math.Ceiling(wear * price * .5)); + Assert.Equal(expected, StationServices.RepairCost(_items, ship)); + } + } + } + [Fact] public void Buy_is_affordable_at_exactly_the_credits_held() { From 467a50431a47844721843611b8f9ee6007958e20 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 03:01:19 +0200 Subject: [PATCH 15/16] StationServicesTests: armour repair is priced at odd and even hull prices An armour price one credit low survived: ceil absorbs it at the fixture's even hull price. The armour test now runs the same wear patterns at five hull prices. Co-Authored-By: Claude Opus 5.5 --- .../StationServicesTests.cs | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index 97bdbfb8b..4d90af690 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -344,34 +344,40 @@ public sealed class StationServicesTests : IDisposable return (max, worn); } + // Hull prices odd and even, so an armour price off by one in either direction moves the rounded cost. [Theory] [InlineData(0)] [InlineData(1)] [InlineData(2)] public void Armour_repair_prices_the_worn_share_of_the_hull_and_restores_every_cell(int pattern) { - var ship = Ship(); - // 0: every cell stripped. 1: a few cells stripped, one of them the last. 2: every cell a little worn. - var (max, worn) = Wound(ship, (x, y) => pattern switch + var skiff = _cache.GetByName("Skiff"); + foreach (var hullPrice in new[] { 1000, 1001, 1337, 777, 4 }) { - 0 => 0, - 1 => x + y >= 7 || (x == 0 && y == 0) || (x == 2 && y == 3) ? 0 : 99, - _ => 3 - }); - Assert.True(worn > 0 && worn <= max); - var hullPrice = _items.GetPrice(ship.Hull); - var expected = (int) Math.Ceiling(worn / max * hullPrice * .5); - var cost = StationServices.RepairCost(_items, ship); - Assert.Equal(expected, cost); - if (pattern == 0) Assert.Equal((int) Math.Ceiling(hullPrice * .5), cost); + skiff.Price = hullPrice; + var ship = Ship(); + // 0: every cell stripped. 1: a few cells stripped, one of them the last. 2: every cell a little worn. + var (max, worn) = Wound(ship, (x, y) => pattern switch + { + 0 => 0, + 1 => x + y >= 7 || (x == 0 && y == 0) || (x == 2 && y == 3) ? 0 : 99, + _ => 3 + }); + Assert.True(worn > 0 && worn <= max); + var price = _items.GetPrice(ship.Hull); + var expected = (int) Math.Ceiling(worn / max * price * .5); + var cost = StationServices.RepairCost(_items, ship); + Assert.Equal(expected, cost); + if (pattern == 0) Assert.Equal((int) Math.Ceiling(price * .5), cost); - var credits = cost; - Assert.True(StationServices.TryRepair(ship, ref credits)); - Assert.Equal(0, credits); - for (var x = 0; x < ship.MaxArmor.GetLength(0); x++) - for (var y = 0; y < ship.MaxArmor.GetLength(1); y++) - Assert.Equal(ship.MaxArmor[x, y], ship.Armor[x, y]); - Assert.Equal(0, StationServices.RepairCost(_items, ship)); + var credits = cost; + Assert.True(StationServices.TryRepair(ship, ref credits)); + Assert.Equal(0, credits); + for (var x = 0; x < ship.MaxArmor.GetLength(0); x++) + for (var y = 0; y < ship.MaxArmor.GetLength(1); y++) + Assert.Equal(ship.MaxArmor[x, y], ship.Armor[x, y]); + Assert.Equal(0, StationServices.RepairCost(_items, ship)); + } } // Gear alone is charged its wear at the repair fraction, rounded up, never below the sell value mending adds: From daa9a8394070c4fc395e4e9649667125f4108ab2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 04:05:50 +0200 Subject: [PATCH 16/16] StationServicesTests: pin partial-cell armour wear and a repair fraction above the sell fraction Armour repair is priced at fractional wear (a fraction of a cell on every cell, and a thousandth of one cell), and gear repair is priced exactly when RepairFraction exceeds SellFraction. Co-Authored-By: Claude Opus 5.5 --- .../StationServicesTests.cs | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index 4d90af690..587f3ee66 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -349,6 +349,8 @@ public sealed class StationServicesTests : IDisposable [InlineData(0)] [InlineData(1)] [InlineData(2)] + [InlineData(3)] + [InlineData(4)] public void Armour_repair_prices_the_worn_share_of_the_hull_and_restores_every_cell(int pattern) { var skiff = _cache.GetByName("Skiff"); @@ -357,11 +359,14 @@ public sealed class StationServicesTests : IDisposable skiff.Price = hullPrice; var ship = Ship(); // 0: every cell stripped. 1: a few cells stripped, one of them the last. 2: every cell a little worn. + // 3: every cell worn by a fraction of a cell. 4: one cell worn by a thousandth, the rest whole. var (max, worn) = Wound(ship, (x, y) => pattern switch { 0 => 0, 1 => x + y >= 7 || (x == 0 && y == 0) || (x == 2 && y == 3) ? 0 : 99, - _ => 3 + 2 => 3, + 3 => (x + y) % 3 == 0 ? 3.999f : (x + y) % 3 == 1 ? 3.5f : 3.25f, + _ => x == 2 && y == 2 ? 4.999f : 99 }); Assert.True(worn > 0 && worn <= max); var price = _items.GetPrice(ship.Hull); @@ -404,6 +409,37 @@ public sealed class StationServicesTests : IDisposable } } + // A repair fraction above the sell fraction prices wear at the repair fraction, and the sell value a repair adds + // stays at the sell fraction: the cost is the larger of the two, exactly, at every price and wear. + [Theory] + [InlineData(.3f, .6f)] + [InlineData(.5f, .9f)] + [InlineData(.1f, .75f)] + public void Gear_repair_above_the_sell_fraction_is_priced_at_the_repair_fraction(float sell, float repair) + { + _items.GameplaySettings.SellFraction = sell; + _items.GameplaySettings.RepairFraction = repair; + var ship = Ship(); + var design = _cache.GetByName("Sweep"); + var gear = Mint("Sweep"); + Assert.True(ship.TryEquip(gear)); + double sellFraction = sell, repairFraction = repair; + for (var dataPrice = 1; dataPrice <= 400; dataPrice++) + { + design.Price = dataPrice; + var price = _items.GetPrice(gear); + for (var durability = 0; durability < 20; durability++) + { + gear.Durability = durability; + var condition = durability / 20.0; + var added = (int) Math.Floor(price * sellFraction) - (int) Math.Floor(price * sellFraction * condition); + var expected = Math.Max(added, (int) Math.Ceiling((1 - condition) * price * repairFraction)); + Assert.True(expected == StationServices.RepairCost(_items, ship), + $"price {price} durability {durability} of 20 at {sell}/{repair}: expected {expected}"); + } + } + } + [Fact] public void Buy_is_affordable_at_exactly_the_credits_held() {