Move loot bodies and pickup contacts into Ymir

This commit is contained in:
Meta Vens 2026-07-11 19:11:46 +01:00
commit 3a38f050de
7 changed files with 142 additions and 43 deletions

View file

@ -15,6 +15,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
MissingWorldPhysicsOwnerCannotAdvanceShips();
TractorRampsAndPullsThroughYmirWithoutTeleportingCargo();
PickupIsCapacityCheckedExactlyOnceAndExpires();
PickupShieldContactCollectsOrBounces();
ThermalCellsUseFossilConductionAndRadiation();
MultipleActorsUseTheSameMovementLever();
AgentClaimsAndCompletesExploreTaskThroughCommands();
@ -802,17 +803,16 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
private static void TractorRampsAndPullsThroughYmirWithoutTeleportingCargo()
{
var ship = Entity(0, 0, "player");
var wreck = Entity(1, 60, "wreck");
wreck.CargoContents = [Cargo(("salvage", 1, 0, 0))];
ship.TargetEntityIndex = 1;
var run = new AetheriaRuntimeRunCheckpointCommit { CurrentZoneIndex = 0, CurrentEntityKey = "zone.0.entity.0", Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [ship, wreck] }] };
ship.DirectionX = 1; ship.DirectionY = 0;
var pickup = new AetheriaRuntimeDroppedPickupCommit { PickupIndex = 3, PositionX = 60, Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "salvage", Quantity = 1 }, LifetimeSeconds = 30 };
var run = new AetheriaRuntimeRunCheckpointCommit { CurrentZoneIndex = 0, CurrentEntityKey = "zone.0.entity.0", Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [ship], DroppedPickups = [pickup] }] };
var command = AetheriaRuntimeDaemonCommandDocument.Create(AetheriaRuntimeDaemonCommandKinds.SetTractorPower, "pilot", "tractor-smoke", 0, "zone.0.entity.0");
command.CommandId = "tractor-on"; command.ScalarValue = 1;
AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-tractor-smoke.cc"), run,
new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.25, SimulationTimeSeconds = 0.25, ObservedCommands = [command], ProjectilePhysics = AetheriaRuntimeProjectilePhysicsUnavailable.Instance, BuildPublications = false });
RequireNear(0.5, ship.TractorPower, 0.000001, "tractor power must use the fossil two-per-second ramp");
Require(wreck.VelocityX < 0 && wreck.PositionX < 60, "Ymir must pull the targeted body toward the tractor source");
RequireEqual(1, CargoQuantity(wreck, "salvage"), "tractor force must not teleport target cargo");
Require(pickup.VelocityX < 0 && pickup.PositionX < 60, "Ymir must pull a pickup inside the forward tractor volume toward the ship");
RequireEqual(1, pickup.Item.Quantity, "tractor force must not consume the pickup item");
RequireEqual(0, CargoQuantity(ship, "salvage"), "scooping must remain a separate capacity-checked transaction");
}
@ -844,6 +844,30 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
RequireEqual(0, zone.DroppedPickups.Count, "pickup must expire after the fossil thirty-second lifetime");
}
private static void PickupShieldContactCollectsOrBounces()
{
var hull = CatalogItem("contact-hull"); hull.HullCapacity = PerformanceStat(1);
var salvage = CatalogItem("contact-salvage"); salvage.Volume = 1;
var catalog = new AetheriaRuntimeCatalogSnapshot([hull, salvage], [], []);
AetheriaRuntimeRunCheckpointCommit Scenario(bool full)
{
var ship = Entity(0, 0, "player"); ship.HullItemKey = hull.ItemKey;
ship.CargoContents = [full ? Cargo((salvage.ItemKey, 1, 0, 0)) : Cargo()];
return new AetheriaRuntimeRunCheckpointCommit { CurrentZoneIndex = 0, CurrentEntityKey = "zone.0.entity.0", Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [ship], DroppedPickups = [new AetheriaRuntimeDroppedPickupCommit { PickupIndex = 10, PositionX = 20, Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = salvage.ItemKey, Quantity = 1 }, LifetimeSeconds = 30 }] }] };
}
var open = Scenario(false);
AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-contact-open.cc"), open,
new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, ProjectilePhysics = AetheriaRuntimeProjectilePhysicsUnavailable.Instance, BuildPublications = false });
RequireEqual(0, open.Zones[0].DroppedPickups.Count, "shield contact with capacity must collect pickup automatically");
RequireEqual(1, CargoQuantity(open.Zones[0].Entities[0], salvage.ItemKey), "contact collection must commit cargo once");
var full = Scenario(true);
AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-contact-full.cc"), full,
new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, ProjectilePhysics = AetheriaRuntimeProjectilePhysicsUnavailable.Instance, BuildPublications = false });
RequireEqual(1, full.Zones[0].DroppedPickups.Count, "full hold must leave contacted pickup alive");
Require(full.Zones[0].DroppedPickups[0].VelocityX > 20, "failed pickup must receive the fossil outward kick");
}
private static (AetheriaRuntimeRunCheckpointCommit Run, AetheriaRuntimeZoneSnapshotCommit Zone, AetheriaRuntimeEntitySnapshotCommit Target) Scenario()
{
var source = Entity(1, -100, "player");

View file

@ -9,6 +9,7 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics
public const double TractorTraction = 25;
public const double TractorDistance = 75;
private const string Prefix = "aetheria.daemon.entity.";
private const string PickupPrefix = "aetheria.daemon.pickup.";
private readonly YmirSimulator _simulator;
public AetheriaYmirWorldPhysics(YmirSimulator? simulator = null) => _simulator = simulator ?? new YmirSimulator();
public string AuthorityId => "ymir.core";
@ -19,31 +20,44 @@ public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics
if (deltaSeconds <= 0) throw new ArgumentOutOfRangeException(nameof(deltaSeconds));
var attached = (entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>()).SelectMany(entity => entity.ChildEntityIndices ?? Array.Empty<int>()).ToHashSet();
var active = (entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>()).Where(entity => entity.IsActive && !attached.Contains(entity.EntityIndex)).ToArray();
var velocity = active.ToDictionary(entity => entity.EntityIndex, entity => new Vec2((float)entity.VelocityX, (float)entity.VelocityY));
foreach (var actor in active.Where(entity => entity.TractorPower > 0 && entity.TargetEntityIndex >= 0))
var entityVelocity = active.ToDictionary(entity => entity.EntityIndex, entity => new Vec2((float)entity.VelocityX, (float)entity.VelocityY));
var pickups = (zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>()).Where(pickup => pickup != null && pickup.AgeSeconds < pickup.LifetimeSeconds).ToArray();
var pickupVelocity = pickups.ToDictionary(pickup => pickup.PickupIndex, pickup => new Vec2((float)pickup.VelocityX, (float)pickup.VelocityZ));
foreach (var actor in active.Where(entity => entity.TractorPower > 0))
{
var target = active.FirstOrDefault(entity => entity.EntityIndex == actor.TargetEntityIndex);
if (target == null) continue;
var dx = actor.PositionX - target.PositionX; var dz = actor.PositionZ - target.PositionZ;
var distance = Math.Sqrt(dx * dx + dz * dz);
if (distance <= 0.001 || distance > TractorDistance + TractorRadius) continue;
var impulse = TractorTraction * actor.TractorPower * deltaSeconds;
var current = velocity[target.EntityIndex];
velocity[target.EntityIndex] = new Vec2(current.X + (float)(dx / distance * impulse), current.Y + (float)(dz / distance * impulse));
var forwardLength = Math.Sqrt(actor.DirectionX * actor.DirectionX + actor.DirectionY * actor.DirectionY);
var fx = forwardLength < 0.001 ? 0 : actor.DirectionX / forwardLength;
var fz = forwardLength < 0.001 ? 1 : actor.DirectionY / forwardLength;
foreach (var pickup in pickups)
{
var dx = pickup.PositionX - actor.PositionX; var dz = pickup.PositionZ - actor.PositionZ;
var along = dx * fx + dz * fz;
var lateral = Math.Abs(dx * fz - dz * fx);
if (along < 0 || along > TractorDistance || lateral > TractorRadius) continue;
var distance = Math.Sqrt(dx * dx + dz * dz);
if (distance <= 0.001) continue;
var impulse = TractorTraction * actor.TractorPower * deltaSeconds;
var current = pickupVelocity[pickup.PickupIndex];
pickupVelocity[pickup.PickupIndex] = new Vec2(current.X - (float)(dx / distance * impulse), current.Y - (float)(dz / distance * impulse));
}
}
var bodies = active
.Select(entity => new PhysicsBody(Prefix + entity.EntityIndex, new Vec2((float)entity.PositionX, (float)entity.PositionZ), velocity[entity.EntityIndex],
.Select(entity => new PhysicsBody(Prefix + entity.EntityIndex, new Vec2((float)entity.PositionX, (float)entity.PositionZ), entityVelocity[entity.EntityIndex],
string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase) ? 48 : 20, 1,
IsStatic: string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase), Restitution: 0.2f,
Direction: new Vec2((float)entity.DirectionX, (float)entity.DirectionY))).ToArray();
Direction: new Vec2((float)entity.DirectionX, (float)entity.DirectionY)))
.Concat(pickups.Select(pickup => new PhysicsBody(PickupPrefix + pickup.PickupIndex, new Vec2((float)pickup.PositionX, (float)pickup.PositionZ), pickupVelocity[pickup.PickupIndex], 5, 1, Restitution: 0.2f)))
.ToArray();
var fields = AetheriaRuntimeDaemonRenderQueries.QueryBodyPoses(zone)
.Select(pose => (Pose: pose, Body: (zone.Bodies ?? Array.Empty<AetheriaRuntimeBodySnapshotCommit>()).FirstOrDefault(body => body != null && body.BodyKey == pose.BodyKey)))
.Where(pair => pair.Body != null && pair.Body.GravityInfluenceRadius > 0 && pair.Body.GravityWellDepth != 0)
.Select(pair => new RadialField("aetheria.daemon.gravity." + pair.Pose.BodyKey, new Vec2((float)pair.Pose.CenterX, (float)pair.Pose.CenterZ), (float)pair.Body!.GravityWellDepth, (float)pair.Body.GravityInfluenceRadius)).ToArray();
var result = _simulator.Step(new SimulationStepRequest((float)deltaSeconds, new YmirWorld((float)zone.SimulationTimeSeconds, bodies, fields)));
int Index(string id) => int.TryParse(id.AsSpan(Prefix.Length), out var value) ? value : -1;
int Index(string id) => id.StartsWith(Prefix, StringComparison.Ordinal) && int.TryParse(id.AsSpan(Prefix.Length), out var value) ? value : -1;
int PickupIndex(string id) => id.StartsWith(PickupPrefix, StringComparison.Ordinal) && int.TryParse(id.AsSpan(PickupPrefix.Length), out var value) ? value : -1;
return new AetheriaRuntimeWorldStep(
result.World.Bodies.Select(body => new AetheriaRuntimeWorldBodyStep { EntityIndex = Index(body.Id), PositionX = body.Position.X, PositionZ = body.Position.Y, VelocityX = body.Velocity.X, VelocityY = body.Velocity.Y, DirectionX = body.Direction?.X ?? 0, DirectionY = body.Direction?.Y ?? 1 }).ToArray(),
result.Contacts.Select(contact => new AetheriaRuntimeWorldContact { EntityAIndex = Index(contact.BodyA), EntityBIndex = Index(contact.BodyB), PointX = contact.Point.X, PointZ = contact.Point.Y, NormalX = contact.Normal.X, NormalZ = contact.Normal.Y, RelativeSpeed = contact.RelativeSpeed }).ToArray());
result.World.Bodies.Where(body => body.Id.StartsWith(Prefix, StringComparison.Ordinal)).Select(body => new AetheriaRuntimeWorldBodyStep { EntityIndex = Index(body.Id), PositionX = body.Position.X, PositionZ = body.Position.Y, VelocityX = body.Velocity.X, VelocityY = body.Velocity.Y, DirectionX = body.Direction?.X ?? 0, DirectionY = body.Direction?.Y ?? 1 }).ToArray(),
result.World.Bodies.Where(body => body.Id.StartsWith(PickupPrefix, StringComparison.Ordinal)).Select(body => new AetheriaRuntimeWorldPickupStep { PickupIndex = PickupIndex(body.Id), PositionX = body.Position.X, PositionZ = body.Position.Y, VelocityX = body.Velocity.X, VelocityZ = body.Velocity.Y }).ToArray(),
result.Contacts.Select(contact => new AetheriaRuntimeWorldContact { EntityAIndex = Index(contact.BodyA), EntityBIndex = Index(contact.BodyB), PickupIndex = Math.Max(PickupIndex(contact.BodyA), PickupIndex(contact.BodyB)), PointX = contact.Point.X, PointZ = contact.Point.Y, NormalX = contact.Normal.X, NormalZ = contact.Normal.Y, RelativeSpeed = contact.RelativeSpeed }).ToArray());
}
}

View file

@ -803,23 +803,7 @@ namespace GameCult.Aetheria.State.Verse
return false;
var pickup = pickups[pickupIndex];
var quantity = Math.Max(1, pickup.Item?.Quantity ?? command.LootPickup.Quantity);
if (pickup.AgeSeconds >= pickup.LifetimeSeconds ||
Math.Pow(pickup.PositionX - entity.PositionX, 2) + Math.Pow(pickup.PositionZ - entity.PositionZ, 2) > 25 * 25 ||
quantity > AetheriaRuntimeCargoCapacityQueries.UnitsThatFit(entity, catalog, pickup.Item?.ItemKey ?? command.LootPickup.ItemKey))
return false;
pickups.RemoveAt(pickupIndex);
zone.DroppedPickups = pickups.ToArray();
AddCargoItem(entity, 0, new AetheriaRuntimeLoadoutItemSlotCommit
{
Item = pickup.Item ?? new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = command.LootPickup.ItemKey ?? "",
Quantity = command.LootPickup.Quantity
}
});
return true;
return AetheriaRuntimePickupTransactions.TryCollect(zone, entity, pickup.PickupIndex, catalog);
}
private static bool ApplyToggleHullConductivity(

View file

@ -51,7 +51,8 @@ namespace GameCult.Aetheria.State.Verse
StepTractorPower(entities, deltaSeconds);
StepRaiderAi(entities);
StepTargetPursuit(entities, settings);
StepWorldPhysics(zone, entities, deltaSeconds, worldPhysics);
var worldStep = StepWorldPhysics(zone, entities, deltaSeconds, worldPhysics);
ResolvePickupContacts(zone, entities, worldStep, catalog);
StepCombat(run, zone, entities, intents, deltaSeconds, settings, projectilePhysics);
AetheriaRuntimeMiningSimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds);
AetheriaRuntimeSurveySimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds);
@ -158,7 +159,7 @@ namespace GameCult.Aetheria.State.Verse
}
}
private static void StepWorldPhysics(
private static AetheriaRuntimeWorldStep StepWorldPhysics(
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
double deltaSeconds,
@ -173,6 +174,14 @@ namespace GameCult.Aetheria.State.Verse
entity.VelocityX = body.VelocityX; entity.VelocityY = body.VelocityY;
entity.DirectionX = body.DirectionX; entity.DirectionY = body.DirectionY;
}
var pickups = (zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>())
.Where(pickup => pickup != null).ToDictionary(pickup => pickup.PickupIndex);
foreach (var body in result.Pickups)
{
if (!pickups.TryGetValue(body.PickupIndex, out var pickup)) continue;
pickup.PositionX = body.PositionX; pickup.PositionZ = body.PositionZ;
pickup.VelocityX = body.VelocityX; pickup.VelocityZ = body.VelocityZ;
}
foreach (var parent in entities)
foreach (var childIndex in parent.ChildEntityIndices ?? Array.Empty<int>())
{
@ -181,6 +190,30 @@ namespace GameCult.Aetheria.State.Verse
child.PositionX = parent.PositionX; child.PositionZ = parent.PositionZ;
child.VelocityX = parent.VelocityX; child.VelocityY = parent.VelocityY;
}
return result;
}
private static void ResolvePickupContacts(
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
AetheriaRuntimeWorldStep worldStep,
AetheriaRuntimeCatalogSnapshot? catalog)
{
foreach (var contact in worldStep.Contacts.Where(contact => contact.PickupIndex >= 0))
{
var entityIndex = Math.Max(contact.EntityAIndex, contact.EntityBIndex);
var entity = entities.FirstOrDefault(value => value.EntityIndex == entityIndex && value.IsActive);
var pickup = (zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>())
.FirstOrDefault(value => value != null && value.PickupIndex == contact.PickupIndex);
if (entity == null || pickup == null) continue;
if (AetheriaRuntimePickupTransactions.TryCollect(zone, entity, pickup.PickupIndex, catalog, requireRange: false))
continue;
var dx = pickup.PositionX - entity.PositionX; var dz = pickup.PositionZ - entity.PositionZ;
var length = Math.Sqrt(dx * dx + dz * dz);
if (length < 0.001) { dx = 1; dz = 0; length = 1; }
pickup.VelocityX += dx / length * 25;
pickup.VelocityZ += dz / length * 25;
}
}
private static void StepCombat(

View file

@ -0,0 +1,33 @@
using System;
using System.Linq;
#nullable enable
namespace GameCult.Aetheria.State.Verse
{
public static class AetheriaRuntimePickupTransactions
{
public static bool TryCollect(AetheriaRuntimeZoneSnapshotCommit zone, AetheriaRuntimeEntitySnapshotCommit entity,
int pickupIndex, AetheriaRuntimeCatalogSnapshot? catalog, bool requireRange = true)
{
var pickups = (zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>()).ToList();
var index = pickups.FindIndex(pickup => pickup != null && pickup.PickupIndex == pickupIndex);
if (index < 0) return false;
var pickup = pickups[index];
var quantity = Math.Max(1, pickup.Item?.Quantity ?? 1);
if (pickup.AgeSeconds >= pickup.LifetimeSeconds ||
(requireRange && Math.Pow(pickup.PositionX - entity.PositionX, 2) + Math.Pow(pickup.PositionZ - entity.PositionZ, 2) > 25 * 25) ||
quantity > AetheriaRuntimeCargoCapacityQueries.UnitsThatFit(entity, catalog, pickup.Item?.ItemKey ?? ""))
return false;
var bays = (entity.CargoContents ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>()).ToList();
if (bays.Count == 0) bays.Add(new AetheriaRuntimeCargoBayLoadoutCommit());
var slots = (bays[0].Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>()).ToList();
var existing = slots.FirstOrDefault(slot => string.Equals(slot?.Item?.ItemKey, pickup.Item.ItemKey, StringComparison.Ordinal));
if (existing != null) existing.Item.Quantity += quantity;
else slots.Add(new AetheriaRuntimeLoadoutItemSlotCommit { Item = pickup.Item });
bays[0].Items = slots; entity.CargoContents = bays;
pickups.RemoveAt(index); zone.DroppedPickups = pickups;
return true;
}
}
}

View file

@ -13,12 +13,22 @@ namespace GameCult.Aetheria.State.Verse
public sealed class AetheriaRuntimeWorldStep
{
public AetheriaRuntimeWorldStep(IReadOnlyList<AetheriaRuntimeWorldBodyStep> bodies, IReadOnlyList<AetheriaRuntimeWorldContact> contacts)
{ Bodies = bodies ?? Array.Empty<AetheriaRuntimeWorldBodyStep>(); Contacts = contacts ?? Array.Empty<AetheriaRuntimeWorldContact>(); }
public AetheriaRuntimeWorldStep(IReadOnlyList<AetheriaRuntimeWorldBodyStep> bodies, IReadOnlyList<AetheriaRuntimeWorldPickupStep> pickups, IReadOnlyList<AetheriaRuntimeWorldContact> contacts)
{ Bodies = bodies ?? Array.Empty<AetheriaRuntimeWorldBodyStep>(); Pickups = pickups ?? Array.Empty<AetheriaRuntimeWorldPickupStep>(); Contacts = contacts ?? Array.Empty<AetheriaRuntimeWorldContact>(); }
public IReadOnlyList<AetheriaRuntimeWorldBodyStep> Bodies { get; }
public IReadOnlyList<AetheriaRuntimeWorldPickupStep> Pickups { get; }
public IReadOnlyList<AetheriaRuntimeWorldContact> Contacts { get; }
}
public sealed class AetheriaRuntimeWorldPickupStep
{
public int PickupIndex { get; set; } = -1;
public double PositionX { get; set; }
public double PositionZ { get; set; }
public double VelocityX { get; set; }
public double VelocityZ { get; set; }
}
public sealed class AetheriaRuntimeWorldBodyStep
{
public int EntityIndex { get; set; } = -1;
@ -34,6 +44,7 @@ namespace GameCult.Aetheria.State.Verse
{
public int EntityAIndex { get; set; } = -1;
public int EntityBIndex { get; set; } = -1;
public int PickupIndex { get; set; } = -1;
public double PointX { get; set; }
public double PointZ { get; set; }
public double NormalX { get; set; }

View file

@ -25,7 +25,7 @@ validation proves contract shape, not wiring, exclusivity, lowering, or parity.
| 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 | one synthetic weapon wired; original engine reference-only | each 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, target, pickup entity and receipt | beam/interaction feedback | fossil 2/s power ramp and authored radius/traction/distance move targets through Ymir; pickup uses stable identity, range/capacity validation, exactly-once removal, and 30s daemon lifetime; instant cargo teleport deleted | put dropped pickup trajectories and failed-capacity bounce into the Ymir world; prove runtime feedback |
| 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 |
| Inventory/equipment | inventory panels + ServerShared | daemon | inventory/equipment surfaces | generic UI | owner-exists; wired/projected in fragments; not proven | all transfer directions and rejection |
| Trade | trade UI/economy | daemon | stock, quote, purchase commands | generic UI | purchase owner wired/projected; not proven | atomic purchase and capacity |