Merge pull request 'Mining cut 6: ore taken is visible in flight; one-click Sell Ore' (#21) from eureka/aetheria-release-mining-6 into master

This commit is contained in:
Nibu 2026-11-13 05:59:05 +00:00
commit f7fd76857e
12 changed files with 419 additions and 26 deletions

View file

@ -21236,7 +21236,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
m_IsActive: 1
--- !u!224 &1396909587
RectTransform:
m_ObjectHideFlags: 0
@ -24703,26 +24703,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: "What the fuck did you just fucking type about me, you little bitch? I\u2019ll
have you know I graduated top of my class at MIT, and I\u2019ve been involved
in numerous secret raids with Anonymous, and I have over 300 confirmed DDoSes.\n\nI
am trained in online trolling and I\u2019m the top hacker in the entire world.
You are nothing to me but just another virus host. I will wipe you the fuck out
with precision the likes of which has never been seen before on the Internet,
mark my fucking words. You think you can get away with typing that shit to me
over the Internet? Think again, fucker.\n\nAs we chat over IRC I am tracing your
IP with my damn bare hands so you better prepare for the storm, maggot. The storm
that wipes out the pathetic little thing you call your computer. You\u2019re
fucking dead, kid. I can be anywhere, anytime, and I can hack into your files
in over seven hundred ways, and that\u2019s just with my bare hands.\n\nNot only
am I extensively trained in hacking, but I have access to the entire arsenal
of every piece of malware ever created and I will use it to its full extent to
wipe your miserable ass off the face of the world wide web, you little shit.
If only you could have known what unholy retribution your little \u201Cclever\u201D
comment was about to bring down upon you, maybe you would have held your fucking
fingers. But you couldn\u2019t, you didn\u2019t, and now you\u2019re paying the
price, you goddamn idiot. I will shit code all over you and you will drown in
it.\n\nYou\u2019re fucking dead, kiddo."
m_text: ""
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 53555f7a2d5e745428813f1b1118734f, type: 2}
m_sharedMaterial: {fileID: -2804126062856497447, guid: 53555f7a2d5e745428813f1b1118734f,

View file

@ -13,7 +13,8 @@ public class EventLog : MonoBehaviour
private SizedQueue<string> LogMessages;
private StringBuilder LogText;
private void Start()
// Awake, not Start: a message can arrive in the frame the parent activates, before Start would run.
private void Awake()
{
LogMessages = new SizedQueue<string>(LineCount);
LogText = new StringBuilder();

View file

@ -1092,6 +1092,12 @@ public class ActionGameManager : MonoBehaviour
_shipSubscriptions.Add(CurrentEntity.Zone.ShotResolved
.Where(outcome => outcome.Source == CurrentEntity)
.Subscribe(outcome => _debugLastShots[outcome.Weapon] = outcome));
_shipSubscriptions.Add(CurrentEntity.Zone.CargoGained
.Where(gain => gain.Taker == CurrentEntity)
.Subscribe(gain =>
{
if (EventLog != null) EventLog.LogMessage(HaulFeed.Line(gain, ItemManager), gain.Adrift > 0 ? Color.yellow : Color.white);
}));
FollowCamera.LookAt = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint;
FollowCamera.Follow = ZoneRenderer.EntityInstances[CurrentEntity].transform;

View file

@ -1872,14 +1872,24 @@ public static class FireControl
var commodity = (SimpleCommodityData) catalog.Get(ores[i].Commodity);
var maxStack = Math.Max(1, commodity.MaxStack);
var lot = counts[i] > 0 ? shooter.ItemManager.ExtractedLot(zoneIndex, chunk.Field, commodity) : 0;
var adrift = 0;
for (var left = counts[i]; left > 0; left -= maxStack)
{
var stack = new SimpleCommodity { Data = new CultRecordRef<ItemData>(key), Lot = lot, Quantity = Math.Min(left, maxStack) };
// A partial store leaves the remainder in Quantity and returns false, so the next bay is offered the rest.
var stored = false;
if (present) foreach (var bay in shooter.CargoBays) if (stored = bay.TryStore(stack)) break;
if (!stored) zone.Release(stack, at, velocity);
if (!stored)
{
adrift += stack.Quantity;
zone.Release(stack, at, velocity);
}
}
if (counts[i] > 0)
zone.CargoGained.OnNext(new CargoGain
{
Taker = shooter, Design = new CultRecordRef<ItemData>(key), Lot = lot, Stored = counts[i] - adrift, Adrift = adrift, Kind = GainKind.Mined
});
}
}

View file

@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Linq;
using GameCult.Caching;
public enum GainKind { Mined, Collected }
// One entry into a hold: what Extract stored (and let float) of one ore, or what a pickup collected. Stored plus Adrift is
// the units; a pickup has Adrift 0. Published on Zone.CargoGained by its two writers and read by presentations only.
public struct CargoGain
{
public Entity Taker;
public CultRecordRef<ItemData> Design;
public int Lot;
public int Stored;
public int Adrift;
public GainKind Kind;
}
// The words for a cargo gain, and the taker's hold fill. Nothing here is stored: the line and the fill are derived on
// every call.
public static class HaulFeed
{
public static string Line(CargoGain gain, ItemManager items)
{
var name = items.ItemData.Get(gain.Design).Name;
var (used, total) = HoldFill(gain.Taker?.CargoBays ?? Enumerable.Empty<EquippedCargoBay>());
var hold = $"(hold {used}/{total})";
if (gain.Kind == GainKind.Collected) return $"Collected {gain.Stored} {name} {hold}";
return gain.Adrift > 0
? $"+{gain.Stored} {name}, {gain.Adrift} adrift {hold}"
: $"+{gain.Stored} {name} {hold}";
}
// Occupied cells over the bays' interiors, a multi-cell item counted per cell.
public static (int used, int total) HoldFill(IEnumerable<EquippedCargoBay> bays)
{
int used = 0, total = 0;
foreach (var bay in bays)
foreach (var v in bay.Data.InteriorShape.AllCoordinates)
{
if (!bay.Data.InteriorShape[v]) continue;
total++;
if (bay.Occupancy[v.x, v.y] != null) used++;
}
return (used, total);
}
}

View file

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

View file

@ -114,6 +114,30 @@ public static class StationServices
return true;
}
// The mined stacks of the bays: simple commodities with a lot whose origin is Extracted. Ammunition and consumables are
// simple commodities too, and a bought or staged commodity has Lot 0, which GetLot cannot resolve.
private static ItemInstance[] OreStacks(ItemManager items, IEnumerable<EquippedCargoBay> bays) =>
bays.SelectMany(bay => bay.Cargo.Keys.OfType<SimpleCommodity>().Where(stack => stack.Lot != 0 && items.GetLot(stack).Origin is Extracted).Select(stack => (ItemInstance) stack)).ToArray();
// What the station pays for every mined stack of the bays: the sum of SellPrice over the stacks TrySellOre sells.
public static int OreValue(ItemManager items, IEnumerable<EquippedCargoBay> bays) =>
(int) Math.Min(OreStacks(items, bays).Sum(stack => (long) SellPrice(items, stack)), int.MaxValue);
// Sells every mined stack of the bays, each through TrySell, so the market's absorb rule and the price are the
// single-stack path's. Returns the units sold.
public static int TrySellOre(IEnumerable<EquippedCargoBay> bays, Entity station, ref int credits)
{
var bayList = bays.ToArray();
var units = 0;
foreach (var stack in OreStacks(station.ItemManager, bayList))
{
var quantity = ((SimpleCommodity) stack).Quantity;
var from = bayList.First(bay => bay.Cargo.ContainsKey(stack));
if (TrySell(from, station, stack, ref credits)) units += quantity;
}
return units;
}
// 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)

View file

@ -90,6 +90,8 @@ public class Zone
public Subject<ShotOutcome> ShotResolved = new Subject<ShotOutcome>();
// Contacts.Step publishes each hull-on-hull contact it resolves; presentations are the only readers.
public Subject<ContactEvent> Contacted = new Subject<ContactEvent>();
// Published by FireControl.Extract and TryPickUp once ore or an item enters (or is offered to) a hold; read by presentations only.
public Subject<CargoGain> CargoGained = new Subject<CargoGain>();
private int _nextShotId;
public int NextShotId() => ++_nextShotId;
@ -441,8 +443,16 @@ public class Zone
{
var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id));
if (floating == null || (floating.Holder != null && !ReferenceEquals(floating.Holder, taker))) return false;
// The quantity is read before the store: a store that merges into a part-filled stack and spills the rest into a
// new cell rewrites the item's Quantity.
var stored = floating.Item is SimpleCommodity stack ? stack.Quantity : 1;
if (!taker.CargoBays.Any(bay => bay.TryStore(floating.Item))) return false;
FloatingItems.Remove(floating);
CargoGained.OnNext(new CargoGain
{
Taker = taker, Design = floating.Item.Data, Lot = floating.Item.Lot, Kind = GainKind.Collected,
Stored = stored
});
return true;
}

View file

@ -312,6 +312,17 @@ public class TradeMenu : MonoBehaviour
Dialog.MoveToCursor();
}
// Sells every mined stack of the player's ships' bays in one step; the station pays through TrySell, so nothing refuses.
private void SellOre()
{
StationServices.TrySellOre(PlayerBays(), GameManager.DockedEntity, ref GameManager.Credits);
UpdateCreditsLabel();
Populate();
}
private IEnumerable<EquippedCargoBay> PlayerBays() =>
GameManager.CurrentEntity.Parent.Children.Where(e => e is Ship {IsPlayerShip: true}).SelectMany(s => s.CargoBays);
private void Repair()
{
if (StationServices.TryRepair(GameManager.CurrentEntity, ref GameManager.Credits))
@ -398,6 +409,8 @@ public class TradeMenu : MonoBehaviour
});
var repairCost = StationServices.RepairCost(GameManager.ItemManager, GameManager.CurrentEntity);
if (repairCost > 0) ContextMenu.AddOption($"Repair ({repairCost:N0})", Repair);
var oreValue = StationServices.OreValue(GameManager.ItemManager, PlayerBays());
if (oreValue > 0) ContextMenu.AddOption($"Sell Ore ({oreValue:N0})", SellOre);
ContextMenu.Show();
});
}

View file

@ -3,7 +3,9 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System.Linq;
using System.Collections.Generic;
using CultMath;
using UniRx;
using Xunit;
// The demo loop on the shipped catalog (aetheria-release, mining 5a and the ore sheet): the player's own gun breaks a rock
@ -36,6 +38,8 @@ public sealed partial class RunStartTests
arena.Update(.02f);
Hold();
Assert.True(player.SetTarget(rock), "the rock is a target");
var gains = new List<CargoGain>();
arena.CargoGained.Subscribe(gains.Add);
laser.Activate();
for (var elapsed = 0f; elapsed < 20f && arena.ChunkExists(rock); elapsed += .02f) { Hold(); arena.Update(.02f); }
var info = player.ChunkInfo(rock);
@ -46,13 +50,19 @@ public sealed partial class RunStartTests
.Where(h => ores.Contains(_items.GetData(h.stack).Name)).ToArray();
Assert.NotEmpty(held);
Assert.Empty(arena.FloatingItems);
// The feed saw every unit that went into the hold and none float.
Assert.NotEmpty(gains);
Assert.All(gains, gain => Assert.Equal((GainKind.Mined, 0), (gain.Kind, gain.Adrift)));
Assert.Equal(held.Sum(h => h.stack.Quantity), gains.Sum(gain => gain.Stored));
var zenith = arena.Entities.Single(entity => entity.HullData.Name == "Zenith");
var credits = 0;
// Every ore stack goes: the market takes it all, whatever the full hold of Zenith has room for.
foreach (var (from, sold) in held) Assert.True(StationServices.TrySell(from, zenith, sold, ref credits), "the market takes the ore");
// Every mined stack goes in one sale: the market takes it all, whatever the full hold of Zenith has room for.
var value = StationServices.OreValue(_items, player.CargoBays);
Assert.True(value > 0);
Assert.Equal(gains.Sum(gain => gain.Stored), StationServices.TrySellOre(player.CargoBays, zenith, ref credits));
Assert.Equal(value, credits);
Assert.DoesNotContain(player.CargoBays.SelectMany(bay => bay.Cargo.Keys.OfType<SimpleCommodity>()), c => ores.Contains(_items.GetData(c).Name));
Assert.True(credits > 0, "the station pays for the ore");
var cell = Enumerable.Range(0, player.MaxArmor.GetLength(0)).SelectMany(x => Enumerable.Range(0, player.MaxArmor.GetLength(1)).Select(y => (x, y)))
.First(c => player.MaxArmor[c.x, c.y] > 0);

View file

@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CultMath;
using GameCult.Caching;
using UniRx;
using Xunit;
// Zone.CargoGained and HaulFeed (aetheria-release mining 6): Extract and TryPickUp publish what entered a hold, once; the feed
// words it and counts the hold. Fixtures are Mining Cut 5a's rig.
public sealed class HaulFeedTests : IDisposable
{
private readonly MiningCut5aTests _five = new MiningCut5aTests();
private readonly MiningCut4bTests _rigs = new MiningCut4bTests();
public void Dispose()
{
_five.Dispose();
_rigs.Dispose();
}
// The 5a rig: a rock of 4 units, the shooter holding one bay of the given size; the event log records every gain.
private (MiningCut4bTests.Rig Rig, EquippedCargoBay Bay, List<CargoGain> Gains) Mining(int w, int h, params (SimpleCommodityData, float)[] ores)
{
var kind = MiningCut3Tests.Kind("Asteroid", 20f);
var rig = _rigs.Build(damage: 1000f, fuse: null, blastRadius: 0f, precision: 1000f, rockSize: .5f, velocity: 0f, kind: kind, galaxySeed: 0);
MiningCut5aTests.Stock(rig.Scene, kind, ores);
var bay = MiningCut5aTests.Bay(rig.Scene, rig.Shooter, w, h, "Hold");
var gains = new List<CargoGain>();
rig.Zone.CargoGained.Subscribe(gains.Add);
return (rig, bay, gains);
}
private static CultMath.Random Dice(MiningCut4bTests.Rig rig, uint n) => SimulationDice.For(rig.Zone.DropSeed, SimulationDice.OreStream, n);
private static void Break(MiningCut4bTests.Rig rig)
{
for (var shot = 0; shot < 40 && rig.Zone.ChunkExists(rig.Rock); shot++)
{
FireControl.Fire(rig.Gun, rig.GunItem, rig.Shooter);
MiningCut4bTests.Run(rig, .05f);
}
Assert.False(rig.Zone.ChunkExists(rig.Rock));
}
[Fact]
public void AnExtractionPublishesWhatWasStoredAndWhatFloated()
{
// Room for everything.
var (rig, bay, gains) = Mining(4, 4, (MiningCut5aTests.Commodity("Rock"), 1f));
Break(rig);
var gain = Assert.Single(gains);
Assert.Equal((GainKind.Mined, 4, 0), (gain.Kind, gain.Stored, gain.Adrift));
Assert.Same(rig.Shooter, gain.Taker);
Assert.Equal(4, MiningCut5aTests.Units(bay, "Rock", rig.Scene));
Assert.NotEqual(0, gain.Lot);
// A full hold: everything floats.
var (full, fullBay, fullGains) = Mining(1, 1, (MiningCut5aTests.Commodity("Grit", maxStack: 1), 1f));
Assert.True(fullBay.TryStore(new SimpleCommodity { Data = full.Scene.Cache.RefOf<ItemData>(full.Scene.Cache.GetByName<SimpleCommodityData>("Grit")), Quantity = 1 }));
Break(full);
var overflow = Assert.Single(fullGains);
Assert.Equal((0, 4), (overflow.Stored, overflow.Adrift));
Assert.Equal(4, MiningCut5aTests.Units(full.Zone.FloatingItems.Select(f => f.Item), "Grit", full.Scene));
// A partial store: the hold takes two of four.
var (part, partBay, partGains) = Mining(1, 1, (MiningCut5aTests.Commodity("Grit", maxStack: 2), 1f));
Break(part);
var split = Assert.Single(partGains);
Assert.Equal((2, 2), (split.Stored, split.Adrift));
Assert.Equal(2, MiningCut5aTests.Units(partBay, "Grit", part.Scene));
Assert.Equal(2, MiningCut5aTests.Units(part.Zone.FloatingItems.Select(f => f.Item), "Grit", part.Scene));
}
[Fact]
public void AGoneShooterPublishesEverythingAdrift()
{
var (rig, _, gains) = Mining(4, 4, (MiningCut5aTests.Commodity("Rock"), 1f));
rig.Shooter.HeatstrokeDeath.OnNext(UniRx.Unit.Default);
var at = rig.Zone.ChunkPose(rig.Rock.Field, rig.Rock.Index).xy;
FireControl.Extract(rig.Zone, rig.Rock, rig.Zone.Wear(rig.Rock, 1e6f), rig.Shooter, Dice(rig, 1));
var gain = Assert.Single(gains);
Assert.Equal((0, 4), (gain.Stored, gain.Adrift));
}
[Fact]
public void AMineAndANullShooterPublishNothing()
{
var (rig, _, gains) = Mining(4, 4, (MiningCut5aTests.Commodity("Rock"), 1f));
FireControl.Extract(rig.Zone, rig.Rock, rig.Zone.Wear(rig.Rock, 1e6f), null, Dice(rig, 1));
Assert.Empty(gains);
Assert.Empty(rig.Zone.FloatingItems);
}
[Fact]
public void AMergeIntoAnExistingStackStillPublishes()
{
var (rig, bay, gains) = Mining(4, 4, (MiningCut5aTests.Commodity("Rock"), 1f));
var chunk = rig.Rock;
FireControl.Extract(rig.Zone, chunk, rig.Zone.Wear(chunk, 1e6f), rig.Shooter, Dice(rig, 1));
var keys = bay.Cargo.Keys.ToArray();
// Same rock field and index, same ore, so the same lot: the second extraction merges and writes no Cargo key.
FireControl.Extract(rig.Zone, chunk, 40f, rig.Shooter, Dice(rig, 2));
Assert.Equal(keys, bay.Cargo.Keys.ToArray());
Assert.Equal(2, gains.Count);
Assert.Equal(gains[0].Lot, gains[1].Lot);
Assert.True(gains[1].Stored > 0);
}
[Fact]
public void EveryOreOfOneExtractionPublishesOnce()
{
var (rig, _, gains) = Mining(8, 8, (MiningCut5aTests.Commodity("Rock"), 1f), (MiningCut5aTests.Commodity("Gold"), 1f));
rig.Zone.Settings.AsteroidYield = new ExponentialLerp { Minimum = 400f, Maximum = 400f, Exponent = 1f };
FireControl.Extract(rig.Zone, rig.Rock, rig.Zone.Wear(rig.Rock, 1e6f), rig.Shooter, Dice(rig, 3));
Assert.Equal(2, gains.Count);
Assert.Equal(2, gains.Select(g => g.Lot).Distinct().Count());
Assert.All(gains, g => Assert.True(g.Stored + g.Adrift > 0));
}
[Fact]
public void APickupPublishesCollected()
{
var grit = MiningCut5aTests.Commodity("Grit", maxStack: 2);
var (rig, _, gains) = Mining(1, 1, (grit, 1f));
Break(rig);
gains.Clear();
var floating = rig.Zone.FloatingItems.First();
var quantity = ((SimpleCommodity) floating.Item).Quantity;
// A taker with no room is refused and publishes nothing.
var packed = new Ship(rig.Scene.Items, rig.Zone, MiningCut3Tests.Mint(rig.Scene, rig.Scene.Hull), new EntitySettings());
rig.Zone.Admit(packed, piloted: false);
Assert.False(rig.Zone.TryPickUp(packed, floating.Id));
Assert.Empty(gains);
var taker = new Ship(rig.Scene.Items, rig.Zone, MiningCut3Tests.Mint(rig.Scene, rig.Scene.Hull), new EntitySettings());
rig.Zone.Admit(taker, piloted: false);
MiningCut5aTests.Bay(rig.Scene, taker, 4, 4, "Taker");
Assert.True(rig.Zone.TryPickUp(taker, floating.Id));
var gain = Assert.Single(gains);
Assert.Equal((GainKind.Collected, quantity, 0), (gain.Kind, gain.Stored, gain.Adrift));
Assert.Same(taker, gain.Taker);
}
[Fact]
public void APickupThatMergesAndSpillsReportsTheWholeStack()
{
var grit = MiningCut5aTests.Commodity("Grit", maxStack: 4);
var (rig, _, gains) = Mining(1, 1, (grit, 1f));
var taker = new Ship(rig.Scene.Items, rig.Zone, MiningCut3Tests.Mint(rig.Scene, rig.Scene.Hull), new EntitySettings());
rig.Zone.Admit(taker, piloted: false);
var bay = MiningCut5aTests.Bay(rig.Scene, taker, 2, 1, "Taker");
var design = rig.Scene.Cache.RefOf<ItemData>(grit);
Assert.True(bay.TryStore(new SimpleCommodity { Data = design, Lot = 7, Quantity = 3 }));
gains.Clear();
var floating = rig.Zone.Release(new SimpleCommodity { Data = design, Lot = 7, Quantity = 4 }, new float2(0, 0), new float2(0, 0));
Assert.True(rig.Zone.TryPickUp(taker, floating.Id));
Assert.Equal(7, MiningCut5aTests.Units(bay, "Grit", rig.Scene));
Assert.Equal((GainKind.Collected, 4, 0), (Assert.Single(gains).Kind, gains[0].Stored, gains[0].Adrift));
}
[Fact]
public void TheLineNamesWhatWasTakenAndWhatFloated()
{
var (rig, bay, _) = Mining(4, 4, (MiningCut5aTests.Commodity("Rock"), 1f));
var items = rig.Scene.Items;
var design = rig.Scene.Cache.RefOf<ItemData>(rig.Scene.Cache.GetByName<SimpleCommodityData>("Rock"));
var fill = HaulFeed.HoldFill(rig.Shooter.CargoBays);
var hold = $"hold {fill.used}/{fill.total}";
var clean = HaulFeed.Line(new CargoGain { Taker = rig.Shooter, Design = design, Stored = 3, Kind = GainKind.Mined }, items);
Assert.Contains("3", clean);
Assert.Contains("Rock", clean);
Assert.Contains(hold, clean);
Assert.DoesNotContain("adrift", clean);
var spill = HaulFeed.Line(new CargoGain { Taker = rig.Shooter, Design = design, Stored = 1, Adrift = 2, Kind = GainKind.Mined }, items);
Assert.Contains("2 adrift", spill);
Assert.Contains("Rock", spill);
var collected = HaulFeed.Line(new CargoGain { Taker = rig.Shooter, Design = design, Stored = 2, Kind = GainKind.Collected }, items);
Assert.DoesNotContain("adrift", collected);
Assert.Contains("Collected 2", collected);
Assert.Contains(hold, collected);
}
[Fact]
public void HoldFillCountsOccupiedCellsAcrossBays()
{
var (rig, bay, _) = Mining(2, 2, (MiningCut5aTests.Commodity("Rock"), 1f));
var second = MiningCut5aTests.Bay(rig.Scene, rig.Shooter, 3, 1, "Aft");
Assert.Equal((0, 7), HaulFeed.HoldFill(new[] { bay, second }));
var rock = rig.Scene.Cache.GetByName<SimpleCommodityData>("Rock");
Assert.True(bay.TryStore(new SimpleCommodity { Data = rig.Scene.Cache.RefOf<ItemData>(rock), Quantity = 1 }));
Assert.True(second.TryStore(new SimpleCommodity { Data = rig.Scene.Cache.RefOf<ItemData>(rock), Quantity = 1 }));
Assert.Equal((2, 7), HaulFeed.HoldFill(new[] { bay, second }));
}
}

View file

@ -505,4 +505,86 @@ public sealed class StationServicesTests : IDisposable
Assert.Equal(BuyResult.Bought, StationServices.TryBuy(_items, ore, 10, ref credits, () => true));
Assert.Equal(0, credits);
}
private SimpleCommodity Mined(int quantity)
{
var data = _cache.GetByName<SimpleCommodityData>("Ore");
return new SimpleCommodity { Data = _cache.RefOf<ItemData>(data), Quantity = quantity, Lot = _items.ExtractedLot(0, default, data) };
}
[Fact]
public void SellOreSellsOnlyMinedStacks()
{
var ship = Ship();
var bay = ship.CargoBays.Single();
var mined = Mined(10);
var staged = Ore(5);
var gun = Mint("Gun");
Assert.True(bay.TryStore(mined));
Assert.True(bay.TryStore(staged));
Assert.True(bay.TryStore(gun));
var station = Station();
var value = StationServices.OreValue(_items, ship.CargoBays);
Assert.Equal(StationServices.SellPrice(_items, mined), value);
Assert.True(value > 0);
var credits = 100;
Assert.Equal(10, StationServices.TrySellOre(ship.CargoBays, station, ref credits));
Assert.Equal(100 + value, credits);
Assert.DoesNotContain(mined, bay.Cargo.Keys);
Assert.Contains(staged, bay.Cargo.Keys);
Assert.Contains(gun, bay.Cargo.Keys);
}
[Fact]
public void SellOreLeavesALottedCommodityThatWasNotExtracted()
{
var ship = Ship();
var bay = ship.CargoBays.Single();
var data = _cache.GetByName<SimpleCommodityData>("Ore");
var lot = _items.Lots.Add(new Lot { Design = _cache.RefOf<ItemData>(data), Origin = new Attributed(), Quality = 1 });
Assert.NotEqual(0, lot);
var traded = new SimpleCommodity { Data = _cache.RefOf<ItemData>(data), Quantity = 6, Lot = lot };
Assert.True(bay.TryStore(traded));
var credits = 100;
Assert.Equal(0, StationServices.OreValue(_items, ship.CargoBays));
Assert.Equal(0, StationServices.TrySellOre(ship.CargoBays, Station(), ref credits));
Assert.Equal(100, credits);
Assert.Contains(traded, bay.Cargo.Keys);
}
[Fact]
public void SellOreAcrossBaysAndShips()
{
var one = Ship();
var two = Ship();
var bays = one.CargoBays.Concat(two.CargoBays).ToArray();
Assert.True(one.CargoBays.Single().TryStore(Mined(30)));
Assert.True(two.CargoBays.Single().TryStore(Mined(40)));
var station = Station();
// Fill the station's bay so nothing more fits: the market absorbs the sale at full price all the same.
for (var i = 0; i < 20; i++) station.CargoBays.Single().TryStore(Ore(100));
var value = StationServices.OreValue(_items, bays);
Assert.Equal(StationServices.SellPrice(_items, Ore(70)), value);
var credits = 0;
Assert.Equal(70, StationServices.TrySellOre(bays, station, ref credits));
Assert.Equal(value, credits);
Assert.All(bays, bay => Assert.Empty(bay.Cargo.Keys.OfType<SimpleCommodity>()));
}
[Fact]
public void SellOreWithNoOreChangesNothing()
{
var ship = Ship();
var bay = ship.CargoBays.Single();
Assert.True(bay.TryStore(Ore(5)));
Assert.True(bay.TryStore(Mint("Gun")));
var held = bay.Cargo.Count;
var credits = 100;
Assert.Equal(0, StationServices.OreValue(_items, ship.CargoBays));
Assert.Equal(0, StationServices.TrySellOre(ship.CargoBays, Station(), ref credits));
Assert.Equal(100, credits);
Assert.Equal(held, bay.Cargo.Count);
}
}