Merge station-services: sell and repair at a docked station, priced by one engine-free owner

Soul gate closed at verdict cut-station-services.s4 (head daa9a839). Merged under operator ruling aetheria-release:ruling:completed-cuts-merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-07 12:24:38 +02:00
commit cf0979481f
12 changed files with 775 additions and 80 deletions

View file

@ -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

View file

@ -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<SavedGame>();
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);
}

View file

@ -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<Entity> 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,

View file

@ -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.

View file

@ -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<string> Failures => _failures;
internal bool PlayerPlaced { get; private set; }
internal Ship PlayerShip { get; private set; }

View file

@ -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;

View file

@ -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<bool> 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<EquippableItem> 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 }

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: efc90d4ec994710468cd5df6ed2c2739
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -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<string>> output, Func<ItemData, IComparable> sortKey)>();
var columns = new List<(string name, int size, Func<(ItemInstance item, ItemData data), Func<string>> output, Func<(ItemInstance item, ItemData data), IComparable> sortKey)>();
columns.Add(("Name", 3,
x => () => x.item is CraftedItemInstance craftedItemInstance ?
$"<color=#{ColorUtility.ToHtmlStringRGB(GameManager.ItemManager.GetTier(craftedItemInstance).tier.Color.ToColor())}>{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<ItemInstance, (ItemInstance item, ItemData data)>(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<SimpleCommodity>().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();
});
}

View file

@ -91,7 +91,7 @@ public sealed class RunSaveTests : IDisposable
var galaxy = new Galaxy(cache, cache.GetGlobal<SavedGame>(), _ => { });
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<SavedGame>(), _ => { });
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<SavedGame>().Credits);
}
[Fact]
public void ClearRemovesEveryRunRecord()
{

View file

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

View file

@ -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>("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<EquippableItemData>(design);
return new EquippableItem
{
Data = _cache.RefOf<ItemData>(data),
Durability = durability ?? data.Durability,
Lot = _items.Lots.Add(new Lot { Design = _cache.RefOf<ItemData>(data), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
};
}
private SimpleCommodity Ore(int quantity) => new SimpleCommodity { Data = _cache.RefOf<ItemData>(_cache.GetByName<SimpleCommodityData>("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<ArgumentException>(() => 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<SimpleCommodity>());
Assert.Equal(10, station.CargoBays.Single().Cargo.Keys.OfType<SimpleCommodity>().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<GearData>("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<GearData>("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<int, int, float> 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<HullData>("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<GearData>("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<GearData>("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);
}
}