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:
parent
8120419ecd
commit
cc9403ce58
3 changed files with 159 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue