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/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/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..a6cb4bfb5 --- /dev/null +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -0,0 +1,141 @@ +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 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) + { + var fraction = (double) items.GameplaySettings.SellFraction; + switch (item) + { + case CraftedItemInstance 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: + throw new ArgumentException($"{item?.GetType().Name ?? "null"} has no sell price.", nameof(item)); + } + } + + // 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) + { + 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) + { + 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: 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; + 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; + Array.Copy(entity.MaxArmor, entity.Armor, entity.MaxArmor.Length); + 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)); + } + + // 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 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)); + } +} + +public enum BuyResult { Bought, ShortOfCredits, NoRoom } 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: diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index c4fe6737a..5c3245907 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(); @@ -185,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 => () => @@ -201,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; @@ -212,15 +216,15 @@ 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 => () => (x.item is CraftedItemInstance craftedItemInstance ? GameManager.ItemManager.GetPrice(craftedItemInstance) : x.data.Price).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 = Inventory.Cargo.Keys + var items = (_selling ? _targetCargo : Inventory).Cargo.Keys .Select(ii=>(ii, GameManager.ItemManager.GetData(ii))); if (MinimumSizeFilter.gameObject.activeSelf) @@ -254,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); })); @@ -264,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); })); @@ -275,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; })); @@ -293,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) @@ -310,11 +318,12 @@ 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 = () => { + if (_selling) return; switch (i.item) { case CraftedItemInstance c: @@ -329,7 +338,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", @@ -340,7 +355,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); @@ -355,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) @@ -363,43 +383,47 @@ 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); - var price = GameManager.ItemManager.GetPrice(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 -= data.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) @@ -411,36 +435,29 @@ public class TradeMenu : MonoBehaviour for (int i = 0; i < lots; i++) { int q = min(remaining, data.MaxStack); - if (q * data.Price < GameManager.Credits) - { - if (Inventory.TryTransferItem(_targetCargo, simpleCommodity, quantity)) - { - GameManager.Credits -= q * data.Price; - 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(() => @@ -468,6 +485,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(); }); } 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); } diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs new file mode 100644 index 000000000..587f3ee66 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -0,0 +1,468 @@ +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(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, 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 }); + _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; + _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(); + + 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 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() + { + 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, 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] + 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); + } + + // 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() + { + 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) * .5); + var gun = (int) Math.Ceiling(.75 * _items.GetPrice(Mint("Gun")) * .5); + 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_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() + { + 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); + } + + [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}"); + } + } + + // 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); + } + + // 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)] + [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"); + foreach (var hullPrice in new[] { 1000, 1001, 1337, 777, 4 }) + { + 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, + 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); + 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)); + } + } + + // 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)); + } + } + } + + // 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() + { + 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); + } +}