From 91ececd147154fe76b9a6a29270de949f8a6eb6b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 11 Jul 2026 20:45:01 +0100 Subject: [PATCH] Restore weapon resource transactions --- Aetheria.State.Daemon.Smoke/Program.cs | 25 +++- Aetheria.State.Verify/Program.cs | 3 + .../AetheriaRuntimeBehaviorMetadata.cs | 7 ++ .../AetheriaRuntimeCargoTransactions.cs | 68 +++++++++++ .../AetheriaRuntimeDaemonOperations.cs | 34 +----- .../AetheriaRuntimeDaemonSimulation.cs | 113 +++++++++++++++++- docs/original-game-migration-matrix.md | 2 +- 7 files changed, 212 insertions(+), 40 deletions(-) create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCargoTransactions.cs diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 921d49143..01d508530 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -604,6 +604,15 @@ internal sealed class AetheriaDaemonYmirSmokeChecks } } ]; + agent.BehaviorStates = + [ + new AetheriaRuntimeBehaviorStateCommit + { + OwnerKind = "fixture", OwnerIndex = 0, BehaviorIndex = 0, + BehaviorKind = "Capacitor", CapacitorCharge = 50, CapacitorCapacity = 50, CapacitorEfficiency = 1 + } + ]; + agent.CargoContents = [Cargo(("test-ammo", 2, 0, 0))]; var weaponPayload = new AetheriaRuntimeBehaviorPayload( 0, "LockWeapon", @@ -611,7 +620,13 @@ internal sealed class AetheriaDaemonYmirSmokeChecks [ new AetheriaRuntimeBehaviorField(2, PerformanceStat(7)), new AetheriaRuntimeBehaviorField(6, PerformanceStat(145)), + new AetheriaRuntimeBehaviorField(9, PerformanceStat(5)), new AetheriaRuntimeBehaviorField(10, PerformanceStat(3)), + new AetheriaRuntimeBehaviorField(12, new AetheriaRuntimeBehaviorValue( + "item-key", "", 0, false, "", "test-ammo", + Array.Empty(), Array.Empty())), + new AetheriaRuntimeBehaviorField(13, Number(2)), + new AetheriaRuntimeBehaviorField(14, Number(0.3)), new AetheriaRuntimeBehaviorField(16, PerformanceStat(330)), new AetheriaRuntimeBehaviorField(19, PerformanceStat(0.2)), new AetheriaRuntimeBehaviorField(21, PerformanceStat(2)), @@ -626,7 +641,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks Array.Empty()); var target = Entity(1, 105, "raider"); target.Kind = "station"; - target.StatGrids = [Grid("hull", 24), Grid("shield", 0), Grid("heat", 0)]; + target.StatGrids = [Grid("hull", 7), Grid("shield", 0), Grid("heat", 0)]; var run = new AetheriaRuntimeRunCheckpointCommit { RunId = "agent-attack-smoke", @@ -695,6 +710,14 @@ internal sealed class AetheriaDaemonYmirSmokeChecks Require((agent.WeaponStates ?? Array.Empty()).Any(value => value.OwnerKind == AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind && value.OwnerIndex == 0 && value.BehaviorKind == "LockWeapon"), "weapon progress must belong to the equipped authored behavior instead of a synthetic entity weapon"); + var authoredWeapon = agent.WeaponStates.Single(value => + value.OwnerKind == AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind); + RequireEqual(1, authoredWeapon.Ammo, + "a committed authored weapon round must decrement exactly one magazine round"); + RequireNear(45, agent.BehaviorStates.Single(value => value.BehaviorKind == "Capacitor").CapacitorCharge, + 0.000001, "a committed authored weapon round must drain its energy from canonical capacitor state"); + RequireEqual(2, CargoQuantity(agent, "test-ammo"), + "cargo ammunition must pay for reload, not each magazine round"); Require(!target.IsActive, $"attack task must end through daemon damage after Ymir projectile contacts; hull={Stat(target, "hull"):0.###} " + $"agent={agent.PositionX:0.###},{agent.PositionZ:0.###} target={target.PositionX:0.###},{target.PositionZ:0.###} " + diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 78c60f7a4..497607edd 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -16598,6 +16598,9 @@ static void RequireUnityDoesNotCallSharedSimulationTicks(string root) "StepCombat(run, zone, entities, intents, deltaSeconds, settings, projectilePhysics, catalog, frameId)", "AetheriaRuntimeEquippedBehaviorQueries.Find(entity, catalog, AetheriaRuntimeBehaviorKinds.InstantWeapon)", "EnsureWeaponState(", + "CommitWeaponRound(attacker, weapon)", + "AetheriaRuntimeCargoTransactions.TryRemoveQuantity(", + "CommitEnergy(entity, weapon.Energy)", "SpawnProjectile(zone, attacker, target, weapon, settings)", "projectilePhysics.Step(zone, entities, deltaSeconds)", "Damage(target, hit.Projectile.Damage)" diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorMetadata.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorMetadata.cs index c87cc7094..717845059 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorMetadata.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeBehaviorMetadata.cs @@ -55,6 +55,8 @@ namespace GameCult.Aetheria.State.Verse Stat("Energy", 9), Stat("Heat", 10), Stat("Visibility", 11), + Integer("MagazineSize", 13), + Number("ReloadTime", 14), Stat("Spread", 15), Stat("Velocity", 16) }; @@ -154,6 +156,11 @@ namespace GameCult.Aetheria.State.Verse return new AetheriaRuntimeBehaviorFieldMetadata(name, key, AetheriaRuntimeBehaviorFieldValueKind.Number); } + private static AetheriaRuntimeBehaviorFieldMetadata Integer(string name, int key) + { + return new AetheriaRuntimeBehaviorFieldMetadata(name, key, AetheriaRuntimeBehaviorFieldValueKind.Integer); + } + private static AetheriaRuntimeBehaviorFieldMetadata Temperature(string name, int key) { return new AetheriaRuntimeBehaviorFieldMetadata(name, key, AetheriaRuntimeBehaviorFieldValueKind.Temperature); diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCargoTransactions.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCargoTransactions.cs new file mode 100644 index 000000000..7325b68f8 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCargoTransactions.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public static class AetheriaRuntimeCargoTransactions + { + public static bool TryRemoveQuantity( + AetheriaRuntimeEntitySnapshotCommit entity, + int cargoIndex, + string itemKey, + int x, + int y, + int quantity, + out AetheriaRuntimeLoadoutItemSlotCommit slot) + { + slot = null!; + var cargoContents = (entity?.CargoContents ?? Array.Empty()).ToArray(); + if (cargoIndex < 0 || cargoIndex >= cargoContents.Length || cargoContents[cargoIndex] == null) + return false; + var items = (cargoContents[cargoIndex].Items ?? Array.Empty()).ToList(); + var itemIndex = items.FindIndex(candidate => candidate?.Item != null && + string.Equals(candidate.Item.ItemKey, itemKey, StringComparison.Ordinal) && + candidate.X == x && candidate.Y == y); + if (itemIndex < 0) return false; + var source = items[itemIndex]; + var sourceQuantity = Math.Max(1, source.Item.Quantity); + if (quantity <= 0 || quantity > sourceQuantity) return false; + slot = Clone(source, quantity); + if (quantity == sourceQuantity) items.RemoveAt(itemIndex); + else source.Item.Quantity = sourceQuantity - quantity; + cargoContents[cargoIndex] = new AetheriaRuntimeCargoBayLoadoutCommit { Items = items.ToArray() }; + entity!.CargoContents = cargoContents; + return true; + } + + public static bool TryFind( + AetheriaRuntimeEntitySnapshotCommit entity, + string itemKey, + out int cargoIndex, + out int x, + out int y) + { + var cargo = entity?.CargoContents ?? Array.Empty(); + for (var bay = 0; bay < cargo.Count; bay++) + foreach (var slot in cargo[bay]?.Items ?? Array.Empty()) + { + if (slot?.Item == null || !string.Equals(slot.Item.ItemKey, itemKey, StringComparison.Ordinal)) continue; + cargoIndex = bay; x = slot.X; y = slot.Y; return true; + } + cargoIndex = x = y = -1; + return false; + } + + private static AetheriaRuntimeLoadoutItemSlotCommit Clone(AetheriaRuntimeLoadoutItemSlotCommit source, int quantity) => new() + { + X = source.X, Y = source.Y, + Item = new AetheriaRuntimeLoadoutItemCommit + { + ItemKey = source.Item.ItemKey, Quality = source.Item.Quality, Durability = source.Item.Durability, + Quantity = quantity, Enabled = source.Item.Enabled, OverrideShutdown = source.Item.OverrideShutdown, + Temperature = source.Item.Temperature + } + }; + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs index e71c06c8a..65bca4090 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs @@ -1858,38 +1858,8 @@ namespace GameCult.Aetheria.State.Verse int quantity, out AetheriaRuntimeLoadoutItemSlotCommit slot) { - slot = null!; - var cargoContents = (entity.CargoContents ?? Array.Empty()).ToArray(); - if (cargoIndex < 0 || cargoIndex >= cargoContents.Length || cargoContents[cargoIndex] == null) - return false; - - var items = (cargoContents[cargoIndex].Items ?? Array.Empty()).ToList(); - var itemIndex = items.FindIndex(candidate => IsCargoSlotMatch(candidate, itemKey, x, y)); - if (itemIndex < 0) - return false; - - var source = items[itemIndex]; - var sourceQuantity = Math.Max(1, source.Item?.Quantity ?? 1); - if (quantity <= 0 || quantity > sourceQuantity) - return false; - - slot = CloneSlot(source); - slot.Item.Quantity = quantity; - if (quantity == sourceQuantity) - { - items.RemoveAt(itemIndex); - } - else - { - source.Item.Quantity = sourceQuantity - quantity; - } - - cargoContents[cargoIndex] = new AetheriaRuntimeCargoBayLoadoutCommit - { - Items = items.ToArray() - }; - entity.CargoContents = cargoContents; - return true; + return AetheriaRuntimeCargoTransactions.TryRemoveQuantity( + entity, cargoIndex, itemKey, x, y, quantity, out slot); } private static void AddCargoItem( diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index 254767367..5c6d90f15 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -244,6 +244,15 @@ namespace GameCult.Aetheria.State.Verse foreach (var weapon in weapons) { weapon.State.Firing = false; + if (weapon.State.Reloading) + { + weapon.State.ReloadProgress = Math.Max(0, weapon.State.ReloadProgress - deltaSeconds); + if (weapon.State.ReloadProgress <= 0) + { + weapon.State.Reloading = false; + weapon.State.Ammo = weapon.MagazineSize; + } + } weapon.State.CooldownProgress = Math.Max(0, weapon.State.CooldownProgress - deltaSeconds); weapon.State.CoolingDown = weapon.State.CooldownProgress > 0; } @@ -270,9 +279,12 @@ namespace GameCult.Aetheria.State.Verse UpdateWeaponLock(attacker, target, weapon, deltaSeconds, settings); if (DistanceSq(attacker, target) > weapon.Range * weapon.Range || weapon.State.LockProgress <= 0.99 || - weapon.State.CooldownProgress > 0) + weapon.State.CooldownProgress > 0 || + weapon.State.Reloading) continue; + if (CommitWeaponRound(attacker, weapon) != WeaponRoundResult.Fired) + continue; var projectile = SpawnProjectile(zone, attacker, target, weapon, settings); AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { EventId = $"projectile:{projectile.ProjectileId}:launched", Kind = "projectile.launched", FrameId = frameId, ZoneIndex = zone.ZoneIndex, SourceEntityIndex = attacker.EntityIndex, TargetEntityIndex = target.EntityIndex, SubjectKey = projectile.ProjectileId, ItemKey = projectile.WeaponKind, ScalarValue = projectile.Damage, PositionX = projectile.PositionX, PositionZ = projectile.PositionZ }); weapon.State.Firing = true; @@ -448,7 +460,8 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeDaemonSimulationSettings settings) { var state = EnsureWeaponState(entity, AetheriaRuntimeBehaviorStateProjector.EquipmentOwnerKind, - behavior.EquipmentIndex, behavior.BehaviorIndex, behavior.Payload.Kind, settings); + behavior.EquipmentIndex, behavior.BehaviorIndex, behavior.Payload.Kind, + Math.Max(0, (int)Math.Round(ReadNumber(behavior.Payload, 13))), settings); return new ResolvedWeapon( state, behavior.Item.ItemKey, @@ -457,6 +470,10 @@ namespace GameCult.Aetheria.State.Verse PositiveOr(behavior.EvaluateStat(19), settings.WeaponCooldownSeconds), PositiveOr(behavior.EvaluateStat(16), settings.ProjectileSpeed), Math.Max(0, behavior.EvaluateStat(10)), + Math.Max(0, behavior.EvaluateStat(9)), + ReadItemKey(behavior.Payload, 12), + Math.Max(0, (int)Math.Round(ReadNumber(behavior.Payload, 13))), + PositiveOr(ReadNumber(behavior.Payload, 14), settings.WeaponCooldownSeconds), PositiveOr(behavior.EvaluateStat(21), settings.WeaponLockSpeed), Math.Max(0, behavior.EvaluateStat(22)), PositiveOr(behavior.EvaluateStat(23), settings.WeaponLockAngleDegrees), @@ -468,11 +485,12 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeEntitySnapshotCommit entity, AetheriaRuntimeDaemonSimulationSettings settings) { - var state = EnsureWeaponState(entity, "daemon-simulation", entity.EntityIndex, 0, "ProjectileWeapon", settings); + var state = EnsureWeaponState(entity, "daemon-simulation", entity.EntityIndex, 0, "ProjectileWeapon", -1, settings); return new ResolvedWeapon(state, IsPlayerOwned(entity) ? "vanguard-bolt" : "raider-bolt", ResolveProjectileDamage(entity, settings), settings.AttackRange, settings.WeaponCooldownSeconds, settings.ProjectileSpeed, ResolveProjectileDamage(entity, settings) * settings.ProjectileHeatScale, + 0, "", -1, settings.WeaponCooldownSeconds, settings.WeaponLockSpeed, settings.WeaponLockSensorImpact, settings.WeaponLockAngleDegrees, settings.WeaponLockDirectionImpact, settings.WeaponLockDecayPerSecond); } @@ -483,6 +501,7 @@ namespace GameCult.Aetheria.State.Verse int ownerIndex, int behaviorIndex, string behaviorKind, + int initialAmmo, AetheriaRuntimeDaemonSimulationSettings settings) { var states = (entity.WeaponStates ?? Array.Empty()).ToList(); @@ -500,7 +519,7 @@ namespace GameCult.Aetheria.State.Verse OwnerIndex = ownerIndex, BehaviorIndex = behaviorIndex, BehaviorKind = behaviorKind, - Ammo = -1, + Ammo = initialAmmo, BurstRemaining = 0, BurstInterval = settings.WeaponCooldownSeconds, LockTargetEntityIndex = -1 @@ -571,14 +590,92 @@ namespace GameCult.Aetheria.State.Verse private static double PositiveOr(double value, double fallback) => double.IsFinite(value) && value > 0 ? value : fallback; + private static string ReadItemKey(AetheriaRuntimeBehaviorPayload payload, int key) + { + var value = (payload.Fields ?? Array.Empty()) + .FirstOrDefault(field => field != null && field.Key == key)?.Value; + return value?.ItemKeyValue ?? value?.StringValue ?? ""; + } + + private static double ReadNumber(AetheriaRuntimeBehaviorPayload payload, int key) + { + return (payload.Fields ?? Array.Empty()) + .FirstOrDefault(field => field != null && field.Key == key)?.Value?.NumberValue ?? 0; + } + + private static WeaponRoundResult CommitWeaponRound( + AetheriaRuntimeEntitySnapshotCommit entity, + ResolvedWeapon weapon) + { + if (weapon.MagazineSize > 1 && weapon.State.Ammo <= 0) + { + if (!string.IsNullOrWhiteSpace(weapon.AmmoItemKey)) + { + if (!AetheriaRuntimeCargoTransactions.TryFind(entity, weapon.AmmoItemKey, + out var cargoIndex, out var x, out var y) || + !AetheriaRuntimeCargoTransactions.TryRemoveQuantity( + entity, cargoIndex, weapon.AmmoItemKey, x, y, 1, out _)) + return WeaponRoundResult.NoAmmo; + } + weapon.State.Reloading = true; + weapon.State.ReloadProgress = weapon.ReloadTime; + return WeaponRoundResult.ReloadStarted; + } + + if (!CanSupplyEnergy(entity, weapon.Energy)) + return WeaponRoundResult.InsufficientEnergy; + CommitEnergy(entity, weapon.Energy); + if (weapon.MagazineSize > 1) + weapon.State.Ammo--; + return WeaponRoundResult.Fired; + } + + private static bool CanSupplyEnergy(AetheriaRuntimeEntitySnapshotCommit entity, double demand) + { + if (demand < 0.01) return true; + var states = entity.BehaviorStates ?? Array.Empty(); + var charge = states.Where(value => value != null && value.BehaviorKind == "Capacitor") + .Sum(value => Math.Max(0, value.CapacitorCharge)); + return charge >= demand || states.Any(value => value != null && value.BehaviorKind == "Reactor"); + } + + private static void CommitEnergy(AetheriaRuntimeEntitySnapshotCommit entity, double demand) + { + if (demand < 0.01) return; + var capacitors = (entity.BehaviorStates ?? Array.Empty()) + .Where(value => value != null && value.BehaviorKind == "Capacitor" && value.CapacitorCharge > 0) + .ToList(); + var remaining = demand; + while (remaining > 0.000001 && capacitors.Count > 0) + { + var share = remaining / capacitors.Count; + foreach (var capacitor in capacitors.ToArray()) + { + var drained = Math.Min(share, capacitor.CapacitorCharge); + capacitor.CapacitorCharge -= drained; + remaining -= drained; + if (capacitor.CapacitorCharge <= 0.000001) capacitors.Remove(capacitor); + } + } + var reactors = (entity.BehaviorStates ?? Array.Empty()) + .Where(value => value != null && value.BehaviorKind == "Reactor") + .ToArray(); + if (remaining > 0.000001 && reactors.Length > 0) + foreach (var reactor in reactors) reactor.ReactorDraw += remaining / reactors.Length; + } + + private enum WeaponRoundResult { Fired, ReloadStarted, InsufficientEnergy, NoAmmo } + private sealed class ResolvedWeapon { public ResolvedWeapon(AetheriaRuntimeWeaponStateCommit state, string itemKey, double damage, double range, - double cooldown, double projectileSpeed, double heat, double lockSpeed, double lockSensorImpact, + double cooldown, double projectileSpeed, double heat, double energy, string ammoItemKey, + int magazineSize, double reloadTime, double lockSpeed, double lockSensorImpact, double lockAngleDegrees, double lockDirectionImpact, double lockDecayPerSecond) { State = state; ItemKey = itemKey; Damage = damage; Range = range; Cooldown = cooldown; - ProjectileSpeed = projectileSpeed; Heat = heat; LockSpeed = lockSpeed; + ProjectileSpeed = projectileSpeed; Heat = heat; Energy = energy; AmmoItemKey = ammoItemKey; + MagazineSize = magazineSize; ReloadTime = reloadTime; LockSpeed = lockSpeed; LockSensorImpact = lockSensorImpact; LockAngleDegrees = lockAngleDegrees; LockDirectionImpact = lockDirectionImpact; LockDecayPerSecond = lockDecayPerSecond; } @@ -590,6 +687,10 @@ namespace GameCult.Aetheria.State.Verse public double Cooldown { get; } public double ProjectileSpeed { get; } public double Heat { get; } + public double Energy { get; } + public string AmmoItemKey { get; } + public int MagazineSize { get; } + public double ReloadTime { get; } public double LockSpeed { get; } public double LockSensorImpact { get; } public double LockAngleDegrees { get; } diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index ef8493fc4..8c1b79a5a 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -24,7 +24,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity. | Projectile flight | weapon/projectile classes | Ymir | projectile entity/event projection | native projectile visual | Ymir.Core adapter wired for spawn/travel/contact/despawn; native presentation and broader weapon parity incomplete | prove every authored projectile family and runtime visual lifecycle | | Shield/hull damage | energy-funded shield, armor cells, equipment and hull | daemon | entity status + damage events | meters, impacts, damage VFX | synthetic scalar owner wired; not parity | typed absorption, penetration, item damage and death causes | | Thermal/energy | cell conduction and capacitor/reactor/radiator network | daemon | ship/equipment status and warnings | schematic/post effects | cell conduction/radiation wired; energy network and medical thresholds remain reference | capacitor/reactor/radiator ordering, shared energy and medical timelines | -| Weapons | ServerShared behaviors + Unity effects | daemon + Ymir where spatial | action capability, weapon state, attack events | effects/audio/HUD | selected weapon groups now resolve equipped typed-catalog behaviors for damage, range, cooldown, projectile velocity, heat and lock parameters; temporal state belongs to the equipped behavior; Ymir owns flight/contact and daemon owns damage; catalog-free fixtures retain an explicit synthetic fallback | energy/ammunition/burst/resource transactions and each remaining weapon family | +| Weapons | ServerShared behaviors + Unity effects | daemon + Ymir where spatial | action capability, weapon state, attack events | effects/audio/HUD | selected weapon groups resolve equipped typed-catalog behaviors for damage, range, cooldown, projectile velocity, heat, lock, energy, magazine and reload parameters; one committed round atomically validates ammo and energy, drains canonical capacitor/reactor state, decrements the magazine, then launches through Ymir and adds heat; empty magazines consume reserve cargo through the shared cargo transaction and enter reload without firing; temporal state belongs to the equipped behavior; catalog-free fixtures retain an explicit synthetic fallback | burst timing/single-ammo-burst semantics, reload boundary timeline, visibility/wear, and each remaining weapon family | | Destruction/drops | entity death and pickup paths | daemon | death/drop events and world entities | effects and pickups | fragments wired; chronology and original drop transaction absent | exactly-once drops and cleanup | | Tractor/scoop | analog tractor and physical timed pickups | daemon + Ymir | tractor power, pickup body and receipt | beam/interaction feedback | fossil 2/s power ramp and authored forward radius/traction/distance feed pickup bodies into the shared Ymir world; shield contact collects exactly once when capacity permits or applies the fossil 25-unit rejection kick; 30s daemon lifetime; instant cargo teleport deleted | prove generic runtime beam, contact, success and rejection feedback | | Dock/undock | wormhole-first interact; first eligible bay; component prerequisites | daemon + Ymir placement | dock commands/context | camera/UI transition | daemon path wired/projected; Ymir placement and parity unproven | priority, eligibility, placement and reconnect |