diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index bf2a5760e..e02e5c50d 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -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"); diff --git a/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs b/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs index 5d891fed0..d240786af 100644 --- a/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs +++ b/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs @@ -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()).SelectMany(entity => entity.ChildEntityIndices ?? Array.Empty()).ToHashSet(); var active = (entities ?? Array.Empty()).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()).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()).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()); } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs index 2219c2487..0080de21a 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs @@ -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( diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index 4a4f5359f..38cc1f7f8 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -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 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()) + .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()) { @@ -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 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()) + .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( diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupTransactions.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupTransactions.cs new file mode 100644 index 000000000..fa2b75be4 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupTransactions.cs @@ -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()).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()).ToList(); + if (bays.Count == 0) bays.Add(new AetheriaRuntimeCargoBayLoadoutCommit()); + var slots = (bays[0].Items ?? Array.Empty()).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; + } + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeWorldPhysics.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeWorldPhysics.cs index 8546c738b..bfdf16a45 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeWorldPhysics.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeWorldPhysics.cs @@ -13,12 +13,22 @@ namespace GameCult.Aetheria.State.Verse public sealed class AetheriaRuntimeWorldStep { - public AetheriaRuntimeWorldStep(IReadOnlyList bodies, IReadOnlyList contacts) - { Bodies = bodies ?? Array.Empty(); Contacts = contacts ?? Array.Empty(); } + public AetheriaRuntimeWorldStep(IReadOnlyList bodies, IReadOnlyList pickups, IReadOnlyList contacts) + { Bodies = bodies ?? Array.Empty(); Pickups = pickups ?? Array.Empty(); Contacts = contacts ?? Array.Empty(); } public IReadOnlyList Bodies { get; } + public IReadOnlyList Pickups { get; } public IReadOnlyList 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; } diff --git a/docs/original-game-migration-matrix.md b/docs/original-game-migration-matrix.md index a71210d94..c7ce3f601 100644 --- a/docs/original-game-migration-matrix.md +++ b/docs/original-game-migration-matrix.md @@ -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 |