Aetheria/Aetheria.State.Daemon/AetheriaYmirWorldPhysics.cs
2026-07-19 07:52:00 +01:00

569 lines
27 KiB
C#

using GameCult.Aetheria.State.Verse;
using Ymir.Core;
namespace Aetheria.State.Daemon;
public sealed class AetheriaYmirWorldPhysics : IAetheriaRuntimeWorldPhysics, IDisposable
{
private const string EntityPrefix = "aetheria.daemon.entity.";
private const string PickupPrefix = "aetheria.daemon.pickup.";
private const string PhysicalPayloadPrefix = "aetheria.physical-payload.";
private const ulong WorldEntityCollisionCategory = 1UL;
private const ulong ShipCollisionCategory = 2UL;
private const ulong PickupCollisionCategory = 4UL;
private readonly Dictionary<WorldKey, SessionState> _sessions = new();
public string ImplementationId => "ymir.box3d.retained-session.v1";
public AetheriaYmirZonePersistenceCapture CapturePersistence(
string runId,
int zoneIndex,
long frameId,
string persistedWorldSessionGeneration,
long persistedWorldJournalEntryCount,
string persistedPayloadSessionGeneration,
long persistedPayloadJournalEntryCount)
{
if (!_sessions.TryGetValue(new WorldKey(runId, zoneIndex), out var state) ||
state.LastFrameId != frameId)
throw new InvalidOperationException(
$"Cannot persist Ymir world for run '{runId}' zone {zoneIndex} at frame {frameId}.");
if (state.PayloadSession != null &&
(state.LastPayloadFrameId != frameId || state.LastPayloadSimulationStepIndex != state.LastSimulationStepIndex))
throw new InvalidOperationException(
$"Cannot persist Ymir payload world for run '{runId}' zone {zoneIndex} at frame {frameId}.");
return new AetheriaYmirZonePersistenceCapture(
runId,
zoneIndex,
frameId,
state.LastSimulationStepIndex,
state.WorldSession.CapturePersistence(
string.Equals(persistedWorldSessionGeneration, state.WorldSession.Info.SessionGeneration, StringComparison.Ordinal)
? persistedWorldJournalEntryCount
: 0),
state.PayloadSession?.CapturePersistence(
string.Equals(persistedPayloadSessionGeneration, state.PayloadSession.Info.SessionGeneration, StringComparison.Ordinal)
? persistedPayloadJournalEntryCount
: 0));
}
public void RestorePersistence(
string runId,
int zoneIndex,
long frameId,
int simulationStepIndex,
YmirSessionResumeDescriptor worldDescriptor,
IReadOnlyList<YmirSessionJournalChunk> worldChunks,
YmirSessionResumeDescriptor? payloadDescriptor,
IReadOnlyList<YmirSessionJournalChunk> payloadChunks)
{
var key = new WorldKey(runId, zoneIndex);
if (_sessions.ContainsKey(key))
throw new InvalidOperationException($"Ymir world for run '{runId}' zone {zoneIndex} is already live.");
var sessionId = $"aetheria.run.{runId}.zone.{zoneIndex}";
if (!string.Equals(worldDescriptor.SessionId, sessionId + ".world", StringComparison.Ordinal) ||
(payloadDescriptor != null &&
!string.Equals(payloadDescriptor.SessionId, sessionId + ".payloads", StringComparison.Ordinal)))
throw new InvalidOperationException("Aetheria Ymir resume belongs to another run or zone.");
if (payloadDescriptor == null && payloadChunks.Count > 0)
throw new InvalidOperationException("Aetheria Ymir resume has payload journal chunks without a descriptor.");
var world = YmirSession.Restore(worldDescriptor, worldChunks);
YmirSession? payload = null;
try
{
payload = payloadDescriptor == null ? null : YmirSession.Restore(payloadDescriptor, payloadChunks);
_sessions.Add(key, new SessionState(world)
{
PayloadSession = payload,
LastFrameId = frameId,
LastSimulationStepIndex = simulationStepIndex,
LastPayloadFrameId = payload == null ? -1 : frameId,
LastPayloadSimulationStepIndex = payload == null ? -1 : simulationStepIndex
});
}
catch
{
payload?.Dispose();
world.Dispose();
throw;
}
}
public void RetainWorlds(string runId, IReadOnlyList<int> zoneIndices)
{
var retainedZones = (zoneIndices ?? Array.Empty<int>()).ToHashSet();
foreach (var key in _sessions.Keys
.Where(key => !string.Equals(key.RunId, runId, StringComparison.Ordinal) ||
!retainedZones.Contains(key.ZoneIndex))
.ToArray())
{
_sessions[key].WorldSession.Dispose();
_sessions[key].PayloadSession?.Dispose();
_sessions.Remove(key);
}
}
public AetheriaRuntimeWorldStep Step(
string runId,
long frameId,
int simulationStepIndex,
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
double deltaSeconds)
{
ArgumentNullException.ThrowIfNull(zone);
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 &&
existing.LastResult != null)
return existing.LastResult;
if (existing != null &&
(frameId < existing.LastFrameId ||
(frameId == existing.LastFrameId && simulationStepIndex < existing.LastSimulationStepIndex)))
throw new InvalidOperationException(
$"Ymir session '{existing.WorldSession.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.WorldSession, desiredBodies, frameId, simulationStepIndex, ref commandOrdinal);
ApplyTractorForces(state.WorldSession, active, pickups, frameId, simulationStepIndex, ref commandOrdinal);
var step = state.WorldSession.Step(new YmirStepSessionCommand(
Header(state.WorldSession, 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 AetheriaRuntimePhysicalPayloadStep StepPhysicalPayloads(
string runId,
long frameId,
int simulationStepIndex,
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,
double deltaSeconds)
{
ArgumentNullException.ThrowIfNull(zone);
if (!_sessions.TryGetValue(new WorldKey(runId, zone.ZoneIndex), out var state) ||
state.LastFrameId != frameId || state.LastSimulationStepIndex != simulationStepIndex)
throw new InvalidOperationException(
$"Payload phase requires the retained world phase for run '{runId}' zone {zone.ZoneIndex} " +
$"at frame/step {frameId}/{simulationStepIndex}.");
if (state.LastPayloadFrameId == frameId && state.LastPayloadSimulationStepIndex == simulationStepIndex &&
state.LastPayloadResult != null)
return state.LastPayloadResult;
if (deltaSeconds <= 0)
throw new ArgumentOutOfRangeException(nameof(deltaSeconds));
var payloadSession = state.PayloadSession ??= YmirSession.Create(new YmirSessionCreateRequest(
$"aetheria.run.{runId}.zone.{zone.ZoneIndex}.payloads",
[],
(float)zone.SimulationTimeSeconds));
var payloads = (zone.PhysicalPayloads ?? Array.Empty<AetheriaRuntimePhysicalPayloadCommit>())
.Where(payload => payload != null && payload.Active)
.OrderBy(payload => payload.PayloadId, StringComparer.Ordinal)
.ToArray();
var desiredBodies = payloads.Select(PayloadBody).ToArray();
var commandOrdinal = 0;
Reconcile(payloadSession, desiredBodies, frameId, simulationStepIndex, ref commandOrdinal);
var starts = payloadSession.Snapshot().Bodies.ToDictionary(body => body.Id, StringComparer.Ordinal);
var stepped = payloadSession.Step(new YmirStepSessionCommand(
Header(payloadSession, frameId, simulationStepIndex, "step", "payloads", ref commandOrdinal),
(float)deltaSeconds,
[]));
var moved = stepped.World.Bodies.ToDictionary(body => body.Id, StringComparer.Ordinal);
var activeEntities = ActiveEntities(entities);
var entitiesByBodyId = activeEntities.ToDictionary(BodyId, StringComparer.Ordinal);
var survivors = new List<AetheriaRuntimePhysicalPayloadCommit>();
var hits = new List<AetheriaRuntimePhysicalPayloadHit>();
foreach (var payload in payloads)
{
var payloadBodyId = PhysicalPayloadPrefix + payload.PayloadId;
var start = starts[payloadBodyId];
var body = moved[payloadBodyId];
payload.PositionX = body.Position.X;
payload.PositionZ = body.Position.Y;
payload.VelocityX = body.Velocity.X;
payload.VelocityY = body.Velocity.Y;
var candidates = activeEntities
.Where(entity => entity.EntityIndex != payload.SourceEntityIndex)
.Select(BodyId)
.OrderBy(id => id, StringComparer.Ordinal)
.ToArray();
var dx = body.Position.X - start.Position.X;
var dz = body.Position.Y - start.Position.Y;
var distance = MathF.Sqrt(dx * dx + dz * dz);
var queryId = $"aetheria:frame:{frameId}:step:{simulationStepIndex}:payload:{payload.PayloadId}";
CircleCastHit? castHit = null;
CircleOverlapHit? overlapHit = null;
if (distance > 1.0e-6f)
{
var cast = state.WorldSession.CastCircle(new YmirSessionCircleCastQuery(
queryId + ":cast",
state.WorldSession.Info.Revision,
start.Position,
new Vec2(dx, dz),
distance,
(float)payload.Radius,
candidates));
RequireQuery(cast.Error, cast.QueryId);
castHit = cast.Hits.FirstOrDefault();
queryId = cast.QueryId;
}
else
{
var overlap = state.WorldSession.OverlapCircle(new YmirSessionCircleOverlapQuery(
queryId + ":overlap",
state.WorldSession.Info.Revision,
body.Position,
(float)payload.Radius,
candidates));
RequireQuery(overlap.Error, overlap.QueryId);
overlapHit = overlap.Hits.FirstOrDefault();
queryId = overlap.QueryId;
}
var targetBodyId = castHit?.BodyId ?? overlapHit?.BodyId;
if (targetBodyId == null || !entitiesByBodyId.TryGetValue(targetBodyId, out var target))
{
survivors.Add(payload);
continue;
}
var point = castHit?.Point ?? overlapHit!.Point;
var normal = castHit?.Normal ?? overlapHit!.Normal;
hits.Add(new AetheriaRuntimePhysicalPayloadHit
{
Payload = payload,
QueryId = queryId,
PhysicalPayloadBodyId = payloadBodyId,
TargetEntityIndex = target.EntityIndex,
TargetBodyId = targetBodyId,
PointX = point.X,
PointZ = point.Y,
NormalX = normal.X,
NormalZ = normal.Y
});
if (string.Equals(payload.PayloadKind, "mine", StringComparison.Ordinal))
{
payload.Stationary = true;
payload.VelocityX = 0;
payload.VelocityY = 0;
if (!body.IsStatic)
RequireAccepted(payloadSession.SetVelocity(new YmirSetBodyVelocityCommand(
Header(payloadSession, frameId, simulationStepIndex, "stop", payload.PayloadId, ref commandOrdinal),
payloadBodyId,
Vec2.Zero,
0)));
survivors.Add(payload);
}
else
{
RequireAccepted(payloadSession.Remove(new YmirRemoveBodyCommand(
Header(payloadSession, frameId, simulationStepIndex, "remove-hit", payload.PayloadId, ref commandOrdinal),
payloadBodyId)));
}
}
var result = new AetheriaRuntimePhysicalPayloadStep(survivors, hits);
state.LastPayloadFrameId = frameId;
state.LastPayloadSimulationStepIndex = simulationStepIndex;
state.LastPayloadResult = result;
return result;
}
public void Dispose()
{
foreach (var state in _sessions.Values)
{
state.WorldSession.Dispose();
state.PayloadSession?.Dispose();
}
_sessions.Clear();
}
private static SessionState CreateSession(
WorldKey key,
AetheriaRuntimeZoneSnapshotCommit zone,
IReadOnlyList<PhysicsBody> bodies)
{
var sessionId = $"aetheria.run.{key.RunId}.zone.{key.ZoneIndex}";
return new SessionState(YmirSession.Create(new YmirSessionCreateRequest(
sessionId + ".world",
bodies,
(float)zone.SimulationTimeSeconds)));
}
private static void Reconcile(
YmirSession session,
IReadOnlyList<PhysicsBody> 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.IsBullet != body.IsBullet ||
present.ParticipatesInFields != body.ParticipatesInFields ||
present.CollisionCategoryBits != body.CollisionCategoryBits ||
present.CollisionMaskBits != body.CollisionMaskBits ||
present.CollisionGroupIndex != body.CollisionGroupIndex)
{
RequireAccepted(session.Remove(new YmirRemoveBodyCommand(
Header(session, frameId, simulationStepIndex, "replace-profile", body.Id, ref commandOrdinal), body.Id)));
RequireAccepted(session.Spawn(new YmirSpawnBodyCommand(
Header(session, frameId, simulationStepIndex, "respawn-profile", body.Id, ref commandOrdinal), body)));
continue;
}
if (present.Radius != body.Radius || present.Mass != body.Mass ||
present.IsStatic != body.IsStatic || present.IsKinematic != body.IsKinematic ||
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, body.IsKinematic)));
if (present.Position != body.Position || present.Direction != body.Direction)
RequireAccepted(session.Teleport(new YmirTeleportBodyCommand(
Header(session, frameId, simulationStepIndex, "pose", body.Id, ref commandOrdinal),
body.Id, body.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<AetheriaRuntimeEntitySnapshotCommit> active,
IReadOnlyList<AetheriaRuntimeDroppedPickupCommit> pickups,
long frameId,
int simulationStepIndex,
ref int commandOrdinal)
{
foreach (var actor in active.Where(entity =>
entity.TractorPower > AetheriaRuntimeTractorMechanics.ActivationThreshold))
{
var forward = Normalize(actor.LookDirectionX, actor.LookDirectionY, 0, 1);
foreach (var pickup in pickups)
{
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 (along < 0 || along > AetheriaRuntimeTractorMechanics.Distance ||
lateral > AetheriaRuntimeTractorMechanics.Radius || distance <= 0.001)
continue;
var force = AetheriaRuntimeTractorMechanics.Traction;
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)))));
}
}
}
private static AetheriaRuntimeWorldStep Lower(
YmirSessionStepResult step,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> 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());
}
private static IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> ActiveEntities(
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities)
{
var source = entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>();
var attached = source.SelectMany(entity => entity.ChildEntityIndices ?? Array.Empty<int>()).ToHashSet();
return source.Where(entity => entity.IsActive && entity.WormholeTransition == null &&
!attached.Contains(entity.EntityIndex)).ToArray();
}
private static IReadOnlyList<AetheriaRuntimeDroppedPickupCommit> ActivePickups(
AetheriaRuntimeZoneSnapshotCommit zone) =>
(zone.DroppedPickups ?? Array.Empty<AetheriaRuntimeDroppedPickupCommit>())
.Where(pickup => pickup != null && pickup.AgeSeconds < pickup.LifetimeSeconds)
.ToArray();
private static IReadOnlyList<PhysicsBody> BuildBodies(
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> active,
IReadOnlyList<AetheriaRuntimeDroppedPickupCommit> 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 isShip = string.Equals(entity.Kind, "ship", 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),
CollisionCategoryBits: isShip ? ShipCollisionCategory : WorldEntityCollisionCategory,
CollisionMaskBits: isShip ? ulong.MaxValue : ulong.MaxValue & ~PickupCollisionCategory,
// The fossil moves entities directly and has no rigidbody collision response
// between ships/stations. Keep their Ymir bodies for fields and queries, but
// do not let invented circle radii hold a ship outside the 25-unit dock rule.
// Pickups retain group zero so Ymir can resolve their physical motion against ships.
// The daemon independently owns collection through post-step XZ proximity.
CollisionGroupIndex: -1);
}).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,
CollisionCategoryBits: PickupCollisionCategory,
CollisionMaskBits: ShipCollisionCategory)))
.ToArray();
private static string BodyId(AetheriaRuntimeEntitySnapshotCommit entity) => EntityPrefix + entity.EntityId;
private static PhysicsBody PayloadBody(AetheriaRuntimePhysicalPayloadCommit payload) => new(
PhysicalPayloadPrefix + payload.PayloadId,
new Vec2((float)payload.PositionX, (float)payload.PositionZ),
payload.Stationary ? Vec2.Zero : new Vec2((float)payload.VelocityX, (float)payload.VelocityY),
(float)payload.Radius,
1,
IsStatic: payload.Stationary,
Restitution: 0,
Direction: new Vec2(0, 1),
IsBullet: !payload.Stationary,
ParticipatesInFields: false,
CollisionCategoryBits: 2,
CollisionMaskBits: 0);
private static IReadOnlyList<RadialField> GravityFields(AetheriaRuntimeZoneSnapshotCommit zone) =>
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();
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 void RequireQuery(YmirQueryError error, string queryId)
{
if (error != YmirQueryError.None)
throw new InvalidOperationException($"Ymir rejected Aetheria query '{queryId}' with {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 worldSession)
{
public YmirSession WorldSession { get; } = worldSession;
public YmirSession? PayloadSession { get; set; }
public long LastFrameId { get; set; } = -1;
public int LastSimulationStepIndex { get; set; } = -1;
public AetheriaRuntimeWorldStep? LastResult { get; set; }
public long LastPayloadFrameId { get; set; } = -1;
public int LastPayloadSimulationStepIndex { get; set; } = -1;
public AetheriaRuntimePhysicalPayloadStep? LastPayloadResult { get; set; }
}
private readonly record struct WorldKey(string RunId, int ZoneIndex);
}