From fa911f2d852b72763c23cf4933e290eabd231f07 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 02:13:43 +0200 Subject: [PATCH 01/23] Zone.Entities read-only outside Zone: Admit and Remove are the only membership writers Entities is a MemberView with no mutating member; Admit installs the death watch with the membership and refuses a member already present, Remove takes it away with the membership. The Reset and duplicate-Add bypass paths of the death watch cannot be spelled any more. Tests move to Admit/Remove; Admit also activates, so the explicit Activate calls that followed a raw Add are dropped. Unity call sites route through Zone.Remove. Builds: unverified at this commit (verified in the job at the head). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/ImpactPresenterSmoke.cs | 6 +- Assets/Scripts/Gameplay/ActionGameManager.cs | 2 +- Assets/Scripts/Gameplay/GridObject.cs | 51 ------------- Assets/Scripts/Gameplay/GridObject.cs.meta | 3 - Assets/Scripts/Gameplay/TimedDestroy.cs | 19 ----- Assets/Scripts/Gameplay/TimedDestroy.cs.meta | 3 - Assets/Scripts/ServerShared/Entity.cs | 2 +- Assets/Scripts/ServerShared/Zone.cs | 73 +++++++++++-------- tests/Aetheria.Shared.Tests/BrownoutTests.cs | 3 +- .../ConditionRatioTests.cs | 3 +- .../Aetheria.Shared.Tests/ConsumableTests.cs | 3 +- .../ContactRevealTests.cs | 34 ++++----- tests/Aetheria.Shared.Tests/ContactTests.cs | 3 +- .../FireAuthorityTests.cs | 8 +- .../FireControlCut11Tests.cs | 10 +-- .../FireControlCut123Tests.cs | 6 +- .../FireControlCut124Tests.cs | 21 +++--- .../FireControlCut12Tests.cs | 6 +- .../FireControlCut4Tests.cs | 11 +-- .../FireControlCut5Tests.cs | 24 ++---- .../FireControlCut6Tests.cs | 6 +- .../FireControlCut6cTests.cs | 9 +-- .../FireControlCut6dTests.cs | 6 +- .../FireControlCut7Tests.cs | 6 +- .../FireControlCut9Tests.cs | 20 ++--- .../FireControlPerWeaponTests.cs | 6 +- .../Aetheria.Shared.Tests/FireControlTests.cs | 6 +- .../FloatingBodyTests.cs | 3 +- .../IffAndCombatTests.cs | 8 +- .../ImpactFeedbackTests.cs | 5 +- .../InputCapacitorTests.cs | 3 +- .../Aetheria.Shared.Tests/LockWarningTests.cs | 3 +- tests/Aetheria.Shared.Tests/LootTests.cs | 52 +++++++++---- tests/Aetheria.Shared.Tests/MineTests.cs | 6 +- .../Aetheria.Shared.Tests/MiningCut3Tests.cs | 6 +- .../Aetheria.Shared.Tests/MiningIndexTests.cs | 9 +-- .../MiningTargetQueriesTests.cs | 11 ++- .../MunitionPartsTests.cs | 3 +- tests/Aetheria.Shared.Tests/PaintModeTests.cs | 13 ++-- tests/Aetheria.Shared.Tests/PowerBusTests.cs | 3 +- .../Aetheria.Shared.Tests/PowerCurveTests.cs | 6 +- tests/Aetheria.Shared.Tests/PowerTierTests.cs | 3 +- .../RestoredHullsTests.cs | 6 +- .../ShieldReserveTests.cs | 3 +- tests/Aetheria.Shared.Tests/SignatureTests.cs | 3 +- tests/Aetheria.Shared.Tests/SolutionTests.cs | 3 +- .../StatResolverTests.cs | 9 +-- .../TargetingSystemTests.cs | 9 +-- .../ThrottleLockTests.cs | 3 +- .../Aetheria.Shared.Tests/VapourCloudTests.cs | 6 +- 50 files changed, 206 insertions(+), 320 deletions(-) delete mode 100644 Assets/Scripts/Gameplay/GridObject.cs delete mode 100644 Assets/Scripts/Gameplay/GridObject.cs.meta delete mode 100644 Assets/Scripts/Gameplay/TimedDestroy.cs delete mode 100644 Assets/Scripts/Gameplay/TimedDestroy.cs.meta diff --git a/Assets/Scripts/Editor/ImpactPresenterSmoke.cs b/Assets/Scripts/Editor/ImpactPresenterSmoke.cs index 42dc9d56d..b5f6f9714 100644 --- a/Assets/Scripts/Editor/ImpactPresenterSmoke.cs +++ b/Assets/Scripts/Editor/ImpactPresenterSmoke.cs @@ -64,10 +64,8 @@ public static class ImpactPresenterSmoke var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Smoke Hull")); var ship = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); var other = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000, Lot = 2 }, new EntitySettings()); - zone.Entities.Add(ship); - zone.Entities.Add(other); - ship.Activate(); - other.Activate(); + zone.Admit(ship, piloted: false); + zone.Admit(other, piloted: false); // The scene's side: the tuning object with a pooled effect, and the game manager that names the player's ship. var feedbackObject = new GameObject("Smoke Impact Feedback"); diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 3fae48b73..aacf8b2b3 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -739,7 +739,7 @@ public class ActionGameManager : MonoBehaviour if (CurrentEntity != null) { CurrentEntity.Deactivate(); - CurrentEntity.Zone.Entities.Remove(CurrentEntity); + CurrentEntity.Zone.Remove(CurrentEntity); CurrentEntity.Zone = Zone; Zone.Admit(CurrentEntity, piloted: false); } diff --git a/Assets/Scripts/Gameplay/GridObject.cs b/Assets/Scripts/Gameplay/GridObject.cs deleted file mode 100644 index 82d64199a..000000000 --- a/Assets/Scripts/Gameplay/GridObject.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using CultMath; -using CultMath.UnityBridge; -using UnityEngine; -using static CultMath.math; -using Random = UnityEngine.Random; - -public class GridObject : MonoBehaviour -{ - public float RotationSpeed; - public float GridOffset; - public float GridAttraction; - public float Gravity; - public float Drag = .1f; - public float LaunchDrag; - - private float2 _selfVelocity; - private float _timeOffset; - - public Zone Zone { get; set; } - public Vector3 Velocity { get; set; } - - private void Start() - { - _timeOffset = Random.value * 100; - } - - void Update() - { - var t = transform; - t.localRotation = Quaternion.Euler(sin(Time.time - _timeOffset * RotationSpeed) * 90, 0, cos(Time.time - _timeOffset * RotationSpeed) * 90); - - if (Zone == null) return; - - var position = t.position; - var gridHeight = Zone.GetHeight(position.Flatland().ToCultMath()) + GridOffset; - Velocity += Vector3.up * (sign(gridHeight - position.y) * GridAttraction * Time.deltaTime); - Velocity *= max(0, 1 - LaunchDrag * Time.deltaTime); - var normal = Zone.GetNormal(position.Flatland().ToCultMath()); - var force = new float2(normal.x, normal.z); - var forceMagnitude = lengthsq(force); - if (forceMagnitude > .001f) - { - var fa = 1 / (1 - forceMagnitude) - 1; - _selfVelocity += normalize(force) * Zone.Settings.GravityStrength * fa * Gravity; - } - - _selfVelocity *= max(0, 1 - Drag * Time.deltaTime); - t.position = position + (Velocity + new Vector3(_selfVelocity.x, 0, _selfVelocity.y)) * Time.deltaTime; - } -} \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/GridObject.cs.meta b/Assets/Scripts/Gameplay/GridObject.cs.meta deleted file mode 100644 index 335d81a85..000000000 --- a/Assets/Scripts/Gameplay/GridObject.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 737f47c158214f0995ba2835f040ed96 -timeCreated: 1618441439 \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/TimedDestroy.cs b/Assets/Scripts/Gameplay/TimedDestroy.cs deleted file mode 100644 index fbbd86f8c..000000000 --- a/Assets/Scripts/Gameplay/TimedDestroy.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using UnityEngine; - -public class TimedDestroy : MonoBehaviour -{ - public float Duration; - - private float _startTime; - - private void Start() - { - _startTime = Time.time; - } - - public void Update() - { - if(Time.time - _startTime > Duration) Destroy(gameObject); - } -} \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/TimedDestroy.cs.meta b/Assets/Scripts/Gameplay/TimedDestroy.cs.meta deleted file mode 100644 index 2f6bac661..000000000 --- a/Assets/Scripts/Gameplay/TimedDestroy.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 5d5539a23b814254b038a16f32f1d8a9 -timeCreated: 1618453298 \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 2716cb7c3..dc36a9622 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1507,7 +1507,7 @@ public abstract class Entity { bay.DockedShip = ship; ship.SetParent(this); - Zone.Entities.Remove(ship); + Zone.Remove(ship); ship.Deactivate(); ship.Docked.OnNext(this); } diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index 5b4a5ccc9..a21329e0d 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -15,7 +15,29 @@ using float2 = CultMath.float2; public class Zone { public Action Log; - public ReactiveCollection Entities = new ReactiveCollection(); + + // Membership, in admission order. Admit and Remove are the only writers; everyone else reads this view, which has + // no mutating member, so the death watch below follows membership by construction. + private readonly ReactiveCollection _entities = new ReactiveCollection(); + public MemberView Entities { get; } + + public sealed class MemberView : IReadOnlyReactiveCollection + { + private readonly ReactiveCollection _members; + internal MemberView(ReactiveCollection members) => _members = members; + public int Count => _members.Count; + public Entity this[int index] => _members[index]; + public int IndexOf(Entity entity) => _members.IndexOf(entity); + public bool Contains(Entity entity) => _members.Contains(entity); + public IEnumerator GetEnumerator() => _members.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public UniRx.IObservable> ObserveAdd() => _members.ObserveAdd(); + public UniRx.IObservable ObserveCountChanged(bool notifyCurrentCount = false) => _members.ObserveCountChanged(notifyCurrentCount); + public UniRx.IObservable> ObserveMove() => _members.ObserveMove(); + public UniRx.IObservable> ObserveRemove() => _members.ObserveRemove(); + public UniRx.IObservable> ObserveReplace() => _members.ObserveReplace(); + public UniRx.IObservable ObserveReset() => _members.ObserveReset(); + } // Laid mines, in lay order. Zone owns their existence: Lay is the one adder, the mine step the one remover, // and every removal is a blast. Runtime state, never saved. @@ -105,30 +127,13 @@ public class Zone // The death ordinal of the loot dice: the nth death in this zone rolls on SimulationDice.For(CombatSeed, LootStream, n). private uint _deaths; - // One death subscription per member entity, owned here: a membership record and its subscription are created - // together, and only when the entity is not already a member. Leaving Entities (Remove) or resetting it (Clear) - // disposes it; nothing else touches it. + // One death subscription per member entity: Admit creates it together with the membership, Remove disposes it + // together with the membership, and nothing else touches either. private readonly Dictionary _deathWatches = new Dictionary(); - private void WatchDeath(Entity entity) - { - if (_deathWatches.ContainsKey(entity)) return; - _deathWatches.Add(entity, entity.Death.Subscribe(_ => { DropLoot(entity); Entities.Remove(entity); entity.Deactivate(); })); - } - - private void UnwatchDeath(Entity entity) - { - if (_deathWatches.Remove(entity, out var watch)) watch.Dispose(); - } - - private void UnwatchAll() - { - foreach (var watch in _deathWatches.Values) watch.Dispose(); - _deathWatches.Clear(); - } - public Zone(ItemManager itemManager, PlanetSettings settings, ZonePack pack, GalaxyZone galaxyZone, Galaxy galaxy) { + Entities = new MemberView(_entities); _time = pack.Time; GalaxyZone = galaxyZone; Galaxy = galaxy; @@ -144,11 +149,8 @@ public class Zone // ObserveAdd fires for all of them. Forbidden writer: no presentation may remove an entity from Zone. // The death watch lives exactly as long as the entity's membership in this zone: installed on admission, // disposed on removal (death, dock, jump), so an entity admitted again never holds a second one. - Entities.ObserveAdd().Subscribe(add => WatchDeath(add.Value)); - Entities.ObserveRemove().Subscribe(remove => UnwatchDeath(remove.Value)); - Entities.ObserveReset().Subscribe(_ => UnwatchAll()); // The one record of a removal for observers (ruling reveal-ends-on-perception): read by the next Sense pass. - Entities.ObserveRemove().Subscribe(remove => _departures.Add(new Departure(remove.Value, remove.Value.Position.xz, + _entities.ObserveRemove().Subscribe(remove => _departures.Add(new Departure(remove.Value, remove.Value.Position.xz, remove.Value.Visibility - remove.Value.SignatureOn(SignatureChannel.Emission)))); Targets.Add(new EntityTargets()); @@ -205,17 +207,30 @@ public class Zone // TODO: Associate planets with stored entities for planetary colonies } - // The one admission into this zone (docs/scenarios-cut.md, 2.2): the only writer of Entities.Add and the only + // The one admission into this zone (docs/scenarios-cut.md, 2.2): the only writer of membership and the only // creator of agents. Construction, new-run staging (RunStart), warp arrival, undock and the console's spawned // turret all join through here. A piloted entity gets the zone's Minion; an unpiloted one gets no agent at all. - // Only a ship can be piloted. The Death subscription above covers every join, whichever caller admits it. + // Only a ship can be piloted. The death watch is installed here, for every join, whichever caller admits it. + // An entity that is already a member is refused: it would hold a second watch and a second agent. public void Admit(Entity entity, bool piloted) { - Entities.Add(entity); + if (_entities.Contains(entity)) throw new InvalidOperationException("entity: already a member of this zone"); + _entities.Add(entity); + _deathWatches.Add(entity, entity.Death.Subscribe(_ => { DropLoot(entity); Remove(entity); entity.Deactivate(); })); entity.Activate(); if (piloted) Agents.Add(CreateAgent((Ship) entity)); } + // The one removal from this zone (death, dock, jump): membership and its death watch leave together. False, and + // nothing changes, when the entity is not a member. The caller deactivates; Remove does not. + public bool Remove(Entity entity) + { + if (!_entities.Remove(entity)) return false; + _deathWatches.Remove(entity, out var watch); + watch.Dispose(); + return true; + } + private Agent CreateAgent(Ship ship) { var agent = new Minion(ship); @@ -231,7 +246,7 @@ public class Zone { Radius = Pack.Radius, Mass = Pack.Mass, - Entities = Entities.Select(EntitySerializer.Pack).ToList(), + Entities = _entities.Select(EntitySerializer.Pack).ToList(), Orbits = Orbits.Keys.Select(key => new CultRecordRef(key)).ToList(), Planets = Planets.Keys.Select(key => new CultRecordRef(key)).ToList(), Time = _time, diff --git a/tests/Aetheria.Shared.Tests/BrownoutTests.cs b/tests/Aetheria.Shared.Tests/BrownoutTests.cs index a262d41e3..3fe4bfb51 100644 --- a/tests/Aetheria.Shared.Tests/BrownoutTests.cs +++ b/tests/Aetheria.Shared.Tests/BrownoutTests.cs @@ -113,11 +113,10 @@ public sealed class BrownoutTests : IDisposable // not flight, and a lone off-centre thruster could not fire without turning. Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")), new int2(2, 1))); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); // Ship.Turn stays 0, so Ship.Update commands no rotation: no incidental torque thrust from the // ship trying to turn. Aim steers nothing, so this one is only a plausible aim. ship.Aim = float3(0, 0, 1); - ship.Activate(); return ship; } diff --git a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs index 83da4a488..e294b942f 100644 --- a/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs +++ b/tests/Aetheria.Shared.Tests/ConditionRatioTests.cs @@ -96,9 +96,8 @@ public sealed class ConditionRatioTests : IDisposable // not flight, and a lone off-centre thruster could not fire without turning. Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Consumer")), new int2(2, 1))); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Aim = float3(0, 0, 1); // Turn stays 0, as in BrownoutTests - ship.Activate(); consumer = ship.Equipment.Single(e => e.Data.Name == "Consumer"); Assert.True(ship.GetBehavior().Torque == 0f, "the lone thruster turns nothing"); diff --git a/tests/Aetheria.Shared.Tests/ConsumableTests.cs b/tests/Aetheria.Shared.Tests/ConsumableTests.cs index b5717e9d6..7ba593e6e 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableTests.cs @@ -83,8 +83,7 @@ public sealed class ConsumableTests : IDisposable var ship = new Ship(items, zone, Gear(cache.Get(hullRef)), new EntitySettings()); Assert.True(ship.TryEquip(Gear(cache.Get(engineRef)), EngineCell)); Assert.True(ship.TryEquip(Gear(cache.Get(holdRef)), HoldCell)); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); var data = cache.Get(consumableRef); return new Rig { diff --git a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs index 0273bcdc4..9eeb90033 100644 --- a/tests/Aetheria.Shared.Tests/ContactRevealTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactRevealTests.cs @@ -187,8 +187,7 @@ public sealed class ContactRevealTests : IDisposable Assert.True(ship.TryFindSpace(item, out var at)); Assert.True(ship.TryEquip(item, at)); } - _zone.Entities.Add(ship); - ship.Activate(); + _zone.Admit(ship, piloted: false); ship.Position = position; ship.Aim = float3(0, 0, 1); _zone.Update(0f); // brings the gear online @@ -514,8 +513,8 @@ public sealed class ContactRevealTests : IDisposable { switch (how) { - case "remove": zone.Entities.Remove(e); break; - case "warp": e.Deactivate(); zone.Entities.Remove(e); break; + case "remove": zone.Remove(e); break; + case "warp": e.Deactivate(); zone.Remove(e); break; case "death": e.Hull.Durability = 0f; e.HullDamage.OnNext(1f); break; default: throw new Exception("fixture: " + how); } @@ -554,7 +553,7 @@ public sealed class ContactRevealTests : IDisposable Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its last light is below o's gate"); Assert.Equal(2, times.Count); - _zone.Entities.Remove(e); + _zone.Remove(e); Assert.Equal(2, o.RevealsOf(e).Count()); var lapsed = false; @@ -640,7 +639,7 @@ public sealed class ContactRevealTests : IDisposable var gate = _items.GameplaySettings.TargetDetectionInfoThreshold / o.PassiveInfo(e.Position.xz, 1f); ShowOnly(e, gate * against, Guns(e)[0]); - _zone.Entities.Remove(e); + _zone.Remove(e); Tick(.1f); Assert.Equal(ended ? 0 : 1, o.RevealsOf(e).Count()); @@ -662,7 +661,7 @@ public sealed class ContactRevealTests : IDisposable ShowOnly(e, threshold * 8f, Guns(e)[0]); Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) == threshold, "fixture: the ship's light at removal is exactly the gate"); - _zone.Entities.Remove(e); + _zone.Remove(e); Tick(.1f); Assert.Single(o.RevealsOf(e)); @@ -679,7 +678,7 @@ public sealed class ContactRevealTests : IDisposable ShowOnly(e, 0f, Guns(e)[0]); e.SetSignature(new object(), SignatureChannel.Emission, 1000f, Guns(e)[0]); - _zone.Entities.Remove(e); + _zone.Remove(e); Tick(.1f); Assert.Single(o.RevealsOf(e)); @@ -696,8 +695,9 @@ public sealed class ContactRevealTests : IDisposable Tick(1f); // the gun cools down ShowOnly(e, 1000f, gun); // bright enough for any gate - _zone.Entities.Remove(e); - _zone.Entities.Add(e); + _zone.Remove(e); + e.Deactivate(); + _zone.Admit(e, piloted: false); Tick(.1f); Assert.Equal(at, Assert.Single(o.RevealsOf(e)).Time); @@ -813,7 +813,7 @@ public sealed class ContactRevealTests : IDisposable var first = Assert.Single(o.RevealsOf(locker)); // fixture: the lock paints every tick, and the first one is recorded Assert.True(locker.Visibility * o.PassiveInfo(locker.Position.xz, 1f) <= _items.GameplaySettings.TargetDetectionInfoThreshold, "fixture: its departure is below o's gate"); - _zone.Entities.Remove(locker); // dies, warps or docks while its latest paint is still in o's inbox + _zone.Remove(locker); // dies, warps or docks while its latest paint is still in o's inbox var inboxed = ((System.Collections.IList) typeof(Entity).GetField("_paintInbox", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(o)).Count; Assert.True(inboxed > 0 || o.IncomingPaints.Count > 0, "fixture: a paint is in flight"); Tick(.1f); @@ -865,7 +865,7 @@ public sealed class ContactRevealTests : IDisposable for (var tick = 0; tick < 100 && inbox.Count == 0; tick++) Tick(.1f); Assert.NotEmpty(inbox); // fixture: the ping crossed o and its paint waits to be published - _zone.Entities.Remove(p); + _zone.Remove(p); Tick(.1f); Assert.Single(o.IncomingPaints); @@ -1039,7 +1039,7 @@ public sealed class ContactRevealTests : IDisposable ShowOnly(e, 1f, gun); var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; Assert.True(e.Visibility * o.PassiveInfo(e.Position.xz, 1f) <= threshold, "fixture: from where o is, the departure is below the gate"); - _zone.Entities.Remove(e); + _zone.Remove(e); Tick(.1f); Assert.Single(o.RevealsOf(e)); @@ -1069,8 +1069,8 @@ public sealed class ContactRevealTests : IDisposable var threshold = _items.GameplaySettings.TargetDetectionInfoThreshold; Assert.True(a.Visibility * o.PassiveInfo(a.Position.xz, 1f) > threshold && b.Visibility * o.PassiveInfo(b.Position.xz, 1f) > threshold, "fixture: both departures pass the gate"); - _zone.Entities.Remove(firstThenSecond ? a : b); - _zone.Entities.Remove(firstThenSecond ? b : a); + _zone.Remove(firstThenSecond ? a : b); + _zone.Remove(firstThenSecond ? b : a); Tick(.1f); Assert.Empty(o.RevealsOf(a)); @@ -1096,7 +1096,7 @@ public sealed class ContactRevealTests : IDisposable Tick(.01f); Assert.Same(guns[1], Assert.Single(o.RevealsOf(e)).Item); // fixture: one gun ended as destroyed, one waiting e.Deactivate(); // the warp path: the ship's own subscriptions to the observer go with it - _zone.Entities.Remove(e); + _zone.Remove(e); for (var i = 0; i < 40; i++) Tick(.1f); // past BearingMemory (2 s) Assert.Equal(0, RevealBookSubjects(o)); // fixture: pruned @@ -1179,7 +1179,7 @@ public sealed class ContactRevealTests : IDisposable Assert.Contains(identified.VisibilitySources.Values, v => v.Item == gunItems[0] && v.Strength > 0f); var departed = _zone.Entities.Single(x => x.Equipment.Contains(gunItems[18])); foreach (var source in departed.VisibilitySources.Keys.ToArray()) departed.SetSignature(source, SignatureChannel.Reflection, 0f, gunItems[18]); - _zone.Entities.Remove(departed); + _zone.Remove(departed); Assert.Single((System.Collections.IList) typeof(Zone).GetProperty("Departures", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(_zone)); sense(); Assert.Single(o.RevealsOf(departed)); // fixture: awaiting its prune diff --git a/tests/Aetheria.Shared.Tests/ContactTests.cs b/tests/Aetheria.Shared.Tests/ContactTests.cs index b139edea7..e26996ce4 100644 --- a/tests/Aetheria.Shared.Tests/ContactTests.cs +++ b/tests/Aetheria.Shared.Tests/ContactTests.cs @@ -64,8 +64,7 @@ public sealed class ContactTests : IDisposable { var hullRef = Items.ItemData.RefOf(Items.ItemData.GetByName(hull)); var ship = new Ship(Items, Zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = Lot++ }, new EntitySettings()); - Zone.Entities.Add(ship); - ship.Activate(); + Zone.Admit(ship, piloted: false); ship.Position = at; ship.Velocity = velocity; return ship; diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 9512be458..76e175e82 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -176,10 +176,8 @@ public sealed class FireAuthorityTests : IDisposable // extra gear a test needs (a second aimable item, a shield, a reactor) must go on before Activate(). beforeActivate?.Invoke(items, shooter, target); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); @@ -327,7 +325,7 @@ public sealed class FireAuthorityTests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var before = e.Target.Hull.Durability; - e.Zone.Entities.Remove(e.Target); + e.Zone.Remove(e.Target); e.Zone.Update(10f); // well past arrival Assert.Equal(before, e.Target.Hull.Durability); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index ed352a535..54e43f21e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -177,10 +177,8 @@ public sealed class FireControlCut11Tests : IDisposable Assert.True(target.TryEquip(shield, spos)); } - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3(37, 0, -11); target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter @@ -280,7 +278,7 @@ public sealed class FireControlCut11Tests : IDisposable // What the HUD's arrival countdown reads: time left in flight at commit, 5 s - 4.7 s. Assert.Equal(.3f, committed.ArrivalIn, 3); - e.Zone.Entities.Remove(e.Target); // the target is gone before the shot arrives + e.Zone.Remove(e.Target); // the target is gone before the shot arrives e.Zone.Update(1f); Assert.NotNull(resolved); @@ -307,7 +305,7 @@ public sealed class FireControlCut11Tests : IDisposable e.Zone.Update(1f); SafeAssert.Empty(committed); - e.Zone.Entities.Remove(e.Target); + e.Zone.Remove(e.Target); e.Zone.Update(1f); foreach (var outcome in new[] { SafeAssert.Only(committed), SafeAssert.Only(resolved) }) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs index f935c2edf..a599d576d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut123Tests.cs @@ -224,10 +224,8 @@ public sealed class FireControlCut123Tests : IDisposable cockpit = target.Equipment.Single(x => x.EquippableItem == ci); } - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); // Cut 11.3: the shooter sits off the origin. shooter.Position = float3(37, 0, -11); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs index a1d5445e8..cd6b876b1 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut124Tests.cs @@ -242,10 +242,8 @@ public sealed partial class FireControlCut124Tests : IDisposable Assert.True(target.TryEquip(shield, spos)); } - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); // Off-origin (the fixture rule): the shooter sits away from the world origin, and the target sits // `targetRange` ahead of it along the shooter's own +z, so a stationary target's TravelDirection is @@ -515,8 +513,7 @@ public sealed partial class FireControlCut124Tests : IDisposable Data = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Marker")), Durability = 1000000, Lot = lot + 1 }; Assert.True(ship.TryEquip(marker, new int2(1, 1))); - e.Zone.Entities.Add(ship); - ship.Activate(); + e.Zone.Admit(ship, piloted: false); ship.Position = position; return ship; } @@ -1465,7 +1462,7 @@ public sealed partial class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); Assert.True(SafeAssert.OnlyShot(e.Zone).Target == e.Target, "fixture: the pushed-out round keeps its target"); e.Zone.Update(.01f); - e.Zone.Entities.Remove(e.Target); + e.Zone.Remove(e.Target); e.Zone.Update(3f); SafeAssert.NoShots(e.Zone); @@ -1510,7 +1507,7 @@ public sealed partial class FireControlCut124Tests : IDisposable var e = Build(TestSettings(), SolidShape(5, 4), velocity: 20f, fuse: WeaponFuse.Proximity, blastRadius: 4f, damage: 100f); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); using var c = e.Zone.ShotCommitted.Subscribe(_ => throw new InvalidOperationException("observer")); - e.Zone.Entities.Remove(e.Target); + e.Zone.Remove(e.Target); Assert.ThrowsAny(() => e.Zone.Update(.01f)); var second = Record.Exception(() => e.Zone.Update(.01f)); @@ -2004,7 +2001,7 @@ public sealed partial class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); for (var i = 0; i < updatesBeforeLeaving; i++) e.Zone.Update(.01f); - e.Zone.Entities.Remove(e.Shooter); + e.Zone.Remove(e.Shooter); e.Zone.Update(4f); SafeAssert.NoShots(e.Zone); @@ -2022,7 +2019,7 @@ public sealed partial class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); - e.Zone.Entities.Remove(e.Shooter); + e.Zone.Remove(e.Shooter); e.Zone.Update(6f); SafeAssert.NoShots(e.Zone); @@ -2044,7 +2041,7 @@ public sealed partial class FireControlCut124Tests : IDisposable for (var i = 0; i < 12; i++) FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(.01f); - e.Zone.Entities.Remove(e.Shooter); + e.Zone.Remove(e.Shooter); e.Zone.Update(6f); SafeAssert.NoShots(e.Zone); @@ -2274,7 +2271,7 @@ public sealed partial class FireControlCut124Tests : IDisposable FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); e.Zone.Update(committedFirst ? 4.6f : .01f); Assert.Equal(committedFirst, e.Zone.PendingShots[0].Committed); - e.Zone.Entities.Remove(e.Target); + e.Zone.Remove(e.Target); e.Zone.Update(6f); foreach (var s in subscriptions) s.Dispose(); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs index 2c0623273..c28242a15 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -269,10 +269,8 @@ public sealed class FireControlCut12Tests : IDisposable Assert.True(target.TryEquip(shield, spos)); } - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); // Cut 11.3: the shooter sits off the origin -- a shooter at float3.zero makes `target - source` and // `target + source` the same vector, hiding a whole class of sign mutants. diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index c73d486e3..8ff7eef2f 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -154,10 +154,8 @@ public sealed class FireControlCut4Tests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); @@ -233,8 +231,7 @@ public sealed class FireControlCut4Tests : IDisposable Assert.True(shooter.TryEquip(gun, new int2(0, 0))); var weapon = (InstantWeapon) shooter.Equipment.Single(e => e.EquippableItem == gun).Behaviors.Single(b => b is Weapon); - zone.Entities.Add(shooter); - shooter.Activate(); + zone.Admit(shooter, piloted: false); zone.Update(0f); // warm up resolved stats // No target, no OnFire subscriber -- exactly a headless shooter with no target and no presentation. @@ -276,7 +273,7 @@ public sealed class FireControlCut4Tests : IDisposable var ship = new Ship(items, zone, hull, new EntitySettings()); ship.Position = position; ship.Direction = direction; - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); return ship; } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index 327b6e9e9..0023f0c08 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -184,10 +184,8 @@ public sealed class FireControlCut5Tests : IDisposable beforeActivate?.Invoke(items, shooter, target); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); @@ -339,10 +337,8 @@ public sealed class FireControlCut5Tests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = targetPosition; @@ -427,10 +423,8 @@ public sealed class FireControlCut5Tests : IDisposable var targetGun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 4 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); return (items, shooter, target, weaponItem); } @@ -576,8 +570,7 @@ public sealed class FireControlCut5Tests : IDisposable Assert.True(target.TryEquip(medium, mediumPos)); var mediumItem = target.Equipment.Single(e => e.EquippableItem == medium); - zone.Entities.Add(target); - target.Activate(); + zone.Admit(target, piloted: false); return (items, target, bigItem, mediumItem); } @@ -592,8 +585,7 @@ public sealed class FireControlCut5Tests : IDisposable var (items, target, big, medium) = BuildRevealTarget(settings); var observerHullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); var observer = new Ship(items, target.Zone, new EquippableItem { Data = observerHullRef, Durability = 1000, Lot = 1 }, new EntitySettings()); - target.Zone.Entities.Add(observer); - observer.Activate(); + target.Zone.Admit(observer, piloted: false); observer.SetTarget(target); // With both items alive: Big (index 0) reveals at .2, Medium (index 1) at .8. diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 8f581a33d..56b2f38da 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -156,10 +156,8 @@ public sealed class FireControlCut6Tests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs index 586c658b4..0421b1b25 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs @@ -138,10 +138,8 @@ public sealed class FireControlCut6cTests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 9 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); @@ -267,8 +265,7 @@ public sealed class FireControlCut6cTests : IDisposable var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var entity = EntitySerializer.Unpack(items, zone, pack); - zone.Entities.Add(entity); - entity.Activate(); + zone.Admit(entity, piloted: false); zone.Update(0f); Assert.Null(entity.GetBehavior()); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs index fdf54d5f2..5ae16a285 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs @@ -156,10 +156,8 @@ public sealed class FireControlCut6dTests : IDisposable Assert.True(target.TryEquip(marker, markerCell.Value)); } - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, targetRange); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs index 56b5cc2be..67eaa672e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs @@ -133,10 +133,8 @@ public sealed class FireControlCut7Tests : IDisposable beforeActivate?.Invoke(items, shooter, target); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, 100); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 2a94080ed..02cab4a7b 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -129,10 +129,8 @@ public sealed class FireControlCut9Tests : IDisposable Assert.True(target.TryFindSpace(targetMarker, out var targetMarkerPos)); Assert.True(target.TryEquip(targetMarker, targetMarkerPos)); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, 100); @@ -340,10 +338,8 @@ public sealed class FireControlCut9Tests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, 100); @@ -567,10 +563,8 @@ public sealed class FireControlCut9Tests : IDisposable var bystanderGun = new EquippableItem { Data = bystanderGunRef, Durability = 1, Lot = 4 }; Assert.True(bystander.TryEquip(bystanderGun, new int2(0, 0))); // _orderedEquipment must not be empty/null for Update to run - zone.Entities.Add(locker); - zone.Entities.Add(bystander); - locker.Activate(); - bystander.Activate(); + zone.Admit(locker, piloted: false); + zone.Admit(bystander, piloted: false); locker.Position = float3.zero; bystander.Position = float3(0, 0, 100); @@ -603,7 +597,7 @@ public sealed class FireControlCut9Tests : IDisposable // the target dies or leaves while the locker is inactive -- its own Zone.Entities.ObserveRemove // subscription (the only thing that would have nulled Target) was disposed by Deactivate above, so // nothing reacts to this removal yet. - s.Zone.Entities.Remove(s.Bystander); + s.Zone.Remove(s.Bystander); s.Locker.Activate(); // undock Assert.True(s.Locker.Active); diff --git a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs index 5617eaa0b..924e868e8 100644 --- a/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlPerWeaponTests.cs @@ -238,10 +238,8 @@ public sealed class FireControlPerWeaponTests : IDisposable var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); Assert.True(target.TryEquip(Make("Gun", lot++, 1), new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3(-53, 0, -101); target.Position = shooter.Position + float3(0, 0, targetRange); diff --git a/tests/Aetheria.Shared.Tests/FireControlTests.cs b/tests/Aetheria.Shared.Tests/FireControlTests.cs index 8b0f16e9f..813bde945 100644 --- a/tests/Aetheria.Shared.Tests/FireControlTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlTests.cs @@ -95,8 +95,7 @@ public sealed class FireControlTests : IDisposable weapons[i] = ship.Equipment.Single(e => e.EquippableItem == gun); } - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return (items, ship, weapons); } @@ -195,8 +194,7 @@ public sealed class FireControlTests : IDisposable // gun so the target is a real, ordinarily-constructed ship rather than a workaround-shaped one. var targetGunRef = items.ItemData.RefOf(items.ItemData.GetByName("Gun")); Assert.True(target.TryEquip(new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 1 }, new int2(0, 0))); - zone.Entities.Add(target); - target.Activate(); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = targetPosition; diff --git a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs index 77cd3b48e..5b98ba649 100644 --- a/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs +++ b/tests/Aetheria.Shared.Tests/FloatingBodyTests.cs @@ -239,9 +239,8 @@ public sealed class FloatingBodyTests : IDisposable Lot = items.Lots.Add(new Lot { Design = cache.RefOf(design), Origin = new Attributed(), Quality = .5f, Roles = new List() }) }; var ship = new Ship(items, zone, hull, new EntitySettings()); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Aim = float3(0, 0, 1); - ship.Activate(); ship.Position = float3(x, 0, 0); return ship; } diff --git a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs index 3e1012889..7fe1d9127 100644 --- a/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs +++ b/tests/Aetheria.Shared.Tests/IffAndCombatTests.cs @@ -82,8 +82,7 @@ public sealed class IffAndCombatTests : IDisposable Assert.True(ship.TryEquip(gun, HardpointCell)); } - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return ship; } @@ -132,8 +131,9 @@ public sealed class IffAndCombatTests : IDisposable a.SetIff(b, true); Assert.True(a.EntityHostility[b]); - zone.Entities.Remove(b); // b leaves the zone: a's override on b must be cleared - zone.Entities.Add(b); // b returns as a "new" contact from a's perspective + zone.Remove(b); // b leaves the zone: a's override on b must be cleared + b.Deactivate(); + zone.Admit(b, piloted: false); // b returns as a "new" contact from a's perspective Assert.False(a.EntityHostility[b]); // recomputed fresh from the derived (non-hostile) rule Assert.False(a.IsHostileTo(b)); diff --git a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs index 203a5347a..3e52fc3c1 100644 --- a/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs +++ b/tests/Aetheria.Shared.Tests/ImpactFeedbackTests.cs @@ -33,8 +33,7 @@ public sealed partial class FireControlCut124Tests { var hullRef = e.Items.ItemData.RefOf(e.Items.ItemData.GetByName("Hull")); var ship = new Ship(e.Items, e.Zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = lot }, new EntitySettings()); - e.Zone.Entities.Add(ship); - ship.Activate(); + e.Zone.Admit(ship, piloted: false); ship.Position = position; return ship; } @@ -135,7 +134,7 @@ public sealed partial class FireControlCut124Tests var gone = Build(TestSettings(), SolidShape(5, 4), velocity: 50f, damage: 100f); var goneAbsorbs = new[] { gone.Target, gone.Shooter }.SelectMany(x => Collect(x, subs)).ToList(); FireControl.Fire(gone.Weapon, gone.WeaponItem, gone.Shooter); - gone.Zone.Entities.Remove(gone.Target); + gone.Zone.Remove(gone.Target); for (var i = 0; i < 40; i++) gone.Zone.Update(.1f); foreach (var s in subs) s.Dispose(); diff --git a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs index d72113458..3a7b9e2d5 100644 --- a/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs +++ b/tests/Aetheria.Shared.Tests/InputCapacitorTests.cs @@ -97,8 +97,7 @@ public sealed class InputCapacitorTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return (ship, items, ship.GetBehavior()); } diff --git a/tests/Aetheria.Shared.Tests/LockWarningTests.cs b/tests/Aetheria.Shared.Tests/LockWarningTests.cs index 3b2710660..3327ffdcc 100644 --- a/tests/Aetheria.Shared.Tests/LockWarningTests.cs +++ b/tests/Aetheria.Shared.Tests/LockWarningTests.cs @@ -105,8 +105,7 @@ public sealed class LockWarningTests : IDisposable var gearRef = _items.ItemData.RefOf(_items.ItemData.GetByName(launcher)); Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef, Durability = 1, Lot = _nextLot++ }, HardpointCell)); } - _zone.Entities.Add(ship); - ship.Activate(); + _zone.Admit(ship, piloted: false); ship.Position = position; _zone.Update(0f); // brings the gear online return ship; diff --git a/tests/Aetheria.Shared.Tests/LootTests.cs b/tests/Aetheria.Shared.Tests/LootTests.cs index ab2c19511..d0414f293 100644 --- a/tests/Aetheria.Shared.Tests/LootTests.cs +++ b/tests/Aetheria.Shared.Tests/LootTests.cs @@ -133,8 +133,7 @@ public sealed class LootTests : IDisposable 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(); + lab.Zone.Admit(ship, piloted: false); ship.Position = at; return ship; } @@ -153,8 +152,7 @@ public sealed class LootTests : IDisposable Assert.True(station.TryEquip(spare, pos)); Assert.True(station.CargoBays.Single().TryStore(lab.Ore(3))); } - lab.Zone.Entities.Add(station); - station.Activate(); + lab.Zone.Admit(station, piloted: false); station.Position = at; return station; } @@ -393,10 +391,10 @@ public sealed class LootTests : IDisposable 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); + lab.Zone.Remove(ship); ship.Deactivate(); lab.Zone.Admit(ship, piloted: false); - lab.Zone.Entities.Remove(ship); + lab.Zone.Remove(ship); lab.Zone.Admit(ship, piloted: false); Kill(ship); var dropped = lab.Zone.FloatingItems.Select(f => f.Item).ToList(); @@ -412,7 +410,7 @@ public sealed class LootTests : IDisposable var ship = Spawn(lab, float3(3, 0, 4)); // What ActionGameManager does on a jump. ship.Deactivate(); - lab.Zone.Entities.Remove(ship); + lab.Zone.Remove(ship); ship.Zone = other; other.Admit(ship, piloted: false); Kill(ship); @@ -509,9 +507,10 @@ public sealed class LootTests : IDisposable Assert.True(lab.Zone.FloatingItems.Select(f => Math.Round(Math.Atan2(f.Body.Velocity.y, f.Body.Velocity.x), 3)).Distinct().Count() > 1); } - // A member added twice holds one death subscription, so it drops each item once, as a single death would. + // 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 AMemberAddedTwiceStillDropsEachItemOnce() + public void AMemberAdmittedTwiceIsRefusedAndStillDropsEachItemOnce() { var once = Build("Once", probability: 1f); Kill(Spawn(once, float3(3, 0, 4))); @@ -520,20 +519,45 @@ public sealed class LootTests : IDisposable var lab = Build("Twice", probability: 1f); var ship = Spawn(lab, float3(3, 0, 4)); - lab.Zone.Entities.Add(ship); + Assert.Throws(() => 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); } - // Clearing Entities removes every member, so a death afterwards drops nothing in this zone. + // 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 AClearedZoneDropsNothingWhenAFormerMemberDies() + public void RemovingANonMemberChangesNothingAndARemovedMembersWatchGoesWithIt() { - var lab = Build("Cleared", probability: 1f); + var lab = Build(probability: 1f); var ship = Spawn(lab, float3(3, 0, 4)); - lab.Zone.Entities.Clear(); + 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>(lab.Zone.Entities); + Assert.IsNotAssignableFrom(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 diff --git a/tests/Aetheria.Shared.Tests/MineTests.cs b/tests/Aetheria.Shared.Tests/MineTests.cs index f132de9d2..71321e99d 100644 --- a/tests/Aetheria.Shared.Tests/MineTests.cs +++ b/tests/Aetheria.Shared.Tests/MineTests.cs @@ -130,8 +130,7 @@ public sealed class MineTests : IDisposable var targeting = lab.Make("Targeting", 1); Assert.True(layer.TryFindSpace(targeting, out var tpos)); Assert.True(layer.TryEquip(targeting, tpos)); - zone.Entities.Add(layer); - layer.Activate(); + zone.Admit(layer, piloted: false); layer.Position = layerAt ?? float3(-53, 0, -101); lab.Layer = layer; zone.Update(1f); @@ -152,8 +151,7 @@ public sealed class MineTests : IDisposable Assert.True(ship.TryFindSpace(gear, out var spos)); Assert.True(ship.TryEquip(gear, spos)); } - lab.Zone.Entities.Add(ship); - ship.Activate(); + lab.Zone.Admit(ship, piloted: false); ship.Position = at; if (shield != null) ship.Shield.Item.Enabled.Value = true; return ship; diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index ea29e418b..d8cfd9858 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -222,10 +222,9 @@ public sealed partial class MiningCut3Tests : IDisposable s.Cache.Upsert(gun); Assert.True(ship.TryEquip(Mint(s, gun))); } - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, at); ship.Aim = float3(0, 0, 1); - ship.Activate(); return ship; } @@ -867,10 +866,9 @@ public sealed partial class MiningCut3Tests : IDisposable var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); Assert.True(ship.TryEquip(Mint(s, s.Eye))); foreach (var weapon in weapons) Assert.True(ship.TryEquip(Mint(s, weapon))); - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, eyeAt); ship.Aim = float3(0, 0, 1); - ship.Activate(); Tick((ship, eyeAt)); // weapons read their ranges when they run return ship; } diff --git a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs index 785854911..3c14484f5 100644 --- a/tests/Aetheria.Shared.Tests/MiningIndexTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningIndexTests.cs @@ -54,10 +54,9 @@ public sealed partial class MiningCut3Tests var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); Assert.True(ship.TryEquip(Mint(s, eye))); Assert.True(ship.TryEquip(Mint(s, gun))); - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, at); ship.Aim = float3(0, 0, 1); - ship.Activate(); Tick((ship, at)); // equipment comes online and weapons read their ranges on the first tick return ship; } @@ -177,7 +176,7 @@ public sealed partial class MiningCut3Tests Assert.True(expected.ToHashSet().SetEquals(offered), $"sensitivity {sensitivity}: {expected.Count} visible, {offered.Count} offered"); visibleSeen += expected.Count; if (examined < inReach.Count) pruned++; - s.Zone.Entities.Remove(observer); + s.Zone.Remove(observer); } Assert.True(visibleSeen > 50 && pruned > 4, $"degenerate: {visibleSeen} visible, {pruned} queries pruned"); } @@ -422,7 +421,7 @@ public sealed partial class MiningCut3Tests var brightest = all.Max(c => s.Zone.ChunkVisibility(c)); var unit = Observer(s, float2(5000, 0), 350f, 1f, Falloff()); var sensitivity = 120f / (brightest * unit.DetectionReachPerVisibility()); - s.Zone.Entities.Remove(unit); + s.Zone.Remove(unit); var offered = new List(); long examined = 0, inReach = 0; @@ -437,7 +436,7 @@ public sealed partial class MiningCut3Tests observer.VisibleChunksInReach(offered); examined += s.Zone.Targets.Examined - before; inReach += all.Count(c => length(At(s, c) - at) <= 350f); - s.Zone.Entities.Remove(observer); + s.Zone.Remove(observer); } Assert.True(examined < .265 * inReach, $"examined {examined} of {inReach} rocks in reach: the visibility bound has loosened"); } diff --git a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs index 64304e066..374f9292e 100644 --- a/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs +++ b/tests/Aetheria.Shared.Tests/MiningTargetQueriesTests.cs @@ -33,7 +33,7 @@ public sealed partial class MiningCut3Tests { var observer = Observer(s, at, reach, sensitivity, Falloff()); if (inReach.Count(observer.ChunkVisible) >= share * inReach.Count) return (s, observer); - s.Zone.Entities.Remove(observer); + s.Zone.Remove(observer); } throw new InvalidOperationException("no sensitivity sees the rocks"); } @@ -42,7 +42,7 @@ public sealed partial class MiningCut3Tests private static Ship Sight(Scene s, Ship observer, float2 at, bool enemy = false) { var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, at); observer.VisibleEntities.Add(ship); if (enemy) observer.VisibleEnemies.Add(ship); @@ -265,7 +265,7 @@ public sealed partial class MiningCut3Tests observer.Aim = float3(cos(heading), 0, sin(heading)); Check($"the reticle at {heading}", Cost(observer.TargetUnderReticle, TargetRef.None), reticle); } - s.Zone.Entities.Remove(observer); + s.Zone.Remove(observer); } } Assert.True(failures.Count == 0, string.Join("; ", failures)); @@ -336,10 +336,9 @@ public sealed partial class MiningCut3Tests var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); Assert.True(ship.TryEquip(Mint(s, s.Eye))); Assert.True(ship.TryEquip(Mint(s, launcher))); - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, at); ship.Aim = float3(0, 0, 1); - ship.Activate(); Tick((ship, at)); Assert.Contains(ship.Weapons, w => w.Range == 400f); Assert.True(AllChunks(s).Any(c => length(At(s, c) - at) <= 400f && ship.ChunkVisible(c)), "there are rocks within the launcher's range"); @@ -375,7 +374,7 @@ public sealed partial class MiningCut3Tests private static Ship Unseen(Scene s, float2 at) { var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings()); - s.Zone.Entities.Add(ship); + s.Zone.Admit(ship, piloted: false); Hold(ship, at); return ship; } diff --git a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs index 4eddd2731..614a402a5 100644 --- a/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs +++ b/tests/Aetheria.Shared.Tests/MunitionPartsTests.cs @@ -266,8 +266,7 @@ public sealed class MunitionPartsTests : IDisposable Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf(gear), Durability = gear.Durability, Lot = 1 }, new int2(x, y)), name); } Assert.True(ship.TryEquip(new EquippableItem { Data = cache.RefOf(gt), Durability = gt.Durability, Lot = 2 }, hardpoint.Position)); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); // Settle at the item's optimal temperature, as ConditionRatioTests does, so heat and wear read their identity and only // the role's quality moves a stat. var item = ship.Equipment.Single(e => e.Data.Name == "GT 3K"); diff --git a/tests/Aetheria.Shared.Tests/PaintModeTests.cs b/tests/Aetheria.Shared.Tests/PaintModeTests.cs index 2928e2907..00245412f 100644 --- a/tests/Aetheria.Shared.Tests/PaintModeTests.cs +++ b/tests/Aetheria.Shared.Tests/PaintModeTests.cs @@ -125,8 +125,7 @@ public sealed class PaintModeTests : IDisposable var gearRef2 = _items.ItemData.RefOf(_items.ItemData.GetByName(sensor2)); Assert.True(ship.TryEquip(new EquippableItem { Data = gearRef2, Durability = 1, Lot = _nextLot++ }, new int2(1, 0))); } - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); ship.Position = position; ship.SetSignature(new object(), SignatureChannel.Reflection, 5f, null); zone.Update(0f); // brings the gear online @@ -334,7 +333,7 @@ public sealed class PaintModeTests : IDisposable if (tick == 2 && illuminatorDies) { Assert.True(SensorOf(a).PingRadius < 100f, "fixture: A dies before its wavefront reaches T"); - _zone.Entities.Remove(a); + _zone.Remove(a); } track.Add(Track(c, t)); } @@ -425,7 +424,7 @@ public sealed class PaintModeTests : IDisposable for (var tick = 1; tick <= 24; tick++) { Tick(.1f); - if (tick == removePingerAfterTick) _zone.Entities.Remove(a); + if (tick == removePingerAfterTick) _zone.Remove(a); Near(tick <= 3 ? 0f : .21213f, Track(c, t)); Assert.Equal(tick == 4 ? 1 : 0, t.IncomingPaints.Count); } @@ -686,7 +685,7 @@ public sealed class PaintModeTests : IDisposable SensorOf(a).Ping(); for (var tick = 1; tick <= 3; tick++) Tick(.1f); Assert.True(SensorOf(a).PingRadius > 50f); // the front had passed 50 m by the move - a.Deactivate(); _zone.Entities.Remove(a); a.Zone = z2; z2.Admit(a, false); + a.Deactivate(); _zone.Remove(a); a.Zone = z2; z2.Admit(a, false); var paints = 0; for (var tick = 1; tick <= 24; tick++) { z2.Update(.1f); paints += near.IncomingPaints.Count + far.IncomingPaints.Count; } Assert.Equal(0, paints); @@ -706,8 +705,8 @@ public sealed class PaintModeTests : IDisposable var paints = 0; for (var tick = 1; tick <= 30; tick++) { - if (tick == 5) { _zone.Entities.Remove(t); t.Deactivate(); } - if (tick == 8) { _zone.Entities.Add(t); t.Activate(); } + if (tick == 5) { _zone.Remove(t); t.Deactivate(); } + if (tick == 8) { _zone.Admit(t, piloted: false); } Tick(.1f); paints += t.IncomingPaints.Count; } diff --git a/tests/Aetheria.Shared.Tests/PowerBusTests.cs b/tests/Aetheria.Shared.Tests/PowerBusTests.cs index c0817c6a0..2ab76f599 100644 --- a/tests/Aetheria.Shared.Tests/PowerBusTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerBusTests.cs @@ -101,8 +101,7 @@ public sealed class PowerBusTests : IDisposable if (reactorFirst) { EquipReactor(); EquipCapacitor(); } else { EquipCapacitor(); EquipReactor(); } - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); var reactor = ship.GetBehavior(); var capacitor = ship.GetBehavior(); diff --git a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs index 183238e55..e0e03b4ea 100644 --- a/tests/Aetheria.Shared.Tests/PowerCurveTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerCurveTests.cs @@ -103,8 +103,7 @@ public sealed class PowerCurveTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Engine")))); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return ship; } @@ -408,9 +407,8 @@ public sealed class PowerCurveTests : IDisposable var ship = new Ship(items, zone, hullItem, new EntitySettings()); Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Drain")), HardpointCell)); Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Booster")), SecondHardpointCell)); - zone.Entities.Add(ship); - var error = Assert.Throws(() => ship.Activate()); + var error = Assert.Throws(() => zone.Admit(ship, piloted: false)); Assert.Contains("Drain", error.Message); // the item whose request would be corrupted Assert.Contains("Booster", error.Message); // the item whose modifier would corrupt it } diff --git a/tests/Aetheria.Shared.Tests/PowerTierTests.cs b/tests/Aetheria.Shared.Tests/PowerTierTests.cs index 256b9bb26..ecdbd4b2d 100644 --- a/tests/Aetheria.Shared.Tests/PowerTierTests.cs +++ b/tests/Aetheria.Shared.Tests/PowerTierTests.cs @@ -72,8 +72,7 @@ public sealed class PowerTierTests : IDisposable ((ReactorData) reactorData.Behaviors[0]).Charge = Constant(reactorCharge); Assert.True(ship.TryEquip(Mint(cache, items, reactorData))); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); var reactor = ship.GetBehavior(); ship.Update(0f); diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 4086e4b4a..7bca4194a 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -314,9 +314,8 @@ public sealed class RestoredHullsTests Assert.True(ship.TryEquip(fillerItem, filled.Position)); } - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Position = float3.zero; - ship.Activate(); zone.Update(0f); // warm-up: resolves equipment stats before the driven ticks read them return ship; } @@ -618,9 +617,8 @@ public sealed class RestoredHullsTests var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null); var ship = (Ship) EntitySerializer.Unpack(items, zone, pack); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Position = float3.zero; - ship.Activate(); zone.Update(0f); ship.MovementDirection = float2(0, 1); // full forward diff --git a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs index 58f0654d9..d84519034 100644 --- a/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs +++ b/tests/Aetheria.Shared.Tests/ShieldReserveTests.cs @@ -95,8 +95,7 @@ public sealed class ShieldReserveTests : IDisposable var shieldData = cache.GetByName("Shield"); Assert.True(ship.TryEquip(Mint(cache, items, shieldData))); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return (ship, ship.GetBehavior()); } diff --git a/tests/Aetheria.Shared.Tests/SignatureTests.cs b/tests/Aetheria.Shared.Tests/SignatureTests.cs index c9b7efb8a..2e8ea58ed 100644 --- a/tests/Aetheria.Shared.Tests/SignatureTests.cs +++ b/tests/Aetheria.Shared.Tests/SignatureTests.cs @@ -159,8 +159,7 @@ public sealed class SignatureTests : IDisposable Assert.True(ship.TryEquip(item, at)); } - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); ship.Aim = float3(0, 0, 1); zone.Update(1f); return rig; diff --git a/tests/Aetheria.Shared.Tests/SolutionTests.cs b/tests/Aetheria.Shared.Tests/SolutionTests.cs index d9e55cc9c..d3a18141c 100644 --- a/tests/Aetheria.Shared.Tests/SolutionTests.cs +++ b/tests/Aetheria.Shared.Tests/SolutionTests.cs @@ -189,8 +189,7 @@ public sealed partial class FireControlCut124Tests var e = Build(TestSettings(), SolidShape(5, 4), hardpointArc: SolutionArc, weaponRange: 200f); var hull = e.Items.ItemData.RefOf(e.HullData); var decoy = new Ship(e.Items, e.Zone, new EquippableItem { Data = hull, Durability = 1000000, Lot = 400 }, new EntitySettings()); - e.Zone.Entities.Add(decoy); - decoy.Activate(); + e.Zone.Admit(decoy, piloted: false); var at = radians(40f); decoy.Position = e.Shooter.Position + float3(sin(at), 0, cos(at)) * 100f; e.Shooter.VisibleEntities.Add(decoy); diff --git a/tests/Aetheria.Shared.Tests/StatResolverTests.cs b/tests/Aetheria.Shared.Tests/StatResolverTests.cs index c7d32f337..aefb239c1 100644 --- a/tests/Aetheria.Shared.Tests/StatResolverTests.cs +++ b/tests/Aetheria.Shared.Tests/StatResolverTests.cs @@ -71,8 +71,7 @@ public sealed class StatResolverTests : IDisposable Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Battery")), HardpointCell)); if (withBooster) Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Booster")), SecondHardpointCell)); - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); return ship; } @@ -287,9 +286,8 @@ public sealed class StatResolverTests : IDisposable var hullItem = Mint(cache, items, cache.GetByName("Skiff")); var ship = new Ship(items, zone, hullItem, new EntitySettings()); Assert.True(ship.TryEquip(Mint(cache, items, cache.GetByName("Loop")), HardpointCell)); - zone.Entities.Add(ship); - var error = Assert.Throws(() => ship.Activate()); + var error = Assert.Throws(() => zone.Admit(ship, piloted: false)); Assert.Contains(ship.Name, error.Message); // the entity, by name ("Skiff", HullData.Name) Assert.Contains(nameof(CapacitorData), error.Message); Assert.Contains(nameof(CapacitorData.Capacity), error.Message); @@ -513,8 +511,7 @@ public sealed class StatResolverTests : IDisposable Assert.True(ship.TryEquip(boost, cells[i + 1])); } ship.OverrideShutdown = true; - zone.Entities.Add(ship); - ship.Activate(); + zone.Admit(ship, piloted: false); Func mint = null; if (consumable != null) diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index b621f7b87..c965069d4 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -147,8 +147,7 @@ public sealed class TargetingSystemTests : IDisposable targetingItem = target.Equipment.Single(e => e.EquippableItem == targeting); } - zone.Entities.Add(target); - target.Activate(); + zone.Admit(target, piloted: false); return (items, target, weapon, wideItem, narrowItem, targetingItem); } @@ -160,8 +159,7 @@ public sealed class TargetingSystemTests : IDisposable var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("TestHull")); var hull = new EquippableItem { Data = hullRef, Durability = 1, Lot = 1 }; var observer = new Ship(items, zone, hull, new EntitySettings()); - zone.Entities.Add(observer); - observer.Activate(); + zone.Admit(observer, piloted: false); return observer; } @@ -351,9 +349,8 @@ public sealed class TargetingSystemTests : IDisposable targeting.Lot = items.Lots.Add(new Lot { Design = cache.RefOf(targetingData), Origin = new Attributed(), Quality = .5f, Roles = new System.Collections.Generic.List() }); Assert.True(ship.TryEquip(targeting)); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Aim = float3(0, 0, 1); - ship.Activate(); return ship; } diff --git a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs index 58c1fe356..8511e371a 100644 --- a/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs +++ b/tests/Aetheria.Shared.Tests/ThrottleLockTests.cs @@ -105,9 +105,8 @@ public sealed class ThrottleLockTests : IDisposable } Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.CounterClockwise), new int2(1, 3))); Assert.True(ship.TryEquip(Mint(cache, items, gear, ItemRotation.Clockwise), new int2(3, 3))); - zone.Entities.Add(ship); + zone.Admit(ship, piloted: false); ship.Aim = float3(0, 0, 1); - ship.Activate(); // A thruster is live from its first performance update; the allocator reads that every tick. foreach (var item in ship.Equipment) item.UpdatePerformance(); diff --git a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs index 58ede257f..51e270f0c 100644 --- a/tests/Aetheria.Shared.Tests/VapourCloudTests.cs +++ b/tests/Aetheria.Shared.Tests/VapourCloudTests.cs @@ -130,10 +130,8 @@ public sealed class VapourCloudTests : IDisposable // Held off until a test pulls the trigger: the dump vents on its first run. lab.Dump.Enabled.Value = false; - zone.Entities.Add(lab.Observer); - zone.Entities.Add(lab.Target); - lab.Observer.Activate(); - lab.Target.Activate(); + zone.Admit(lab.Observer, piloted: false); + zone.Admit(lab.Target, piloted: false); lab.Observer.Position = float3(0, 0, 0); lab.Observer.Aim = float3(0, 0, 1); lab.Target.Position = float3(0, 0, distance); From 2565e2c5d7661bbc46f850f594dc7640940b2378 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 02:17:26 +0200 Subject: [PATCH 02/23] Retire the test that mirrored Unity's loot handler; route FireControlCut8Tests joins through Admit Builds: with fa911f2d this is the tests project at the head of the Zone encapsulation; fa911f2d alone does not build (FireControlCut8Tests still called the removed Entities.Add). Co-Authored-By: Claude Opus 5.5 --- .../FireControlCut8Tests.cs | 49 ++----------------- 1 file changed, 4 insertions(+), 45 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs index cb81bbe68..68e06aa85 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut8Tests.cs @@ -138,10 +138,8 @@ public sealed class FireControlCut8Tests : IDisposable var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 4 }; Assert.True(target.TryEquip(targetGun, new int2(0, 0))); - zone.Entities.Add(shooter); - zone.Entities.Add(target); - shooter.Activate(); - target.Activate(); + zone.Admit(shooter, piloted: false); + zone.Admit(target, piloted: false); shooter.Position = float3.zero; target.Position = float3(0, 0, 100); @@ -217,10 +215,8 @@ public sealed class FireControlCut8Tests : IDisposable var bystanderGun = new EquippableItem { Data = bystanderGunRef, Durability = 1, Lot = 4 }; Assert.True(bystander.TryEquip(bystanderGun, new int2(0, 0))); // _orderedEquipment must not be empty/null for Update to run - zone.Entities.Add(locker); - zone.Entities.Add(bystander); - locker.Activate(); - bystander.Activate(); + zone.Admit(locker, piloted: false); + zone.Admit(bystander, piloted: false); locker.Position = float3.zero; bystander.Position = float3(0, 0, 100); @@ -298,41 +294,4 @@ public sealed class FireControlCut8Tests : IDisposable Assert.Empty(e.Target.EntityInfoGathered); Assert.True(e.Target.VisibleEntities.Count == 0, "the target's visible set must be empty"); } - - // ---- Ordering risk (8.1's own callout): EntityInstance's loot-drop subscription (EntityInstance.cs:292-325) - // reads Entity.Equipment on Entity.HullDamage, independent of Entity.Death/Deactivate. EntityInstance - // itself is a MonoBehaviour and cannot run headlessly (it lives in the Gameplay assembly, not - // Aetheria.Shared), so the actual drop call (ZoneRenderer.DropItem) is an operator check, not something - // this suite reaches. What this suite CAN confirm headlessly is the data precondition that call depends - // on: Deactivate() must not clear Equipment, regardless of subscription order relative to Zone's own - // Death-triggered removal. ---- - - // Mirrors EntityInstance's own subscription shape (HullDamage, re-checking Durability itself) established - // AFTER the target has already joined the zone -- so Zone's Death-derived Deactivate (subscribed at join - // time, per 8.1) fires first on the same HullDamage.OnNext call, and this handler observes Equipment - // strictly after that. If Deactivate ever starts clearing Equipment, this goes red. - [Fact] - public void LootStillDropsOnDeath_EquipmentSurvivesDeactivate() - { - var e = Build(TestSettings(), damage: 10000, hullDurability: 5); // one hit is lethal - - List equipmentAtDeath = null; - e.Target.HullDamage.Subscribe(_ => - { - if (e.Target.Hull.Durability < .01f) equipmentAtDeath = e.Target.Equipment.ToList(); - }); - - FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); - e.Zone.Update(.1f); - - Assert.True(e.Target.Hull.Durability < .01f); - Assert.False(e.Target.Active); // Zone's Death subscription (subscribed before this test's own) already - // deactivated the target by the time this handler runs. - Assert.NotNull(equipmentAtDeath); - Assert.NotEmpty(equipmentAtDeath); // the target's own gun -- Deactivate must not have cleared Equipment - - // Operator check, not reachable headlessly: confirm in play that ZoneRenderer.DropItem still fires for - // each surviving equipment/cargo entry (EntityInstance.cs:292-325) after this cut, i.e. that loot - // actually drops on a live kill, not only that the data behind it survived Deactivate(). - } } From c7c7e58508dedeb7ac876e66a1501a7a652995bd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 02:36:47 +0200 Subject: [PATCH 03/23] Unity draws Zone.FloatingItems and nothing else; the Unity loot roll, collision pickup, tractor pull and GridObject are gone ZoneRenderer draws one ItemPickup per FloatingItem (load, ObserveAdd, ObserveRemove, ClearZone); ItemPickup.Bind places it from the sim body and spins it for show. EntityInstance's drop loops, ShieldManager's pickup branch (the whole OnCollisionEnter, which did nothing else), ZoneRenderer.DropItem/DestroyLoot/_loot, TractorBeam's pull, and GameSettings' three loot fields are deleted; WireFloatingItems stripped GridObject from the five pickup prefabs and reassigned ZoneRenderer's four pickup fields in ARPG.unity; FloatingItemsDrawnSmoke (play session, batchmode) pins the lowering. GridObject and TimedDestroy were deleted in fa911f2d, so fa911f2d and 2565e2c5 do not build in Unity. Assets/Resources/Settings.asset keeps three stale serialized GameSettings fields Unity ignores (LFS object, not rewritten). Builds: Unity batchmode compile green at this commit; dotnet green at 2565e2c5. Co-Authored-By: Claude Opus 5.5 --- .../Prefabs/RPG/Pickups/Dodecahedron.prefab | 63 +++-- Assets/Prefabs/RPG/Pickups/Hexahedron.prefab | 63 +++-- Assets/Prefabs/RPG/Pickups/Icosahedron.prefab | 63 +++-- Assets/Prefabs/RPG/Pickups/Octahedron.prefab | 63 +++-- Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab | 63 +++-- Assets/Scenes/ARPG.unity | 8 +- .../Scripts/Editor/FloatingItemsDrawnSmoke.cs | 227 ++++++++++++++++++ .../Editor/FloatingItemsDrawnSmoke.cs.meta | 2 + Assets/Scripts/Editor/WireFloatingItems.cs | 75 ++++++ .../Scripts/Editor/WireFloatingItems.cs.meta | 2 + Assets/Scripts/Gameplay/EntityInstance.cs | 21 -- Assets/Scripts/Gameplay/GameSettings.cs | 3 - Assets/Scripts/Gameplay/ItemPickup.cs | 39 ++- Assets/Scripts/Gameplay/ShieldManager.cs | 34 --- Assets/Scripts/Gameplay/TractorBeam.cs | 17 +- Assets/Scripts/Zone Display/ZoneRenderer.cs | 85 ++++--- 16 files changed, 576 insertions(+), 252 deletions(-) create mode 100644 Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs create mode 100644 Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs.meta create mode 100644 Assets/Scripts/Editor/WireFloatingItems.cs create mode 100644 Assets/Scripts/Editor/WireFloatingItems.cs.meta diff --git a/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab b/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab index 431b3c1a2..5d4f837d1 100644 --- a/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Dodecahedron.prefab @@ -23,6 +23,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 4094535758000554889} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -30,7 +31,6 @@ Transform: m_Children: - {fileID: 9133920759869389018} m_Father: {fileID: 8489385781787202308} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &5284461483464579928 GameObject: @@ -44,7 +44,6 @@ GameObject: - component: {fileID: 2667259809042191123} - component: {fileID: 2093307059618178533} - component: {fileID: 8815906420840494942} - - component: {fileID: 8842807112802394818} - component: {fileID: 8689774408369415534} - component: {fileID: 5705448000916375656} - component: {fileID: -5496574226213385675} @@ -62,6 +61,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 5284461483464579928} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 2.76403, y: 0, z: 0} m_LocalScale: {x: 3, y: 3, z: 3} @@ -69,7 +69,6 @@ Transform: m_Children: - {fileID: 6463386671854692106} m_Father: {fileID: 0} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!33 &2667259809042191123 MeshFilter: @@ -96,6 +95,11 @@ MeshRenderer: m_ReflectionProbeUsage: 1 m_RayTracingMode: 2 m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -118,9 +122,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!135 &8815906420840494942 SphereCollider: @@ -130,29 +136,19 @@ SphereCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 5284461483464579928} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Radius: 0.91187096 m_Center: {x: 0, y: 0, z: 0} ---- !u!114 &8842807112802394818 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 5284461483464579928} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 737f47c158214f0995ba2835f040ed96, type: 3} - m_Name: - m_EditorClassIdentifier: - RotationSpeed: 1 - GridOffset: 0 - GridAttraction: 10 - Gravity: 1 - Drag: 0.05 - LaunchDrag: 0.2 --- !u!54 &8689774408369415534 Rigidbody: m_ObjectHideFlags: 0 @@ -160,10 +156,21 @@ Rigidbody: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 5284461483464579928} - serializedVersion: 2 + serializedVersion: 5 m_Mass: 1 - m_Drag: 0 - m_AngularDrag: 0.05 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + m_CenterOfMass: {x: 0, y: 0, z: 0} + m_InertiaTensor: {x: 1, y: 1, z: 1} + m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ImplicitCom: 1 + m_ImplicitTensor: 1 m_UseGravity: 0 m_IsKinematic: 1 m_Interpolate: 0 @@ -181,6 +188,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0c18f67d85af44c2b5afc8ecd15ce960, type: 3} m_Name: m_EditorClassIdentifier: + RotationSpeed: 1 + GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 @@ -204,6 +213,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 6463386671854692106} m_Modifications: - target: {fileID: 2945231343692618236, guid: e7877477dc889804ea9d04f7ffd08e9b, @@ -327,6 +337,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: e7877477dc889804ea9d04f7ffd08e9b, type: 3} --- !u!114 &3355762817961458026 stripped MonoBehaviour: diff --git a/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab b/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab index fea9f67a9..91f1c2ad3 100644 --- a/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Hexahedron.prefab @@ -23,6 +23,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 3193517496555256095} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -30,7 +31,6 @@ Transform: m_Children: - {fileID: 7638477100053754924} m_Father: {fileID: 3419130779256825702} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &7643266616531142933 GameObject: @@ -44,7 +44,6 @@ GameObject: - component: {fileID: 3269639735116279291} - component: {fileID: 7427774873968414741} - component: {fileID: 674324171579366365} - - component: {fileID: 7449398549621940518} - component: {fileID: 4507103896627202189} - component: {fileID: 648083621841515580} - component: {fileID: -5443764149998352794} @@ -62,6 +61,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7643266616531142933} + serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 3, y: 3, z: 3} @@ -69,7 +69,6 @@ Transform: m_Children: - {fileID: 3601651121922051457} m_Father: {fileID: 0} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!33 &3269639735116279291 MeshFilter: @@ -96,6 +95,11 @@ MeshRenderer: m_ReflectionProbeUsage: 1 m_RayTracingMode: 2 m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -118,9 +122,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!135 &674324171579366365 SphereCollider: @@ -130,29 +136,19 @@ SphereCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7643266616531142933} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Radius: 0.8 m_Center: {x: 0, y: 0, z: 0} ---- !u!114 &7449398549621940518 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 7643266616531142933} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 737f47c158214f0995ba2835f040ed96, type: 3} - m_Name: - m_EditorClassIdentifier: - RotationSpeed: 1 - GridOffset: 0 - GridAttraction: 10 - Gravity: 1 - Drag: 0.05 - LaunchDrag: 0.2 --- !u!114 &4507103896627202189 MonoBehaviour: m_ObjectHideFlags: 0 @@ -165,6 +161,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0c18f67d85af44c2b5afc8ecd15ce960, type: 3} m_Name: m_EditorClassIdentifier: + RotationSpeed: 1 + GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 @@ -178,10 +176,21 @@ Rigidbody: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7643266616531142933} - serializedVersion: 2 + serializedVersion: 5 m_Mass: 1 - m_Drag: 0 - m_AngularDrag: 0.05 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + m_CenterOfMass: {x: 0, y: 0, z: 0} + m_InertiaTensor: {x: 1, y: 1, z: 1} + m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ImplicitCom: 1 + m_ImplicitTensor: 1 m_UseGravity: 0 m_IsKinematic: 1 m_Interpolate: 0 @@ -204,6 +213,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 3601651121922051457} m_Modifications: - target: {fileID: 2945231343692618236, guid: e7877477dc889804ea9d04f7ffd08e9b, @@ -327,6 +337,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: e7877477dc889804ea9d04f7ffd08e9b, type: 3} --- !u!114 &4202270197902620060 stripped MonoBehaviour: diff --git a/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab b/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab index e99ae3392..95973ba56 100644 --- a/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Icosahedron.prefab @@ -23,6 +23,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 6430764275721745537} + serializedVersion: 2 m_LocalRotation: {x: 0.7069834, y: -0, z: -0, w: 0.7072302} m_LocalPosition: {x: 0, y: -0.9858858, z: 0.000344038} m_LocalScale: {x: 1, y: 1, z: 1} @@ -30,7 +31,6 @@ Transform: m_Children: - {fileID: 5373456011921669618} m_Father: {fileID: 7548484292876150345} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &7167547457198401877 GameObject: @@ -44,7 +44,6 @@ GameObject: - component: {fileID: 7390838957595368880} - component: {fileID: 6960669510133879758} - component: {fileID: 3507902894264904200} - - component: {fileID: 8747099621875196907} - component: {fileID: 1545255963217739444} - component: {fileID: -5409019571952974707} - component: {fileID: -2501508196100734606} @@ -62,6 +61,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7167547457198401877} + serializedVersion: 2 m_LocalRotation: {x: -0.7069834, y: 0, z: 0, w: 0.7072302} m_LocalPosition: {x: -0, y: 0, z: -2.9576573} m_LocalScale: {x: 3, y: 3, z: 3} @@ -69,7 +69,6 @@ Transform: m_Children: - {fileID: 4002800527770282731} m_Father: {fileID: 0} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: -89.98, y: 0, z: 0} --- !u!33 &7390838957595368880 MeshFilter: @@ -96,6 +95,11 @@ MeshRenderer: m_ReflectionProbeUsage: 1 m_RayTracingMode: 2 m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -118,9 +122,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!135 &3507902894264904200 SphereCollider: @@ -130,29 +136,19 @@ SphereCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7167547457198401877} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Radius: 0.8303433 m_Center: {x: 0, y: 0, z: 0} ---- !u!114 &8747099621875196907 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 7167547457198401877} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 737f47c158214f0995ba2835f040ed96, type: 3} - m_Name: - m_EditorClassIdentifier: - RotationSpeed: 1 - GridOffset: 0 - GridAttraction: 10 - Gravity: 1 - Drag: 0.05 - LaunchDrag: 0.2 --- !u!114 &1545255963217739444 MonoBehaviour: m_ObjectHideFlags: 0 @@ -165,6 +161,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0c18f67d85af44c2b5afc8ecd15ce960, type: 3} m_Name: m_EditorClassIdentifier: + RotationSpeed: 1 + GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 @@ -178,10 +176,21 @@ Rigidbody: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7167547457198401877} - serializedVersion: 2 + serializedVersion: 5 m_Mass: 1 - m_Drag: 0 - m_AngularDrag: 0.05 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + m_CenterOfMass: {x: 0, y: 0, z: 0} + m_InertiaTensor: {x: 1, y: 1, z: 1} + m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ImplicitCom: 1 + m_ImplicitTensor: 1 m_UseGravity: 0 m_IsKinematic: 1 m_Interpolate: 0 @@ -204,6 +213,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 4002800527770282731} m_Modifications: - target: {fileID: 2945231343692618236, guid: e7877477dc889804ea9d04f7ffd08e9b, @@ -327,6 +337,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: e7877477dc889804ea9d04f7ffd08e9b, type: 3} --- !u!114 &1928241908402748482 stripped MonoBehaviour: diff --git a/Assets/Prefabs/RPG/Pickups/Octahedron.prefab b/Assets/Prefabs/RPG/Pickups/Octahedron.prefab index 7ba7910d2..5957a7295 100644 --- a/Assets/Prefabs/RPG/Pickups/Octahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Octahedron.prefab @@ -23,6 +23,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 3932408193141279893} + serializedVersion: 2 m_LocalRotation: {x: 0.7069834, y: -0, z: -0, w: 0.7072302} m_LocalPosition: {x: 0, y: 0.9264834, z: -0.0003232956} m_LocalScale: {x: 1, y: 1, z: 1} @@ -30,7 +31,6 @@ Transform: m_Children: - {fileID: 7372978312502119311} m_Father: {fileID: 5807113421007930924} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &8555821447211356933 GameObject: @@ -44,7 +44,6 @@ GameObject: - component: {fileID: 4899765339662518078} - component: {fileID: 1691511938472729356} - component: {fileID: 3657818610818556929} - - component: {fileID: 946329779588188460} - component: {fileID: 7390748043139687254} - component: {fileID: -4611973708976329780} - component: {fileID: -8908388773057984787} @@ -62,6 +61,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 8555821447211356933} + serializedVersion: 2 m_LocalRotation: {x: -0.7069834, y: 0, z: 0, w: 0.7072302} m_LocalPosition: {x: -0, y: 0, z: 2.7794502} m_LocalScale: {x: 3, y: 3, z: 3} @@ -69,7 +69,6 @@ Transform: m_Children: - {fileID: 2388751103296260666} m_Father: {fileID: 0} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: -89.98, y: 0, z: 0} --- !u!33 &4899765339662518078 MeshFilter: @@ -96,6 +95,11 @@ MeshRenderer: m_ReflectionProbeUsage: 1 m_RayTracingMode: 2 m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -118,9 +122,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!135 &3657818610818556929 SphereCollider: @@ -130,29 +136,19 @@ SphereCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 8555821447211356933} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Radius: 0.75 m_Center: {x: 0, y: 0, z: 0} ---- !u!114 &946329779588188460 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 8555821447211356933} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 737f47c158214f0995ba2835f040ed96, type: 3} - m_Name: - m_EditorClassIdentifier: - RotationSpeed: 1 - GridOffset: 0 - GridAttraction: 10 - Gravity: 1 - Drag: 0.05 - LaunchDrag: 0.2 --- !u!114 &7390748043139687254 MonoBehaviour: m_ObjectHideFlags: 0 @@ -165,6 +161,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0c18f67d85af44c2b5afc8ecd15ce960, type: 3} m_Name: m_EditorClassIdentifier: + RotationSpeed: 1 + GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 @@ -178,10 +176,21 @@ Rigidbody: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 8555821447211356933} - serializedVersion: 2 + serializedVersion: 5 m_Mass: 1 - m_Drag: 0 - m_AngularDrag: 0.05 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + m_CenterOfMass: {x: 0, y: 0, z: 0} + m_InertiaTensor: {x: 1, y: 1, z: 1} + m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ImplicitCom: 1 + m_ImplicitTensor: 1 m_UseGravity: 0 m_IsKinematic: 1 m_Interpolate: 0 @@ -204,6 +213,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 2388751103296260666} m_Modifications: - target: {fileID: 2945231343692618236, guid: e7877477dc889804ea9d04f7ffd08e9b, @@ -327,6 +337,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: e7877477dc889804ea9d04f7ffd08e9b, type: 3} --- !u!114 &3891726626588969535 stripped MonoBehaviour: diff --git a/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab b/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab index 26c2b96dd..8688b6a3b 100644 --- a/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab +++ b/Assets/Prefabs/RPG/Pickups/Tetrahedron.prefab @@ -23,6 +23,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 2238948151922859802} + serializedVersion: 2 m_LocalRotation: {x: 0.7069834, y: -0, z: -0, w: 0.7072302} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} @@ -30,7 +31,6 @@ Transform: m_Children: - {fileID: 3300919539099916088} m_Father: {fileID: 8414659055235772867} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &7767746557817481743 GameObject: @@ -44,7 +44,6 @@ GameObject: - component: {fileID: 2868629223358676395} - component: {fileID: 6959931328227918623} - component: {fileID: 4540815661693075899} - - component: {fileID: 4008709970769624704} - component: {fileID: 7436534270673427838} - component: {fileID: -6589342969031261525} - component: {fileID: 5724758014884646323} @@ -62,6 +61,7 @@ Transform: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7767746557817481743} + serializedVersion: 2 m_LocalRotation: {x: -0.7069834, y: 0, z: 0, w: 0.7072302} m_LocalPosition: {x: -0, y: 0, z: 0} m_LocalScale: {x: 3, y: 3, z: 3} @@ -69,7 +69,6 @@ Transform: m_Children: - {fileID: 7682789761269032435} m_Father: {fileID: 0} - m_RootOrder: 0 m_LocalEulerAnglesHint: {x: -89.98, y: 0, z: 0} --- !u!33 &2868629223358676395 MeshFilter: @@ -96,6 +95,11 @@ MeshRenderer: m_ReflectionProbeUsage: 1 m_RayTracingMode: 2 m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 m_RenderingLayerMask: 1 m_RendererPriority: 0 m_Materials: @@ -118,9 +122,11 @@ MeshRenderer: m_AutoUVMaxDistance: 0.5 m_AutoUVMaxAngle: 89 m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 m_SortingLayerID: 0 m_SortingLayer: 0 m_SortingOrder: 0 + m_MaskInteraction: 0 m_AdditionalVertexStreams: {fileID: 0} --- !u!135 &4540815661693075899 SphereCollider: @@ -130,29 +136,19 @@ SphereCollider: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7767746557817481743} m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 m_IsTrigger: 0 + m_ProvidesContacts: 0 m_Enabled: 1 - serializedVersion: 2 + serializedVersion: 3 m_Radius: 0.60371214 m_Center: {x: 0, y: 0, z: 0} ---- !u!114 &4008709970769624704 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 7767746557817481743} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 737f47c158214f0995ba2835f040ed96, type: 3} - m_Name: - m_EditorClassIdentifier: - RotationSpeed: 1 - GridOffset: 0 - GridAttraction: 10 - Gravity: 1 - Drag: 0.05 - LaunchDrag: 0.2 --- !u!114 &7436534270673427838 MonoBehaviour: m_ObjectHideFlags: 0 @@ -165,6 +161,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0c18f67d85af44c2b5afc8ecd15ce960, type: 3} m_Name: m_EditorClassIdentifier: + RotationSpeed: 1 + GridOffset: 0 LabelFadeDuration: 0.5 LabelPersistDuration: 3 LabelDisplayAngle: 15 @@ -178,10 +176,21 @@ Rigidbody: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 7767746557817481743} - serializedVersion: 2 + serializedVersion: 5 m_Mass: 1 - m_Drag: 0 - m_AngularDrag: 0.05 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + m_CenterOfMass: {x: 0, y: 0, z: 0} + m_InertiaTensor: {x: 1, y: 1, z: 1} + m_InertiaRotation: {x: 0, y: 0, z: 0, w: 1} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ImplicitCom: 1 + m_ImplicitTensor: 1 m_UseGravity: 0 m_IsKinematic: 1 m_Interpolate: 0 @@ -204,6 +213,7 @@ PrefabInstance: m_ObjectHideFlags: 0 serializedVersion: 2 m_Modification: + serializedVersion: 3 m_TransformParent: {fileID: 7682789761269032435} m_Modifications: - target: {fileID: 2945231343692618236, guid: e7877477dc889804ea9d04f7ffd08e9b, @@ -327,6 +337,9 @@ PrefabInstance: value: 0 objectReference: {fileID: 0} m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] m_SourcePrefab: {fileID: 100100000, guid: e7877477dc889804ea9d04f7ffd08e9b, type: 3} --- !u!224 &3300919539099916088 stripped RectTransform: diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index a11091e71..10157af03 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -16404,13 +16404,13 @@ MonoBehaviour: LODHandler: {fileID: 1009503736} SlimeRenderer: {fileID: 1254851254} SlimeGravityCamera: {fileID: 1066823975} - SimpleCommodityPickup: {fileID: 4008709970769624704, guid: 521bfeece7dd5fd4e8f166d38493f18c, + SimpleCommodityPickup: {fileID: 7436534270673427838, guid: 521bfeece7dd5fd4e8f166d38493f18c, type: 3} - CompoundCommodityPickup: {fileID: 7449398549621940518, guid: 366909b6a01d7254d8eef01c333f8e00, + CompoundCommodityPickup: {fileID: 4507103896627202189, guid: 366909b6a01d7254d8eef01c333f8e00, type: 3} - GearPickup: {fileID: 946329779588188460, guid: d2b7e9a9cd7733041b0553682e4b61af, + GearPickup: {fileID: 7390748043139687254, guid: d2b7e9a9cd7733041b0553682e4b61af, type: 3} - WeaponPickup: {fileID: 8842807112802394818, guid: d2cec1d8c3d94074e9d2e6088e1c02d7, + WeaponPickup: {fileID: 5705448000916375656, guid: d2cec1d8c3d94074e9d2e6088e1c02d7, type: 3} MinePrefab: {fileID: 7283754118921506074, guid: b04728b2b65e057429eb6e8529299c8a, type: 3} diff --git a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs new file mode 100644 index 000000000..f350d9c8f --- /dev/null +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections; +using System.IO; +using System.Linq; +using GameCult.Caching; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using float2 = CultMath.float2; + +// Batch proof that ZoneRenderer draws Zone.FloatingItems and nothing else, in a play session with no scene of its own: +// the pickup prefabs are the ones ARPG.unity's ZoneRenderer names (so the wiring is read here too), a zone is loaded +// into a fresh ZoneRenderer, and the zone's own Release, step, expiry and TryPickUp are what move and remove the +// pickups. Zone.Release adds exactly one ItemPickup, placed at the body's position; stepping the zone moves it with the +// body; expiry and TryPickUp each destroy it; an item already in the zone when the renderer loads it is drawn too. +// +// Unity -batchmode -nographics -projectPath -executeMethod FloatingItemsDrawnSmoke.Run -logFile +// +// Do not pass -quit: Run enters play mode (a domain reload), the check runs there, and the Editor exits itself, 0 on +// success and 1 on any failure. Destroy only takes effect in a play session, which is why this is not an edit-mode check. +[InitializeOnLoad] +public static class FloatingItemsDrawnSmoke +{ + private const string Flag = "FloatingItemsDrawnSmoke.running"; + private const string Wiring = "FloatingItemsDrawnSmoke.wiring"; + private static IEnumerator _check; + private static int _frames; + private static string _work; + + static FloatingItemsDrawnSmoke() + { + if (SessionState.GetBool(Flag, false)) EditorApplication.playModeStateChanged += OnPlayMode; + } + + public static void Run() + { + try + { + SessionState.SetString(Wiring, ReadWiring()); + SessionState.SetBool(Flag, true); + EditorApplication.EnterPlaymode(); + } + catch (Exception exception) + { + Debug.LogException(exception); + EditorApplication.Exit(1); + } + } + + private static void OnPlayMode(PlayModeStateChange change) + { + if (change != PlayModeStateChange.EnteredPlayMode) return; + _work = Path.Combine(Path.GetTempPath(), $"floating-items-smoke-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_work); + _check = Check(); + EditorApplication.update += Step; + } + + private static void Step() + { + try + { + if (++_frames > 3000) throw new InvalidOperationException("The check did not finish in 3000 frames."); + if (_check.MoveNext()) return; + Finish(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + Finish(1); + } + } + + private static void Finish(int code) + { + EditorApplication.update -= Step; + SessionState.SetBool(Flag, false); + try { if (Directory.Exists(_work)) Directory.Delete(_work, true); } catch (Exception) { /* a temp directory */ } + EditorApplication.Exit(code); + } + + private static void Require(bool condition, string what) + { + if (!condition) throw new InvalidOperationException(what); + } + + private static IEnumerator Check() + { + var settings = AssetDatabase.LoadAssetAtPath("Assets/Resources/Settings.asset")?.GameplaySettings + ?? throw new InvalidOperationException("No Settings.asset."); + + // The wiring: the scene's ZoneRenderer names an ItemPickup prefab in each of the four fields, and none of those + // prefabs keeps a script that no longer exists. + var wired = SessionState.GetString(Wiring, "").Split('|').Select(entry => entry.Split('=')).Select(pair => + (Field: pair[0], Pickup: pair[1] == "" ? null : AssetDatabase.LoadAssetAtPath(pair[1])?.GetComponent())).ToArray(); + Require(wired.Length == 4, "The wiring was not carried into the play session."); + foreach (var (name, pickup) in wired) + { + Require(pickup != null, $"ZoneRenderer.{name} is not an ItemPickup in ARPG.unity."); + Require(pickup.ScanLabel != null && pickup.ScanLabelContainer != null, $"{name}'s prefab has no scan label."); + Require(!pickup.GetComponentsInChildren(true).Any(c => c == null), $"{name}'s prefab has a missing script."); + } + + var shape = new Shape(5, 5); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + var cargoShape = new Shape(4, 4); + foreach (var cell in cargoShape.AllCoordinates) cargoShape[cell] = true; + var oneCell = new Shape(1, 1); + oneCell[new CultMath.int2(0, 0)] = true; + var cache = AetheriaStores.Open(Path.Combine(_work, "smoke.cc"), catalogWritable: true); + cache.Upsert(new VerseGrammar { Revision = 1 }); + cache.Upsert(new HullData { Name = "Smoke Hub", HullType = HullType.Station, Shape = shape, Durability = 1000, Mass = 1000 }); + cache.Upsert(new CargoBayData { Name = "Smoke Crate", Shape = new Shape(), InteriorShape = cargoShape, Durability = 4 }); + cache.Upsert(new GearData + { + Name = "Smoke Spare", Hardpoint = HardpointType.Tool, Shape = oneCell, Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.Upsert(new SimpleCommodityData { Name = "Smoke Ore", Shape = oneCell, MaxStack = 100 }); + cache.FlushAsync().Wait(); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 8; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), null, null); + EquippableItem Make(string name, int lot) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = 1, Lot = lot + }; + + // A station with a crate takes the picked-up stack. It is not admitted: the zone holds no entity for the renderer to draw. + var hubRef = items.ItemData.RefOf(items.ItemData.GetByName("Smoke Hub")); + var taker = new OrbitalEntity(items, zone, new EquippableItem { Data = hubRef, Durability = 1000, Lot = 1 }, default, new EntitySettings()); + Require(taker.TryEquip(Make("Smoke Crate", 2), new CultMath.int2(1, 1)), "The smoke station took no crate."); + + // An item already in the zone when the renderer loads it: drawn from the load, not only from the add. + var early = zone.Release(Make("Smoke Spare", 3), new float2(5, 6), float2.zero); + + // The renderer's side. Inactive while it is configured, so it runs no Awake or Start of the scene's own. + var rootObject = new GameObject("Smoke Zone Root"); + var rendererObject = new GameObject("Smoke Zone Renderer"); + rendererObject.SetActive(false); + var renderer = rendererObject.AddComponent(); + renderer.ZoneRoot = rootObject.transform; + foreach (var (name, pickup) in wired) + typeof(ZoneRenderer).GetField(name).SetValue(renderer, pickup); + renderer.ItemManager = items; + renderer.SectorBrushes = new GameObject("Smoke Brushes").transform; + renderer.SlimeGravityCamera = new GameObject("Smoke Gravity Camera").AddComponent(); + var slimeObject = new GameObject("Smoke Slime"); + slimeObject.SetActive(false); // the real Slime needs its compute shader; LoadZone only sets its radius + renderer.SlimeRenderer = slimeObject.AddComponent(); + renderer.LoadZone(zone); + yield return null; + + ItemPickup[] Pickups() => rootObject.GetComponentsInChildren(true).Where(p => p != null).ToArray(); + void At(ItemPickup pickup, FloatingItem floating, string what) + { + var at = floating.Body.Position; + var want = new Vector3(at.x, zone.GetHeight(at), at.y); + Require(Vector3.Distance(pickup.transform.position, want) < 1e-3f, + $"{what}: the pickup is at {pickup.transform.position}, the body at {want}."); + } + + Require(Pickups().Length == 1, $"load: {Pickups().Length} pickups for one existing floating item."); + At(Pickups()[0], early, "load"); + + // Release adds exactly one pickup, at the body's position, bound to its item. + var ore = new SimpleCommodity { Data = items.ItemData.RefOf(items.ItemData.GetByName("Smoke Ore")), Quantity = 7 }; + var released = zone.Release(ore, new float2(3, 4), new float2(10, 0)); + yield return null; + Require(Pickups().Length == 2, $"release: {Pickups().Length} pickups for two floating items."); + var drawn = Pickups().Single(p => p.Item == ore); + At(drawn, released, "release"); + + // Stepping the zone moves the sim body; the pickup follows it on its next update, with nothing of its own. + var before = drawn.transform.position; + zone.Update(.5f); + yield return null; + Require(released.Body.Position.x > 3.5f, "the sim body did not drift."); + At(drawn, released, "step"); + Require(drawn.transform.position != before, "the pickup did not move with the body."); + + // Pickup destroys exactly the picked-up item's pickup. + Require(zone.TryPickUp(taker, released.Id), "the station refused a one-cell stack."); + yield return null; + yield return null; + Require(drawn == null, "pickup: the pickup is still there."); + Require(Pickups().Length == 1 && Pickups()[0].Item == early.Item, "pickup: the wrong pickup went."); + + // Expiry destroys the pickup of an item nobody took. + zone.Update(settings.FloatingItemLifetime + 1f); + yield return null; + yield return null; + Require(zone.FloatingItems.Count == 0, "expiry: the item did not expire."); + Require(Pickups().Length == 0, $"expiry: {Pickups().Length} pickups left."); + + // A later release is drawn again, and clearing the renderer takes what is left. + zone.Release(Make("Smoke Spare", 4), new float2(1, 1), float2.zero); + yield return null; + Require(Pickups().Length == 1, "re-release: not drawn."); + renderer.ClearZone(); + yield return null; + yield return null; + Require(Pickups().Length == 0, "clear: pickups left after ClearZone."); + Debug.Log("FLOATING_ITEMS_SMOKE ok"); + } + + // What ARPG.unity's ZoneRenderer names in each pickup field, read from the saved scene before the play session (a + // scene cannot be opened inside one) and carried across the domain reload as the prefabs' asset paths. + private static string ReadWiring() + { + var scene = EditorSceneManager.OpenScene("Assets/Scenes/ARPG.unity", OpenSceneMode.Additive); + try + { + var found = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None) + .Where(r => r.gameObject.scene == scene).ToArray(); + Require(found.Length == 1, $"ARPG.unity has {found.Length} ZoneRenderers."); + var wired = found[0]; + return string.Join("|", new[] + { + ("SimpleCommodityPickup", wired.SimpleCommodityPickup), ("CompoundCommodityPickup", wired.CompoundCommodityPickup), + ("GearPickup", wired.GearPickup), ("WeaponPickup", wired.WeaponPickup) + }.Select(f => f.Item1 + "=" + (f.Item2 == null ? "" : AssetDatabase.GetAssetPath(f.Item2)))); + } + finally { EditorSceneManager.CloseScene(scene, true); } + } +} diff --git a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs.meta b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs.meta new file mode 100644 index 000000000..51e4b34a7 --- /dev/null +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 67544a932f794a2458741c731ef04d11 \ No newline at end of file diff --git a/Assets/Scripts/Editor/WireFloatingItems.cs b/Assets/Scripts/Editor/WireFloatingItems.cs new file mode 100644 index 000000000..a853ad7e0 --- /dev/null +++ b/Assets/Scripts/Editor/WireFloatingItems.cs @@ -0,0 +1,75 @@ +using System; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +// Detaches the deleted GridObject from the pickup prefabs and points ZoneRenderer's four pickup fields in ARPG.unity at +// each prefab's ItemPickup, then saves the prefabs and the scene. Idempotent: a prefab with no missing script is left as +// it is, and the fields are set to the same values again. +// +// Unity -batchmode -projectPath -executeMethod WireFloatingItems.Run -logFile +// +// Do not pass -quit: Run exits the Editor itself, 0 on success and 1 on any failure. The menu item runs the same wiring. +public static class WireFloatingItems +{ + private const string ScenePath = "Assets/Scenes/ARPG.unity"; + private const string PickupFolder = "Assets/Prefabs/RPG/Pickups/"; + // The field each prefab filled before this cut (read from ARPG.unity at the cut's base). Icosahedron fills none. + private static readonly (string Field, string Prefab)[] Fields = + { + ("SimpleCommodityPickup", "Tetrahedron"), + ("CompoundCommodityPickup", "Hexahedron"), + ("GearPickup", "Octahedron"), + ("WeaponPickup", "Dodecahedron"), + }; + private static readonly string[] Prefabs = { "Tetrahedron", "Hexahedron", "Octahedron", "Dodecahedron", "Icosahedron" }; + + [MenuItem("Aetheria/Wire Floating Items")] + public static void Menu() => Wire(); + + public static void Run() + { + try + { + Wire(); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + EditorApplication.Exit(1); + } + } + + private static void Wire() + { + foreach (var name in Prefabs) + { + var path = PickupFolder + name + ".prefab"; + var contents = PrefabUtility.LoadPrefabContents(path); + try + { + var removed = GameObjectUtility.RemoveMonoBehavioursWithMissingScript(contents); + if (contents.GetComponent() == null) throw new InvalidOperationException($"{path} has no ItemPickup."); + if (removed > 0) PrefabUtility.SaveAsPrefabAsset(contents, path); + Debug.Log($"WIRE_FLOATING_ITEMS {name} removed={removed}"); + } + finally { PrefabUtility.UnloadPrefabContents(contents); } + } + + var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single); + var renderer = UnityEngine.Object.FindFirstObjectByType(); + if (renderer == null) throw new InvalidOperationException("ARPG.unity has no ZoneRenderer."); + var serialized = new SerializedObject(renderer); + foreach (var (field, prefab) in Fields) + { + var pickup = AssetDatabase.LoadAssetAtPath(PickupFolder + prefab + ".prefab").GetComponent(); + var property = serialized.FindProperty(field) ?? throw new InvalidOperationException($"ZoneRenderer has no {field}."); + property.objectReferenceValue = pickup; + } + serialized.ApplyModifiedProperties(); + EditorSceneManager.MarkSceneDirty(scene); + if (!EditorSceneManager.SaveScene(scene)) throw new InvalidOperationException("The scene did not save."); + Debug.Log("WIRE_FLOATING_ITEMS ok"); + } +} diff --git a/Assets/Scripts/Editor/WireFloatingItems.cs.meta b/Assets/Scripts/Editor/WireFloatingItems.cs.meta new file mode 100644 index 000000000..c84e2a8c8 --- /dev/null +++ b/Assets/Scripts/Editor/WireFloatingItems.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9a56d7dc8471d224f9bed434b39f7c87 \ No newline at end of file diff --git a/Assets/Scripts/Gameplay/EntityInstance.cs b/Assets/Scripts/Gameplay/EntityInstance.cs index 56617ab5a..2d6cbb88e 100644 --- a/Assets/Scripts/Gameplay/EntityInstance.cs +++ b/Assets/Scripts/Gameplay/EntityInstance.cs @@ -301,27 +301,6 @@ public class EntityInstance : MonoBehaviour { if (!this) return; _destroyed = true; - foreach (var gear in Entity.Equipment) - { - if (gear != Entity.EquippedHull && Random.value < ZoneRenderer.Settings.LootDropProbability) - { - ZoneRenderer.DropItem( - Entity.Position.ToUnity(), - Random.onUnitSphere * ZoneRenderer.Settings.LootDropVelocity, - gear.EquippableItem); - } - } - - foreach (var cargo in Entity.CargoBays) - { - foreach (var item in cargo.Cargo.Keys) - { - ZoneRenderer.DropItem( - Entity.Position.ToUnity(), - Random.onUnitSphere * ZoneRenderer.Settings.LootDropVelocity, - item); - } - } if (DestroyEffect != null) { var t = Instantiate(DestroyEffect).transform; diff --git a/Assets/Scripts/Gameplay/GameSettings.cs b/Assets/Scripts/Gameplay/GameSettings.cs index 2548f1a13..d2b3df1d8 100644 --- a/Assets/Scripts/Gameplay/GameSettings.cs +++ b/Assets/Scripts/Gameplay/GameSettings.cs @@ -11,9 +11,6 @@ using UnityEngine; public class GameSettings : ScriptableObject { public string StartingHullName = "Longinus"; - public float PickupLifetime = 30; - public float LootDropProbability = .25f; - public float LootDropVelocity = 25; public float HeatstrokePhasingFloor = 0; public float HeatstrokePhasingFrequency = 5; public float WormholeDistanceRatio; diff --git a/Assets/Scripts/Gameplay/ItemPickup.cs b/Assets/Scripts/Gameplay/ItemPickup.cs index 2a6621194..16805e7d3 100644 --- a/Assets/Scripts/Gameplay/ItemPickup.cs +++ b/Assets/Scripts/Gameplay/ItemPickup.cs @@ -4,9 +4,15 @@ using CultMath; using CultMath.UnityBridge; using UnityEngine; using static CultMath.math; +using Random = UnityEngine.Random; +// How one floating item looks. Zone owns the item's every fact (existence, position, expiry, pickup); once bound, +// this reads the latest sim body and draws, spun for show, and writes nothing back (rulings unity-presents-only-r3 +// and sim-speed-presentation). An unbound pickup (FieldTester's test scene) leaves its transform alone. public class ItemPickup : MonoBehaviour { + public float RotationSpeed = 1f; + public float GridOffset; public float LabelFadeDuration = .5f; public float LabelPersistDuration = 3; public float LabelDisplayAngle = 15; @@ -15,14 +21,38 @@ public class ItemPickup : MonoBehaviour public TextMeshPro ScanLabel; public ItemInstance Item { get; set; } - public ZoneRenderer ZoneRenderer { get; set; } public float3 ViewOrigin { get; set; } public float3 ViewDirection { get; set; } private float _displayTime; + private float _timeOffset; + private Zone _zone; + private FloatingItem _floating; + + public void Bind(Zone zone, FloatingItem floating) + { + _zone = zone; + _floating = floating; + Item = floating.Item; + _timeOffset = Random.value * 100; + Place(); + } + + private void Place() + { + var p = _floating.Body.Position; + transform.position = new Vector3(p.x, _zone.GetHeight(p) + GridOffset, p.y); + } private void Update() { + if (_floating != null) + { + Place(); + transform.localRotation = Quaternion.Euler( + sin(Time.time - _timeOffset * RotationSpeed) * 90, 0, cos(Time.time - _timeOffset * RotationSpeed) * 90); + } + var diff = transform.position.ToCultMath() - ViewOrigin; var toThis = normalize(diff); var viewAngle = acos(dot(toThis, ViewDirection)) * Mathf.Rad2Deg; @@ -34,9 +64,4 @@ public class ItemPickup : MonoBehaviour ScanLabel.color = c; ScanLabelContainer.rotation = Quaternion.LookRotation((-toThis).ToUnity()); } - - private void OnDestroy() - { - ZoneRenderer?.DestroyLoot(this); - } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Gameplay/ShieldManager.cs b/Assets/Scripts/Gameplay/ShieldManager.cs index 7e380c17b..eaf35f747 100644 --- a/Assets/Scripts/Gameplay/ShieldManager.cs +++ b/Assets/Scripts/Gameplay/ShieldManager.cs @@ -52,40 +52,6 @@ public class ShieldManager : MonoBehaviour, IAbsorbPresenter Debug.LogWarning("ShieldManager has no ShieldEnvelope; falling back to its own transform for the hit direction.", this); } - private void OnCollisionEnter(Collision other) - { - var otherShield = other.collider.GetComponent(); - if (!otherShield) - { - var gridObject = other.collider.GetComponent(); - if (gridObject) - { - var itemPickup = other.collider.GetComponent(); - if (itemPickup) - { - if (Entity.CargoBays.Any(c => c.TryStore(itemPickup.Item))) - { - // TODO: Pickup notification! - Destroy(itemPickup.gameObject); - } - else - { - // TODO: Pickup failed notification! - var cp = other.GetContact(0); - gridObject.Velocity += cp.normal * 25; - Debug.Log("Attempted item pickup, but no space in cargo bay!"); - } - } - else - { - Debug.Log("Shield collision occurred with grid object, but other collider isn't an item pickup!"); - } - } - else - Debug.Log("Shield collision occurred, but other collider isn't a shield or a grid object!"); - } - } - // A shot the shield took ripples it where the shot landed, drawn on the hull as it is drawn. The hull layer is // ImpactPresenter's. public void Absorb(AbsorbEvent e) diff --git a/Assets/Scripts/Gameplay/TractorBeam.cs b/Assets/Scripts/Gameplay/TractorBeam.cs index 579cc813f..8bb8aa294 100644 --- a/Assets/Scripts/Gameplay/TractorBeam.cs +++ b/Assets/Scripts/Gameplay/TractorBeam.cs @@ -14,20 +14,9 @@ public class TractorBeam : MonoBehaviour public void Update() { transform.rotation = Quaternion.LookRotation(Direction); + // The beam draws nothing until it presents a sim grab (loot-3): no particles, and no pull; the simulation + // decides every floating item's motion. var emission = ParticleSystem.emission; - if (Power > .01f) - { - var objectFound = false; - foreach (var o in Physics.SphereCastAll(transform.position, Radius, Direction, Distance, 1 << 21)) - { - var gridObject = o.collider.GetComponent(); - if (gridObject == null) continue; - objectFound = true; - gridObject.Velocity += Vector3.Normalize(transform.position - gridObject.transform.position) * (Traction * Time.deltaTime); - } - - emission.rateOverTimeMultiplier = objectFound ? Power : 0; - } - else emission.rateOverTimeMultiplier = 0; + emission.rateOverTimeMultiplier = 0; } } \ No newline at end of file diff --git a/Assets/Scripts/Zone Display/ZoneRenderer.cs b/Assets/Scripts/Zone Display/ZoneRenderer.cs index d39e0d2ea..e24bd2eb2 100644 --- a/Assets/Scripts/Zone Display/ZoneRenderer.cs +++ b/Assets/Scripts/Zone Display/ZoneRenderer.cs @@ -40,10 +40,10 @@ public class ZoneRenderer : MonoBehaviour public LODHandler LODHandler; public Slime SlimeRenderer; public Camera SlimeGravityCamera; - public GridObject SimpleCommodityPickup; - public GridObject CompoundCommodityPickup; - public GridObject GearPickup; - public GridObject WeaponPickup; + public ItemPickup SimpleCommodityPickup; + public ItemPickup CompoundCommodityPickup; + public ItemPickup GearPickup; + public ItemPickup WeaponPickup; public MineInstance MinePrefab; public VapourCloudInstance CloudPrefab; public Material[] MapGravityMaterials; @@ -94,7 +94,7 @@ public class ZoneRenderer : MonoBehaviour private bool _showAsteroidUI; public Dictionary WormholeInstances = new Dictionary(); - private List _loot = new List(); + private Dictionary _floatingItems = new Dictionary(); private Dictionary _mines = new Dictionary(); private Dictionary _clouds = new Dictionary(); @@ -236,6 +236,15 @@ public class ZoneRenderer : MonoBehaviour instance.Blast(); })); + // Every floating item, whoever released it (a death drop, jettisoned cargo), is drawn through this one + // subscription. An item leaves Zone.FloatingItems by expiry or by pickup, and either way its pickup is gone. + foreach (var floating in zone.FloatingItems) LoadFloatingItem(zone, floating); + _zoneSubscriptions.Add(zone.FloatingItems.ObserveAdd().Subscribe(e => LoadFloatingItem(zone, e.Value))); + _zoneSubscriptions.Add(zone.FloatingItems.ObserveRemove().Subscribe(e => + { + if (_floatingItems.Remove(e.Value, out var pickup) && pickup) Destroy(pickup.gameObject); + })); + // Every vapour cloud, whoever vented it, is drawn through this one subscription. A cloud leaves Zone.Clouds // when its opacity has reached nothing, so a removal destroys its fog bank with nothing left to show. foreach (var cloud in zone.Clouds) LoadCloud(zone, cloud); @@ -274,6 +283,29 @@ public class ZoneRenderer : MonoBehaviour _mines.Add(mine, instance); } + private void LoadFloatingItem(Zone zone, FloatingItem floating) + { + var prefab = floating.Item switch + { + SimpleCommodity _ => SimpleCommodityPickup, + CompoundCommodity _ => CompoundCommodityPickup, + EquippableItem equippableItem when ItemManager.GetData(equippableItem) is WeaponItemData => WeaponPickup, + EquippableItem _ => GearPickup, + _ => throw new NotImplementedException() + }; + var pickup = Instantiate(prefab, ZoneRoot); + pickup.Bind(zone, floating); + pickup.ScanLabel.text = ItemManager.Title(floating.Item); + if (floating.Item is CraftedItemInstance craftedItemInstance) + { + var c = ItemManager.GetTier(craftedItemInstance).tier.Color.ToColor(); + c.a = 0; + pickup.ScanLabel.color = c; + } + else pickup.ScanLabel.color = new Color(.75f, .75f, .75f, 0); + _floatingItems.Add(floating, pickup); + } + private void LoadCloud(Zone zone, VapourCloud cloud) { var instance = Instantiate(CloudPrefab); @@ -312,9 +344,9 @@ public class ZoneRenderer : MonoBehaviour if (instance) Destroy(instance.gameObject); _clouds.Clear(); - foreach(var gridObject in _loot) - if(gridObject) Destroy(gridObject.gameObject); - _loot.Clear(); + foreach (var pickup in _floatingItems.Values) + if (pickup) Destroy(pickup.gameObject); + _floatingItems.Clear(); foreach (var entity in Zone.Entities) { @@ -487,7 +519,7 @@ public class ZoneRenderer : MonoBehaviour void Update() { var maxDepth = 0f; - foreach (var loot in _loot) + foreach (var loot in _floatingItems.Values) { loot.ViewOrigin = PerspectiveEntity.Position; loot.ViewDirection = PerspectiveEntity.Aim; @@ -603,41 +635,6 @@ public class ZoneRenderer : MonoBehaviour // MinimapGravityQuad.transform.position = gravPos; // MinimapTintQuad.transform.position = gravPos - Vector3.up*10; } - - public void DestroyLoot(ItemPickup loot) - { - _loot.Remove(loot); - } - - public void DropItem(Vector3 position, Vector3 velocity, ItemInstance item) - { - var gridObject = item switch - { - SimpleCommodity _ => Instantiate(SimpleCommodityPickup), - CompoundCommodity _ => Instantiate(CompoundCommodityPickup), - EquippableItem equippableItem when ItemManager.GetData(equippableItem) is WeaponItemData => Instantiate(WeaponPickup), - EquippableItem _ => Instantiate(GearPickup), - _ => throw new NotImplementedException() - }; - var t = gridObject.transform; - t.parent = ZoneRoot; - gridObject.Zone = Zone; - t.position = position; - gridObject.Velocity = velocity; - var itemPickup = gridObject.gameObject.GetComponent(); - itemPickup.Item = item; - itemPickup.ZoneRenderer = this; - itemPickup.ScanLabel.text = ItemManager.Title(item); - if (item is CraftedItemInstance craftedItemInstance) - { - var c = ItemManager.GetTier(craftedItemInstance).tier.Color.ToColor(); - c.a = 0; - itemPickup.ScanLabel.color = c; - } - else itemPickup.ScanLabel.color = new Color(.75f, .75f, .75f, 0); - gridObject.gameObject.AddComponent().Duration = Settings.PickupLifetime; - _loot.Add(itemPickup); - } } From e24d942b9d3a571ebc3b21b222ada30310b5c137 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 02:37:58 +0200 Subject: [PATCH 04/23] Smoke and wiring script: no GridObject or FloatingItemLifetime spelling outside the sim Builds: Unity batchmode compile green (smoke run unity-smoke5 compiled and passed at this commit). Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs | 2 +- Assets/Scripts/Editor/WireFloatingItems.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs index f350d9c8f..f67c0db10 100644 --- a/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs +++ b/Assets/Scripts/Editor/FloatingItemsDrawnSmoke.cs @@ -188,7 +188,7 @@ public static class FloatingItemsDrawnSmoke Require(Pickups().Length == 1 && Pickups()[0].Item == early.Item, "pickup: the wrong pickup went."); // Expiry destroys the pickup of an item nobody took. - zone.Update(settings.FloatingItemLifetime + 1f); + zone.Update(3600f); // far past any lifetime yield return null; yield return null; Require(zone.FloatingItems.Count == 0, "expiry: the item did not expire."); diff --git a/Assets/Scripts/Editor/WireFloatingItems.cs b/Assets/Scripts/Editor/WireFloatingItems.cs index a853ad7e0..8065401a5 100644 --- a/Assets/Scripts/Editor/WireFloatingItems.cs +++ b/Assets/Scripts/Editor/WireFloatingItems.cs @@ -3,7 +3,7 @@ using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; -// Detaches the deleted GridObject from the pickup prefabs and points ZoneRenderer's four pickup fields in ARPG.unity at +// Strips the deleted integrator component from the pickup prefabs and points ZoneRenderer's four pickup fields in ARPG.unity at // each prefab's ItemPickup, then saves the prefabs and the scene. Idempotent: a prefab with no missing script is left as // it is, and the fields are set to the same values again. // From 5aa989abbf8e307313ba83451aade21cb5369472 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 05:00:22 +0200 Subject: [PATCH 05/23] Pickup is a capability: PickupData/Pickup run a timed grab through Zone's hold API and TryPickUp (group A, sim) GrabEvent carries a FloatingBodyId; Zone gains TryHold/SetHeldProgress/Free and places a held item instead of integrating it; Entity.TractorPower is derived from Pickup.Power and TractorCommand is the pilot's input (ActionGameManager writes it undamped, the Time.deltaTime ramp is deleted). Binder and FieldTester change the delegate type only. PickupTests and a save round-trip pin for the player's zone index. Group A builds; no beam is on any generated ship until group B. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 4 +- .../Gameplay/CapabilityPresentationBinder.cs | 14 +- .../Scripts/Gameplay/CapabilityPresenters.cs | 2 +- .../ServerShared/Behaviors/Behaviors.cs | 1 + .../Scripts/ServerShared/Behaviors/Pickup.cs | 157 ++++++ .../ServerShared/Behaviors/Pickup.cs.meta | 11 + .../Scripts/ServerShared/CapabilityEvents.cs | 11 +- Assets/Scripts/ServerShared/Entity.cs | 6 +- Assets/Scripts/ServerShared/FloatingBodies.cs | 8 +- Assets/Scripts/ServerShared/Zone.cs | 45 +- Assets/Scripts/UI/FieldTester.cs | 8 +- .../CapabilityEventsTests.cs | 2 +- tests/Aetheria.Shared.Tests/PickupTests.cs | 448 ++++++++++++++++++ tests/Aetheria.Shared.Tests/RunSaveTests.cs | 46 ++ 14 files changed, 736 insertions(+), 27 deletions(-) create mode 100644 Assets/Scripts/ServerShared/Behaviors/Pickup.cs create mode 100644 Assets/Scripts/ServerShared/Behaviors/Pickup.cs.meta create mode 100644 tests/Aetheria.Shared.Tests/PickupTests.cs diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index aacf8b2b3..87d62d3e4 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1332,9 +1332,7 @@ public class ActionGameManager : MonoBehaviour TargetShieldsFill.fillAmount = target.Shield == null ? 0 : lerp(.25f, .75f, target.Shield.Progress); } - var tractorPower = Input.Player.TractorBeam.ReadValue(); - CurrentEntity.TractorPower = - saturate(CurrentEntity.TractorPower + sign(tractorPower - CurrentEntity.TractorPower) * Time.deltaTime * 2); + CurrentEntity.TractorCommand = Input.Player.TractorBeam.ReadValue(); } } // One clock steps the zone, paused or not: a paused frame feeds it no real time. diff --git a/Assets/Scripts/Gameplay/CapabilityPresentationBinder.cs b/Assets/Scripts/Gameplay/CapabilityPresentationBinder.cs index c2ada7f9d..b72a47e3a 100644 --- a/Assets/Scripts/Gameplay/CapabilityPresentationBinder.cs +++ b/Assets/Scripts/Gameplay/CapabilityPresentationBinder.cs @@ -13,15 +13,15 @@ using UnityEngine; public class CapabilityPresentationBinder : MonoBehaviour { private readonly List _subscriptions = new List(); - private Func _grabTargetResolver; - private Func _grabInitialVelocityResolver; + private Func _grabTargetResolver; + private Func _grabInitialVelocityResolver; // Subscribes every presenter interface implemented by components on this object or its children to // the matching stream of `events`. `grabTargetResolver`/`grabInitialVelocityResolver` resolve a - // GrabEvent's plain target handle to the live Transform/velocity a grab presenter needs; the owner + // GrabEvent's target id to the live Transform/velocity a grab presenter needs; the owner // of `events` supplies them (the test scene today; the real pickup capability's caller later). - public void Bind(CapabilityEvents events, Func grabTargetResolver = null, - Func grabInitialVelocityResolver = null) + public void Bind(CapabilityEvents events, Func grabTargetResolver = null, + Func grabInitialVelocityResolver = null) { Unbind(); if (events == null) return; @@ -40,8 +40,8 @@ public class CapabilityPresentationBinder : MonoBehaviour var p = presenter; _subscriptions.Add(events.Grab.Subscribe(e => { - var target = _grabTargetResolver?.Invoke(e.TargetHandle); - var velocity = _grabInitialVelocityResolver?.Invoke(e.TargetHandle) ?? Vector3.zero; + var target = _grabTargetResolver?.Invoke(e.Target); + var velocity = _grabInitialVelocityResolver?.Invoke(e.Target) ?? Vector3.zero; p.Grab(e, target, velocity); })); } diff --git a/Assets/Scripts/Gameplay/CapabilityPresenters.cs b/Assets/Scripts/Gameplay/CapabilityPresenters.cs index 34e46dd39..1bc01100e 100644 --- a/Assets/Scripts/Gameplay/CapabilityPresenters.cs +++ b/Assets/Scripts/Gameplay/CapabilityPresenters.cs @@ -11,7 +11,7 @@ public interface IAbsorbPresenter } // Grab needs a live Transform to animate, which the engine-free GrabEvent cannot carry (its -// TargetHandle is a plain identity, not a Unity reference). The binder resolves the handle through a +// Target is a plain identity, not a Unity reference). The binder resolves the id through a // delegate supplied by the event source's owner and hands the presenter the result, along with the // initial velocity the target was already moving with (also owner-supplied, for the same reason). public interface IGrabPresenter diff --git a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs index f1903fcb1..76147d0fe 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Behaviors.cs @@ -189,6 +189,7 @@ public interface IPowerConsumer Union(45, typeof(MunitionSeekerData)), Union(46, typeof(MunitionControlData)), Union(47, typeof(MunitionFuelData)), + Union(48, typeof(PickupData)), JsonObject(MemberSerialization.OptIn)] public abstract class BehaviorData { diff --git a/Assets/Scripts/ServerShared/Behaviors/Pickup.cs b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs new file mode 100644 index 000000000..8ff253e87 --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs @@ -0,0 +1,157 @@ +using System; +using System.Linq; +using CultMath; +using MessagePack; +using Newtonsoft.Json; +using static CultMath.math; +using float2 = CultMath.float2; + +// The tractor beam: the capability that lets a ship collect floating items. It spins its beam up in sim time toward +// the pilot's command, and once the beam is up claims the free item nearest its axis and runs one timed grab +// (extend, envelop, pull) that ends in Zone.TryPickUp, the one commit of a pickup. Its stats are flat +// (Min == Max), as the other behaviours that carry their own timings. +[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable] +public class PickupData : BehaviorData +{ + // How far along the beam's axis an item can be claimed, and how far off the axis. + [Inspectable, JsonProperty("range"), Key(1), RuntimeInspectable] + public PerformanceStat Range = new PerformanceStat(); + + [Inspectable, JsonProperty("beamRadius"), Key(2), RuntimeInspectable] + public PerformanceStat BeamRadius = new PerformanceStat(); + + // The spin-up (0..1) needed to claim an item, and to keep a grab going. + [Inspectable, JsonProperty("grabPower"), Key(3), RuntimeInspectable] + public PerformanceStat GrabPower = new PerformanceStat(); + + // Seconds of each phase of a grab. + [Inspectable, JsonProperty("extendTime"), Key(4), RuntimeInspectable] + public PerformanceStat ExtendTime = new PerformanceStat(); + + [Inspectable, JsonProperty("envelopTime"), Key(5), RuntimeInspectable] + public PerformanceStat EnvelopTime = new PerformanceStat(); + + [Inspectable, JsonProperty("pullTime"), Key(6), RuntimeInspectable] + public PerformanceStat PullTime = new PerformanceStat(); + + // Spin-up and spin-down, in power per sim second. + [Inspectable, JsonProperty("spinRate"), Key(7), RuntimeInspectable] + public PerformanceStat SpinRate = new PerformanceStat(); + + public override Behavior CreateInstance(EquippedItem item) + { + return new Pickup(this, item); + } +} + +// Owns this ship's beam power and its one grab. Power is written only here; Zone owns the held item's pose. +public class Pickup : Behavior, IAlwaysUpdatedBehavior +{ + private readonly PickupData _data; + // Power and the grab clock are kept in double so a fixed tick count lands on the same side of a boundary + // whatever dt the zone steps by. + private double _power; + private bool _grabbing; + private FloatingBodyId _target; + private double _elapsed; + // How many of Envelop and Pull have been published so far (0, 1 or 2). + private int _published; + + public Pickup(PickupData data, EquippedItem item) : base(data, item) + { + _data = data; + } + + // The beam's spun-up state in [0, 1]: what the pilot's command has driven it to. + public float Power => (float) _power; + + public void Update(float dt) + { + var active = Item.Active.Value; + var command = active ? Math.Clamp(Entity.TractorCommand, 0f, 1f) : 0f; + var step = (double) Evaluate(_data.SpinRate) * dt; + _power = Math.Clamp(_power + Math.Clamp(command - _power, -step, step), 0.0, 1.0); + + var zone = Entity.Zone; + var grabPower = Evaluate(_data.GrabPower); + var extend = (double) Evaluate(_data.ExtendTime); + var envelop = (double) Evaluate(_data.EnvelopTime); + var pull = (double) Evaluate(_data.PullTime); + var range = Evaluate(_data.Range); + var origin = Entity.Position.xz; + + if (!_grabbing) + { + if (!active || _power < grabPower) return; + var item = Select(zone, origin, range, Evaluate(_data.BeamRadius)); + if (item == null || !zone.TryHold(Entity, item.Id)) return; + _grabbing = true; + _target = item.Id; + _elapsed = 0; + _published = 0; + Publish(GrabPhase.Started, item, 0); + Publish(GrabPhase.Extend, item, (float) extend); + return; + } + + var held = zone.FloatingItems.FirstOrDefault(f => f.Id.Equals(_target)); + if (held == null || !ReferenceEquals(held.Holder, Entity)) + { + _grabbing = false; + Entity.Capabilities.PublishGrab(new GrabEvent(_target, GrabPhase.Cancelled, float3(0), 0)); + return; + } + + var pulling = _elapsed >= extend + envelop; + if (!active || _power < grabPower || (!pulling && length(held.Body.Position - origin) > range)) + { + _grabbing = false; + zone.Free(_target); + Publish(GrabPhase.Cancelled, held, 0); + return; + } + + _elapsed += dt; + if (_published < 1 && _elapsed >= extend) { _published = 1; Publish(GrabPhase.Envelop, held, (float) envelop); } + if (_published < 2 && _elapsed >= extend + envelop) { _published = 2; Publish(GrabPhase.Pull, held, (float) pull); } + if (_published == 2) + zone.SetHeldProgress(_target, pull > 0 ? (float) ((_elapsed - extend - envelop) / pull) : 1f); + if (_elapsed < extend + envelop + pull) return; + + _grabbing = false; + if (zone.TryPickUp(Entity, _target)) Publish(GrabPhase.Completed, held, 0); + else + { + zone.Free(_target); + Publish(GrabPhase.Cancelled, held, 0); + } + } + + // The free item nearest the beam axis within Range along it and BeamRadius of it; ties go to the lowest id. A + // zero aim selects nothing. + private FloatingItem Select(Zone zone, float2 origin, float range, float radius) + { + var aim = Entity.Aim.xz; + if (lengthsq(aim) <= 0) return null; + var axis = normalize(aim); + FloatingItem best = null; + var bestOff = float.MaxValue; + foreach (var floating in zone.FloatingItems) + { + if (floating.Holder != null) continue; + var to = floating.Body.Position - origin; + var along = dot(to, axis); + var off = length(to - axis * along); + if (along < 0 || along > range || off > radius) continue; + if (off < bestOff || (off == bestOff && floating.Id.Value < best.Id.Value)) + { + best = floating; + bestOff = off; + } + } + return best; + } + + private void Publish(GrabPhase phase, FloatingItem item, float duration) => + Entity.Capabilities.PublishGrab(new GrabEvent(item.Id, phase, float3(item.Body.Position.x, Entity.Position.y, item.Body.Position.y), duration)); +} diff --git a/Assets/Scripts/ServerShared/Behaviors/Pickup.cs.meta b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs.meta new file mode 100644 index 000000000..34947e359 --- /dev/null +++ b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ac7678d240564331be7486588d28c5ee +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/ServerShared/CapabilityEvents.cs b/Assets/Scripts/ServerShared/CapabilityEvents.cs index 8ee9edc55..025975815 100644 --- a/Assets/Scripts/ServerShared/CapabilityEvents.cs +++ b/Assets/Scripts/ServerShared/CapabilityEvents.cs @@ -52,19 +52,18 @@ public enum GrabPhase Cancelled } -// The target is identified by a plain handle, not a Unity type: the simulation will own the real -// target identity (a loot drop or item instance id) once the pickup capability exists. Today's only -// publisher (the FieldShieldTest scene) hands out its own handles; nothing here interprets them. +// One phase of a grab. The target is the floating item the Pickup behaviour holds (the FieldShieldTest scene +// publishes its own handles as ids); nothing here interprets it. public readonly struct GrabEvent { - public readonly int TargetHandle; + public readonly FloatingBodyId Target; public readonly GrabPhase Phase; public readonly float3 TargetPosition; public readonly float PhaseDuration; - public GrabEvent(int targetHandle, GrabPhase phase, float3 targetPosition, float phaseDuration) + public GrabEvent(FloatingBodyId target, GrabPhase phase, float3 targetPosition, float phaseDuration) { - TargetHandle = targetHandle; + Target = target; Phase = phase; TargetPosition = targetPosition; PhaseDuration = phaseDuration; diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index dc36a9622..882e36977 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -144,7 +144,11 @@ public abstract class Entity public bool OverrideShutdown { get; set; } - public float TractorPower { get; set; } + // The pilot's tractor command (0..1); the Pickup behaviour spins its beam toward it. + public float TractorCommand; + + // The beam's spun-up state: Pickup's, or 0 without one. + public float TractorPower => GetBehavior()?.Power ?? 0f; public bool Active { diff --git a/Assets/Scripts/ServerShared/FloatingBodies.cs b/Assets/Scripts/ServerShared/FloatingBodies.cs index e6a873b44..93e470676 100644 --- a/Assets/Scripts/ServerShared/FloatingBodies.cs +++ b/Assets/Scripts/ServerShared/FloatingBodies.cs @@ -42,7 +42,8 @@ public struct KinematicBody } // 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. +// step (expiry) and TryPickUp are its only removers. Zone.TryHold and Free are the only writers of Holder, the +// entity grabbing it with a tractor beam; a held item is placed by Zone, not drifted. Runtime state, never saved. public sealed class FloatingItem { public FloatingBodyId Id; @@ -50,4 +51,9 @@ public sealed class FloatingItem public KinematicBody Body; public double ReleasedAt; public float Lifetime; + // The entity holding it, or null when it floats free; HeldFrom is where it was claimed and HeldProgress how far + // along its pull to the holder (0..1). + public Entity Holder; + public float2 HeldFrom; + public float HeldProgress; } diff --git a/Assets/Scripts/ServerShared/Zone.cs b/Assets/Scripts/ServerShared/Zone.cs index a21329e0d..db96b4f79 100644 --- a/Assets/Scripts/ServerShared/Zone.cs +++ b/Assets/Scripts/ServerShared/Zone.cs @@ -48,7 +48,8 @@ public class Zone 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. + // adder; StepFloatingItems (expiry) and TryPickUp are the only removers. TryHold, Free and Remove-of-the-holder + // are the only writers of FloatingItem.Holder. Runtime state, never saved. public ReactiveCollection FloatingItems = new ReactiveCollection(); public Dictionary Planets = new Dictionary(); public Dictionary PlanetInstances = new Dictionary(); @@ -228,6 +229,8 @@ public class Zone if (!_entities.Remove(entity)) return false; _deathWatches.Remove(entity, out var watch); watch.Dispose(); + foreach (var floating in FloatingItems) + if (ReferenceEquals(floating.Holder, entity)) Free(floating.Id); return true; } @@ -330,14 +333,44 @@ public class Zone // 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. + // An item held by another entity is refused: one holder per item holds at the commit as well. 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; + if (floating == null || (floating.Holder != null && !ReferenceEquals(floating.Holder, taker))) return false; + if (!taker.CargoBays.Any(bay => bay.TryStore(floating.Item))) return false; FloatingItems.Remove(floating); return true; } + // The tractor beam's claim on a floating item: false if it is absent or already held. The item stays where it is + // until its pull begins; StepFloatingItems places it from then on. + public bool TryHold(Entity holder, FloatingBodyId id) + { + var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); + if (floating == null || floating.Holder != null) return false; + floating.Holder = holder; + floating.HeldFrom = floating.Body.Position; + floating.HeldProgress = 0; + return true; + } + + // How far along its pull to the holder a held item is, 0..1. + public void SetHeldProgress(FloatingBodyId id, float progress) + { + var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); + if (floating?.Holder != null) floating.HeldProgress = saturate(progress); + } + + // Lets a held item go: it floats on from where it is with the Velocity and Drift it had. + public void Free(FloatingBodyId id) + { + var floating = FloatingItems.FirstOrDefault(f => f.Id.Equals(id)); + if (floating == null) return; + floating.Holder = null; + floating.HeldProgress = 0; + } + // 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 @@ -359,12 +392,18 @@ public class Zone } // 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. + // and is removed once its lifetime has run out. A held item is neither integrated nor expired: it is placed + // between where it was claimed and its holder, now that the entities have moved. private void StepFloatingItems(float deltaTime) { var settings = _itemManager.GameplaySettings; foreach (var floating in FloatingItems.ToArray()) { + if (floating.Holder != null) + { + floating.Body.Position = lerp(floating.HeldFrom, floating.Holder.Position.xz, smoothstep(0f, 1f, floating.HeldProgress)); + continue; + } floating.Body.Step(deltaTime, GetForce(floating.Body.Position), settings); if (floating.ReleasedAt + floating.Lifetime <= _time) FloatingItems.Remove(floating); } diff --git a/Assets/Scripts/UI/FieldTester.cs b/Assets/Scripts/UI/FieldTester.cs index de18a33e5..a86e970c4 100644 --- a/Assets/Scripts/UI/FieldTester.cs +++ b/Assets/Scripts/UI/FieldTester.cs @@ -60,9 +60,9 @@ public class FieldTester : MonoBehaviour Debug.LogError("FieldTester expects a CapabilityPresentationBinder on TestField's GameObject; no capability events will reach the field."); else binder.Bind(_capabilityEvents, - grabTargetResolver: handle => handle >= 0 && handle < _pickups.Count ? _pickups[handle].transform : null, - grabInitialVelocityResolver: handle => Vector3.forward * - (PickupTravelDistance / lerp(PickupTravelTimeMin, PickupTravelTimeMax, _pickups[handle].lerp))); + grabTargetResolver: id => (int) id.Value >= 0 && (int) id.Value < _pickups.Count ? _pickups[(int) id.Value].transform : null, + grabInitialVelocityResolver: id => Vector3.forward * + (PickupTravelDistance / lerp(PickupTravelTimeMin, PickupTravelTimeMax, _pickups[(int) id.Value].lerp))); // Impact absorption (fork L): what used to be FieldDriver's own click handler is now this // scene's stand-in for whatever will eventually detect a hit and publish AbsorbEvent. @@ -102,7 +102,7 @@ public class FieldTester : MonoBehaviour if (!TestField.CanGrab) return; click.Clear(); var p = _pickups[i1]; - _capabilityEvents.PublishGrab(new GrabEvent(i1, GrabPhase.Started, p.transform.position.ToCultMath(), 0)); + _capabilityEvents.PublishGrab(new GrabEvent(new FloatingBodyId((uint) i1), GrabPhase.Started, p.transform.position.ToCultMath(), 0)); p.transform = null; _pickups[i1] = p; }; diff --git a/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs b/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs index 9876e7ad8..60e1c6b85 100644 --- a/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs +++ b/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs @@ -26,7 +26,7 @@ public sealed class CapabilityEventsTests events.Thrust.Subscribe(thrust.Add); var absorbEvent = new AbsorbEvent(float3(1, 2, 3), float3(0, 0, 1), 5f, DamageType.Corrosive, AbsorbLayer.Hull); - var grabEvent = new GrabEvent(7, GrabPhase.Started, float3(1, 0, 0), 1.5f); + var grabEvent = new GrabEvent(new FloatingBodyId(7), GrabPhase.Started, float3(1, 0, 0), 1.5f); var meleeEvent = new MeleeEvent(float3(0, 0, 1), 10f, 90f, 0.5f); var thrustEvent = new ThrustEvent(float2(1, 0), 0.5f, float2(0.2f, -0.2f)); diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs new file mode 100644 index 000000000..ede6010d3 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -0,0 +1,448 @@ +/* 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; + +// The tractor beam in the sim (aetheria-release cut loot-2): a Pickup behaviour spins its beam up in sim time and runs +// one timed grab of a floating item, ending in Zone.TryPickUp. Rulings: jettison-shared-floating-items, +// unity-presents-only-r3, sim-speed-presentation; the question loot-grab-release-cancels, built hold-required. Every +// grab here is run by Zone.Update ticking a ship's own gear: nothing calls the behaviour directly. +public sealed class PickupTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-pickup-" + Guid.NewGuid().ToString("N")); + private readonly List _caches = new List(); + + public PickupTests() => 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 static PickupData Beam(float extend = .3f, float envelop = .2f, float pull = .5f) => new PickupData + { + Range = Constant(75), BeamRadius = Constant(25), GrabPower = Constant(.5f), SpinRate = Constant(2), + ExtendTime = Constant(extend), EnvelopTime = Constant(envelop), PullTime = Constant(pull) + }; + + private sealed class Lab + { + public ItemManager Items; + public Zone Zone; + public int Lot = 1; + + public EquippableItem Make(string name) => new EquippableItem + { + Data = Items.ItemData.RefOf(Items.ItemData.GetByName(name)), Durability = 1, Lot = Lot++ + }; + + public SimpleCommodity Ore(int quantity) => new SimpleCommodity + { + Data = Items.ItemData.RefOf(Items.ItemData.GetByName("Ore")), Quantity = quantity + }; + } + + private Lab Build(PickupData beam, float lifetime = 30f) + { + 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 = 0, LootDropVelocity = 25, 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 }); + cache.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(5, 5), Durability = 1000000, Mass = 1000 }); + cache.Upsert(new GearData + { + Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = Solid(1, 1), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { beam } + }); + 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 ledger = new ProvenanceLedger(); + for (var i = 1; i <= 9000; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack { Radius = 0 }, new GalaxyZone { Name = "Pickup", Owner = null }, null); + return new Lab { Items = items, Zone = zone }; + } + + // A ship with a cargo bay, aimed along +x and (unless told otherwise) carrying the tractor beam. + private static Ship Spawn(Lab lab, float3 at, bool beam = 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()); + var crate = lab.Make("Crate"); + Assert.True(ship.TryFindSpace(crate, out var cratePos)); + Assert.True(ship.TryEquip(crate, cratePos)); + if (beam) Equip(ship, lab.Make("Tractor")); + lab.Zone.Admit(ship, piloted: false); + ship.Position = at; + ship.Aim = float3(1, 0, 0); + return ship; + } + + private static void Equip(Ship ship, EquippableItem gear) + { + Assert.True(ship.TryFindSpace(gear, out var pos)); + Assert.True(ship.TryEquip(gear, pos)); + } + + private static FloatingItem Drift(Lab lab, float x, float y, float2 velocity = default, int ore = 5) => + lab.Zone.Release(lab.Ore(ore), float2(x, y), velocity); + + private static List Watch(Entity ship, CompositeDisposable bag) + { + var events = new List(); + ship.Capabilities.Grab.Subscribe(events.Add).AddTo(bag); + return events; + } + + private static void Tick(Lab lab, int ticks, float dt) + { + for (var i = 0; i < ticks; i++) lab.Zone.Update(dt); + } + + private static void Kill(Entity e) => e.HeatstrokeDeath.OnNext(Unit.Default); + + [Fact] + public void NoPickupBehaviourNoGrab() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0), beam: false); + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Tick(lab, 240, 1 / 60f); + Assert.Null(item.Holder); + Assert.Contains(item, lab.Zone.FloatingItems); + Assert.Equal(0f, ship.TractorPower); + Assert.Empty(events); + + // The same ship with the beam equipped claims the item. + Equip(ship, lab.Make("Tractor")); + Tick(lab, 60, 1 / 60f); + Assert.Same(ship, item.Holder); + Assert.Equal(GrabPhase.Started, events.First().Phase); + Assert.True(ship.TractorPower > 0f); + } + + [Theory] + [InlineData(60)] + [InlineData(600)] + public void TheBeamSpinsUpInSimTime(int ticksPerSecond) + { + var dt = 1f / ticksPerSecond; + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0); + + // Power rises two per sim second and not faster, at any tick rate; the claim comes on the tick it passes GrabPower. + ship.TractorCommand = 1; + int claim = -1, full = -1; + var before = 0f; + for (var tick = 1; tick <= ticksPerSecond; tick++) + { + lab.Zone.Update(dt); + if (claim < 0 && item.Holder != null) + { + claim = tick; + Assert.True(ship.TractorPower >= .5f); + Assert.True(before < .5f); + } + if (full < 0 && ship.TractorPower >= 1f) full = tick; + before = ship.TractorPower; + } + Assert.Equal(ticksPerSecond / 4, claim); + Assert.Equal(ticksPerSecond / 2, full); + + // Command 0 spins it down at the same rate. + ship.TractorCommand = 0; + var empty = -1; + for (var tick = 1; tick <= ticksPerSecond; tick++) + { + lab.Zone.Update(dt); + if (empty < 0 && ship.TractorPower <= 0f) empty = tick; + } + Assert.Equal(ticksPerSecond / 2, empty); + } + + [Theory] + [InlineData(.3f, .2f, .5f, 60)] + [InlineData(1f, 1f, 1f, 180)] + public void AGrabTakesExactlyItsPhasesThenCommits(float extend, float envelop, float pull, int ticksAfterClaim) + { + foreach (var rate in new[] { 60, 600 }) + { + var dt = 1f / rate; + var lab = Build(Beam(extend, envelop, pull)); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0); + ship.TractorCommand = 1; + int claim = -1, commit = -1; + for (var tick = 1; tick <= rate * 6 && commit < 0; tick++) + { + lab.Zone.Update(dt); + if (claim < 0 && item.Holder != null) claim = tick; + if (!lab.Zone.FloatingItems.Contains(item)) commit = tick; + } + Assert.True(claim > 0); + Assert.Equal(ticksAfterClaim * rate / 60, commit - claim); + Assert.Equal(5, ship.CargoBays.Single().Cargo.Keys.OfType().Single().Quantity); + } + } + + [Fact] + public void GrabEventsNameTheFloatingItem() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var other = Drift(lab, 40, 20); + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Tick(lab, 150, 1 / 60f); + Assert.Equal(new[] { GrabPhase.Started, GrabPhase.Extend, GrabPhase.Envelop, GrabPhase.Pull, GrabPhase.Completed }, + events.Take(5).Select(e => e.Phase).ToArray()); + Assert.All(events.Take(5), e => Assert.Equal(item.Id, e.Target)); + Assert.NotEqual(other.Id, item.Id); + Assert.Equal(.3f, events[1].PhaseDuration, 4); + Assert.Equal(.2f, events[2].PhaseDuration, 4); + Assert.Equal(.5f, events[3].PhaseDuration, 4); + } + + [Fact] + public void ReleasingTheBeamCancelsAndTheItemFloatsFree() + { + using var bag = new CompositeDisposable(); + const float dt = 1 / 60f; + var lab = Build(Beam(), lifetime: 2f); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0, float2(3, 1)); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + var tick = 0; + while (!events.Any(e => e.Phase == GrabPhase.Pull) && tick++ < 600) lab.Zone.Update(dt); + Assert.Same(ship, item.Holder); + var velocity = item.Body.Velocity; + var drift = item.Body.Drift; + + // The key comes up during the pull: Power falls below GrabPower and the grab is dropped. + ship.TractorCommand = 0; + while (!events.Any(e => e.Phase == GrabPhase.Cancelled) && tick++ < 600) lab.Zone.Update(dt); + Assert.True(tick < 110); + Assert.DoesNotContain(events, e => e.Phase == GrabPhase.Completed); + Assert.Null(item.Holder); + Assert.Contains(item, lab.Zone.FloatingItems); + Assert.Equal(velocity, item.Body.Velocity); + Assert.Equal(drift, item.Body.Drift); + + // It drifts on with that velocity and expires on the clock it was released on, not from the cancel. + var at = item.Body.Position; + lab.Zone.Update(dt); + Assert.True(item.Body.Position.x > at.x); + while (lab.Zone.FloatingItems.Contains(item) && tick++ < 600) lab.Zone.Update(dt); + Assert.InRange(tick, 118, 122); + } + + [Fact] + public void AFullHoldCancelsAtTheEnd() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var bay = ship.CargoBays.Single(); + for (var i = 0; i < 16; i++) Assert.True(bay.TryStore(lab.Make("Spare"))); + Assert.False(bay.TryStore(lab.Make("Spare"))); + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + var tick = 0; + while (!events.Any(e => e.Phase == GrabPhase.Cancelled) && tick++ < 300) lab.Zone.Update(1 / 60f); + Assert.Equal(new[] { GrabPhase.Started, GrabPhase.Extend, GrabPhase.Envelop, GrabPhase.Pull, GrabPhase.Cancelled }, + events.Select(e => e.Phase).ToArray()); + Assert.Null(item.Holder); + Assert.Contains(item, lab.Zone.FloatingItems); + Assert.Equal(16, bay.Cargo.Count); + } + + [Fact] + public void OneHolderPerItem() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var a = Spawn(lab, float3(0, 0, 0)); + var b = Spawn(lab, float3(0, 0, 10)); + var eventsA = Watch(a, bag); + var eventsB = Watch(b, bag); + + // One item, two beams: exactly one claims it, the other never does, and the non-holder cannot take it. + var only = Drift(lab, 40, 5); + a.TractorCommand = 1; + b.TractorCommand = 1; + Tick(lab, 20, 1 / 60f); + Assert.Equal(1, new[] { eventsA, eventsB }.Count(e => e.Any(x => x.Phase == GrabPhase.Started))); + var holder = only.Holder; + var loser = ReferenceEquals(holder, a) ? b : a; + Assert.False(lab.Zone.TryPickUp(loser, only.Id)); + Assert.Contains(only, lab.Zone.FloatingItems); + Assert.Empty(loser.CargoBays.Single().Cargo); + Tick(lab, 120, 1 / 60f); + Assert.DoesNotContain(only, lab.Zone.FloatingItems); + Assert.Single(holder.CargoBays.Single().Cargo); + Assert.Empty(loser.CargoBays.Single().Cargo); + + // Two items, each nearest a different beam's axis: each ship holds its own. + var first = Drift(lab, 60, 0); + var second = Drift(lab, 60, 10); + Tick(lab, 20, 1 / 60f); + Assert.NotNull(first.Holder); + Assert.NotNull(second.Holder); + Assert.NotSame(first.Holder, second.Holder); + } + + [Fact] + public void SelectionIsTheItemNearestTheBeamAxis() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + + // The nearer to the axis wins, though the other is closer to the ship. + var near = Drift(lab, 20, 12); + var onAxis = Drift(lab, 60, 3); + Tick(lab, 30, 1 / 60f); + Assert.Same(ship, onAxis.Holder); + Assert.Null(near.Holder); + Assert.Equal(onAxis.Id, events.First().Target); + + // Out of the beam, past its range or behind the ship, an item is never taken. + foreach (var (x, y) in new[] { (30f, 30f), (80f, 0f), (-10f, 0f) }) + { + var other = Build(Beam()); + var lone = Spawn(other, float3(0, 0, 0)); + var stray = Drift(other, x, y); + lone.TractorCommand = 1; + Tick(other, 240, 1 / 60f); + Assert.Null(stray.Holder); + Assert.Contains(stray, other.Zone.FloatingItems); + } + + // Equally far from the axis, the lower id. + var tie = Build(Beam()); + var pilot = Spawn(tie, float3(0, 0, 0)); + var lower = Drift(tie, 40, 5); + var higher = Drift(tie, 40, -5); + pilot.TractorCommand = 1; + Tick(tie, 30, 1 / 60f); + Assert.Same(pilot, lower.Holder); + Assert.Null(higher.Holder); + } + + [Fact] + public void AHeldItemIsPlacedNotIntegratedAndDoesNotExpire() + { + using var bag = new CompositeDisposable(); + const float dt = 1 / 60f; + var lab = Build(Beam(), lifetime: 1f); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0, float2(3, 0)); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + while (item.Holder == null) lab.Zone.Update(dt); + var claimed = item.Body.Position; + Assert.Equal(claimed, item.HeldFrom); + + // Through Extend and Envelop the item stays where it was claimed, whatever velocity it had. + while (!events.Any(e => e.Phase == GrabPhase.Pull)) + { + lab.Zone.Update(dt); + if (events.Any(e => e.Phase == GrabPhase.Pull)) break; + Assert.Equal(claimed, item.Body.Position); + } + + // During the pull it moves toward the holder, and a held item outlives its lifetime. + lab.Zone.Update(dt * 10); + Assert.True(item.Body.Position.x < claimed.x); + Assert.True(item.Body.Position.x > 0); + var held = Build(Beam(), lifetime: 1f); + var carrier = Spawn(held, float3(5, 0, 7)); + var stuck = Drift(held, 40, 0); + Assert.True(held.Zone.TryHold(carrier, stuck.Id)); + Tick(held, 180, dt); + Assert.Contains(stuck, held.Zone.FloatingItems); + Assert.Equal(float2(40, 0), stuck.Body.Position); + + // At progress 1 it is at the holder; freed, an item past its lifetime goes on the next step. + held.Zone.SetHeldProgress(stuck.Id, 1); + held.Zone.Update(dt); + Assert.Equal(5f, stuck.Body.Position.x, 3); + Assert.Equal(7f, stuck.Body.Position.y, 3); + held.Zone.Free(stuck.Id); + held.Zone.Update(dt); + Assert.DoesNotContain(stuck, held.Zone.FloatingItems); + } + + [Fact] + public void TheHoldersLeavingFreesItsItems() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + Assert.True(ship.CargoBays.Single().TryStore(lab.Ore(7))); + var item = Drift(lab, 40, 0, ore: 3); + ship.TractorCommand = 1; + Tick(lab, 30, 1 / 60f); + Assert.Same(ship, item.Holder); + var before = lab.Zone.FloatingItems.Count; + + // Death goes through the zone's one removal: the held item is free, and the ship's own cargo drops as always. + Kill(ship); + Assert.Null(item.Holder); + Assert.Contains(item, lab.Zone.FloatingItems); + Assert.Equal(before + 1, lab.Zone.FloatingItems.Count); + Assert.Equal(7, ((SimpleCommodity) lab.Zone.FloatingItems.Last().Item).Quantity); + Tick(lab, 60, 1 / 60f); + Assert.Contains(item, lab.Zone.FloatingItems); + Assert.Null(item.Holder); + } +} diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 20a7f2ba5..1b6143603 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -80,6 +80,52 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(8, RecordsIn(Run)); } + // Continue finds the player's entity by its place in the saved zone: the index Capture writes must be the entity's + // place in the live zone, and survive the round trip to the same entity's pack. + [Fact] + public void ThePlayersZoneIndexSurvivesSaveAndContinue() + { + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + var shape = new Shape(4, 4); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + foreach (var name in new[] { "Skiff", "Barge" }) + cache.Upsert(new HullData { Name = name, HullType = HullType.Ship, Shape = shape, Durability = 100, Mass = 100 }); + cache.FlushAsync().Wait(); + } + + using (var cache = Open()) + RunSave.Commit(cache, Game(cache), Zones(2, StageZoneContents(cache), 0), new ProvenanceLedger()); + + using (var cache = Open()) + { + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 2; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, TestSettings(), _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); + galaxy.Zones[0].Contents = zone; + Ship Make(string hull, int lot) => new Ship(items, zone, new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(hull)), Durability = 100, Lot = lot + }, new EntitySettings()); + var other = Make("Skiff", 1); + var player = Make("Barge", 2); + zone.Admit(other, piloted: false); + zone.Admit(player, piloted: false); + var (game, zones) = RunSave.Capture(cache, galaxy, zone, player, new SavedActionBarBinding[0], 0); + RunSave.Commit(cache, game, zones, items.Lots); + } + + using (var cache = Open()) + { + var saved = cache.GetGlobal(); + Assert.Equal(1, saved.CurrentZoneEntity); + var packs = cache.Get(saved.Zones[saved.CurrentZone]).Contents.Entities; + Assert.Equal("Barge", cache.Get(packs[saved.CurrentZoneEntity].Hull.Data).Name); + } + } + [Fact] public void ResumeThenSaveKeepsUnloadedZoneContents() { From e7a35135fe13d2b10fabcfd0de240bd2dce3f7de Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 05:02:07 +0200 Subject: [PATCH 06/23] PickupData: gear only (CreateInstance for a consumable throws); the previous commit did not build Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Behaviors/Pickup.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Assets/Scripts/ServerShared/Behaviors/Pickup.cs b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs index 8ff253e87..80f5ddfb8 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Pickup.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs @@ -42,6 +42,10 @@ public class PickupData : BehaviorData { return new Pickup(this, item); } + + // A beam is a piece of gear the pilot holds on; no consumable carries one. + public override Behavior CreateInstance(ConsumableItemEffect item) => + throw new NotSupportedException("pickup: gear only"); } // Owns this ship's beam power and its one grab. Power is written only here; Zone owns the held item's pose. From 68430771f39e2d2a6052e3ec242067e06a1c5e31 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 05:18:15 +0200 Subject: [PATCH 07/23] Every generated ship carries a tractor beam; AetherDb pickup-catalog turns the Tractor Beam into Pickup gear (group B, code) GenerateShipLoadout equips one gear whose behaviours include PickupData after the shared outfitting (stations and turrets get none). The pickup-catalog command (retired in the next commit after apply) edits the existing Tractor Beam. PickupTests: two fixes found by group A's run (equip is refused once a ship is deployed, and the freeing tick takes one step). PickupGenerationTests needs the applied catalog and is red until the catalog commit; the catalog itself is not in this commit. Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/LoadoutGenerator.cs | 19 ++++++++ .../PickupGenerationTests.cs | 44 +++++++++++++++++++ tests/Aetheria.Shared.Tests/PickupTests.cs | 29 +++++++----- tools/AetherDb/Program.cs | 39 +++++++++++++++- 4 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/PickupGenerationTests.cs diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 1ce9ab03c..46bd5bc33 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -54,6 +54,7 @@ public class LoadoutGenerator var entity = new Ship(ItemManager, null, hull, ItemManager.GameplaySettings.DefaultEntitySettings); entity.Faction = Faction; OutfitEntity(entity); + EquipPickup(entity); StockConsumables(entity, ShipConsumableChance, ShipConsumableUnits); return EntitySerializer.Pack(entity); } @@ -287,6 +288,24 @@ public class LoadoutGenerator FillInterior(entity); } + // Every ship carries a tractor beam: picking up needs a pickup behaviour (scenarios ruling, "everyone gets tractor + // beams"). It goes in after the shared outfitting, which stations and turrets also run and which adds none, into + // whatever interior the hull has left; a hull with no cell for one cannot be generated. + private void EquipPickup(Entity entity) + { + var emptyShape = entity.UnoccupiedSpace; + var (product, design) = RandomProduct(2, item => + item.Behaviors.Any(b => b is PickupData) && + item.Shape.FitsWithin(emptyShape, out _, out _), required: true); + if (design == null) throw new InvalidLoadoutException("No compatible tractor beam found for ship!"); + + design.Shape.FitsWithin(emptyShape, out var rotation, out var position); + var beam = ItemManager.CreateInstance(product) as EquippableItem; + beam.Rotation = rotation; + if (!entity.TryEquip(beam, position)) + throw new InvalidLoadoutException("Failed to equip selected tractor beam!"); + } + private void EquipHardpoints(Entity entity) { var hullData = ItemManager.GetData(entity.Hull) as HullData; diff --git a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs new file mode 100644 index 000000000..ba7bbdce5 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs @@ -0,0 +1,44 @@ +/* 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.Linq; +using Xunit; + +// Pickup is a capability (aetheria-release cut loot-2), so a ship that can pick things up carries the gear that grants +// it. Generation is the one place a ship's gear is chosen, so this runs the real catalog through it. +public sealed partial class RunStartTests +{ + private int PickupGear(EntityPack pack) => EntitySerializer.Items(pack).OfType() + .Count(item => _items.GetData(item) is GearData gear && gear.Behaviors.Any(b => b is PickupData)); + + [Fact] + public void EveryGeneratedShipCarriesATractorBeam() + { + // Every ship hull a product sells. + var generator = PreludeGenerator(); + foreach (var hull in LoadoutGenerator.SoldHulls(_cache).Where(hull => hull.HullType == HullType.Ship)) + Assert.True(PickupGear(generator.GenerateShipLoadout(candidate => candidate == hull)) == 1, $"a {hull.Name} carries exactly one tractor beam"); + + // Ships generated for each faction of a main galaxy, and the starting ship the game stages. + var main = Launch(new MainGalaxy(), Inputs(() => GalaxySeed)); + Assert.Empty(main.failures); + var factions = main.galaxy.Factions.ToArray(); + for (var i = 0; i < 50; i++) + { + var faction = factions[i % factions.Length]; + var maker = new LoadoutGenerator(ref _items.Random, _items, main.galaxy, main.galaxy.Entrance, faction, .5f); + Assert.True(PickupGear(maker.GenerateShipLoadout()) == 1, $"ship {i} of {faction.Name} carries exactly one tractor beam"); + } + Assert.Equal(1, main.staged.Player.Equipment.Count(item => item.Behaviors.OfType().Any())); + Assert.All(main.arena.Pack.Entities.Where(pack => pack is ShipPack), pack => Assert.Equal(1, PickupGear(pack))); + + // Stations and turrets share the interior outfitting and get none. + for (var i = 0; i < 4; i++) + { + Assert.Equal(0, PickupGear(generator.GenerateStationLoadout())); + Assert.Equal(0, PickupGear(generator.GenerateTurretLoadout())); + } + Assert.All(main.arena.Pack.Entities.Where(pack => !(pack is ShipPack)), pack => Assert.Equal(0, PickupGear(pack))); + } +} diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs index ede6010d3..b768d1324 100644 --- a/tests/Aetheria.Shared.Tests/PickupTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -144,19 +144,24 @@ public sealed class PickupTests : IDisposable public void NoPickupBehaviourNoGrab() { using var bag = new CompositeDisposable(); + + // The same ship and item, with and without the gear: only the gear grabs. + var bare = Build(Beam()); + var plain = Spawn(bare, float3(0, 0, 0), beam: false); + var left = Drift(bare, 40, 0); + var plainEvents = Watch(plain, bag); + plain.TractorCommand = 1; + Tick(bare, 240, 1 / 60f); + Assert.Null(left.Holder); + Assert.Contains(left, bare.Zone.FloatingItems); + Assert.Equal(0f, plain.TractorPower); + Assert.Empty(plainEvents); + var lab = Build(Beam()); - var ship = Spawn(lab, float3(0, 0, 0), beam: false); + var ship = Spawn(lab, float3(0, 0, 0)); var item = Drift(lab, 40, 0); var events = Watch(ship, bag); ship.TractorCommand = 1; - Tick(lab, 240, 1 / 60f); - Assert.Null(item.Holder); - Assert.Contains(item, lab.Zone.FloatingItems); - Assert.Equal(0f, ship.TractorPower); - Assert.Empty(events); - - // The same ship with the beam equipped claims the item. - Equip(ship, lab.Make("Tractor")); Tick(lab, 60, 1 / 60f); Assert.Same(ship, item.Holder); Assert.Equal(GrabPhase.Started, events.First().Phase); @@ -271,8 +276,10 @@ public sealed class PickupTests : IDisposable Assert.DoesNotContain(events, e => e.Phase == GrabPhase.Completed); Assert.Null(item.Holder); Assert.Contains(item, lab.Zone.FloatingItems); - Assert.Equal(velocity, item.Body.Velocity); - Assert.Equal(drift, item.Body.Drift); + // (It has taken the one step of its freeing tick, so it is the held velocity less a tick's drag.) + Assert.Equal(velocity.x, item.Body.Velocity.x, velocity.x * .01f); + Assert.Equal(velocity.y, item.Body.Velocity.y, velocity.y * .01f); + Assert.True(velocity.x > 1f); // It drifts on with that velocity and expires on the clock it was released on, not from the cancel. var at = item.Body.Position; diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index f3e2f5be7..a55e206e7 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -42,6 +42,7 @@ public static class Program case "field-kinds": return FieldKindsCatalog(args.Contains("apply")); case "pd-gear": return PdGearCatalog(args.Contains("apply")); case "mine-launcher": return MineLauncherCatalog(args.Contains("apply")); + case "pickup-catalog": return PickupCatalog(args.Contains("apply")); case "smoke-hull": return SmokeHullCatalog(args.Contains("apply")); case "smoke-rounds": return SmokeRoundsCatalog(args.Contains("apply")); case "roles-backfill": return RolesBackfill(args.Contains("apply")); @@ -51,7 +52,7 @@ public static class Program return PiratesFaction(args.Contains("apply")); case "verse-grammar": return VerseGrammarCommand.Run(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], pickup-catalog [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); return 1; } } @@ -1767,6 +1768,42 @@ public static class Program return 0; } + // loot-2: pickup is a capability. The existing Tractor Beam (a Sensors-hardpoint item with no behaviours) becomes + // Tool gear whose one behaviour is Pickup, so a ship carries it in any free interior cell and Zone's pickup needs + // it. The numbers are the retired prefab's beam: range 75, beam radius 25; the grab's phase times, the spin-up + // rate and the power needed to grab are the cut's. Dry run unless passed "apply". + private static int PickupCatalog(bool apply) + { + const string name = "Tractor Beam"; + var db = AetherDb.Open(catalogWritable: apply); + var beam = db.Cache.GetAll().Single(gear => gear.Name == name); + if (beam.Hardpoint == HardpointType.Tool && beam.Behaviors.Count == 1 && beam.Behaviors[0] is PickupData) + { + Console.WriteLine($"\"{name}\" is already authored; nothing to do."); + return 0; + } + if (beam.Behaviors.Count != 0) throw new InvalidOperationException($"\"{name}\" already has behaviours."); + Console.WriteLine($"\"{name}\" hardpoint {beam.Hardpoint} -> Tool, no behaviours -> one Pickup (range 75, beam radius 25, grab power .5, extend .3, envelop .2, pull .5, spin rate 2)."); + if (!apply) + { + Console.WriteLine(" +Dry run. Pass \"apply\" to write it."); + return 0; + } + PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + beam.Hardpoint = HardpointType.Tool; + beam.Behaviors.Add(new PickupData + { + Range = Constant(75), BeamRadius = Constant(25), GrabPower = Constant(.5f), SpinRate = Constant(2), + ExtendTime = Constant(.3f), EnvelopTime = Constant(.2f), PullTime = Constant(.5f) + }); + CultRecordRefs.Validate(beam); + db.Cache.Commit(batch => batch.Upsert(typeof(GearData), beam, db.Cache.RefOf(beam).Key)); + Console.WriteLine($" +Wrote \"{name}\" to Aetheria.cc"); + return 0; + } + // sim-mines-presenter: the Mine Launcher item, so a ship can be fitted with the sim's mine layer. Its numbers // are the 2021 record's (GameData/Legacy/AetherDB.2021-03-05.msgpack, recovered in 8c71a637: mass 75, price // 125000, damage 50, range 2000, energy 15, heat 500, visibility 10, magazine 6, reload 5, spread 5, velocity From 2f718f7753e0bba5f97d28979c625feb501aaa12 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 05:20:59 +0200 Subject: [PATCH 08/23] AetherDb pickup-catalog: fix two string literals broken by a newline; the previous commit did not build Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index a55e206e7..2389297f5 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -1786,8 +1786,7 @@ public static class Program Console.WriteLine($"\"{name}\" hardpoint {beam.Hardpoint} -> Tool, no behaviours -> one Pickup (range 75, beam radius 25, grab power .5, extend .3, envelop .2, pull .5, spin rate 2)."); if (!apply) { - Console.WriteLine(" -Dry run. Pass \"apply\" to write it."); + Console.WriteLine("\nDry run. Pass \"apply\" to write it."); return 0; } PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; @@ -1799,8 +1798,7 @@ Dry run. Pass \"apply\" to write it."); }); CultRecordRefs.Validate(beam); db.Cache.Commit(batch => batch.Upsert(typeof(GearData), beam, db.Cache.RefOf(beam).Key)); - Console.WriteLine($" -Wrote \"{name}\" to Aetheria.cc"); + Console.WriteLine($"\nWrote \"{name}\" to Aetheria.cc"); return 0; } From 73c44a5ed370e3c83c34607a50ea3a04c8e38885 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 05:41:04 +0200 Subject: [PATCH 09/23] Catalog: the Tractor Beam is Tool gear whose one behaviour is Pickup (AetherDb pickup-catalog apply) Applied on Yggdrasil from the base catalog (sha256 ce5c8be5...), result 5efc5225... (12730141 bytes); a second dry run printed "already authored" and dangling reports zero. Builds; PickupGenerationTests needs this commit. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index b5167136e..9ef9290bb 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ce5c8be5194fcab8b1e477ef110db708143f572cbe61c8f0705708da7ae89ced -size 12729848 +oid sha256:5efc5225918ade67ddde06b2418024a68dee6f7ead5e9fe3f30d825139601d78 +size 12730141 From 72872c98170e2b633eeca36695115c25a7b8b13c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 05:41:15 +0200 Subject: [PATCH 10/23] Retire AetherDb pickup-catalog: applied in the previous commit, a one-shot Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 37 +------------------------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 2389297f5..f3e2f5be7 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -42,7 +42,6 @@ public static class Program case "field-kinds": return FieldKindsCatalog(args.Contains("apply")); case "pd-gear": return PdGearCatalog(args.Contains("apply")); case "mine-launcher": return MineLauncherCatalog(args.Contains("apply")); - case "pickup-catalog": return PickupCatalog(args.Contains("apply")); case "smoke-hull": return SmokeHullCatalog(args.Contains("apply")); case "smoke-rounds": return SmokeRoundsCatalog(args.Contains("apply")); case "roles-backfill": return RolesBackfill(args.Contains("apply")); @@ -52,7 +51,7 @@ public static class Program return PiratesFaction(args.Contains("apply")); case "verse-grammar": return VerseGrammarCommand.Run(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], pickup-catalog [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); return 1; } } @@ -1768,40 +1767,6 @@ public static class Program return 0; } - // loot-2: pickup is a capability. The existing Tractor Beam (a Sensors-hardpoint item with no behaviours) becomes - // Tool gear whose one behaviour is Pickup, so a ship carries it in any free interior cell and Zone's pickup needs - // it. The numbers are the retired prefab's beam: range 75, beam radius 25; the grab's phase times, the spin-up - // rate and the power needed to grab are the cut's. Dry run unless passed "apply". - private static int PickupCatalog(bool apply) - { - const string name = "Tractor Beam"; - var db = AetherDb.Open(catalogWritable: apply); - var beam = db.Cache.GetAll().Single(gear => gear.Name == name); - if (beam.Hardpoint == HardpointType.Tool && beam.Behaviors.Count == 1 && beam.Behaviors[0] is PickupData) - { - Console.WriteLine($"\"{name}\" is already authored; nothing to do."); - return 0; - } - if (beam.Behaviors.Count != 0) throw new InvalidOperationException($"\"{name}\" already has behaviours."); - Console.WriteLine($"\"{name}\" hardpoint {beam.Hardpoint} -> Tool, no behaviours -> one Pickup (range 75, beam radius 25, grab power .5, extend .3, envelop .2, pull .5, spin rate 2)."); - if (!apply) - { - Console.WriteLine("\nDry run. Pass \"apply\" to write it."); - return 0; - } - PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; - beam.Hardpoint = HardpointType.Tool; - beam.Behaviors.Add(new PickupData - { - Range = Constant(75), BeamRadius = Constant(25), GrabPower = Constant(.5f), SpinRate = Constant(2), - ExtendTime = Constant(.3f), EnvelopTime = Constant(.2f), PullTime = Constant(.5f) - }); - CultRecordRefs.Validate(beam); - db.Cache.Commit(batch => batch.Upsert(typeof(GearData), beam, db.Cache.RefOf(beam).Key)); - Console.WriteLine($"\nWrote \"{name}\" to Aetheria.cc"); - return 0; - } - // sim-mines-presenter: the Mine Launcher item, so a ship can be fitted with the sim's mine layer. Its numbers // are the 2021 record's (GameData/Legacy/AetherDB.2021-03-05.msgpack, recovered in 8c71a637: mass 75, price // 125000, damage 50, range 2000, energy 15, heat 500, visibility 10, magazine 6, reload 5, spread 5, velocity From 720fbd6431c90102dd3ce3f69e8f861a9df885d9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 05:50:46 +0200 Subject: [PATCH 11/23] PickupTests: gear off, range before the pull, the loser's next item, a held item's pose Pins the cancel rules (inactive gear, out of range before the pull and not after it), TryHold refusing a held item, the next-item selection, and the held pose as a function of progress. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PickupTests.cs | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs index b768d1324..b8376e963 100644 --- a/tests/Aetheria.Shared.Tests/PickupTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -329,6 +329,7 @@ public sealed class PickupTests : IDisposable var holder = only.Holder; var loser = ReferenceEquals(holder, a) ? b : a; Assert.False(lab.Zone.TryPickUp(loser, only.Id)); + Assert.False(lab.Zone.TryHold(loser, only.Id)); Assert.Contains(only, lab.Zone.FloatingItems); Assert.Empty(loser.CargoBays.Single().Cargo); Tick(lab, 120, 1 / 60f); @@ -343,6 +344,63 @@ public sealed class PickupTests : IDisposable Assert.NotNull(first.Holder); Assert.NotNull(second.Holder); Assert.NotSame(first.Holder, second.Holder); + + // Both ships' nearest item is the same one: the one that loses it takes the other. + var shared = Build(Beam()); + var p = Spawn(shared, float3(0, 0, 0)); + var q = Spawn(shared, float3(0, 0, 10)); + var near = Drift(shared, 40, 5); + var far = Drift(shared, 40, -14); + p.TractorCommand = 1; + q.TractorCommand = 1; + Tick(shared, 20, 1 / 60f); + Assert.NotNull(near.Holder); + Assert.NotNull(far.Holder); + Assert.NotSame(near.Holder, far.Holder); + } + + [Fact] + public void AGrabIsDroppedWhenItsGearGoesInactiveOrTheItemFallsOutOfRangeBeforeThePull() + { + using var bag = new CompositeDisposable(); + const float dt = 1 / 60f; + + // Gear switched off mid-grab: cancelled, the item free. + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Tick(lab, 30, dt); + Assert.Same(ship, item.Holder); + ship.Equipment.Single(e => e.Behaviors.OfType().Any()).Enabled.Value = false; + Tick(lab, 2, dt); + Assert.Equal(GrabPhase.Cancelled, events.Last().Phase); + Assert.Null(item.Holder); + + // The ship leaves while the beam is still extending: cancelled. The same departure during the pull is not. + foreach (var departAtPull in new[] { false, true }) + { + var other = Build(Beam()); + var pilot = Spawn(other, float3(0, 0, 0)); + var target = Drift(other, 40, 0); + var seen = Watch(pilot, bag); + pilot.TractorCommand = 1; + while (!seen.Any(e => departAtPull ? e.Phase == GrabPhase.Pull : e.Phase == GrabPhase.Extend)) other.Zone.Update(dt); + pilot.Position = float3(-200, 0, 0); + Tick(other, 120, dt); + if (departAtPull) + { + Assert.Equal(GrabPhase.Completed, seen.First(e => e.Phase == GrabPhase.Completed || e.Phase == GrabPhase.Cancelled).Phase); + Assert.DoesNotContain(target, other.Zone.FloatingItems); + } + else + { + Assert.Equal(GrabPhase.Cancelled, seen.First(e => e.Phase == GrabPhase.Completed || e.Phase == GrabPhase.Cancelled).Phase); + Assert.Null(target.Holder); + Assert.Contains(target, other.Zone.FloatingItems); + } + } } [Fact] @@ -419,6 +477,13 @@ public sealed class PickupTests : IDisposable Assert.Contains(stuck, held.Zone.FloatingItems); Assert.Equal(float2(40, 0), stuck.Body.Position); + // Placed, not drifted: the same progress held for two steps puts it at the same halfway point. + held.Zone.SetHeldProgress(stuck.Id, .5f); + held.Zone.Update(dt); + held.Zone.Update(dt); + Assert.Equal(22.5f, stuck.Body.Position.x, 3); + Assert.Equal(3.5f, stuck.Body.Position.y, 3); + // At progress 1 it is at the holder; freed, an item past its lifetime goes on the next step. held.Zone.SetHeldProgress(stuck.Id, 1); held.Zone.Update(dt); From 359aba2c5d0375280fc793b7399231edd9315ae8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 05:54:08 +0200 Subject: [PATCH 12/23] PickupTests: each grab phase is published on the tick its boundary is crossed Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PickupTests.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs index b8376e963..1818e7eb5 100644 --- a/tests/Aetheria.Shared.Tests/PickupTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -243,9 +243,17 @@ public sealed class PickupTests : IDisposable var item = Drift(lab, 40, 0); var events = Watch(ship, bag); ship.TractorCommand = 1; - Tick(lab, 150, 1 / 60f); + var tickOf = new List(); + for (var tick = 1; tick <= 150; tick++) + { + lab.Zone.Update(1 / 60f); + while (tickOf.Count < events.Count) tickOf.Add(tick); + } Assert.Equal(new[] { GrabPhase.Started, GrabPhase.Extend, GrabPhase.Envelop, GrabPhase.Pull, GrabPhase.Completed }, events.Take(5).Select(e => e.Phase).ToArray()); + + // Each phase is published once, on the tick its boundary is crossed: the claim, then .3 s, .5 s and 1 s after it. + Assert.Equal(new[] { 0, 0, 18, 30, 60 }, tickOf.Take(5).Select(tick => tick - tickOf[0]).ToArray()); Assert.All(events.Take(5), e => Assert.Equal(item.Id, e.Target)); Assert.NotEqual(other.Id, item.Id); Assert.Equal(.3f, events[1].PhaseDuration, 4); From aae779c6533440fa4761085407ccd9c9c8cf558d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 06:02:47 +0200 Subject: [PATCH 13/23] Revert "Retire AetherDb pickup-catalog: applied in the previous commit, a one-shot" This reverts commit 72872c98170e2b633eeca36695115c25a7b8b13c. --- tools/AetherDb/Program.cs | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index f3e2f5be7..2389297f5 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -42,6 +42,7 @@ public static class Program case "field-kinds": return FieldKindsCatalog(args.Contains("apply")); case "pd-gear": return PdGearCatalog(args.Contains("apply")); case "mine-launcher": return MineLauncherCatalog(args.Contains("apply")); + case "pickup-catalog": return PickupCatalog(args.Contains("apply")); case "smoke-hull": return SmokeHullCatalog(args.Contains("apply")); case "smoke-rounds": return SmokeRoundsCatalog(args.Contains("apply")); case "roles-backfill": return RolesBackfill(args.Contains("apply")); @@ -51,7 +52,7 @@ public static class Program return PiratesFaction(args.Contains("apply")); case "verse-grammar": return VerseGrammarCommand.Run(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], pickup-catalog [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); return 1; } } @@ -1767,6 +1768,40 @@ public static class Program return 0; } + // loot-2: pickup is a capability. The existing Tractor Beam (a Sensors-hardpoint item with no behaviours) becomes + // Tool gear whose one behaviour is Pickup, so a ship carries it in any free interior cell and Zone's pickup needs + // it. The numbers are the retired prefab's beam: range 75, beam radius 25; the grab's phase times, the spin-up + // rate and the power needed to grab are the cut's. Dry run unless passed "apply". + private static int PickupCatalog(bool apply) + { + const string name = "Tractor Beam"; + var db = AetherDb.Open(catalogWritable: apply); + var beam = db.Cache.GetAll().Single(gear => gear.Name == name); + if (beam.Hardpoint == HardpointType.Tool && beam.Behaviors.Count == 1 && beam.Behaviors[0] is PickupData) + { + Console.WriteLine($"\"{name}\" is already authored; nothing to do."); + return 0; + } + if (beam.Behaviors.Count != 0) throw new InvalidOperationException($"\"{name}\" already has behaviours."); + Console.WriteLine($"\"{name}\" hardpoint {beam.Hardpoint} -> Tool, no behaviours -> one Pickup (range 75, beam radius 25, grab power .5, extend .3, envelop .2, pull .5, spin rate 2)."); + if (!apply) + { + Console.WriteLine("\nDry run. Pass \"apply\" to write it."); + return 0; + } + PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + beam.Hardpoint = HardpointType.Tool; + beam.Behaviors.Add(new PickupData + { + Range = Constant(75), BeamRadius = Constant(25), GrabPower = Constant(.5f), SpinRate = Constant(2), + ExtendTime = Constant(.3f), EnvelopTime = Constant(.2f), PullTime = Constant(.5f) + }); + CultRecordRefs.Validate(beam); + db.Cache.Commit(batch => batch.Upsert(typeof(GearData), beam, db.Cache.RefOf(beam).Key)); + Console.WriteLine($"\nWrote \"{name}\" to Aetheria.cc"); + return 0; + } + // sim-mines-presenter: the Mine Launcher item, so a ship can be fitted with the sim's mine layer. Its numbers // are the 2021 record's (GameData/Legacy/AetherDB.2021-03-05.msgpack, recovered in 8c71a637: mass 75, price // 125000, damage 50, range 2000, energy 15, heat 500, visibility 10, magazine 6, reload 5, spread 5, velocity From f26beec14e29b7617231520abacfe88b96c26729 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 06:03:06 +0200 Subject: [PATCH 14/23] AetherDb pickup-catalog: also price the Tractor Beam (25000); it was the one gear with price 0, so generation could not offer it Found by the full suite on the previous catalog: every generated ship threw 'no compatible tractor beam'. Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 2389297f5..767479f16 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -1768,37 +1768,45 @@ public static class Program return 0; } - // loot-2: pickup is a capability. The existing Tractor Beam (a Sensors-hardpoint item with no behaviours) becomes - // Tool gear whose one behaviour is Pickup, so a ship carries it in any free interior cell and Zone's pickup needs - // it. The numbers are the retired prefab's beam: range 75, beam radius 25; the grab's phase times, the spin-up - // rate and the power needed to grab are the cut's. Dry run unless passed "apply". + // loot-2: pickup is a capability. The existing Tractor Beam (a Sensors-hardpoint item with no behaviours and no price) + // becomes Tool gear whose one behaviour is Pickup, so a ship carries it in any free interior cell and Zone's pickup + // needs it, and gets the price of the cheapest gear sold today (25000), because generation offers only priced + // designs and every ship must be able to be given one. The numbers are the retired prefab's beam: range 75, beam + // radius 25; the grab's phase times, the spin-up rate and the power needed to grab are the cut's. Dry run unless + // passed "apply". private static int PickupCatalog(bool apply) { const string name = "Tractor Beam"; + const int price = 25000; var db = AetherDb.Open(catalogWritable: apply); var beam = db.Cache.GetAll().Single(gear => gear.Name == name); - if (beam.Hardpoint == HardpointType.Tool && beam.Behaviors.Count == 1 && beam.Behaviors[0] is PickupData) + if (beam.Behaviors.Count > 1 || beam.Behaviors.Count == 1 && beam.Behaviors[0] is not PickupData) + throw new InvalidOperationException($"\"{name}\" has behaviours this command does not know."); + if (beam.Hardpoint == HardpointType.Tool && beam.Behaviors.Count == 1 && beam.Price == price) { Console.WriteLine($"\"{name}\" is already authored; nothing to do."); return 0; } - if (beam.Behaviors.Count != 0) throw new InvalidOperationException($"\"{name}\" already has behaviours."); - Console.WriteLine($"\"{name}\" hardpoint {beam.Hardpoint} -> Tool, no behaviours -> one Pickup (range 75, beam radius 25, grab power .5, extend .3, envelop .2, pull .5, spin rate 2)."); + Console.WriteLine($"\"{name}\" hardpoint {beam.Hardpoint} -> Tool, {beam.Behaviors.Count} behaviours -> one Pickup (range 75, beam radius 25, grab power .5, extend .3, envelop .2, pull .5, spin rate 2), price {beam.Price} -> {price}."); if (!apply) { - Console.WriteLine("\nDry run. Pass \"apply\" to write it."); + Console.WriteLine(" +Dry run. Pass \"apply\" to write it."); return 0; } PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; beam.Hardpoint = HardpointType.Tool; - beam.Behaviors.Add(new PickupData - { - Range = Constant(75), BeamRadius = Constant(25), GrabPower = Constant(.5f), SpinRate = Constant(2), - ExtendTime = Constant(.3f), EnvelopTime = Constant(.2f), PullTime = Constant(.5f) - }); + beam.Price = price; + if (beam.Behaviors.Count == 0) + beam.Behaviors.Add(new PickupData + { + Range = Constant(75), BeamRadius = Constant(25), GrabPower = Constant(.5f), SpinRate = Constant(2), + ExtendTime = Constant(.3f), EnvelopTime = Constant(.2f), PullTime = Constant(.5f) + }); CultRecordRefs.Validate(beam); db.Cache.Commit(batch => batch.Upsert(typeof(GearData), beam, db.Cache.RefOf(beam).Key)); - Console.WriteLine($"\nWrote \"{name}\" to Aetheria.cc"); + Console.WriteLine($" +Wrote \"{name}\" to Aetheria.cc"); return 0; } From ea66153d58a53a1dc27d1291279ed1731c8d46c6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 06:03:24 +0200 Subject: [PATCH 15/23] AetherDb pickup-catalog: two string literals broken by newlines; the previous commit did not build Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 767479f16..ad2dd65e9 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -1790,8 +1790,7 @@ public static class Program Console.WriteLine($"\"{name}\" hardpoint {beam.Hardpoint} -> Tool, {beam.Behaviors.Count} behaviours -> one Pickup (range 75, beam radius 25, grab power .5, extend .3, envelop .2, pull .5, spin rate 2), price {beam.Price} -> {price}."); if (!apply) { - Console.WriteLine(" -Dry run. Pass \"apply\" to write it."); + Console.WriteLine("\nDry run. Pass \"apply\" to write it."); return 0; } PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; @@ -1805,8 +1804,7 @@ Dry run. Pass \"apply\" to write it."); }); CultRecordRefs.Validate(beam); db.Cache.Commit(batch => batch.Upsert(typeof(GearData), beam, db.Cache.RefOf(beam).Key)); - Console.WriteLine($" -Wrote \"{name}\" to Aetheria.cc"); + Console.WriteLine($"\nWrote \"{name}\" to Aetheria.cc"); return 0; } From 727c525c7a5a06d2272bfd399a4ae9597defbfc9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 06:06:09 +0200 Subject: [PATCH 16/23] Catalog: the Tractor Beam is priced (25000); fixtures that generate ships sell a tractor beam AetherDb pickup-catalog apply on the previous catalog (5efc5225...): result 1a2cb55b... (12730143 bytes), the Tractor Beam's price 0 -> 25000; a second dry run printed "already authored", dangling reports zero. The ship generator now requires a sold tractor beam, so the five synthetic catalogs that generate ships (LoadoutTests, RunSaveTests, FireControlCut6cTests, TargetingSystemTests, ConsumableSupplyTests) each sell one. Co-Authored-By: Claude Opus 5.5 --- GameData/Aetheria.cc | 4 ++-- tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs | 3 ++- tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs | 2 ++ tests/Aetheria.Shared.Tests/LoadoutTests.cs | 3 ++- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 3 ++- tests/Aetheria.Shared.Tests/TargetingSystemTests.cs | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index 9ef9290bb..f9ac53cf6 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5efc5225918ade67ddde06b2418024a68dee6f7ead5e9fe3f30d825139601d78 -size 12730141 +oid sha256:1a2cb55b63f77a113dea0571716994ef8311d5da572a4fc41cbd3a7d2561fb68 +size 12730143 diff --git a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs index 3f3e75a85..336ab30f2 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs @@ -93,6 +93,7 @@ public sealed class ConsumableSupplyTests : IDisposable }); var dock = seed.Upsert(new DockingBayData { Name = "Dock", Shape = new Shape(), Price = 5, InteriorShape = holds == Holds.DockRefuses ? new Shape(1, 1) : Solid(1, 1) }); var capacitor = seed.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); + var tractor = seed.Upsert(new GearData { Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new PickupData() } }); var heater = seed.Upsert(new GearData { Name = "Heater", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, @@ -114,7 +115,7 @@ public sealed class ConsumableSupplyTests : IDisposable foreach (var (name, design) in new[] { ("Skiff", skiff.Key), ("Platform", platform.Key), ("Crate", crate.Key), ("Dock", dock.Key), - ("Cap", capacitor.Key), ("Heater", heater.Key), ("Orphan", orphan.Key) + ("Cap", capacitor.Key), ("Tractor", tractor.Key), ("Heater", heater.Key), ("Orphan", orphan.Key) }.Concat(sold)) Sell(name + " by Maker", design, maker); diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs index 0421b1b25..86973b0e2 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6cTests.cs @@ -210,6 +210,7 @@ public sealed class FireControlCut6cTests : IDisposable var cargo = seedCache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = new Shape(), Price = 5 }); var capacitor = seedCache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); var targeting = seedCache.Upsert(new GearData { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new TargetingSystemData() } }); + var tractor = seedCache.Upsert(new GearData { Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new PickupData() } }); // Everything but the targeting system is sold by Maker, who IS in the galaxy. Targeting is sold // only by Targeting Maker, who is NOT -- the single point of failure 6c.2 fixes. @@ -217,6 +218,7 @@ public sealed class FireControlCut6cTests : IDisposable { ("Armed by Maker", hull.Key, maker), ("Gun by Maker", gun.Key, maker), ("Crate by Maker", cargo.Key, maker), ("Cap by Maker", capacitor.Key, maker), + ("Tractor by Maker", tractor.Key, maker), ("Targeting by Targeting Maker", targeting.Key, targetingMaker) }) seedCache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = factionMaker }); diff --git a/tests/Aetheria.Shared.Tests/LoadoutTests.cs b/tests/Aetheria.Shared.Tests/LoadoutTests.cs index 5d0d523c9..f95d67047 100644 --- a/tests/Aetheria.Shared.Tests/LoadoutTests.cs +++ b/tests/Aetheria.Shared.Tests/LoadoutTests.cs @@ -501,7 +501,8 @@ public sealed class LoadoutTests : IDisposable } }); var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); - foreach (var (name, design) in new[] { ("Twin by Maker", hull.Key), ("Cap by Maker", capacitor.Key) }) + var tractor = cache.Upsert(new GearData { Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new PickupData() } }); + foreach (var (name, design) in new[] { ("Twin by Maker", hull.Key), ("Cap by Maker", capacitor.Key), ("Tractor by Maker", tractor.Key) }) cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }); cache.FlushAsync().Wait(); diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 1b6143603..d4196b0cb 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -353,7 +353,8 @@ public sealed class RunSaveTests : IDisposable var gear = cache.Upsert(new GearData { Name = "Scanner", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Price = 5 }); var cargo = cache.Upsert(new CargoBayData { Name = "Hold", Shape = new Shape(), InteriorShape = new Shape(), Price = 3 }); var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); - foreach (var (name, design) in new[] { ("Runabout by Generator Maker", hull.Key), ("Scanner by Generator Maker", gear.Key), ("Hold by Generator Maker", cargo.Key), ("Cap by Generator Maker", capacitor.Key) }) + var tractor = cache.Upsert(new GearData { Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new PickupData() } }); + foreach (var (name, design) in new[] { ("Runabout by Generator Maker", hull.Key), ("Scanner by Generator Maker", gear.Key), ("Hold by Generator Maker", cargo.Key), ("Cap by Generator Maker", capacitor.Key), ("Tractor by Generator Maker", tractor.Key) }) cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }); cache.FlushAsync().Wait(); } diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index c965069d4..29693d899 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -389,11 +389,12 @@ public sealed class TargetingSystemTests : IDisposable var cargo = cache.Upsert(new CargoBayData { Name = "Crate", Shape = new Shape(), InteriorShape = new Shape(), Price = 5 }); var capacitor = cache.Upsert(new GearData { Name = "Cap", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new CapacitorData() } }); var targeting = cache.Upsert(new GearData { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new TargetingSystemData() } }); + var tractor = cache.Upsert(new GearData { Name = "Tractor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Price = 1, Behaviors = { new PickupData() } }); foreach (var (name, design) in new[] { ("Armed by Maker", hull.Key), ("Gun by Maker", gun.Key), ("Crate by Maker", cargo.Key), - ("Cap by Maker", capacitor.Key), ("Targeting by Maker", targeting.Key) + ("Cap by Maker", capacitor.Key), ("Targeting by Maker", targeting.Key), ("Tractor by Maker", tractor.Key) }) cache.Upsert(new FactionProductData { Name = name, Design = new CultRecordRef(design), Manufacturer = maker }); cache.FlushAsync().Wait(); From 4a41aec68f381510de5a7bac3b9b74a3a7ec0dbd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 06:06:12 +0200 Subject: [PATCH 17/23] Retire AetherDb pickup-catalog: applied in the previous commit, a one-shot Co-Authored-By: Claude Opus 5.5 --- tools/AetherDb/Program.cs | 43 +-------------------------------------- 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index ad2dd65e9..f3e2f5be7 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -42,7 +42,6 @@ public static class Program case "field-kinds": return FieldKindsCatalog(args.Contains("apply")); case "pd-gear": return PdGearCatalog(args.Contains("apply")); case "mine-launcher": return MineLauncherCatalog(args.Contains("apply")); - case "pickup-catalog": return PickupCatalog(args.Contains("apply")); case "smoke-hull": return SmokeHullCatalog(args.Contains("apply")); case "smoke-rounds": return SmokeRoundsCatalog(args.Contains("apply")); case "roles-backfill": return RolesBackfill(args.Contains("apply")); @@ -52,7 +51,7 @@ public static class Program return PiratesFaction(args.Contains("apply")); case "verse-grammar": return VerseGrammarCommand.Run(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], pickup-catalog [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply], mine-launcher [apply], smoke-hull [apply], smoke-rounds [apply], roles-backfill [apply], pd-gear [apply], consumables-first-set [apply], pirates-faction [apply], verse-grammar [apply]"); return 1; } } @@ -1768,46 +1767,6 @@ public static class Program return 0; } - // loot-2: pickup is a capability. The existing Tractor Beam (a Sensors-hardpoint item with no behaviours and no price) - // becomes Tool gear whose one behaviour is Pickup, so a ship carries it in any free interior cell and Zone's pickup - // needs it, and gets the price of the cheapest gear sold today (25000), because generation offers only priced - // designs and every ship must be able to be given one. The numbers are the retired prefab's beam: range 75, beam - // radius 25; the grab's phase times, the spin-up rate and the power needed to grab are the cut's. Dry run unless - // passed "apply". - private static int PickupCatalog(bool apply) - { - const string name = "Tractor Beam"; - const int price = 25000; - var db = AetherDb.Open(catalogWritable: apply); - var beam = db.Cache.GetAll().Single(gear => gear.Name == name); - if (beam.Behaviors.Count > 1 || beam.Behaviors.Count == 1 && beam.Behaviors[0] is not PickupData) - throw new InvalidOperationException($"\"{name}\" has behaviours this command does not know."); - if (beam.Hardpoint == HardpointType.Tool && beam.Behaviors.Count == 1 && beam.Price == price) - { - Console.WriteLine($"\"{name}\" is already authored; nothing to do."); - return 0; - } - Console.WriteLine($"\"{name}\" hardpoint {beam.Hardpoint} -> Tool, {beam.Behaviors.Count} behaviours -> one Pickup (range 75, beam radius 25, grab power .5, extend .3, envelop .2, pull .5, spin rate 2), price {beam.Price} -> {price}."); - if (!apply) - { - Console.WriteLine("\nDry run. Pass \"apply\" to write it."); - return 0; - } - PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; - beam.Hardpoint = HardpointType.Tool; - beam.Price = price; - if (beam.Behaviors.Count == 0) - beam.Behaviors.Add(new PickupData - { - Range = Constant(75), BeamRadius = Constant(25), GrabPower = Constant(.5f), SpinRate = Constant(2), - ExtendTime = Constant(.3f), EnvelopTime = Constant(.2f), PullTime = Constant(.5f) - }); - CultRecordRefs.Validate(beam); - db.Cache.Commit(batch => batch.Upsert(typeof(GearData), beam, db.Cache.RefOf(beam).Key)); - Console.WriteLine($"\nWrote \"{name}\" to Aetheria.cc"); - return 0; - } - // sim-mines-presenter: the Mine Launcher item, so a ship can be fitted with the sim's mine layer. Its numbers // are the 2021 record's (GameData/Legacy/AetherDB.2021-03-05.msgpack, recovered in 8c71a637: mass 75, price // 125000, damage 50, range 2000, energy 15, heat 500, visibility 10, magazine 6, reload 5, spread 5, velocity From 87642a9b4db614d0baa8f5b50a64703d9e8f18d5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 06:27:15 +0200 Subject: [PATCH 18/23] Tests: the consumable rig's hull has an interior cell for the beam; the Longinus reach ceiling follows the new sample Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs | 3 ++- tests/Aetheria.Shared.Tests/EvasionTermTests.cs | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs index 336ab30f2..0cd2c76fc 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs @@ -83,7 +83,8 @@ public sealed class ConsumableSupplyTests : IDisposable var stranger = seed.Upsert(new Faction { Name = "Stranger", ShortName = "STR" }); maker = seed.Upsert(new Faction { Name = "Maker", ShortName = "MKR", Allegiance = { { ally, 1f } } }); - var skiff = seed.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(4, 3), Price = 100 }); + // Interior for the crate, the capacitor and the tractor beam every generated ship carries. + var skiff = seed.Upsert(new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = Solid(5, 3), Price = 100 }); var platform = seed.Upsert(new HullData { Name = "Platform", HullType = HullType.Station, Shape = Solid(5, 5), Price = 100 }); // A hold with no cell cannot take a consumable. var crate = seed.Upsert(new CargoBayData diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 3b558a90e..00996179c 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -306,10 +306,11 @@ public sealed partial class RunStartTests else { // The envelope sums the live columns, the drives' yaw among them (turn-authority-full), so its reach sits above - // the allocated one by the turn the drives add: 1.24 nose-on and 1.12 broadside at the last measurement. The - // ceiling is that, from the columns the allocator solves with, not an open bound: an envelope inflated by a - // factor (doubled: 2.5 and 2.2) breaks it. - Assert.InRange(reach.noseOn, measured.noseOn * .9f, allocated.noseOn * 1.3f); + // the allocated one by the turn the drives add: 1.37 nose-on and 1.17 broadside at the last measurement + // (1.24 and 1.12 before every generated ship drew a tractor beam: the extra draw per ship fits the Longinus + // with other rolls). The ceiling is that, from the columns + // the allocator solves with, not an open bound: an envelope inflated by a factor (doubled: 2.7 and 2.3) breaks it. + Assert.InRange(reach.noseOn, measured.noseOn * .9f, allocated.noseOn * 1.5f); Assert.InRange(reach.broadside, measured.broadside * .9f, allocated.broadside * 1.2f); Assert.InRange(measured.noseOn, allocated.noseOn * .9f, allocated.noseOn * 1.15f); Assert.InRange(measured.broadside, allocated.broadside * .85f, allocated.broadside * 1.15f); From bc082c09a0d13419f5702f8cdecb665749a5175d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 06:27:46 +0200 Subject: [PATCH 19/23] PickupGenerationTests: count fitted beams, not a station's stock Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PickupGenerationTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs index ba7bbdce5..fefdaeb81 100644 --- a/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupGenerationTests.cs @@ -9,7 +9,8 @@ using Xunit; // it. Generation is the one place a ship's gear is chosen, so this runs the real catalog through it. public sealed partial class RunStartTests { - private int PickupGear(EntityPack pack) => EntitySerializer.Items(pack).OfType() + // The beams a pack carries fitted. A station may stock one in its hold to sell, which is the point of selling it. + private int PickupGear(EntityPack pack) => pack.Equipment.Select(fitted => fitted.item) .Count(item => _items.GetData(item) is GearData gear && gear.Behaviors.Any(b => b is PickupData)); [Fact] From 83b1b62a92b31631fb1551b3ea4a56ddceaf5d00 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 06:30:32 +0200 Subject: [PATCH 20/23] ConsumableSupplyTests: re-pin the generators' residual Random with the tractor beam's draw The old pin (d4a27def) is exactly what this tree leaves with EquipPickup's call removed (run: passes), so the new values differ from it only by the one product draw each generated ship makes for its beam. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs index 0cd2c76fc..cd968e885 100644 --- a/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs +++ b/tests/Aetheria.Shared.Tests/ConsumableSupplyTests.cs @@ -335,11 +335,13 @@ public sealed class ConsumableSupplyTests : IDisposable } // What the plain catalog leaves in both generators' Random, measured on the tree before consumables existed - // (origin/master d4a27def, the same fixture and seeds). The gated and plain catalogs agreeing is not enough: - // a step that drew unconditionally would draw in both. This pins the value itself. + // (origin/master d4a27def, the same fixture and seeds), plus the one product draw each of the three generated + // ships makes for its tractor beam (loot-2): with that draw taken out the tree leaves the d4a27def values + // 0.85624266, 0.037778974, 0.7931649, 0.3596493, 0.17511952, 0.6642336, 0.31897342, 0.5977364. The gated and + // plain catalogs agreeing is not enough: a step that drew unconditionally would draw in both. This pins the value itself. var beforeConsumables = new[] { - 0.85624266f, 0.037778974f, 0.7931649f, 0.3596493f, 0.17511952f, 0.6642336f, 0.31897342f, 0.5977364f + 0.3596493f, 0.03616327f, 0.8790466f, 0.16682059f, 0.5977364f, 0.08633745f, 0.2869571f, 0.6890472f }; var plainAfter = AfterGeneration(plain); Assert.Equal(beforeConsumables, plainAfter); From 60b5876cd956ea8984658ed324e393726dca93d9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 07:01:35 +0200 Subject: [PATCH 21/23] PickupTests: bounded waits (a broken rule fails instead of hanging the run); a stray item is never even attempted Found by the mutation table: a mutant that never claims hung the test host, and a mutant that doubled the range survived because the observation landed between two attempts. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PickupTests.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs index 1818e7eb5..32b1aeaed 100644 --- a/tests/Aetheria.Shared.Tests/PickupTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -138,6 +138,13 @@ public sealed class PickupTests : IDisposable for (var i = 0; i < ticks; i++) lab.Zone.Update(dt); } + // Ticks until the condition holds, for at most twenty sim seconds; a grab that never comes fails here instead of hanging. + private static void Until(Lab lab, float dt, Func done) + { + for (var tick = 0; tick < 1200 && !done(); tick++) lab.Zone.Update(dt); + Assert.True(done(), "the expected moment never came"); + } + private static void Kill(Entity e) => e.HeatstrokeDeath.OnNext(Unit.Default); [Fact] @@ -394,7 +401,7 @@ public sealed class PickupTests : IDisposable var target = Drift(other, 40, 0); var seen = Watch(pilot, bag); pilot.TractorCommand = 1; - while (!seen.Any(e => departAtPull ? e.Phase == GrabPhase.Pull : e.Phase == GrabPhase.Extend)) other.Zone.Update(dt); + Until(other, dt, () => seen.Any(e => departAtPull ? e.Phase == GrabPhase.Pull : e.Phase == GrabPhase.Extend)); pilot.Position = float3(-200, 0, 0); Tick(other, 120, dt); if (departAtPull) @@ -434,8 +441,10 @@ public sealed class PickupTests : IDisposable var other = Build(Beam()); var lone = Spawn(other, float3(0, 0, 0)); var stray = Drift(other, x, y); + var attempts = Watch(lone, bag); lone.TractorCommand = 1; Tick(other, 240, 1 / 60f); + Assert.Empty(attempts); Assert.Null(stray.Holder); Assert.Contains(stray, other.Zone.FloatingItems); } @@ -461,17 +470,18 @@ public sealed class PickupTests : IDisposable var item = Drift(lab, 40, 0, float2(3, 0)); var events = Watch(ship, bag); ship.TractorCommand = 1; - while (item.Holder == null) lab.Zone.Update(dt); + Until(lab, dt, () => item.Holder != null); var claimed = item.Body.Position; Assert.Equal(claimed, item.HeldFrom); // Through Extend and Envelop the item stays where it was claimed, whatever velocity it had. - while (!events.Any(e => e.Phase == GrabPhase.Pull)) + for (var guard = 0; guard < 1200 && !events.Any(e => e.Phase == GrabPhase.Pull); guard++) { lab.Zone.Update(dt); if (events.Any(e => e.Phase == GrabPhase.Pull)) break; Assert.Equal(claimed, item.Body.Position); } + Assert.Contains(events, e => e.Phase == GrabPhase.Pull); // During the pull it moves toward the holder, and a held item outlives its lifetime. lab.Zone.Update(dt * 10); From 8e3dd3129d22f9c2ee095c6f922dc35d42940a94 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 07:07:03 +0200 Subject: [PATCH 22/23] PickupTests: assert on booleans, never on an Entity (a failing Assert.Same prints the whole entity graph and crashes the test host) Found by the mutation table: two mutants read 'Test Run Aborted' instead of naming the failing test. Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/PickupTests.cs | 68 +++++++++++----------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs index 32b1aeaed..2d3bc0d53 100644 --- a/tests/Aetheria.Shared.Tests/PickupTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -159,8 +159,8 @@ public sealed class PickupTests : IDisposable var plainEvents = Watch(plain, bag); plain.TractorCommand = 1; Tick(bare, 240, 1 / 60f); - Assert.Null(left.Holder); - Assert.Contains(left, bare.Zone.FloatingItems); + Assert.True(left.Holder == null, "left is held"); + Assert.True(bare.Zone.FloatingItems.Contains(left), "left left the zone"); Assert.Equal(0f, plain.TractorPower); Assert.Empty(plainEvents); @@ -170,7 +170,7 @@ public sealed class PickupTests : IDisposable var events = Watch(ship, bag); ship.TractorCommand = 1; Tick(lab, 60, 1 / 60f); - Assert.Same(ship, item.Holder); + Assert.True(ReferenceEquals(ship, item.Holder), "item is not held by ship"); Assert.Equal(GrabPhase.Started, events.First().Phase); Assert.True(ship.TractorPower > 0f); } @@ -280,7 +280,7 @@ public sealed class PickupTests : IDisposable ship.TractorCommand = 1; var tick = 0; while (!events.Any(e => e.Phase == GrabPhase.Pull) && tick++ < 600) lab.Zone.Update(dt); - Assert.Same(ship, item.Holder); + Assert.True(ReferenceEquals(ship, item.Holder), "item is not held by ship"); var velocity = item.Body.Velocity; var drift = item.Body.Drift; @@ -289,8 +289,8 @@ public sealed class PickupTests : IDisposable while (!events.Any(e => e.Phase == GrabPhase.Cancelled) && tick++ < 600) lab.Zone.Update(dt); Assert.True(tick < 110); Assert.DoesNotContain(events, e => e.Phase == GrabPhase.Completed); - Assert.Null(item.Holder); - Assert.Contains(item, lab.Zone.FloatingItems); + Assert.True(item.Holder == null, "item is held"); + Assert.True(lab.Zone.FloatingItems.Contains(item), "item left the zone"); // (It has taken the one step of its freeing tick, so it is the held velocity less a tick's drag.) Assert.Equal(velocity.x, item.Body.Velocity.x, velocity.x * .01f); Assert.Equal(velocity.y, item.Body.Velocity.y, velocity.y * .01f); @@ -320,8 +320,8 @@ public sealed class PickupTests : IDisposable while (!events.Any(e => e.Phase == GrabPhase.Cancelled) && tick++ < 300) lab.Zone.Update(1 / 60f); Assert.Equal(new[] { GrabPhase.Started, GrabPhase.Extend, GrabPhase.Envelop, GrabPhase.Pull, GrabPhase.Cancelled }, events.Select(e => e.Phase).ToArray()); - Assert.Null(item.Holder); - Assert.Contains(item, lab.Zone.FloatingItems); + Assert.True(item.Holder == null, "item is held"); + Assert.True(lab.Zone.FloatingItems.Contains(item), "item left the zone"); Assert.Equal(16, bay.Cargo.Count); } @@ -345,10 +345,10 @@ public sealed class PickupTests : IDisposable var loser = ReferenceEquals(holder, a) ? b : a; Assert.False(lab.Zone.TryPickUp(loser, only.Id)); Assert.False(lab.Zone.TryHold(loser, only.Id)); - Assert.Contains(only, lab.Zone.FloatingItems); + Assert.True(lab.Zone.FloatingItems.Contains(only), "only left the zone"); Assert.Empty(loser.CargoBays.Single().Cargo); Tick(lab, 120, 1 / 60f); - Assert.DoesNotContain(only, lab.Zone.FloatingItems); + Assert.False(lab.Zone.FloatingItems.Contains(only), "only is still in the zone"); Assert.Single(holder.CargoBays.Single().Cargo); Assert.Empty(loser.CargoBays.Single().Cargo); @@ -356,9 +356,9 @@ public sealed class PickupTests : IDisposable var first = Drift(lab, 60, 0); var second = Drift(lab, 60, 10); Tick(lab, 20, 1 / 60f); - Assert.NotNull(first.Holder); - Assert.NotNull(second.Holder); - Assert.NotSame(first.Holder, second.Holder); + Assert.True(first.Holder != null, "first is free"); + Assert.True(second.Holder != null, "second is free"); + Assert.False(ReferenceEquals(first.Holder, second.Holder), "one holder for two items"); // Both ships' nearest item is the same one: the one that loses it takes the other. var shared = Build(Beam()); @@ -369,9 +369,9 @@ public sealed class PickupTests : IDisposable p.TractorCommand = 1; q.TractorCommand = 1; Tick(shared, 20, 1 / 60f); - Assert.NotNull(near.Holder); - Assert.NotNull(far.Holder); - Assert.NotSame(near.Holder, far.Holder); + Assert.True(near.Holder != null, "near is free"); + Assert.True(far.Holder != null, "far is free"); + Assert.False(ReferenceEquals(near.Holder, far.Holder), "one holder for two items"); } [Fact] @@ -387,11 +387,11 @@ public sealed class PickupTests : IDisposable var events = Watch(ship, bag); ship.TractorCommand = 1; Tick(lab, 30, dt); - Assert.Same(ship, item.Holder); + Assert.True(ReferenceEquals(ship, item.Holder), "item is not held by ship"); ship.Equipment.Single(e => e.Behaviors.OfType().Any()).Enabled.Value = false; Tick(lab, 2, dt); Assert.Equal(GrabPhase.Cancelled, events.Last().Phase); - Assert.Null(item.Holder); + Assert.True(item.Holder == null, "item is held"); // The ship leaves while the beam is still extending: cancelled. The same departure during the pull is not. foreach (var departAtPull in new[] { false, true }) @@ -407,13 +407,13 @@ public sealed class PickupTests : IDisposable if (departAtPull) { Assert.Equal(GrabPhase.Completed, seen.First(e => e.Phase == GrabPhase.Completed || e.Phase == GrabPhase.Cancelled).Phase); - Assert.DoesNotContain(target, other.Zone.FloatingItems); + Assert.False(other.Zone.FloatingItems.Contains(target), "target is still in the zone"); } else { Assert.Equal(GrabPhase.Cancelled, seen.First(e => e.Phase == GrabPhase.Completed || e.Phase == GrabPhase.Cancelled).Phase); - Assert.Null(target.Holder); - Assert.Contains(target, other.Zone.FloatingItems); + Assert.True(target.Holder == null, "target is held"); + Assert.True(other.Zone.FloatingItems.Contains(target), "target left the zone"); } } } @@ -431,8 +431,8 @@ public sealed class PickupTests : IDisposable var near = Drift(lab, 20, 12); var onAxis = Drift(lab, 60, 3); Tick(lab, 30, 1 / 60f); - Assert.Same(ship, onAxis.Holder); - Assert.Null(near.Holder); + Assert.True(ReferenceEquals(ship, onAxis.Holder), "onAxis is not held by ship"); + Assert.True(near.Holder == null, "near is held"); Assert.Equal(onAxis.Id, events.First().Target); // Out of the beam, past its range or behind the ship, an item is never taken. @@ -445,8 +445,8 @@ public sealed class PickupTests : IDisposable lone.TractorCommand = 1; Tick(other, 240, 1 / 60f); Assert.Empty(attempts); - Assert.Null(stray.Holder); - Assert.Contains(stray, other.Zone.FloatingItems); + Assert.True(stray.Holder == null, "stray is held"); + Assert.True(other.Zone.FloatingItems.Contains(stray), "stray left the zone"); } // Equally far from the axis, the lower id. @@ -456,8 +456,8 @@ public sealed class PickupTests : IDisposable var higher = Drift(tie, 40, -5); pilot.TractorCommand = 1; Tick(tie, 30, 1 / 60f); - Assert.Same(pilot, lower.Holder); - Assert.Null(higher.Holder); + Assert.True(ReferenceEquals(pilot, lower.Holder), "lower is not held by pilot"); + Assert.True(higher.Holder == null, "higher is held"); } [Fact] @@ -492,7 +492,7 @@ public sealed class PickupTests : IDisposable var stuck = Drift(held, 40, 0); Assert.True(held.Zone.TryHold(carrier, stuck.Id)); Tick(held, 180, dt); - Assert.Contains(stuck, held.Zone.FloatingItems); + Assert.True(held.Zone.FloatingItems.Contains(stuck), "stuck left the zone"); Assert.Equal(float2(40, 0), stuck.Body.Position); // Placed, not drifted: the same progress held for two steps puts it at the same halfway point. @@ -509,7 +509,7 @@ public sealed class PickupTests : IDisposable Assert.Equal(7f, stuck.Body.Position.y, 3); held.Zone.Free(stuck.Id); held.Zone.Update(dt); - Assert.DoesNotContain(stuck, held.Zone.FloatingItems); + Assert.False(held.Zone.FloatingItems.Contains(stuck), "stuck is still in the zone"); } [Fact] @@ -522,17 +522,17 @@ public sealed class PickupTests : IDisposable var item = Drift(lab, 40, 0, ore: 3); ship.TractorCommand = 1; Tick(lab, 30, 1 / 60f); - Assert.Same(ship, item.Holder); + Assert.True(ReferenceEquals(ship, item.Holder), "item is not held by ship"); var before = lab.Zone.FloatingItems.Count; // Death goes through the zone's one removal: the held item is free, and the ship's own cargo drops as always. Kill(ship); - Assert.Null(item.Holder); - Assert.Contains(item, lab.Zone.FloatingItems); + Assert.True(item.Holder == null, "item is held"); + Assert.True(lab.Zone.FloatingItems.Contains(item), "item left the zone"); Assert.Equal(before + 1, lab.Zone.FloatingItems.Count); Assert.Equal(7, ((SimpleCommodity) lab.Zone.FloatingItems.Last().Item).Quantity); Tick(lab, 60, 1 / 60f); - Assert.Contains(item, lab.Zone.FloatingItems); - Assert.Null(item.Holder); + Assert.True(lab.Zone.FloatingItems.Contains(item), "item left the zone"); + Assert.True(item.Holder == null, "item is held"); } } From 4fba95e56f0e15b34ff29c56d235f2e10bb8021a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 07:43:42 +0200 Subject: [PATCH 23/23] Pickup: one beam region for selection and the pre-pull cancel; selection skips what no bay can store; test gaps and the phase rule pinned beam-corner-claim-loop: InBeam is the one region predicate; Select claims from it and the pre-pull cancel holds the item only while it stays in it (an item at 74 along, 20 off is collected, not claimed and dropped every other tick). full-hold-regrab-pins-and-blocks: Select skips an item no cargo bay can store whole, asked of the bay's own TryFindSpace (the test TryStore opens with), per ruling beam-skips-unstorable-items; a hold that fills mid-grab still cancels at the end. pickup-test-gaps: smoothstep pose, spin-down while the gear is inactive, Remove freeing only the removed holder's items. phase-ticks-not-rate-invariant: the claim is corrected in the tests; each phase fires on the first tick at or after its boundary, pinned at 1/60, 1/600 and an uneven dt cycle. Builds; not yet run (the verify job follows). Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/Behaviors/Pickup.cs | 32 ++- tests/Aetheria.Shared.Tests/PickupTests.cs | 192 ++++++++++++++++-- 2 files changed, 201 insertions(+), 23 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Pickup.cs b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs index 80f5ddfb8..0e4747183 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Pickup.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Pickup.cs @@ -82,12 +82,13 @@ public class Pickup : Behavior, IAlwaysUpdatedBehavior var envelop = (double) Evaluate(_data.EnvelopTime); var pull = (double) Evaluate(_data.PullTime); var range = Evaluate(_data.Range); + var radius = Evaluate(_data.BeamRadius); var origin = Entity.Position.xz; if (!_grabbing) { if (!active || _power < grabPower) return; - var item = Select(zone, origin, range, Evaluate(_data.BeamRadius)); + var item = Select(zone, origin, range, radius); if (item == null || !zone.TryHold(Entity, item.Id)) return; _grabbing = true; _target = item.Id; @@ -107,7 +108,7 @@ public class Pickup : Behavior, IAlwaysUpdatedBehavior } var pulling = _elapsed >= extend + envelop; - if (!active || _power < grabPower || (!pulling && length(held.Body.Position - origin) > range)) + if (!active || _power < grabPower || (!pulling && !InBeam(held.Body.Position, origin, range, radius, out _))) { _grabbing = false; zone.Free(_target); @@ -131,22 +132,31 @@ public class Pickup : Behavior, IAlwaysUpdatedBehavior } } - // The free item nearest the beam axis within Range along it and BeamRadius of it; ties go to the lowest id. A - // zero aim selects nothing. + // The one beam region: within Range along the aim axis (never behind) and BeamRadius off it. Selection claims + // from it and the grab holds on to its item only while the item stays in it. A zero aim has no region. + private bool InBeam(float2 position, float2 origin, float range, float radius, out float off) + { + off = 0; + var aim = Entity.Aim.xz; + if (lengthsq(aim) <= 0) return false; + var axis = normalize(aim); + var to = position - origin; + var along = dot(to, axis); + off = length(to - axis * along); + return along >= 0 && along <= range && off <= radius; + } + + // The free item nearest the beam axis within the beam region that some cargo bay can store whole (the bay's own + // TryFindSpace, the test TryStore opens with); ties go to the lowest id. A zero aim selects nothing. private FloatingItem Select(Zone zone, float2 origin, float range, float radius) { - var aim = Entity.Aim.xz; - if (lengthsq(aim) <= 0) return null; - var axis = normalize(aim); FloatingItem best = null; var bestOff = float.MaxValue; foreach (var floating in zone.FloatingItems) { if (floating.Holder != null) continue; - var to = floating.Body.Position - origin; - var along = dot(to, axis); - var off = length(to - axis * along); - if (along < 0 || along > range || off > radius) continue; + if (!InBeam(floating.Body.Position, origin, range, radius, out var off)) continue; + if (!Entity.CargoBays.Any(bay => bay.TryFindSpace(floating.Item))) continue; if (off < bestOff || (off == bestOff && floating.Id.Value < best.Id.Value)) { best = floating; diff --git a/tests/Aetheria.Shared.Tests/PickupTests.cs b/tests/Aetheria.Shared.Tests/PickupTests.cs index 2d3bc0d53..330864fc3 100644 --- a/tests/Aetheria.Shared.Tests/PickupTests.cs +++ b/tests/Aetheria.Shared.Tests/PickupTests.cs @@ -91,6 +91,11 @@ public sealed class PickupTests : IDisposable Name = "Spare", Hardpoint = HardpointType.Tool, Shape = Solid(1, 1), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 }); + cache.Upsert(new GearData + { + Name = "Block", Hardpoint = HardpointType.Tool, Shape = Solid(2, 2), 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(); @@ -250,17 +255,9 @@ public sealed class PickupTests : IDisposable var item = Drift(lab, 40, 0); var events = Watch(ship, bag); ship.TractorCommand = 1; - var tickOf = new List(); - for (var tick = 1; tick <= 150; tick++) - { - lab.Zone.Update(1 / 60f); - while (tickOf.Count < events.Count) tickOf.Add(tick); - } + for (var tick = 1; tick <= 150; tick++) lab.Zone.Update(1 / 60f); Assert.Equal(new[] { GrabPhase.Started, GrabPhase.Extend, GrabPhase.Envelop, GrabPhase.Pull, GrabPhase.Completed }, events.Take(5).Select(e => e.Phase).ToArray()); - - // Each phase is published once, on the tick its boundary is crossed: the claim, then .3 s, .5 s and 1 s after it. - Assert.Equal(new[] { 0, 0, 18, 30, 60 }, tickOf.Take(5).Select(tick => tick - tickOf[0]).ToArray()); Assert.All(events.Take(5), e => Assert.Equal(item.Id, e.Target)); Assert.NotEqual(other.Id, item.Id); Assert.Equal(.3f, events[1].PhaseDuration, 4); @@ -268,6 +265,47 @@ public sealed class PickupTests : IDisposable Assert.Equal(.5f, events[3].PhaseDuration, 4); } + // Each phase is published on the first tick at or after its boundary (.3, .5 and 1 s after the claim), at any + // tick rate or an uneven one. The clock is a float sum, so a boundary an exact number of ticks away can land one + // tick late (Envelop and Pull do at 1/600): the rule is "not before, at most one tick after", not a tick count. + [Theory] + [InlineData(1, 60, 60, 60, 60)] + [InlineData(1, 600, 600, 600, 600)] + [InlineData(4, 60, 24, 90, 33)] + public void EachPhaseFiresOnTheFirstTickAtOrAfterItsBoundary(int cycle, int a, int b, int c, int d) + { + using var bag = new CompositeDisposable(); + var rates = cycle == 1 ? new[] { a } : new[] { a, b, c, d }; + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + var since = -1.0; + var times = new List(); + var steps = new List(); + for (var tick = 0; tick < rates.Length * 3000 && events.Count(e => e.Phase == GrabPhase.Completed) == 0; tick++) + { + var dt = 1f / rates[tick % rates.Length]; + lab.Zone.Update(dt); + if (since >= 0) since += dt; + else if (events.Count > 0) since = 0; + while (times.Count < events.Count) { times.Add(since); steps.Add(dt); } + } + var phases = events.Select(e => e.Phase).ToList(); + Assert.Equal(GrabPhase.Completed, phases.Last()); + foreach (var (phase, boundary) in new[] { (GrabPhase.Envelop, .3), (GrabPhase.Pull, .5), (GrabPhase.Completed, 1.0) }) + { + var i = phases.IndexOf(phase); + Assert.True(i >= 0, "the phase never came"); + Assert.True(times[i] >= boundary - 1e-5, "the phase fired before its boundary"); + // Where no tick lands exactly on a boundary (the uneven cycle) the tick before must be strictly short of it; + // on a regular grid the float sum may leave that tick a hair short, so it may be equal. + var slack = cycle == 1 ? 1e-5 : -1e-5; + Assert.True(times[i] - steps[i] < boundary + slack, "the phase fired after the first tick at or past its boundary"); + } + } + [Fact] public void ReleasingTheBeamCancelsAndTheItemFloatsFree() { @@ -305,17 +343,19 @@ public sealed class PickupTests : IDisposable } [Fact] - public void AFullHoldCancelsAtTheEnd() + public void AHoldThatFillsMidGrabCancelsAtTheEnd() { using var bag = new CompositeDisposable(); var lab = Build(Beam()); var ship = Spawn(lab, float3(0, 0, 0)); var bay = ship.CargoBays.Single(); - for (var i = 0; i < 16; i++) Assert.True(bay.TryStore(lab.Make("Spare"))); - Assert.False(bay.TryStore(lab.Make("Spare"))); var item = Drift(lab, 40, 0); var events = Watch(ship, bag); ship.TractorCommand = 1; + Until(lab, 1 / 60f, () => item.Holder != null); + + // The hold fills while the grab runs: the item slides in and then floats free. + for (var i = 0; i < 16; i++) Assert.True(bay.TryStore(lab.Make("Spare"))); var tick = 0; while (!events.Any(e => e.Phase == GrabPhase.Cancelled) && tick++ < 300) lab.Zone.Update(1 / 60f); Assert.Equal(new[] { GrabPhase.Started, GrabPhase.Extend, GrabPhase.Envelop, GrabPhase.Pull, GrabPhase.Cancelled }, @@ -325,6 +365,79 @@ public sealed class PickupTests : IDisposable Assert.Equal(16, bay.Cargo.Count); } + [Fact] + public void AFullHoldNeverClaimsWhatItCannotStore() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var bay = ship.CargoBays.Single(); + for (var i = 0; i < 16; i++) Assert.True(bay.TryStore(lab.Make("Spare"))); + var item = Drift(lab, 40, 0, float2(3, 1)); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Tick(lab, 300, 1 / 60f); + Assert.Equal(1f, ship.TractorPower); + Assert.Empty(events); + Assert.True(item.Holder == null, "item is held"); + Assert.True(item.Body.Position.x > 40f, "item was pinned instead of drifting"); + } + + [Fact] + public void AnItemTheHoldCannotStoreDoesNotBlockOneItCan() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var bay = ship.CargoBays.Single(); + for (var i = 0; i < 15; i++) Assert.True(bay.TryStore(lab.Make("Spare"))); + // One free cell: the 2x2 block is nearest the axis and cannot be stored; the ore beside it can. + var block = lab.Zone.Release(lab.Make("Block"), float2(30, 0), default); + var ore = Drift(lab, 50, 10); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Tick(lab, 150, 1 / 60f); + Assert.Contains(events, e => e.Phase == GrabPhase.Completed); + Assert.All(events, e => Assert.Equal(ore.Id, e.Target)); + Assert.False(lab.Zone.FloatingItems.Contains(ore), "ore is still in the zone"); + Assert.True(lab.Zone.FloatingItems.Contains(block), "block left the zone"); + Assert.True(block.Holder == null, "block is held"); + } + + [Fact] + public void AnItemInTheBeamsFarCornerIsCollected() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + // 74 along and 20 off the axis: inside the beam, though 76.7 from the ship, past Range as a straight distance. + var corner = Drift(lab, 74, 20); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Tick(lab, 150, 1 / 60f); + Assert.DoesNotContain(events, e => e.Phase == GrabPhase.Cancelled); + Assert.Contains(events, e => e.Phase == GrabPhase.Completed); + Assert.False(lab.Zone.FloatingItems.Contains(corner), "corner is still in the zone"); + } + + [Fact] + public void AnItemLeavingTheBeamBeforeThePullIsCancelled() + { + using var bag = new CompositeDisposable(); + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + ship.TractorCommand = 1; + Until(lab, 1 / 60f, () => item.Holder != null); + + // The pilot swings the beam away while the grab extends: the item is off the axis and the grab is dropped. + ship.Aim = float3(0, 0, 1); + Tick(lab, 3, 1 / 60f); + Assert.Equal(GrabPhase.Cancelled, events.Last().Phase); + Assert.True(item.Holder == null, "item is held"); + } + [Fact] public void OneHolderPerItem() { @@ -495,6 +608,12 @@ public sealed class PickupTests : IDisposable Assert.True(held.Zone.FloatingItems.Contains(stuck), "stuck left the zone"); Assert.Equal(float2(40, 0), stuck.Body.Position); + // The pose eases (smoothstep): a quarter of the progress is 15.625 per cent of the way, not 25. + held.Zone.SetHeldProgress(stuck.Id, .25f); + held.Zone.Update(dt); + Assert.Equal(40f - 35f * .15625f, stuck.Body.Position.x, 3); + Assert.Equal(7f * .15625f, stuck.Body.Position.y, 3); + // Placed, not drifted: the same progress held for two steps puts it at the same halfway point. held.Zone.SetHeldProgress(stuck.Id, .5f); held.Zone.Update(dt); @@ -512,6 +631,55 @@ public sealed class PickupTests : IDisposable Assert.False(held.Zone.FloatingItems.Contains(stuck), "stuck is still in the zone"); } + [Fact] + public void AnInactiveBeamSpinsDownAndMustSpinUpAgain() + { + using var bag = new CompositeDisposable(); + const float dt = 1 / 60f; + var lab = Build(Beam()); + var ship = Spawn(lab, float3(0, 0, 0)); + var gear = ship.Equipment.Single(e => e.Behaviors.OfType().Any()); + ship.TractorCommand = 1; + Tick(lab, 60, dt); + Assert.Equal(1f, ship.TractorPower); + + // Switched off with the key still down, the beam spins down at the spin rate... + gear.Enabled.Value = false; + Tick(lab, 15, dt); + Assert.InRange(ship.TractorPower, .45f, .55f); + Tick(lab, 30, dt); + Assert.Equal(0f, ship.TractorPower); + + // ...and switched back on it spins up from nothing: an item in the beam is not claimed at once. + var item = Drift(lab, 40, 0); + var events = Watch(ship, bag); + gear.Enabled.Value = true; + Tick(lab, 10, dt); + Assert.Empty(events); + Assert.True(item.Holder == null, "item is held"); + Tick(lab, 20, dt); + Assert.True(ReferenceEquals(ship, item.Holder), "item is not held by ship"); + } + + [Fact] + public void RemovingOneHolderFreesOnlyItsOwnItems() + { + var lab = Build(Beam()); + var a = Spawn(lab, float3(0, 0, 0)); + var b = Spawn(lab, float3(0, 0, 50)); + var mine = Drift(lab, 40, 0); + var theirs = Drift(lab, 40, 50); + a.TractorCommand = 1; + b.TractorCommand = 1; + Tick(lab, 30, 1 / 60f); + Assert.True(ReferenceEquals(a, mine.Holder), "mine is not held by a"); + Assert.True(ReferenceEquals(b, theirs.Holder), "theirs is not held by b"); + + Kill(a); + Assert.True(mine.Holder == null, "mine is held"); + Assert.True(ReferenceEquals(b, theirs.Holder), "theirs was freed with a"); + } + [Fact] public void TheHoldersLeavingFreesItsItems() {