From 5aa989abbf8e307313ba83451aade21cb5369472 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 13 Oct 2026 05:00:22 +0200 Subject: [PATCH] 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() {