diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 27fa8a967..1055ac23c 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -32,10 +32,100 @@ internal sealed class AetheriaDaemonYmirSmokeChecks public void RunPickup() { + RetainedWorldAdvancesEveryFixedSubstep(); + StableEntityIdentitySurvivesCrossZoneReindex(); PickupIsCapacityCheckedExactlyOnceAndExpires(); PickupShieldContactCollectsOrBounces(); } + private static void RetainedWorldAdvancesEveryFixedSubstep() + { + var ship = Entity(0, 0, "player"); + ship.VelocityX = 10; + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "retained-substep-smoke", + CurrentZoneIndex = 0, + CurrentEntityKey = "zone.0.entity.0", + Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [ship] }] + }; + using var physics = new AetheriaYmirWorldPhysics(); + AetheriaRuntimeDaemonTickRunner.Tick( + Path.Combine(Path.GetTempPath(), "aetheria-retained-substeps.cc"), + run, + new AetheriaRuntimeDaemonTickOptions + { + WorldPhysics = physics, + FrameId = 1, + FixedDeltaSeconds = 0.1, + SimulationStepCount = 3, + SimulationTimeSeconds = 0.3, + PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, + BuildPublications = false + }); + RequireNear(3, ship.PositionX, 0.05, + "one retained Box3D world must advance once for every fixed substep, not once per publication frame"); + } + + private static void StableEntityIdentitySurvivesCrossZoneReindex() + { + var departing = Entity(0, 0, "player"); + departing.EntityId = "cross-zone.departing"; + var survivor = Entity(1, 100, "player"); + survivor.EntityId = "cross-zone.survivor"; + var source = new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 0, + Entities = [departing, survivor] + }; + var destination = new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 1, Entities = [] }; + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "cross-zone-retained-smoke", + CurrentZoneIndex = 0, + CurrentEntityKey = "zone.0.entity.0", + Zones = [source, destination] + }; + using var physics = new AetheriaYmirWorldPhysics(); + AetheriaRuntimeDaemonTickRunner.Tick( + Path.Combine(Path.GetTempPath(), "aetheria-cross-zone-before.cc"), + run, + new AetheriaRuntimeDaemonTickOptions + { + WorldPhysics = physics, + FrameId = 1, + FixedDeltaSeconds = 0.1, + SimulationTimeSeconds = 0.1, + PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, + BuildPublications = false + }); + + survivor.EntityIndex = 0; + departing.EntityIndex = 0; + departing.PositionX = 250; + source.Entities = [survivor]; + destination.Entities = [departing]; + run.CurrentZoneIndex = 1; + run.CurrentEntityKey = "zone.1.entity.0"; + AetheriaRuntimeDaemonTickRunner.Tick( + Path.Combine(Path.GetTempPath(), "aetheria-cross-zone-after.cc"), + run, + new AetheriaRuntimeDaemonTickOptions + { + WorldPhysics = physics, + FrameId = 2, + FixedDeltaSeconds = 0.1, + SimulationTimeSeconds = 0.2, + PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, + BuildPublications = false + }); + + RequireNear(100, survivor.PositionX, 0.05, + "source-zone reindex must not give a survivor the departed entity's retained Box3D body"); + RequireNear(250, departing.PositionX, 0.05, + "a cross-zone arrival must retain its stable identity while spawning in the destination session"); + } + public void Run() { PositiveGravityDepthAttractsAndProjectsAsAWell(); @@ -110,7 +200,9 @@ internal sealed class AetheriaDaemonYmirSmokeChecks GravityTerrainDepth = 5 }; - var step = new AetheriaYmirWorldPhysics().Step(zone, zone.Entities, 0.1); + using var physics = new AetheriaYmirWorldPhysics(); + physics.RetainWorlds("gravity-sign-smoke", [zone.ZoneIndex]); + var step = physics.Step("gravity-sign-smoke", 1, 0, zone, zone.Entities, 0.1); var steppedActor = step.Bodies.Single(value => value.EntityIndex == actor.EntityIndex); Require(steppedActor.VelocityX < 0, "positive authored gravity depth must accelerate a body toward the well center through Ymir"); @@ -2568,13 +2660,21 @@ internal sealed class AetheriaDaemonYmirSmokeChecks RequireEqual(1, zone.DroppedPickups.Count, "nearby pickup without a Ymir contact fact must remain in the world"); RequireEqual(0, CargoQuantity(ship, salvage.ItemKey), "proximity must not mutate cargo"); - var contact = new AetheriaRuntimeWorldContact { EntityAIndex = 0, PickupIndex = 7, NormalX = 1 }; + var contact = new AetheriaRuntimeWorldBeginContact + { + FactId = "ymir-fact-pickup-7", + EntityAIndex = 0, + PickupIndex = 7, + NormalX = 1 + }; AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-contact-dedup.cc"), run, new AetheriaRuntimeDaemonTickOptions { WorldPhysics = new ScriptedWorldPhysics(contact, contact), FrameId = 2, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.2, Catalog = catalog, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); RequireEqual(0, zone.DroppedPickups.Count, "one Ymir contact must consume the pickup"); RequireEqual(1, CargoQuantity(ship, salvage.ItemKey), "duplicate contact facts must commit cargo exactly once"); RequireEqual(1, run.GameEvents.Count(value => value.Kind == "pickup.collected" && value.PickupIndex == 7), "duplicate contact facts must emit one collection event"); + RequireEqual(1, run.PickupContactReceipts.Count(value => value.FactId == contact.FactId), + "duplicate delivery of one Ymir fact must persist one Aetheria consumption receipt"); zone.DroppedPickups = [new AetheriaRuntimeDroppedPickupCommit { PickupIndex = 8, PositionX = 10, Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = salvage.ItemKey, Quantity = 1 }, LifetimeSeconds = 30 }]; AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-expiry-smoke.cc"), run, @@ -2598,20 +2698,32 @@ internal sealed class AetheriaDaemonYmirSmokeChecks 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); + using var openPhysics = new AetheriaYmirWorldPhysics(); 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, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = openPhysics, FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.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"); RequireEqual(1, open.GameEvents.Count(value => value.Kind == "pickup.collected" && value.PickupIndex == 10), "contact collection must emit one stable event"); + RequireEqual(1, open.PickupContactReceipts.Count, + "a real Box3D Begin fact must persist one cargo-consumption receipt"); var full = Scenario(true); + using var fullPhysics = new AetheriaYmirWorldPhysics(); 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, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = fullPhysics, FrameId = 1, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.1, Catalog = catalog, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.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"); RequireEqual(1, full.GameEvents.Count(value => value.Kind == "pickup.rejected" && value.PickupIndex == 10), "capacity rejection must emit one stable event"); + RequireEqual(1, full.PickupContactReceipts.Count, + "capacity rejection must consume its Box3D Begin fact exactly once"); + AetheriaRuntimeDaemonTickRunner.Tick(Path.Combine(Path.GetTempPath(), "aetheria-pickup-contact-full-second.cc"), full, + new AetheriaRuntimeDaemonTickOptions { WorldPhysics = fullPhysics, FrameId = 2, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.2, Catalog = catalog, PhysicalPayloadPhysics = AetheriaRuntimePhysicalPayloadPhysicsUnavailable.Instance, BuildPublications = false }); + RequireEqual(1, full.PickupContactReceipts.Count, + "persistent or separating contact must not replay an already consumed Begin fact"); + RequireEqual(1, full.GameEvents.Count(value => value.Kind == "pickup.rejected" && value.PickupIndex == 10), + "a consumed rejection fact must not emit a second gameplay event"); var feedback = AetheriaRuntimeDaemonGameSurfaceBuilder.Build( new AetheriaRuntimeDaemonFrameDocument { FrameId = 1, Run = full }, new AetheriaRuntimeDaemonHealthDocument(), @@ -2659,6 +2771,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks private static AetheriaRuntimeEntitySnapshotCommit Entity(int index, double x, string faction) => new() { EntityIndex = index, + EntityId = $"smoke.entity.{index}", Kind = "ship", FactionKey = faction, PositionX = x, @@ -2716,11 +2829,22 @@ internal sealed class AetheriaDaemonYmirSmokeChecks throw new InvalidOperationException($"{message}. Expected {expected}; actual {actual}."); } - private sealed class ScriptedWorldPhysics(params AetheriaRuntimeWorldContact[] contacts) : IAetheriaRuntimeWorldPhysics + private sealed class ScriptedWorldPhysics(params AetheriaRuntimeWorldBeginContact[] contacts) : IAetheriaRuntimeWorldPhysics { public string ImplementationId => "smoke.scripted-world"; + public void RetainWorlds(string runId, IReadOnlyList zoneIndices) { } + + public AetheriaRuntimeWorldPickupStep ApplyPickupRejection( + string runId, + int zoneIndex, + AetheriaRuntimeWorldBeginContact contact) => + new() { PickupIndex = contact.PickupIndex, VelocityX = contact.NormalX * 25, VelocityZ = contact.NormalZ * 25 }; + public AetheriaRuntimeWorldStep Step( + string runId, + long frameId, + int simulationStepIndex, AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyList entities, double deltaSeconds) => new([], [], contacts); diff --git a/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs b/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs index f8b8db5e4..d8ba2a134 100644 --- a/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs +++ b/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs @@ -3,61 +3,365 @@ using Ymir.Core; namespace Aetheria.State.Daemon; -public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics +public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDisposable { public const double TractorRadius = 25; 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 ImplementationId => "ymir.core"; - public AetheriaRuntimeWorldStep Step(AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyList entities, double deltaSeconds) + private const string EntityPrefix = "aetheria.daemon.entity."; + private const string PickupPrefix = "aetheria.daemon.pickup."; + private readonly Dictionary _sessions = new(); + + public string ImplementationId => "ymir.box3d.retained-session.v1"; + + public void RetainWorlds(string runId, IReadOnlyList zoneIndices) + { + var retainedZones = (zoneIndices ?? Array.Empty()).ToHashSet(); + foreach (var key in _sessions.Keys + .Where(key => !string.Equals(key.RunId, runId, StringComparison.Ordinal) || + !retainedZones.Contains(key.ZoneIndex)) + .ToArray()) + { + _sessions[key].Session.Dispose(); + _sessions.Remove(key); + } + } + + public AetheriaRuntimeWorldPickupStep ApplyPickupRejection( + string runId, + int zoneIndex, + AetheriaRuntimeWorldBeginContact contact) + { + ArgumentNullException.ThrowIfNull(contact); + if (string.IsNullOrWhiteSpace(contact.FactId)) + throw new ArgumentException("Pickup rejection requires a Ymir Begin fact id.", nameof(contact)); + if (!_sessions.TryGetValue(new WorldKey(runId, zoneIndex), out var state)) + throw new InvalidOperationException($"No retained Ymir session owns run '{runId}' zone {zoneIndex}."); + + var pickupId = PickupPrefix + contact.PickupIndex; + var entityIndex = contact.EntityAIndex >= 0 ? contact.EntityAIndex : contact.EntityBIndex; + var stableEntityId = contact.EntityAIndex >= 0 ? contact.EntityAId : contact.EntityBId; + if (string.IsNullOrWhiteSpace(stableEntityId)) + throw new InvalidOperationException($"Ymir Begin fact '{contact.FactId}' has no stable entity identity."); + var entityId = EntityPrefix + stableEntityId; + var bodies = state.Session.Snapshot().Bodies.ToDictionary(body => body.Id, StringComparer.Ordinal); + if (!bodies.TryGetValue(pickupId, out var pickup) || !bodies.TryGetValue(entityId, out var entity)) + throw new InvalidOperationException($"Ymir Begin fact '{contact.FactId}' references a body outside its retained session."); + + var normalSign = contact.EntityAIndex == entityIndex ? 1.0 : -1.0; + var normal = Normalize( + contact.NormalX * normalSign, + contact.NormalZ * normalSign, + pickup.Position.X >= entity.Position.X ? 1 : -1, + 0); + RequireAccepted(state.Session.SetVelocity(new YmirSetBodyVelocityCommand( + new YmirCommandHeader($"aetheria:fact:{contact.FactId}:pickup-rejection", state.Session.Info.Revision), + pickupId, + new Vec2( + pickup.Velocity.X + (float)(normal.X * 25.0), + pickup.Velocity.Y + (float)(normal.Y * 25.0)), + pickup.AngularVelocity))); + + var rejected = state.Session.Snapshot().Bodies.Single(body => body.Id == pickupId); + return new AetheriaRuntimeWorldPickupStep + { + PickupIndex = contact.PickupIndex, + PositionX = rejected.Position.X, + PositionZ = rejected.Position.Y, + VelocityX = rejected.Velocity.X, + VelocityZ = rejected.Velocity.Y + }; + } + + public AetheriaRuntimeWorldStep Step( + string runId, + long frameId, + int simulationStepIndex, + AetheriaRuntimeZoneSnapshotCommit zone, + IReadOnlyList entities, + double deltaSeconds) { ArgumentNullException.ThrowIfNull(zone); - 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 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)); + if (string.IsNullOrWhiteSpace(runId)) + throw new ArgumentException("A retained Ymir world requires a run id.", nameof(runId)); + if (deltaSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(deltaSeconds)); + + var key = new WorldKey(runId, zone.ZoneIndex); + if (_sessions.TryGetValue(key, out var existing) && + existing.LastFrameId == frameId && existing.LastSimulationStepIndex == simulationStepIndex) + return existing.LastResult!; + if (existing != null && + (frameId < existing.LastFrameId || + (frameId == existing.LastFrameId && simulationStepIndex < existing.LastSimulationStepIndex))) + throw new InvalidOperationException( + $"Ymir session '{existing.Session.Info.SessionId}' cannot step backward from " + + $"frame/step {existing.LastFrameId}/{existing.LastSimulationStepIndex} to {frameId}/{simulationStepIndex}."); + + var active = ActiveEntities(entities); + var pickups = ActivePickups(zone); + var desiredBodies = BuildBodies(active, pickups); + var state = existing ?? CreateSession(key, zone, desiredBodies); + if (existing == null) + _sessions.Add(key, state); + + var commandOrdinal = 0; + Reconcile(state.Session, desiredBodies, frameId, simulationStepIndex, ref commandOrdinal); + ApplyTractorForces(state.Session, active, pickups, frameId, simulationStepIndex, ref commandOrdinal); + + var step = state.Session.Step(new YmirStepSessionCommand( + Header(state.Session, frameId, simulationStepIndex, "step", "world", ref commandOrdinal), + (float)deltaSeconds, + GravityFields(zone))); + var result = Lower(step, active); + state.LastFrameId = frameId; + state.LastSimulationStepIndex = simulationStepIndex; + state.LastResult = result; + return result; + } + + public void Dispose() + { + foreach (var state in _sessions.Values) + state.Session.Dispose(); + _sessions.Clear(); + } + + private static SessionState CreateSession( + WorldKey key, + AetheriaRuntimeZoneSnapshotCommit zone, + IReadOnlyList bodies) + { + var sessionId = $"aetheria.run.{key.RunId}.zone.{key.ZoneIndex}"; + return new SessionState(YmirSession.Create(new YmirSessionCreateRequest( + sessionId, + bodies, + (float)zone.SimulationTimeSeconds))); + } + + private static void Reconcile( + YmirSession session, + IReadOnlyList desiredBodies, + long frameId, + int simulationStepIndex, + ref int commandOrdinal) + { + var desired = desiredBodies.ToDictionary(body => body.Id, StringComparer.Ordinal); + var current = session.Snapshot().Bodies.ToDictionary(body => body.Id, StringComparer.Ordinal); + + foreach (var body in current.Values.Where(body => !desired.ContainsKey(body.Id)).OrderBy(body => body.Id, StringComparer.Ordinal)) + RequireAccepted(session.Remove(new YmirRemoveBodyCommand( + Header(session, frameId, simulationStepIndex, "remove", body.Id, ref commandOrdinal), body.Id))); + + foreach (var body in desired.Values.OrderBy(body => body.Id, StringComparer.Ordinal)) + { + if (!current.TryGetValue(body.Id, out var present)) + { + RequireAccepted(session.Spawn(new YmirSpawnBodyCommand( + Header(session, frameId, simulationStepIndex, "spawn", body.Id, ref commandOrdinal), body))); + continue; + } + + if (present.Radius != body.Radius || present.Mass != body.Mass || + present.IsStatic != body.IsStatic || present.Restitution != body.Restitution) + RequireAccepted(session.Configure(new YmirConfigureBodyCommand( + Header(session, frameId, simulationStepIndex, "configure", body.Id, ref commandOrdinal), + body.Id, body.Radius, body.Mass, body.IsStatic, body.Restitution))); + + if (present.Direction != body.Direction) + RequireAccepted(session.Teleport(new YmirTeleportBodyCommand( + Header(session, frameId, simulationStepIndex, "direction", body.Id, ref commandOrdinal), + body.Id, present.Position, body.Direction ?? new Vec2(0, 1)))); + + if (!body.IsStatic && (present.Velocity != body.Velocity || present.AngularVelocity != body.AngularVelocity)) + RequireAccepted(session.SetVelocity(new YmirSetBodyVelocityCommand( + Header(session, frameId, simulationStepIndex, "velocity", body.Id, ref commandOrdinal), + body.Id, body.Velocity, body.AngularVelocity))); + } + } + + private static void ApplyTractorForces( + YmirSession session, + IReadOnlyList active, + IReadOnlyList pickups, + long frameId, + int simulationStepIndex, + ref int commandOrdinal) + { foreach (var actor in active.Where(entity => entity.TractorPower > 0)) { - 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; + var forward = Normalize(actor.DirectionX, actor.DirectionY, 0, 1); 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 dx = pickup.PositionX - actor.PositionX; + var dz = pickup.PositionZ - actor.PositionZ; + var along = dx * forward.X + dz * forward.Y; + var lateral = Math.Abs(dx * forward.Y - dz * forward.X); 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)); + if (along < 0 || along > TractorDistance || lateral > TractorRadius || distance <= 0.001) + continue; + + var force = TractorTraction * actor.TractorPower; + var pickupId = PickupPrefix + pickup.PickupIndex; + RequireAccepted(session.ApplyForce(new YmirApplyForceCommand( + Header(session, frameId, simulationStepIndex, "tractor", $"{actor.EntityIndex}:{pickup.PickupIndex}", ref commandOrdinal), + pickupId, + new Vec2((float)(-dx / distance * force), (float)(-dz / distance * force))))); } } - var bodies = active - .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))) - .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) => 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.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()); } + + private static AetheriaRuntimeWorldStep Lower( + YmirSessionStepResult step, + IReadOnlyList active) + { + var bodies = step.World.Bodies; + var entitiesByBodyId = active.ToDictionary(BodyId, StringComparer.Ordinal); + return new AetheriaRuntimeWorldStep( + bodies.Where(body => body.Id.StartsWith(EntityPrefix, StringComparison.Ordinal)) + .Select(body => new AetheriaRuntimeWorldBodyStep + { + EntityIndex = entitiesByBodyId[body.Id].EntityIndex, + 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(), + bodies.Where(body => body.Id.StartsWith(PickupPrefix, StringComparison.Ordinal)) + .Select(body => new AetheriaRuntimeWorldPickupStep + { + PickupIndex = ParseIndex(body.Id, PickupPrefix), + PositionX = body.Position.X, + PositionZ = body.Position.Y, + VelocityX = body.Velocity.X, + VelocityZ = body.Velocity.Y + }).ToArray(), + step.ContactFacts + .Where(fact => fact.Kind == YmirContactFactKind.Begin && + (fact.BodyA.StartsWith(PickupPrefix, StringComparison.Ordinal) || + fact.BodyB.StartsWith(PickupPrefix, StringComparison.Ordinal))) + .Select(fact => + { + entitiesByBodyId.TryGetValue(fact.BodyA, out var entityA); + entitiesByBodyId.TryGetValue(fact.BodyB, out var entityB); + return new AetheriaRuntimeWorldBeginContact + { + FactId = fact.FactId, + EntityAId = entityA?.EntityId ?? "", + EntityBId = entityB?.EntityId ?? "", + EntityAIndex = entityA?.EntityIndex ?? -1, + EntityBIndex = entityB?.EntityIndex ?? -1, + PickupIndex = Math.Max(ParseIndex(fact.BodyA, PickupPrefix), ParseIndex(fact.BodyB, PickupPrefix)), + PointX = fact.Point?.X ?? 0, + PointZ = fact.Point?.Y ?? 0, + NormalX = fact.Normal?.X ?? 0, + NormalZ = fact.Normal?.Y ?? 0, + RelativeSpeed = fact.RelativeSpeed ?? 0 + }; + }) + .Where(contact => contact.PickupIndex >= 0 && + (contact.EntityAIndex >= 0 || contact.EntityBIndex >= 0)) + .ToArray()); + } + + private static IReadOnlyList ActiveEntities( + IReadOnlyList entities) + { + var source = entities ?? Array.Empty(); + var attached = source.SelectMany(entity => entity.ChildEntityIndices ?? Array.Empty()).ToHashSet(); + return source.Where(entity => entity.IsActive && !attached.Contains(entity.EntityIndex)).ToArray(); + } + + private static IReadOnlyList ActivePickups( + AetheriaRuntimeZoneSnapshotCommit zone) => + (zone.DroppedPickups ?? Array.Empty()) + .Where(pickup => pickup != null && pickup.AgeSeconds < pickup.LifetimeSeconds) + .ToArray(); + + private static IReadOnlyList BuildBodies( + IReadOnlyList active, + IReadOnlyList pickups) => + active.Select(entity => + { + if (string.IsNullOrWhiteSpace(entity.EntityId)) + throw new InvalidOperationException( + $"Entity index {entity.EntityIndex} has no stable runtime identity for retained Ymir ownership."); + var isStatic = string.Equals(entity.Kind, "station", StringComparison.OrdinalIgnoreCase); + var direction = Normalize(entity.DirectionX, entity.DirectionY, 0, 1); + return new PhysicsBody( + BodyId(entity), + new Vec2((float)entity.PositionX, (float)entity.PositionZ), + isStatic ? new Vec2(0, 0) : new Vec2((float)entity.VelocityX, (float)entity.VelocityY), + isStatic ? 48 : 20, + 1, + IsStatic: isStatic, + Restitution: 0.2f, + Direction: new Vec2((float)direction.X, (float)direction.Y)); + }).Concat(pickups.Select(pickup => new PhysicsBody( + PickupPrefix + pickup.PickupIndex, + new Vec2((float)pickup.PositionX, (float)pickup.PositionZ), + new Vec2((float)pickup.VelocityX, (float)pickup.VelocityZ), + 5, + 1, + Restitution: 0.2f))) + .ToArray(); + + private static string BodyId(AetheriaRuntimeEntitySnapshotCommit entity) => EntityPrefix + entity.EntityId; + + private static IReadOnlyList GravityFields(AetheriaRuntimeZoneSnapshotCommit zone) => + 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(); + + private static YmirCommandHeader Header( + YmirSession session, + long frameId, + int simulationStepIndex, + string kind, + string subject, + ref int ordinal) => + new($"aetheria:frame:{frameId}:step:{simulationStepIndex}:{ordinal++}:{kind}:{subject}", session.Info.Revision); + + private static void RequireAccepted(YmirCommandReceipt receipt) + { + if (receipt.Outcome != YmirCommandOutcome.Accepted) + throw new InvalidOperationException( + $"Ymir rejected Aetheria command '{receipt.CommandId}' with {receipt.Error}."); + } + + private static int ParseIndex(string id, string prefix) => + id.StartsWith(prefix, StringComparison.Ordinal) && + int.TryParse(id.AsSpan(prefix.Length), out var value) + ? value + : -1; + + private static (double X, double Y) Normalize( + double x, + double y, + double fallbackX, + double fallbackY) + { + var length = Math.Sqrt(x * x + y * y); + return length <= 0.001 ? (fallbackX, fallbackY) : (x / length, y / length); + } + + private sealed class SessionState(YmirSession session) + { + public YmirSession Session { get; } = session; + public long LastFrameId { get; set; } = -1; + public int LastSimulationStepIndex { get; set; } = -1; + public AetheriaRuntimeWorldStep? LastResult { get; set; } + } + + private readonly record struct WorldKey(string RunId, int ZoneIndex); } diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index a04151501..09063c790 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -15,7 +15,7 @@ using System.Net.Sockets; var options = AetheriaDaemonHostOptions.Parse(args); var startedAtUtc = DateTimeOffset.UtcNow.ToString("O"); var physicalPayloadPhysics = new AetheriaYmirPhysicalPayloadPhysics(); -var worldPhysics = new AetheriaYmirWorldPhysics(); +using var worldPhysics = new AetheriaYmirWorldPhysics(); var traceClientRudp = string.Equals( Environment.GetEnvironmentVariable("AETHERIA_TRACE_CLIENT_RUDP"), "1", diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 3b6d374d1..0a1cca966 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -6315,7 +6315,16 @@ public class DaemonRuntimeDocumentTests { public string ImplementationId => "test.world-passthrough"; - public AetheriaRuntimeWorldStep Step(AetheriaRuntimeZoneSnapshotCommit zone, + public void RetainWorlds(string runId, IReadOnlyList zoneIndices) { } + + public AetheriaRuntimeWorldPickupStep ApplyPickupRejection( + string runId, + int zoneIndex, + AetheriaRuntimeWorldBeginContact contact) => + new() { PickupIndex = contact.PickupIndex }; + + public AetheriaRuntimeWorldStep Step(string runId, long frameId, int simulationStepIndex, + AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyList entities, double deltaSeconds) { return new AetheriaRuntimeWorldStep( @@ -6325,7 +6334,7 @@ public class DaemonRuntimeDocumentTests VelocityX = entity.VelocityX, VelocityY = entity.VelocityY, DirectionX = entity.DirectionX, DirectionY = entity.DirectionY }).ToArray(), - Array.Empty(), Array.Empty()); + Array.Empty(), Array.Empty()); } } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index 2065ebdc7..4db85f2d3 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -23,7 +23,8 @@ namespace GameCult.Aetheria.State.Verse IAetheriaRuntimeWorldPhysics worldPhysics, AetheriaRuntimeCatalogSnapshot? catalog = null, long frameId = 0, - double simulationTimeSeconds = 0) + double simulationTimeSeconds = 0, + int simulationStepIndex = 0) { if (run == null || deltaSeconds <= 0) return; @@ -32,6 +33,13 @@ namespace GameCult.Aetheria.State.Verse if (worldPhysics == null) throw new ArgumentNullException(nameof(worldPhysics)); + worldPhysics.RetainWorlds( + run.RunId, + (run.Zones ?? Array.Empty()) + .Where(zone => zone != null) + .Select(zone => zone.ZoneIndex) + .ToArray()); + foreach (var zone in run.Zones ?? Array.Empty()) { if (zone == null) @@ -40,8 +48,6 @@ namespace GameCult.Aetheria.State.Verse var entities = (zone.Entities ?? Array.Empty()) .Where(entity => entity != null) .ToArray(); - if (entities.Length == 0) - continue; StepPickupLifetimes(run, zone, frameId, deltaSeconds); @@ -64,8 +70,10 @@ namespace GameCult.Aetheria.State.Verse foreach (var movement in intents?.Movements ?? Enumerable.Empty()) ApplyMovementIntent(run, entities, movement, settings); StepTractorPower(entities, deltaSeconds); - var worldStep = StepWorldPhysics(zone, entities, deltaSeconds, worldPhysics); - ResolvePickupContacts(run, zone, entities, worldStep.Contacts, catalog, frameId); + var worldStep = StepWorldPhysics( + run.RunId, frameId, simulationStepIndex, zone, entities, deltaSeconds, worldPhysics); + ResolvePickupContacts( + run, zone, entities, worldStep.BeginContacts, worldPhysics, catalog, frameId); StepCombat(run, zone, entities, intents, deltaSeconds, settings, physicalPayloadPhysics, catalog, frameId, simulationTimeSeconds); AetheriaRuntimeMiningSimulation.Step(run, zone, entities, intents, catalog, frameId, simulationTimeSeconds, deltaSeconds); @@ -121,12 +129,15 @@ namespace GameCult.Aetheria.State.Verse } private static AetheriaRuntimeWorldStep StepWorldPhysics( + string runId, + long frameId, + int simulationStepIndex, AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyList entities, double deltaSeconds, IAetheriaRuntimeWorldPhysics worldPhysics) { - var result = worldPhysics.Step(zone, entities, deltaSeconds); + var result = worldPhysics.Step(runId, frameId, simulationStepIndex, zone, entities, deltaSeconds); var byIndex = entities.ToDictionary(entity => entity.EntityIndex); foreach (var body in result.Bodies) { @@ -158,40 +169,71 @@ namespace GameCult.Aetheria.State.Verse AetheriaRuntimeRunCheckpointCommit run, AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyList entities, - IReadOnlyList contacts, + IReadOnlyList beginContacts, + IAetheriaRuntimeWorldPhysics worldPhysics, AetheriaRuntimeCatalogSnapshot? catalog, long frameId) { var entitiesByIndex = entities .Where(value => value != null && value.IsActive) .ToDictionary(value => value.EntityIndex); - foreach (var contact in (contacts ?? Array.Empty()) - .Where(value => value != null && value.PickupIndex >= 0) - .GroupBy(value => (value.EntityAIndex, value.EntityBIndex, value.PickupIndex)) - .Select(group => group.First())) + foreach (var contact in (beginContacts ?? Array.Empty()) + .Where(value => value != null && value.PickupIndex >= 0 && + !string.IsNullOrWhiteSpace(value.FactId))) { var entityIndex = contact.EntityAIndex >= 0 ? contact.EntityAIndex : contact.EntityBIndex; - if (!entitiesByIndex.TryGetValue(entityIndex, out var entity)) + entitiesByIndex.TryGetValue(entityIndex, out var entity); + var entityId = entity?.EntityId ?? + (contact.EntityAIndex >= 0 ? contact.EntityAId : contact.EntityBId); + var priorReceipt = AetheriaRuntimePickupContactReceipts.Find(run, contact.FactId); + if (priorReceipt != null) + { + if (priorReceipt.ZoneIndex != zone.ZoneIndex || + !string.Equals(priorReceipt.EntityId, entityId, StringComparison.Ordinal) || + priorReceipt.PickupIndex != contact.PickupIndex) + throw new InvalidOperationException( + $"Ymir fact id '{contact.FactId}' was reused for a different pickup contact."); continue; + } var pickup = (zone.DroppedPickups ?? Array.Empty()) .FirstOrDefault(value => value != null && value.PickupIndex == contact.PickupIndex); - if (pickup == null) - continue; - var itemKey = pickup.Item?.ItemKey ?? ""; - var quantity = Math.Max(1, pickup.Item?.Quantity ?? 1); - var result = AetheriaRuntimePickupTransactions.ApplyContact(zone, entity, contact, catalog); - if (result == AetheriaRuntimePickupContactResult.Ignored) - continue; + var itemKey = pickup?.Item?.ItemKey ?? ""; + var quantity = Math.Max(1, pickup?.Item?.Quantity ?? 1); + var result = entity == null + ? AetheriaRuntimePickupContactResult.Ignored + : AetheriaRuntimePickupTransactions.ApplyContact(zone, entity, contact, catalog); var kind = result == AetheriaRuntimePickupContactResult.Collected ? "pickup.collected" - : "pickup.rejected"; + : result == AetheriaRuntimePickupContactResult.RejectedCapacity + ? "pickup.rejected" + : "pickup.ignored"; + if (result == AetheriaRuntimePickupContactResult.RejectedCapacity && pickup != null) + { + var rejected = worldPhysics.ApplyPickupRejection(run.RunId, zone.ZoneIndex, contact); + pickup.PositionX = rejected.PositionX; + pickup.PositionZ = rejected.PositionZ; + pickup.VelocityX = rejected.VelocityX; + pickup.VelocityZ = rejected.VelocityZ; + } + AetheriaRuntimePickupContactReceipts.Append(run, new AetheriaRuntimePickupContactReceiptCommit + { + FactId = contact.FactId, + FrameId = frameId, + ZoneIndex = zone.ZoneIndex, + EntityIndex = entityIndex, + EntityId = entityId, + PickupIndex = contact.PickupIndex, + Outcome = kind + }); + if (result == AetheriaRuntimePickupContactResult.Ignored) + continue; AetheriaRuntimeGameEvents.Append(run, new AetheriaRuntimeGameEventCommit { - EventId = $"frame:{frameId}:zone:{zone.ZoneIndex}:entity:{entity.EntityIndex}:pickup:{contact.PickupIndex}:{kind}", + EventId = $"ymir-fact:{contact.FactId}:{kind}", Kind = kind, FrameId = frameId, ZoneIndex = zone.ZoneIndex, - TargetEntityIndex = entity.EntityIndex, + TargetEntityIndex = entityIndex, PickupIndex = contact.PickupIndex, ItemKey = itemKey, ScalarValue = quantity diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs index ed3e61eb5..a99bb45e2 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs @@ -189,7 +189,8 @@ namespace GameCult.Aetheria.State.Verse options.WorldPhysics ?? throw new InvalidOperationException("Ymir world physics owner is required."), options.Catalog, options.FrameId, - stepTime); + stepTime, + simulationStep); Trace("world-step"); StampZoneSimulationTime(operationResult.Run, stepTime); } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupContactReceipts.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupContactReceipts.cs new file mode 100644 index 000000000..25d9d47b9 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupContactReceipts.cs @@ -0,0 +1,39 @@ +using System; +using System.Linq; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public static class AetheriaRuntimePickupContactReceipts + { + public static bool Contains(AetheriaRuntimeRunCheckpointCommit run, string factId) + { + return Find(run, factId) != null; + } + + public static AetheriaRuntimePickupContactReceiptCommit? Find( + AetheriaRuntimeRunCheckpointCommit run, + string factId) => + run == null || string.IsNullOrWhiteSpace(factId) + ? null + : (run.PickupContactReceipts ?? Array.Empty()) + .FirstOrDefault(value => value != null && + string.Equals(value.FactId, factId, StringComparison.Ordinal)); + + public static void Append( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimePickupContactReceiptCommit receipt) + { + if (run == null || receipt == null || string.IsNullOrWhiteSpace(receipt.FactId)) + throw new ArgumentException("Pickup contact receipts require a Ymir fact id.", nameof(receipt)); + if (Contains(run, receipt.FactId)) + return; + + run.PickupContactReceipts = + (run.PickupContactReceipts ?? Array.Empty()) + .Append(receipt) + .ToArray(); + } + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupContactReceipts.cs.meta b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupContactReceipts.cs.meta new file mode 100644 index 000000000..83656762c --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupContactReceipts.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 54a8cbe4b7d84d35955c2084fe0dd9e4 diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupTransactions.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupTransactions.cs index 87c7f2cdb..47d83fd39 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupTransactions.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimePickupTransactions.cs @@ -17,7 +17,7 @@ namespace GameCult.Aetheria.State.Verse public static AetheriaRuntimePickupContactResult ApplyContact( AetheriaRuntimeZoneSnapshotCommit zone, AetheriaRuntimeEntitySnapshotCommit entity, - AetheriaRuntimeWorldContact contact, + AetheriaRuntimeWorldBeginContact contact, AetheriaRuntimeCatalogSnapshot? catalog) { if (zone == null || entity == null || contact == null || !entity.IsActive || @@ -42,19 +42,7 @@ namespace GameCult.Aetheria.State.Verse .DefaultIfEmpty(-1) .First(); if (cargoIndex < 0) - { - var normalSign = contact.EntityAIndex == entity.EntityIndex ? 1.0 : -1.0; - var length = Math.Sqrt(contact.NormalX * contact.NormalX + contact.NormalZ * contact.NormalZ); - var normalX = length > 0.000001 - ? contact.NormalX / length * normalSign - : pickup.PositionX >= entity.PositionX ? 1.0 : -1.0; - var normalZ = length > 0.000001 - ? contact.NormalZ / length * normalSign - : 0.0; - pickup.VelocityX += normalX * 25.0; - pickup.VelocityZ += normalZ * 25.0; return AetheriaRuntimePickupContactResult.RejectedCapacity; - } var bays = (entity.CargoContents ?? Array.Empty()).ToList(); while (bays.Count <= cargoIndex) bays.Add(new AetheriaRuntimeCargoBayLoadoutCommit()); var slots = (bays[cargoIndex].Items ?? Array.Empty()).ToList(); diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs index ef674c56e..7fea19681 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs @@ -211,6 +211,10 @@ namespace GameCult.Aetheria.State.Verse public IReadOnlyList ShotReceipts { get; set; } = Array.Empty(); + [Key(16)] + public IReadOnlyList PickupContactReceipts { get; set; } = + Array.Empty(); + public AetheriaRuntimeLoadoutTemplateCommit CreateLoadoutTemplate(string entityKey) { return TryParseEntityKey(entityKey, out var zoneIndex, out var entityIndex) @@ -498,6 +502,18 @@ namespace GameCult.Aetheria.State.Verse [Key(38)] public IReadOnlyList DamageCells { get; set; } = Array.Empty(); } + [MessagePackObject] + public sealed class AetheriaRuntimePickupContactReceiptCommit + { + [Key(0)] public string FactId { get; set; } = ""; + [Key(1)] public long FrameId { get; set; } + [Key(2)] public int ZoneIndex { get; set; } = -1; + [Key(3)] public int EntityIndex { get; set; } = -1; + [Key(4)] public int PickupIndex { get; set; } = -1; + [Key(5)] public string Outcome { get; set; } = ""; + [Key(6)] public string EntityId { get; set; } = ""; + } + [MessagePackObject] public sealed class AetheriaRuntimeDamageCellCommit { diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeWorldPhysics.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeWorldPhysics.cs index 197c1dacc..089b4e520 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeWorldPhysics.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeWorldPhysics.cs @@ -8,16 +8,34 @@ namespace GameCult.Aetheria.State.Verse public interface IAetheriaRuntimeWorldPhysics { string ImplementationId { get; } - AetheriaRuntimeWorldStep Step(AetheriaRuntimeZoneSnapshotCommit zone, IReadOnlyList entities, double deltaSeconds); + void RetainWorlds(string runId, IReadOnlyList zoneIndices); + AetheriaRuntimeWorldPickupStep ApplyPickupRejection( + string runId, + int zoneIndex, + AetheriaRuntimeWorldBeginContact contact); + AetheriaRuntimeWorldStep Step( + string runId, + long frameId, + int simulationStepIndex, + AetheriaRuntimeZoneSnapshotCommit zone, + IReadOnlyList entities, + double deltaSeconds); } public sealed class AetheriaRuntimeWorldStep { - public AetheriaRuntimeWorldStep(IReadOnlyList bodies, IReadOnlyList pickups, IReadOnlyList contacts) - { Bodies = bodies ?? Array.Empty(); Pickups = pickups ?? Array.Empty(); Contacts = contacts ?? Array.Empty(); } + public AetheriaRuntimeWorldStep( + IReadOnlyList bodies, + IReadOnlyList pickups, + IReadOnlyList beginContacts) + { + Bodies = bodies ?? Array.Empty(); + Pickups = pickups ?? Array.Empty(); + BeginContacts = beginContacts ?? Array.Empty(); + } public IReadOnlyList Bodies { get; } public IReadOnlyList Pickups { get; } - public IReadOnlyList Contacts { get; } + public IReadOnlyList BeginContacts { get; } } public sealed class AetheriaRuntimeWorldPickupStep @@ -40,8 +58,11 @@ namespace GameCult.Aetheria.State.Verse public double DirectionY { get; set; } } - public sealed class AetheriaRuntimeWorldContact + public sealed class AetheriaRuntimeWorldBeginContact { + public string FactId { get; set; } = ""; + public string EntityAId { get; set; } = ""; + public string EntityBId { get; set; } = ""; public int EntityAIndex { get; set; } = -1; public int EntityBIndex { get; set; } = -1; public int PickupIndex { get; set; } = -1; diff --git a/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index 34ecddca7..640afa738 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -145,13 +145,25 @@ Aetheria owns only the configured prefab and its asset-manifest advertisement. Current Ymir control flow: -- Aetheria depends on injected physics ports, not on a Ymir daemon topology. - The default daemon process constructs the Ymir implementations in-process; - tests can supply another deterministic implementation without changing game - authority. -- The daemon projects ships, pickups, fields, and explicitly physical payloads - into Ymir. Ymir computes integration, collision, and spatial contact results; - the daemon commits and interprets them in the same simulation transaction. +- Ymir is deliberately a Box3D wrapper and physics-daemon boundary; its name + means **Not Invented Here**. Box3D owns integration and contact lifecycle. + Ymir owns stable game-facing body identity, session revisions, command + receipts, and typed contact-fact identity. +- `AetheriaYmirWorldPhysics` owns one retained `YmirSession` for each + `(RunId, ZoneIndex)`. Aetheria owns the active run/zone set and disposes + sessions when a run or zone leaves it. Each fixed simulation substep has its + own physics-step identity even when several substeps share one publication + frame. Entity bodies use stable `EntityId`, never mutable zone-list indices; + current indices are projection data and may change during cross-zone moves. +- Session creation is the only full body bootstrap. Ordinary ticks lower + membership changes, movement/orientation changes, tractor forces, gravity + fields, and pickup-rejection velocity into explicit retained-session + commands. Post-step body values in the run checkpoint are projections from + Ymir, not an independent physics solver. +- Cargo collection accepts only typed Ymir `Begin` facts. Aetheria persists one + pickup-contact receipt per Ymir `FactId` before exposing the resulting event, + so duplicate fact delivery cannot add cargo twice. Proximity and client loot + commands do not collect cargo. - Ordinary direct, constant, and charged weapon fire resolves through deterministic shot receipts. It does not create a Ymir body or derive damage from collision. @@ -179,6 +191,15 @@ This is the right authority direction: daemon physics enters Ymir from daemon state, while Unity only asks Ymir a local presentation-picking question. Remaining debt: +- `AetheriaYmirPhysicalPayloadPhysics` still creates isolated snapshot worlds + for retained mines and other explicitly physical payloads. Payload prepare, + zone step, and consequence resolution must be split so those bodies join the + same retained run/zone session. Until then the retained-session migration is + proven for ships and pickups, not complete for every physical payload. +- The in-process session registry is the live daemon implementation. CultMesh + discovery/routing, durable Ymir command receipt storage, and complete Ymir + checkpoint reconstruction remain daemon-boundary work; the checkpoint keeps + Aetheria's durable pickup-fact consumption ledger. - Clickable raycasts still construct query bodies from Unity click bounds. That is presentation picking, not simulation authority, but it should stay clearly labeled as renderer/UI picking.