From cc9403ce5840e90063538603d8a349d760f91380 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 20:51:45 +0200 Subject: [PATCH] Loot-1 fix batch: whole-or-nothing commodity store, and tests for the Soul s1/s2 findings EquippedCargoBay.TryStore(SimpleCommodity) now returns false before touching anything when TryFindSpace says the whole quantity does not fit (its documented contract: true only when ALL is stored), so Zone.TryPickUp keeps "first accepting bay, otherwise nothing changes" with no rollback in Zone. Tests: refused pickup of a stackable item changes nothing; a stack plus free cell stores whole; first accepting bay; headings cover the circle; the loot stream is not the mine stream; dock/undock and jump drop once and only in the zone the ship is in. Untested on this commit: not yet built (verify job follows). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Entity.cs | 2 +- tests/Aetheria.Shared.Tests/LootTests.cs | 124 +++++++++++++++++++++++ tests/Aetheria.Shared.Tests/MineTests.cs | 34 +++++++ 3 files changed, 159 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index f38053ef5..2716cb7c3 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -2376,7 +2376,7 @@ public class EquippedCargoBay : EquippedItem // Returns true only when ALL of the items are successfully stored public bool TryStore(SimpleCommodity item) { - TryFindSpace(item, out var positions); + if (!TryFindSpace(item, out var positions)) return false; foreach (var position in positions) { if (TryStore(item, position)) return true; diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs index 02998bca7..c8a07a74d 100644 --- a/tests/Aetheria.Shared.Tests/LootTests.cs +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -384,4 +384,128 @@ public sealed class LootTests : IDisposable var restored = new Zone(lab.Items, new PlanetSettings(), pack, new GalaxyZone { Name = "Loot", Owner = null }, null); Assert.Empty(restored.FloatingItems); } + + // The death watch lives as long as the entity is a member of the zone (aetheria-release loot-1, Soul s1): an + // entity that docks and undocks, or jumps, never holds a second one and never drops in a zone it has left. + [Fact] + public void ADockedAndUndockedShipDropsEachItemOnce() + { + var lab = Build(probability: 1f); + var ship = Spawn(lab, float3(3, 0, 4)); + // What Entity.TryDock then Entity.TryUndock do to the zone: Entities.Remove + Deactivate, then Zone.Admit. + lab.Zone.Entities.Remove(ship); + ship.Deactivate(); + lab.Zone.Admit(ship, piloted: false); + lab.Zone.Entities.Remove(ship); + lab.Zone.Admit(ship, piloted: false); + Kill(ship); + var dropped = lab.Zone.FloatingItems.Select(f => f.Item).ToList(); + Assert.NotEmpty(dropped); + Assert.Equal(dropped.Count, dropped.Distinct().Count()); + } + + [Fact] + public void AShipThatJumpedDropsNothingInTheZoneItLeft() + { + var lab = Build("JumpA", probability: 1f); + var other = new Zone(lab.Items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "JumpB", Owner = null }, null); + var ship = Spawn(lab, float3(3, 0, 4)); + // What ActionGameManager does on a jump. + ship.Deactivate(); + lab.Zone.Entities.Remove(ship); + ship.Zone = other; + other.Admit(ship, piloted: false); + Kill(ship); + Assert.NotEmpty(other.FloatingItems); + Assert.Empty(lab.Zone.FloatingItems); + } + + [Fact] + public void ADeathDropsOnceAndTheRemovedShipDropsNoMore() + { + var lab = Build(probability: 1f); + var ship = Spawn(lab, float3(3, 0, 4)); + Kill(ship); + var count = lab.Zone.FloatingItems.Count; + Assert.NotEmpty(lab.Zone.FloatingItems); + Assert.DoesNotContain(ship, lab.Zone.Entities); + // A second death signal from the removed entity reaches no subscription. + Kill(ship); + Assert.Equal(count, lab.Zone.FloatingItems.Count); + } + + // TryPickUp commits whole or changes nothing: a stackable item the bays can only partly take stays whole. + [Fact] + public void ARefusedPickUpOfAStackableItemChangesNothing() + { + var lab = Build(); + var taker = Station(lab, float3(0, 0, 0), stocked: false); + var bay = taker.CargoBays.Single(); + Assert.True(bay.TryStore(lab.Ore(95))); + while (bay.TryStore(lab.Make("Spare"))) { } + var stack = bay.Cargo.Keys.OfType().Single(); + var ore = lab.Ore(30); + var floating = lab.Zone.Release(ore, new float2(1, 2), float2.zero); + var mass = taker.Mass; + for (var attempt = 0; attempt < 3; attempt++) + { + Assert.False(lab.Zone.TryPickUp(taker, floating.Id)); + Assert.Equal(30, ore.Quantity); + Assert.Equal(95, stack.Quantity); + Assert.Equal(mass, taker.Mass); + Assert.Same(floating, lab.Zone.FloatingItems.Single()); + } + } + + [Fact] + public void AStackableItemSplitAcrossAStackAndAFreeCellIsStoredWhole() + { + var lab = Build(); + var taker = Station(lab, float3(0, 0, 0), stocked: false); + var bay = taker.CargoBays.Single(); + Assert.True(bay.TryStore(lab.Ore(95))); + var floating = lab.Zone.Release(lab.Ore(30), new float2(1, 2), float2.zero); + Assert.True(lab.Zone.TryPickUp(taker, floating.Id)); + Assert.Empty(lab.Zone.FloatingItems); + Assert.Equal(125, bay.Cargo.Keys.OfType().Sum(c => c.Quantity)); + } + + [Fact] + public void PickUpFillsTheFirstAcceptingBay() + { + var lab = Build(); + var taker = Station(lab, float3(0, 0, 0), stocked: false, secondBay: true); + var item = lab.Make("Spare"); + var floating = lab.Zone.Release(item, new float2(1, 2), float2.zero); + Assert.True(lab.Zone.TryPickUp(taker, floating.Id)); + Assert.Contains(item, taker.CargoBays[0].Cargo.Keys); + Assert.DoesNotContain(item, taker.CargoBays[1].Cargo.Keys); + } + + [Fact] + public void DropHeadingsCoverTheWholeCircle() + { + var lab = Build(probability: 1f); + var velocities = new List(); + for (var i = 0; i < 40; i++) + { + var before = lab.Zone.FloatingItems.Count; + Kill(Spawn(lab, float3(i, 0, 0), cargo: false)); + velocities.AddRange(lab.Zone.FloatingItems.Skip(before).Select(f => f.Body.Velocity)); + } + Assert.Contains(velocities, v => v.x > 10); + Assert.Contains(velocities, v => v.x < -10); + Assert.Contains(velocities, v => v.y > 10); + Assert.Contains(velocities, v => v.y < -10); + Assert.True(Math.Abs(velocities.Sum(v => v.x) / velocities.Count) < 8 && Math.Abs(velocities.Sum(v => v.y) / velocities.Count) < 8, + "headings are not centred on the origin"); + } + + [Fact] + public void OneDeathsDropsLeaveOnDistinctHeadings() + { + var lab = Build(probability: 1f); + Kill(Spawn(lab, float3(1, 0, 1))); + Assert.True(lab.Zone.FloatingItems.Select(f => Math.Round(Math.Atan2(f.Body.Velocity.y, f.Body.Velocity.x), 3)).Distinct().Count() > 1); + } } diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index 1323f8541..f132de9d2 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -742,4 +742,38 @@ public sealed class MineTests : IDisposable Assert.False(string.IsNullOrWhiteSpace(log[0])); Assert.Empty(lab.Zone.PendingShots); } + + // Loot is drawn on its own stream (aetheria-release loot-1): a mine laid with body id 1 and the zone's first + // death share ordinal 1, so only the stream tells their draws apart. The lay's first draw fixes the mine's + // turn; setting LootDropProbability just either side of that same number decides the first gate draw of the + // first death. With one stream the gate would follow the mine's turn. + private (Lab Lab, float U) LayThenReadTurn(float? probability) + { + var lab = Build(spread: 40f, velocity: 5f, layerFaction: A); + lab.Layer.Velocity = float2.zero; + lab.Layer.Direction = normalize(float2(1, 2)); + var mine = LayOne(lab); + var mount = FireControl.MountDirection(lab.WeaponItem).xz; + var turned = mine.Body.Velocity / 5f; + var degrees = (MathF.Atan2(turned.y, turned.x) - MathF.Atan2(mount.y, mount.x)) * 180f / MathF.PI; + if (probability.HasValue) lab.Items.GameplaySettings.LootDropProbability = probability.Value; + return (lab, (degrees + 20f) / 40f); + } + + private static bool FirstGearDropped(Lab lab) + { + var gear = lab.Layer.Equipment.First(e => e != lab.Layer.EquippedHull).EquippableItem; + lab.Layer.HeatstrokeDeath.OnNext(UniRx.Unit.Default); + return lab.Zone.FloatingItems.Any(f => f.Item == gear); + } + + [Fact] + public void LootDiceAreNotTheMineDice() + { + var u = LayThenReadTurn(null).U; + Assert.InRange(u, .01f, .99f); + var above = FirstGearDropped(LayThenReadTurn(u + .003f).Lab); + var below = FirstGearDropped(LayThenReadTurn(u - .003f).Lab); + Assert.False(above && !below, "the first loot gate draw equals the first mine draw"); + } }