From 149c513269164e6d55c6e1465b13ee1502985ce7 Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 05:14:48 +0000 Subject: [PATCH 1/5] Mining 6 C1-C3: Zone.CargoGained, published by Extract and TryPickUp; HaulFeed words and hold fill Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 12 ++++- Assets/Scripts/ServerShared/HaulFeed.cs | 47 ++++++++++++++++++++ Assets/Scripts/ServerShared/HaulFeed.cs.meta | 11 +++++ Assets/Scripts/ServerShared/Zone.cs | 7 +++ 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 Assets/Scripts/ServerShared/HaulFeed.cs create mode 100644 Assets/Scripts/ServerShared/HaulFeed.cs.meta diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index bb6d48d6c..9102b16eb 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -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(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(key), Lot = lot, Stored = counts[i] - adrift, Adrift = adrift, Kind = GainKind.Mined + }); } } diff --git a/Assets/Scripts/ServerShared/HaulFeed.cs b/Assets/Scripts/ServerShared/HaulFeed.cs new file mode 100644 index 000000000..b6f4055c8 --- /dev/null +++ b/Assets/Scripts/ServerShared/HaulFeed.cs @@ -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 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()); + 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 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); + } +} diff --git a/Assets/Scripts/ServerShared/HaulFeed.cs.meta b/Assets/Scripts/ServerShared/HaulFeed.cs.meta new file mode 100644 index 000000000..b9b4b88b6 --- /dev/null +++ b/Assets/Scripts/ServerShared/HaulFeed.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4d3ef606ec5240119cbb3d7dd51123d9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 7cbde82ef..0d729d2be 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -90,6 +90,8 @@ public class Zone public Subject ShotResolved = new Subject(); // Contacts.Step publishes each hull-on-hull contact it resolves; presentations are the only readers. public Subject Contacted = new Subject(); + // Published by FireControl.Extract and TryPickUp once ore or an item enters (or is offered to) a hold; read by presentations only. + public Subject CargoGained = new Subject(); private int _nextShotId; public int NextShotId() => ++_nextShotId; @@ -443,6 +445,11 @@ public class Zone if (floating == null || (floating.Holder != null && !ReferenceEquals(floating.Holder, taker))) return false; 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 = floating.Item is SimpleCommodity stack ? stack.Quantity : 1 + }); return true; } -- 2.47.3 From d09850cfce116ed3c61512ab9484171293101a0d Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 05:14:48 +0000 Subject: [PATCH 2/5] Mining 6 C4: StationServices.OreValue and TrySellOre sell mined stacks through TrySell Co-Authored-By: Claude Sonnet 5.5 --- .../Scripts/ServerShared/StationServices.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Assets/Scripts/ServerShared/StationServices.cs b/Assets/Scripts/ServerShared/StationServices.cs index be15fc74e..dace9288e 100644 --- a/Assets/Scripts/ServerShared/StationServices.cs +++ b/Assets/Scripts/ServerShared/StationServices.cs @@ -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 bays) => + bays.SelectMany(bay => bay.Cargo.Keys.OfType().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 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 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) -- 2.47.3 From 1662c53b9c0ac38f3b947a6584652d4e26f53b33 Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 05:16:35 +0000 Subject: [PATCH 3/5] Mining 6 tests: CargoGained from Extract and TryPickUp, HaulFeed, TrySellOre, DemoLoop sells ore in one step Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/DemoLoopTests.cs | 16 +- tests/Aetheria.Shared.Tests/HaulFeedTests.cs | 178 ++++++++++++++++++ .../StationServicesTests.cs | 65 +++++++ 3 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/HaulFeedTests.cs diff --git a/tests/Aetheria.Shared.Tests/DemoLoopTests.cs b/tests/Aetheria.Shared.Tests/DemoLoopTests.cs index 7e765174c..a2daf444a 100644 --- a/tests/Aetheria.Shared.Tests/DemoLoopTests.cs +++ b/tests/Aetheria.Shared.Tests/DemoLoopTests.cs @@ -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(); + 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()), 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); diff --git a/tests/Aetheria.Shared.Tests/HaulFeedTests.cs b/tests/Aetheria.Shared.Tests/HaulFeedTests.cs new file mode 100644 index 000000000..7157020b7 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/HaulFeedTests.cs @@ -0,0 +1,178 @@ +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 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(); + 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(full.Scene.Cache.GetByName("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 TheLineNamesWhatWasTakenAndWhatFloated() + { + var (rig, bay, _) = Mining(4, 4, (MiningCut5aTests.Commodity("Rock"), 1f)); + var items = rig.Scene.Items; + var design = rig.Scene.Cache.RefOf(rig.Scene.Cache.GetByName("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); + Assert.DoesNotContain("adrift", HaulFeed.Line(new CargoGain { Taker = rig.Shooter, Design = design, Stored = 2, Kind = GainKind.Collected }, items)); + } + + [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("Rock"); + Assert.True(bay.TryStore(new SimpleCommodity { Data = rig.Scene.Cache.RefOf(rock), Quantity = 1 })); + Assert.True(second.TryStore(new SimpleCommodity { Data = rig.Scene.Cache.RefOf(rock), Quantity = 1 })); + Assert.Equal((2, 7), HaulFeed.HoldFill(new[] { bay, second })); + } +} diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index 824ef7158..b255ad71b 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -505,4 +505,69 @@ 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("Ore"); + return new SimpleCommodity { Data = _cache.RefOf(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 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())); + } + + [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); + } } -- 2.47.3 From c131045e7d9c9e19668d5b0a61a93a4d3e241d7d Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 05:32:18 +0000 Subject: [PATCH 4/5] Mining 6 C5: Event Log shows each take (CargoGained), Sell Ore foldout entry; dormant log activated, placeholder text removed Option event-log of question mining-6-feedback-surface (recommended; no ruling). Unity files are not compiled here. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scenes/ARPG.unity | 23 ++------------------ Assets/Scripts/EventLog.cs | 3 ++- Assets/Scripts/Gameplay/ActionGameManager.cs | 6 +++++ Assets/Scripts/UI/Menu/TradeMenu.cs | 13 +++++++++++ 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index 4d5b15473..f583a38ec 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -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, diff --git a/Assets/Scripts/EventLog.cs b/Assets/Scripts/EventLog.cs index 54db8dd57..967653b1d 100644 --- a/Assets/Scripts/EventLog.cs +++ b/Assets/Scripts/EventLog.cs @@ -13,7 +13,8 @@ public class EventLog : MonoBehaviour private SizedQueue 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(LineCount); LogText = new StringBuilder(); diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index a0cf3a4c1..8c2b38345 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -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; diff --git a/Assets/Scripts/UI/Menu/TradeMenu.cs b/Assets/Scripts/UI/Menu/TradeMenu.cs index 13345f88a..5f4123ff2 100644 --- a/Assets/Scripts/UI/Menu/TradeMenu.cs +++ b/Assets/Scripts/UI/Menu/TradeMenu.cs @@ -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 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(); }); } -- 2.47.3 From b5cfa3cbea7a07b12b45411ef8c3d88d65c9baca Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 05:58:28 +0000 Subject: [PATCH 5/5] Mining 6 fix: pickup reports the whole stack (read Quantity before TryStore); pin sell-ore Origin and Collected hold fill Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Zone.cs | 5 ++++- tests/Aetheria.Shared.Tests/HaulFeedTests.cs | 22 ++++++++++++++++++- .../StationServicesTests.cs | 17 ++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 0d729d2be..74d1486c3 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -443,12 +443,15 @@ 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 = floating.Item is SimpleCommodity stack ? stack.Quantity : 1 + Stored = stored }); return true; } diff --git a/tests/Aetheria.Shared.Tests/HaulFeedTests.cs b/tests/Aetheria.Shared.Tests/HaulFeedTests.cs index 7157020b7..34eb8a3db 100644 --- a/tests/Aetheria.Shared.Tests/HaulFeedTests.cs +++ b/tests/Aetheria.Shared.Tests/HaulFeedTests.cs @@ -143,6 +143,23 @@ public sealed class HaulFeedTests : IDisposable 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(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() { @@ -161,7 +178,10 @@ public sealed class HaulFeedTests : IDisposable 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); - Assert.DoesNotContain("adrift", HaulFeed.Line(new CargoGain { Taker = rig.Shooter, Design = design, Stored = 2, Kind = GainKind.Collected }, items)); + 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] diff --git a/tests/Aetheria.Shared.Tests/StationServicesTests.cs b/tests/Aetheria.Shared.Tests/StationServicesTests.cs index b255ad71b..3821a22b4 100644 --- a/tests/Aetheria.Shared.Tests/StationServicesTests.cs +++ b/tests/Aetheria.Shared.Tests/StationServicesTests.cs @@ -536,6 +536,23 @@ public sealed class StationServicesTests : IDisposable Assert.Contains(gun, bay.Cargo.Keys); } + [Fact] + public void SellOreLeavesALottedCommodityThatWasNotExtracted() + { + var ship = Ship(); + var bay = ship.CargoBays.Single(); + var data = _cache.GetByName("Ore"); + var lot = _items.Lots.Add(new Lot { Design = _cache.RefOf(data), Origin = new Attributed(), Quality = 1 }); + Assert.NotEqual(0, lot); + var traded = new SimpleCommodity { Data = _cache.RefOf(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() { -- 2.47.3