From 0857dbe15cb284ce7b4f5cdd87be29c02c216422 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 00:47:07 +0200 Subject: [PATCH] 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); + } }