Aetheria/tests/Aetheria.Shared.Tests/LootTests.cs
soul cc0c164574 determinism-ledger: every ItemManager states its seed; scatter on the zone's dice
- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it.
- RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a
  zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed.
- Continue seeds the item stream from the saved run's seed (ContinueStep);
  a scene with no galaxy uses NoRunSeed.
- Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream,
  1-based pack ordinal), never the item stream.
- Loadout's scratch manager and AetherDb's catalog command take fixed seeds;
  every test and Editor construction gains 1u (mechanical rewrite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:01:41 +02:00

648 lines
29 KiB
C#

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CultMath;
using GameCult.Caching;
using UniRx;
using Xunit;
using static CultMath.math;
using float2 = CultMath.float2;
using float3 = CultMath.float3;
using int2 = CultMath.int2;
// Loot in the sim (aetheria-release cut loot-1): a death drops gear by probability and cargo whole, rolled on the
// zone's own dice; the drops float as free bodies, expire, and are collected through Zone.TryPickUp. Rulings:
// jettison-shared-floating-items, mines-float-like-loot, one-gravity-law. Every death here goes through the
// production path: an entity's own death stream, observed by the one death subscription in Zone.
public sealed class LootTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-loot-" + Guid.NewGuid().ToString("N"));
private readonly List<CultCache> _caches = new List<CultCache>();
public LootTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
foreach (var c in _caches) c.Dispose();
Directory.Delete(_root, true);
}
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
private static Shape Solid(int w, int h)
{
var shape = new Shape(w, h);
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
return shape;
}
private sealed class Lab
{
public ItemManager Items;
public Zone Zone;
public int Lot = 1;
public EquippableItem Make(string name, float durability = 1) => new EquippableItem
{
Data = Items.ItemData.RefOf<ItemData>(Items.ItemData.GetByName<EquippableItemData>(name)), Durability = durability, Lot = Lot++
};
public SimpleCommodity Ore(int quantity) => new SimpleCommodity
{
Data = Items.ItemData.RefOf<ItemData>(Items.ItemData.GetByName<SimpleCommodityData>("Ore")), Quantity = quantity
};
public string Describe(ItemInstance item) => Items.GetData(item).Name + (item is SimpleCommodity c ? "x" + c.Quantity : "");
}
private Lab Build(string zoneName = "Loot", float probability = .5f, float lifetime = 30f, float velocity = 25f,
bool well = false, float spread = 0f, uint? runSeed = null)
{
var settings = new GameplaySettings
{
DefaultEntitySettings = new EntitySettings(),
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
QualityPriceModifier = new ExponentialLerp(),
TargetDetectionInfoThreshold = .1f, TargetGearInfoThreshold = .8f,
FiringArc = 170, CommitHorizon = .5f, SchematicCellSize = 1f,
UnaidedAccuracy = .05f, UnaidedTracking = 10f, UnaidedPrecision = 2f, AgentMinHitProbability = 0f,
LootDropProbability = probability, LootDropVelocity = velocity, FloatingItemLifetime = lifetime
};
var cache = AetheriaStores.Open(Path.Combine(_root, Guid.NewGuid().ToString("N") + ".cc"), Path.Combine(_root, Guid.NewGuid().ToString("N") + ".run.cc"), catalogWritable: true);
_caches.Add(cache);
cache.Upsert(new VerseGrammar { Revision = 1 });
var hardpoint = new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() };
cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(5, 5), Durability = 1000000, Mass = 1000, Hardpoints = { hardpoint } });
cache.Upsert(new HullData { Name = "Hub", HullType = HullType.Station, Shape = Solid(5, 5), Durability = 1000000, Mass = 1000 });
cache.Upsert(new GearData
{
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new InstantWeaponData
{
Damage = Constant(1), Range = Constant(1000), MinRange = Constant(0), Velocity = Constant(0), Spread = Constant(spread),
DamageSpread = Constant(0), Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000),
} }
});
cache.Upsert(new GearData
{
Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
Behaviors = { new TargetingSystemData { Accuracy = Constant(1), Resolution = Constant(1000), Precision = Constant(1), Tracking = Constant(100000) } }
});
// One occupied cell, so a bay can be filled with it.
cache.Upsert(new GearData
{
Name = "Spare", Hardpoint = HardpointType.Tool, Shape = Solid(1, 1), Durability = 1,
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000
});
cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = Solid(4, 4), Durability = 4 });
cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = Solid(1, 1), MaxStack = 100 });
cache.FlushAsync().Wait();
var galaxy = runSeed.HasValue ? RunSeedFixture.Continue(cache, zoneName, runSeed.Value) : null;
var ledger = new ProvenanceLedger();
for (var i = 1; i <= 9000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
var items = new ItemManager(1u, cache, ledger, settings, _ => { });
var planets = new PlanetSettings();
var pack = new ZonePack { Radius = well ? 1000 : 0 };
if (well) WellFixture.Dig(cache, pack, planets, 300, 100);
var zone = galaxy != null
? new Zone(items, planets, pack, galaxy.Zones[0], galaxy)
: new Zone(items, planets, pack, new GalaxyZone { Name = zoneName, Owner = null }, null);
return new Lab { Items = items, Zone = zone };
}
// A ship carrying a gun, a targeting system and a spare, with ore and a spare gun in its crate.
private static Ship Spawn(Lab lab, float3 at, bool cargo = true)
{
var hull = lab.Items.ItemData.RefOf<ItemData>(lab.Items.ItemData.GetByName<HullData>("Skiff"));
var ship = new Ship(lab.Items, lab.Zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = lab.Lot++ }, new EntitySettings());
Assert.True(ship.TryEquip(lab.Make("Gun"), new int2(0, 0)));
foreach (var name in new[] { "Targeting", "Spare", "Spare", "Spare", "Spare" })
{
var gear = lab.Make(name);
Assert.True(ship.TryFindSpace(gear, out var pos));
Assert.True(ship.TryEquip(gear, pos));
}
var crate = lab.Make("Crate");
Assert.True(ship.TryFindSpace(crate, out var cratePos));
Assert.True(ship.TryEquip(crate, cratePos));
if (cargo)
{
Assert.True(ship.CargoBays.Single().TryStore(lab.Ore(7)));
Assert.True(ship.CargoBays.Single().TryStore(lab.Make("Spare")));
}
lab.Zone.Admit(ship, piloted: false);
ship.Position = at;
return ship;
}
private static Entity Station(Lab lab, float3 at, bool stocked = true, bool secondBay = false)
{
var hull = lab.Items.ItemData.RefOf<ItemData>(lab.Items.ItemData.GetByName<HullData>("Hub"));
var station = new OrbitalEntity(lab.Items, lab.Zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = lab.Lot++ }, default, new EntitySettings());
Assert.True(station.TryEquip(lab.Make("Crate"), new int2(1, 1)));
// Equipping is refused once a station is active, so a second bay goes in before Activate.
if (secondBay) Assert.True(station.TryEquip(lab.Make("Crate"), new int2(2, 2)));
if (stocked)
{
var spare = lab.Make("Spare");
Assert.True(station.TryFindSpace(spare, out var pos));
Assert.True(station.TryEquip(spare, pos));
Assert.True(station.CargoBays.Single().TryStore(lab.Ore(3)));
}
lab.Zone.Admit(station, piloted: false);
station.Position = at;
return station;
}
private static void Kill(Entity e) => e.HeatstrokeDeath.OnNext(Unit.Default);
private static List<(string Item, float2 Position, float2 Velocity)> Released(Lab lab, int from) =>
lab.Zone.FloatingItems.Skip(from).Select(f => (lab.Describe(f.Item), f.Body.Position, f.Body.Velocity)).ToList();
private static void AssertSameDrops(List<(string Item, float2 Position, float2 Velocity)> a, List<(string Item, float2 Position, float2 Velocity)> b)
{
Assert.Equal(a.Count, b.Count);
for (var i = 0; i < a.Count; i++)
{
Assert.Equal(a[i].Item, b[i].Item);
Assert.Equal(a[i].Position, b[i].Position);
Assert.Equal(a[i].Velocity, b[i].Velocity);
}
}
private static bool Differ(List<(string Item, float2 Position, float2 Velocity)> a, List<(string Item, float2 Position, float2 Velocity)> b) =>
a.Count != b.Count || a.Zip(b, (x, y) => x.Item != y.Item || !x.Position.Equals(y.Position) || !x.Velocity.Equals(y.Velocity)).Any(d => d);
[Fact]
public void DeathDropsAreRolledOnTheZonesDice()
{
var at = float3(31, 0, -17);
var first = Build("Dice");
Kill(Spawn(first, at));
var dropsA = Released(first, 0);
Assert.True(dropsA.Count > 2, "the fixture must drop enough for a draw to show");
Assert.All(dropsA, d => Assert.Equal(new float2(31, -17), d.Position));
Assert.All(dropsA, d => Assert.Equal(25f, length(d.Velocity), 3));
// Another zone with the same name (the same galaxy seed) and the same ship drops the same things the same way,
// whatever the item manager's own generator has been asked in between.
var second = Build("Dice");
for (var i = 0; i < 5; i++) second.Items.Random.NextFloat();
var shipB = Spawn(second, at);
for (var i = 0; i < 5; i++) second.Items.Random.NextFloat();
Kill(shipB);
AssertSameDrops(dropsA, Released(second, 0));
// Another combat seed rolls differently.
var other = Build("Other dice");
Kill(Spawn(other, at));
Assert.True(Differ(dropsA, Released(other, 0)));
// A second death in the same zone uses the next ordinal.
var count = first.Zone.FloatingItems.Count;
Kill(Spawn(first, at));
Assert.True(Differ(dropsA, Released(first, count)));
}
private List<(string Item, float2 Position, float2 Velocity)> DropsAt(string zoneName, uint runSeed)
{
var lab = Build(zoneName, runSeed: runSeed);
Kill(Spawn(lab, float3(31, 0, -17)));
return Released(lab, 0);
}
// A new game rolls different drops from other games: the same ship, killed in the same zone, at another run seed.
[Fact]
public void ANewGameRollsDifferentDrops()
{
var one = DropsAt("Dice", 1);
Assert.True(one.Count > 2, "the fixture must drop enough for a draw to show");
Assert.True(Differ(one, DropsAt("Dice", 2)));
}
// Reloading one save repeats its drops, and the run's other zones still roll on their own names.
[Fact]
public void ReloadingASaveRepeatsItsDrops()
{
var first = DropsAt("Dice", 7);
Assert.True(first.Count > 2, "the fixture must drop enough for a draw to show");
AssertSameDrops(first, DropsAt("Dice", 7));
Assert.True(Differ(first, DropsAt("Other dice", 7)));
}
// Pinned at eb27ced1, before run-seed: a zone with no galaxy, and a zone of a save written before run-seed (seed 0),
// roll on the zone's name alone and drop exactly what they dropped.
[Theory]
[InlineData(false)]
[InlineData(true)]
public void AZoneWithoutARunSeedKeepsItsDrops(bool restoredFromAnOldSave)
{
var drops = restoredFromAnOldSave ? DropsAt("Dice", 0) : Released(KillInNewLab("Dice"), 0);
Assert.Equal(new[] { "Spare", "Spare", "Spare", "Orex7", "Spare" }, drops.Select(d => d.Item));
Assert.All(drops, d => Assert.Equal(new float2(31, -17), d.Position));
var velocities = new[]
{
float2(-14.870228f, -20.096674f), float2(-4.9265895f, -24.509768f), float2(-17.977364f, 17.372805f),
float2(-5.908107f, 24.291857f), float2(-24.228783f, -6.1616626f)
};
for (var i = 0; i < velocities.Length; i++)
{
Assert.Equal(velocities[i].x, drops[i].Velocity.x, 3);
Assert.Equal(velocities[i].y, drops[i].Velocity.y, 3);
}
}
private Lab KillInNewLab(string zoneName)
{
var lab = Build(zoneName);
Kill(Spawn(lab, float3(31, 0, -17)));
return lab;
}
[Fact]
public void GearDropsByProbabilityCargoAlways()
{
foreach (var probability in new[] { 0f, 1f })
{
var lab = Build(probability: probability);
var ship = Spawn(lab, float3(5, 0, 5));
var gear = ship.Equipment.Where(e => e != ship.EquippedHull).Select(e => (ItemInstance) e.EquippableItem).ToList();
var cargo = ship.CargoBays.Single().Cargo.Keys.ToList();
Assert.Equal(6, gear.Count);
Assert.Equal(2, cargo.Count);
Kill(ship);
var dropped = lab.Zone.FloatingItems.Select(f => f.Item).ToList();
Assert.All(cargo, c => Assert.Contains(c, dropped));
Assert.DoesNotContain(ship.EquippedHull.EquippableItem, dropped);
if (probability == 0f) Assert.Equal(cargo.Count, dropped.Count);
else
{
Assert.Equal(cargo.Count + gear.Count, dropped.Count);
Assert.All(gear, g => Assert.Contains(g, dropped));
}
}
// 400 deaths at .5: the drop rate of the equipped gear is .5 within three sigma (sigma = .5 / sqrt(2400)).
var rate = Build(probability: .5f);
var equipped = 0;
for (var i = 0; i < 400; i++)
{
var ship = Spawn(rate, float3(i, 0, 0), cargo: false);
var gear = ship.Equipment.Where(e => e != ship.EquippedHull).Select(e => (ItemInstance) e.EquippableItem).ToList();
var before = rate.Zone.FloatingItems.Count;
Kill(ship);
equipped += rate.Zone.FloatingItems.Skip(before).Count(f => gear.Contains(f.Item));
}
Assert.InRange(equipped / (400f * 6f), .5f - 3 * .0102f, .5f + 3 * .0102f);
// A station's death drops through the same path.
var hub = Build(probability: 1f);
Kill(Station(hub, float3(9, 0, 9)));
Assert.Equal(new[] { "Orex3", "Spare" }, hub.Zone.FloatingItems.Select(f => hub.Describe(f.Item)).OrderBy(x => x).ToArray());
}
// One unaimed shot with spread, and what the commit decided.
private static (ShotResult Result, int2 Cell, float Lateral, float2 Bearing) OneShot(Lab lab, Ship shooter)
{
ShotOutcome outcome = null;
using var c = lab.Zone.ShotCommitted.Subscribe(o => outcome = o);
var gun = shooter.Equipment.Single(e => lab.Items.GetData(e.EquippableItem).Name == "Gun");
((InstantWeapon) gun.Behaviors.Single(b => b is Weapon)).Activate();
lab.Zone.Update(.01f);
Assert.NotNull(outcome);
return (outcome.Result, outcome.Cell, outcome.Lateral, outcome.Bearing);
}
[Fact]
public void LootDiceDoNotTouchShotDice()
{
(ShotResult Result, int2 Cell, float Lateral, float2 Bearing) Run(bool death)
{
var lab = Build("Shots");
var shooter = Spawn(lab, float3(37, 0, -11), cargo: false);
var target = Spawn(lab, float3(37, 0, 89), cargo: false);
var bystander = Spawn(lab, float3(900, 0, 900));
shooter.SetTarget(target);
shooter.EntityInfoGathered[target] = 1f;
shooter.SetIff(target, true);
lab.Zone.Update(0f);
if (death) Kill(bystander);
return OneShot(lab, shooter);
}
var quiet = Run(false);
var after = Run(true);
Assert.Equal(ShotResult.Hit, quiet.Result);
Assert.NotEqual(0f, quiet.Lateral);
Assert.Equal(quiet, after);
}
[Fact]
public void AFloatingItemDriftsAsABodyAndExpires()
{
var lab = Build(lifetime: 3f, well: true);
var settings = lab.Items.GameplaySettings;
var start = new float2(40, -25);
var launch = new float2(6, 2);
var item = lab.Ore(1);
var floating = lab.Zone.Release(item, start, launch);
Assert.Same(item, floating.Item);
Assert.Equal(lab.Zone.Time, floating.ReleasedAt);
Assert.Equal(3f, floating.Lifetime);
Assert.True(length(lab.Zone.GetForce(start)) > 0, "the fixture's zone must pull, or drift proves nothing");
var removals = 0;
using var sub = lab.Zone.FloatingItems.ObserveRemove().Subscribe(_ => removals++);
var alone = new KinematicBody { Position = start, Velocity = launch };
var steps = 0;
while (lab.Zone.FloatingItems.Count > 0)
{
Assert.True(steps < 20, "the item must expire");
alone.Step(.5f, lab.Zone.GetForce(alone.Position), settings);
lab.Zone.Update(.5f);
steps++;
if (lab.Zone.FloatingItems.Count > 0)
{
Assert.Equal(alone.Position, floating.Body.Position);
Assert.Equal(alone.Velocity, floating.Body.Velocity);
Assert.Equal(alone.Drift, floating.Body.Drift);
}
}
// Three seconds at half-second steps: it survives five steps and goes on the sixth, not before and not after.
Assert.Equal(6, steps);
Assert.Equal(1, removals);
}
[Fact]
public void TryPickUpStoresAndRemoves()
{
var lab = Build();
var taker = Station(lab, float3(0, 0, 0), stocked: false, secondBay: true);
Assert.Equal(2, taker.CargoBays.Count);
// The first bay is full, so the first bay that accepts the item is the second.
while (taker.CargoBays[0].TryStore(lab.Make("Spare"))) { }
var removals = 0;
using var sub = lab.Zone.FloatingItems.ObserveRemove().Subscribe(_ => removals++);
var item = lab.Make("Spare");
var floating = lab.Zone.Release(item, new float2(1, 2), new float2(3, 4));
Assert.True(lab.Zone.TryPickUp(taker, floating.Id));
Assert.Equal(1, removals);
Assert.Empty(lab.Zone.FloatingItems);
Assert.Contains(item, taker.CargoBays[1].Cargo.Keys);
Assert.DoesNotContain(item, taker.CargoBays[0].Cargo.Keys);
// The same id again is unknown now.
Assert.False(lab.Zone.TryPickUp(taker, floating.Id));
// Every bay full: false, and the item floats on with its body unchanged.
while (taker.CargoBays[1].TryStore(lab.Make("Spare"))) { }
var stuck = lab.Zone.Release(lab.Make("Spare"), new float2(7, 8), new float2(1, 1));
var body = stuck.Body;
Assert.False(lab.Zone.TryPickUp(taker, stuck.Id));
Assert.Same(stuck, lab.Zone.FloatingItems.Single());
Assert.Equal(body.Position, stuck.Body.Position);
Assert.Equal(body.Velocity, stuck.Body.Velocity);
Assert.Equal(1, removals);
Assert.False(lab.Zone.TryPickUp(taker, new FloatingBodyId(9999)));
}
[Fact]
public void DropsTakeFreshBodyIds()
{
var lab = Build(probability: 1f);
Kill(Spawn(lab, float3(0, 0, 0)));
var before = lab.Zone.FloatingItems.Select(f => f.Id.Value).ToList();
var mine = new Mine { Id = lab.Zone.NextBodyId(), Body = new KinematicBody { Position = new float2(50, 50) }, Lifetime = 1000, FuseDelay = 1000, ArmingDelay = 1000 };
lab.Zone.Lay(mine);
Kill(Spawn(lab, float3(0, 0, 0)));
var all = lab.Zone.FloatingItems.Select(f => f.Id.Value).ToList();
var after = all.Skip(before.Count).ToList();
Assert.NotEmpty(before);
Assert.NotEmpty(after);
Assert.Equal(Enumerable.Range(1, all.Count + 1).Select(i => (uint) i), before.Concat(new[] { mine.Id.Value }).Concat(after));
}
[Fact]
public void FloatingItemsAreNotSaved()
{
var lab = Build(probability: 1f);
Kill(Spawn(lab, float3(0, 0, 0)));
Assert.NotEmpty(lab.Zone.FloatingItems);
var pack = lab.Zone.PackZone();
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.Remove(ship);
ship.Deactivate();
lab.Zone.Admit(ship, piloted: false);
lab.Zone.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.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.False(lab.Zone.Entities.Any(x => ReferenceEquals(x, ship)), "the ship is removed from the zone");
// 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);
}
// Membership has one owner (aetheria-release zone-entities-encapsulation): a second admission of a member is
// refused, so it never holds a second death watch and the zone never lists it twice.
[Fact]
public void AMemberAdmittedTwiceIsRefusedAndStillDropsEachItemOnce()
{
var once = Build("Once", probability: 1f);
Kill(Spawn(once, float3(3, 0, 4)));
var baseline = once.Zone.FloatingItems.Count;
Assert.NotEqual(0, baseline);
var lab = Build("Twice", probability: 1f);
var ship = Spawn(lab, float3(3, 0, 4));
Assert.Throws<InvalidOperationException>(() => lab.Zone.Admit(ship, piloted: false));
Assert.Equal(1, lab.Zone.Entities.Count(e => e == ship));
Kill(ship);
Assert.Equal(baseline, lab.Zone.FloatingItems.Count);
}
// Remove is the one removal: a non-member is refused with nothing changed, and a member's removal takes its death
// watch with it, so a death signal afterwards drops nothing and a re-admission watches exactly once.
[Fact]
public void RemovingANonMemberChangesNothingAndARemovedMembersWatchGoesWithIt()
{
var lab = Build(probability: 1f);
var ship = Spawn(lab, float3(3, 0, 4));
var stranger = Spawn(Build("Elsewhere", probability: 1f), float3(1, 0, 1));
var members = lab.Zone.Entities.Count;
Assert.False(lab.Zone.Remove(stranger));
Assert.Equal(members, lab.Zone.Entities.Count);
Assert.True(lab.Zone.Remove(ship));
Assert.False(lab.Zone.Remove(ship));
Kill(ship);
Assert.Empty(lab.Zone.FloatingItems);
ship.Deactivate();
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());
}
// The membership view has no mutating member: a caller can read it and subscribe to it, and nothing else.
[Fact]
public void TheMembershipViewOffersNoWayToMutateMembership()
{
var lab = Build();
Spawn(lab, float3(3, 0, 4));
Assert.IsNotAssignableFrom<System.Collections.Generic.ICollection<Entity>>(lab.Zone.Entities);
Assert.IsNotAssignableFrom<System.Collections.IList>(lab.Zone.Entities);
}
// The pickup is whole or nothing at the arithmetic boundary: two stacks of 90 hold 20 more in all and no free
// cell is left, so a stack of 20 is stored (quantity 200 across both) and a stack of 21 changes nothing.
[Fact]
public void APickUpOfExactlyTheRoomLeftFitsAndOneMoreChangesNothing()
{
var lab = Build();
var taker = Station(lab, float3(0, 0, 0), stocked: false);
var bay = taker.CargoBays[0];
Assert.True(bay.TryStore(lab.Ore(90), new int2(0, 0)));
Assert.True(bay.TryStore(lab.Ore(90), new int2(1, 0)));
while (bay.TryStore(lab.Make("Spare"))) { }
(int Quantity, int Cells) Held() => (bay.Cargo.Keys.OfType<SimpleCommodity>().Sum(c => c.Quantity), bay.Cargo.Count);
var before = Held();
var over = lab.Ore(21);
var overFloating = lab.Zone.Release(over, new float2(1, 1), float2.zero);
Assert.False(lab.Zone.TryPickUp(taker, overFloating.Id));
Assert.Equal(21, over.Quantity);
Assert.Equal(before, Held());
Assert.Contains(overFloating, lab.Zone.FloatingItems);
var exact = lab.Zone.Release(lab.Ore(20), new float2(1, 1), float2.zero);
Assert.True(lab.Zone.TryPickUp(taker, exact.Id));
Assert.Equal((200, before.Cells), Held());
Assert.DoesNotContain(exact, lab.Zone.FloatingItems);
}
}