From fa4467e9a3e1b4e389ad474ee7355601b00d6044 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 04:00:08 +0200 Subject: [PATCH] Loot 1 (simulation): Zone owns floating items, rolls death drops on SimulationDice, expires them and commits pickups Zone.Release is the one adder, StepFloatingItems and TryPickUp the only removers; DropLoot runs from the one death subscription before the entity leaves the zone. Three C# default settings. Unity's drop path is untouched (loot-1-present deletes it). Not yet built or run at this commit. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FloatingBodies.cs | 11 + Assets/Scripts/ServerShared/Settings.cs | 5 + Assets/Scripts/ServerShared/Zone.cs | 65 ++- tests/Aetheria.Shared.Tests/LootTests.cs | 388 ++++++++++++++++++ 4 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 tests/Aetheria.Shared.Tests/LootTests.cs diff --git a/Assets/Scripts/ServerShared/FloatingBodies.cs b/Assets/Scripts/ServerShared/FloatingBodies.cs index a5d26ae20..e6a873b44 100644 --- a/Assets/Scripts/ServerShared/FloatingBodies.cs +++ b/Assets/Scripts/ServerShared/FloatingBodies.cs @@ -40,3 +40,14 @@ public struct KinematicBody Position += (Velocity + Drift) * dt; } } + +// An item adrift in a zone: a death drop or jettisoned cargo. Zone.Release is its only adder; Zone's floating-item +// step (expiry) and TryPickUp are its only removers. Runtime state, never saved. +public sealed class FloatingItem +{ + public FloatingBodyId Id; + public ItemInstance Item; + public KinematicBody Body; + public double ReleasedAt; + public float Lifetime; +} diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 9c4bd459f..32914f77f 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -178,6 +178,11 @@ public class GameplaySettings public float FloatingBodyDrag = .05f; public float FloatingBodyLaunchDrag = .2f; public float FloatingBodyGravity = 1f; + // Loot: the chance each equipped item (the hull excepted) drops when its carrier dies, the launch speed of a + // dropped item, and how many seconds a floating item lasts. + public float LootDropProbability = .25f; + public float LootDropVelocity = 25f; + public float FloatingItemLifetime = 30f; public float SevereHeatstrokeRiskThreshold = .25f; public float WormholeDepth = 1000; public float WormholeExitVelocity = 20; diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 4aa2063cc..a3d390992 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -24,6 +24,10 @@ public class Zone // Vented vapour clouds, in vent order. Zone owns their existence: Vent is the one adder, StepClouds the one // remover, and Obscuration the one reader that detection uses. Runtime state, never saved. public ReactiveCollection Clouds = new ReactiveCollection(); + + // Floating items (death drops, jettisoned cargo), in release order. Zone owns their existence: Release is the one + // adder; StepFloatingItems (expiry) and TryPickUp are the only removers. Runtime state, never saved. + public ReactiveCollection FloatingItems = new ReactiveCollection(); public Dictionary Planets = new Dictionary(); public Dictionary PlanetInstances = new Dictionary(); @@ -93,6 +97,9 @@ public class Zone private uint _lastBodyId; public FloatingBodyId NextBodyId() => new FloatingBodyId(++_lastBodyId); + // The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(CombatSeed, LootStream, n). + private uint _deaths; + public Zone(ItemManager itemManager, PlanetSettings settings, ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy) { _time = pack.Time; @@ -108,7 +115,7 @@ public class Zone // in the simulation, not only in Unity's own loot-drop subscription. One subscription point covers // every join, whichever caller admits the entity (deserialization below, a jump, a spawned turret) -- // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. - Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { Entities.Remove(add.Value); add.Value.Deactivate(); })); + Entities.ObserveAdd().Subscribe(add => add.Value.Death.Subscribe(_ => { DropLoot(add.Value); Entities.Remove(add.Value); add.Value.Deactivate(); })); Targets.Add(new EntityTargets()); @@ -232,6 +239,7 @@ public class Zone StepMines(deltaTime); StepClouds(deltaTime); + StepFloatingItems(deltaTime); // Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that // firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and @@ -252,6 +260,61 @@ public class Zone Clouds.Add(cloud); } + // The one adder of floating items: death drops and jettison both come here. The item drifts as a KinematicBody + // launched at `velocity` and expires after FloatingItemLifetime. + public FloatingItem Release(ItemInstance item, float2 position, float2 velocity) + { + var floating = new FloatingItem + { + Id = NextBodyId(), Item = item, Body = new KinematicBody { Position = position, Velocity = velocity }, + ReleasedAt = _time, Lifetime = _itemManager.GameplaySettings.FloatingItemLifetime + }; + FloatingItems.Add(floating); + return floating; + } + + // The one commit of a pickup: the first of the taker's cargo bays that accepts the item stores it and the item + // stops floating. Otherwise nothing changes. + public bool TryPickUp(Entity taker, FloatingBodyId id) + { + var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); + if (floating == null || !taker.CargoBays.Any(bay => bay.TryStore(floating.Item))) return false; + FloatingItems.Remove(floating); + return true; + } + + // Called from the death subscription, before the entity leaves the zone: one generator per death. Each equipped + // item but the hull drops on a LootDropProbability draw; every cargo stack drops whole. Each drop leaves from the + // dead entity's position at a random heading. Draw order: Equipment order (the probability draw, then the heading + // of a dropped item), then each cargo bay's stacks in CargoBays order (a heading each). + private void DropLoot(Entity dead) + { + var settings = _itemManager.GameplaySettings; + var random = SimulationDice.For(CombatSeed, SimulationDice.LootStream, ++_deaths); + var at = new float2(dead.Position.x, dead.Position.z); + float2 Heading() { var angle = random.NextFloat() * 2f * PI; return float2(cos(angle), sin(angle)); } + foreach (var equipped in dead.Equipment.ToArray()) + { + if (equipped == dead.EquippedHull || !(random.NextFloat() < settings.LootDropProbability)) continue; + Release(equipped.EquippableItem, at, Heading() * settings.LootDropVelocity); + } + foreach (var bay in dead.CargoBays.ToArray()) + foreach (var stack in bay.Cargo.Keys.ToArray()) + Release(stack, at, Heading() * settings.LootDropVelocity); + } + + // After StepClouds and before FireControl.Step: each floating item drifts as a KinematicBody on the zone's force + // and is removed once its lifetime has run out. + private void StepFloatingItems(float deltaTime) + { + var settings = _itemManager.GameplaySettings; + foreach (var floating in FloatingItems.ToArray()) + { + floating.Body.Step(deltaTime, GetForce(floating.Body.Position), settings); + if (floating.ReleasedAt + floating.Lifetime <= _time) FloatingItems.Remove(floating); + } + } + // The fraction of a sight line's signal that survives the clouds between `a` and `b`: the product of // (1 - opacity now) over every cloud whose disc the segment touches, so 1f when none does. Derived from the // live clouds at each call and stored nowhere; Sensor.Gain is where it enters detection. diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs new file mode 100644 index 000000000..ffdea32b9 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -0,0 +1,388 @@ +/* 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 _caches = new List(); + + 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 = 4001; + + public EquippableItem Make(string name, float durability = 1) => new EquippableItem + { + Data = Items.ItemData.RefOf(Items.ItemData.GetByName(name)), Durability = durability, Lot = Lot++ + }; + + public SimpleCommodity Ore(int quantity) => new SimpleCommodity + { + Data = Items.ItemData.RefOf(Items.ItemData.GetByName("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, + PlanetSettings planets = null, float spread = 0f) + { + var settings = new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, TargetArmorInfoThreshold = .2f, 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"), 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), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + 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 CargoBayData { Name = "Tiny", Shape = new Shape(), InteriorShape = Solid(1, 1), Durability = 4 }); + cache.Upsert(new SimpleCommodityData { Name = "Ore", Shape = Solid(1, 1), MaxStack = 100 }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 4000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, planets ?? new PlanetSettings(), new ZonePack(), 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, string bay = "Crate", bool cargo = true) + { + var hull = lab.Items.ItemData.RefOf(lab.Items.ItemData.GetByName("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" }) + { + var gear = lab.Make(name); + Assert.True(ship.TryFindSpace(gear, out var pos)); + Assert.True(ship.TryEquip(gear, pos)); + } + var crate = lab.Make(bay); + 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.Entities.Add(ship); + ship.Activate(); + ship.Position = at; + return ship; + } + + private static Entity Station(Lab lab, float3 at) + { + var hull = lab.Items.ItemData.RefOf(lab.Items.ItemData.GetByName("Hub")); + var station = new OrbitalEntity(lab.Items, lab.Zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = lab.Lot++ }, default, new EntitySettings()); + var spare = lab.Make("Spare"); + Assert.True(station.TryFindSpace(spare, out var pos)); + Assert.True(station.TryEquip(spare, pos)); + var crate = lab.Make("Crate"); + Assert.True(station.TryFindSpace(crate, out var cratePos)); + Assert.True(station.TryEquip(crate, cratePos)); + Assert.True(station.CargoBays.Single().TryStore(lab.Ore(3))); + lab.Zone.Entities.Add(station); + station.Activate(); + 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))); + } + + [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(3, 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(1200)). + 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 * 3f), .5f - 3 * .0144f, .5f + 3 * .0144f); + + // 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", spread: 5f); + 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.NotEqual(0f, quiet.Lateral); + Assert.Equal(quiet, after); + } + + [Fact] + public void AFloatingItemDriftsAsABodyAndExpires() + { + var planets = new PlanetSettings { ZoneDepth = 1000, ZoneDepthExponent = 1, GravityStrength = 1 }; + var lab = Build(lifetime: 3f, planets: planets); + 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 = Spawn(lab, float3(0, 0, 0), bay: "Tiny", cargo: false); + var big = lab.Make("Crate"); + Assert.True(taker.TryFindSpace(big, out var pos)); + Assert.True(taker.TryEquip(big, pos)); + Assert.Equal(2, taker.CargoBays.Count); + + var removals = 0; + using var sub = lab.Zone.FloatingItems.ObserveRemove().Subscribe(_ => removals++); + var ore = lab.Ore(5); + var floating = lab.Zone.Release(ore, new float2(1, 2), new float2(3, 4)); + + // The one-cell bay is full, so the first bay that accepts the ore is the second. + Assert.True(taker.CargoBays[0].TryStore(lab.Make("Spare"))); + Assert.True(lab.Zone.TryPickUp(taker, floating.Id)); + Assert.Equal(1, removals); + Assert.Empty(lab.Zone.FloatingItems); + Assert.Contains(ore, taker.CargoBays[1].Cargo.Keys); + Assert.DoesNotContain(ore, 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); + } +}