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