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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 20:51:45 +02:00
commit cc9403ce58
3 changed files with 159 additions and 1 deletions

View file

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

View file

@ -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<SimpleCommodity>().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<SimpleCommodity>().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<float2>();
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);
}
}

View file

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