Split reactive gameplay facts from topology

This commit is contained in:
Meta Vens 2026-07-19 07:36:10 +01:00
commit 82ce77209e
4 changed files with 134 additions and 48 deletions

View file

@ -942,7 +942,18 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
tick.Frame,
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
Require(Flatten(surface.Surface.Root).Any(node => node.Kind == "feedback.event" &&
var reactiveMount = Flatten(surface.Surface.Root).Single(node =>
node.Id == "aetheria.daemon.game.reactive");
var reactiveSlot = reactiveMount.EmbeddedDocuments.Single();
Require(reactiveSlot.DocumentId == AetheriaRuntimeVerseRecordKeys.DaemonGameReactiveSurface.ToString() &&
reactiveSlot.SchemaId == EveSurfaceDocument.SchemaId &&
reactiveSlot.RouteHint.Kind == CultMeshLocalityKind.SharedMemory,
"the static pilot surface must advertise one exact, mapped-friendly reactive gameplay document");
Require(!Flatten(surface.Surface.Root).Any(node =>
node.Kind is "feedback.event" or "shot.receipt"),
"the static pilot surface must not retain mutable gameplay facts");
var reactive = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(tick.Frame);
Require(Flatten(reactive.Surface.Root).Any(node => node.Kind == "feedback.event" &&
node.Props["eventKind"] == "simulation.interrupted" &&
node.Props["reason"] == "entity.damaged" &&
node.Props["scalarValue"] == "4" && node.Props["auxiliaryValue"] == "1"),
@ -2635,7 +2646,9 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"),
catalog: catalog);
Require(Flatten(surface.Surface.Root).Any(node => node.Kind == "feedback.event" &&
var reactive = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 1, Run = successRun });
Require(Flatten(reactive.Surface.Root).Any(node => node.Kind == "feedback.event" &&
node.Props["eventKind"] == "ship.undocked" &&
node.Props["sourceEntityIndex"] == "0" && node.Props["targetEntityIndex"] == "1" &&
node.Props["scalarValue"] == "0"),
@ -3687,7 +3700,9 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
node.Props["behaviorKind"] == AetheriaRuntimeBehaviorKinds.ConstantWeapon &&
node.Props["itemKey"] == "test-beam" && node.Props["firing"] == "false"),
$"Eve world entity must project recoverable generic ConstantWeapon state; nodes={string.Join(";", weaponNodes.Select(node => string.Join(",", node.Props.Select(pair => $"{pair.Key}={pair.Value}"))))}");
Require(Flatten(surface.Surface.Root).Any(node => node.Kind == "feedback.event" &&
var reactive = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 11, Run = run });
Require(Flatten(reactive.Surface.Root).Any(node => node.Kind == "feedback.event" &&
node.Props["eventKind"] == "weapon.firing.started" && node.Props["itemKey"] == "test-beam"),
"Eve feedback stream must project continuous weapon transition chronology");
var cockpit = Flatten(surface.Surface.Root).Single(node => node.Id == "aetheria.daemon.game.cockpit");
@ -3796,7 +3811,9 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
Require(Flatten(surface.Surface.Root).Any(node => node.Kind == "weapon.state" &&
node.Props.ContainsKey("chargeMalfunctionRisk")),
"Eve must expose charged hold duration and malfunction risk generically");
Require(Flatten(surface.Surface.Root).Any(node => node.Kind == "feedback.event" &&
var reactive = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 11, Run = run });
Require(Flatten(reactive.Surface.Root).Any(node => node.Kind == "feedback.event" &&
node.Props["eventKind"] == "weapon.charge.committed"),
"Eve feedback must expose charged solution commit chronology");
}
@ -4034,10 +4051,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
value.TargetEntityIndex == 2 && value.Reason == "target-invalid") == 1,
$"an invalid target must clear an idle weapon's authoritative lock and publish one loss transition; " +
$"target={state.LockTargetEntityIndex} progress={state.LockProgress:0.###} shots={run.ShotReceipts.Count} events={string.Join('|', run.GameEvents.Where(value => value.Kind.StartsWith("weapon.lock.", StringComparison.Ordinal)).Select(value => $"{value.Kind}:{value.TargetEntityIndex}:{value.Reason}:{value.AuxiliaryValue:0.###}->{value.ScalarValue:0.###}"))}");
var resetSurface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 22, Run = run },
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
var resetSurface = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 22, Run = run });
Require(Flatten(resetSurface.Surface.Root).Any(node => node.Kind == "feedback.event" &&
node.Props["eventKind"] == "weapon.lock.lost" &&
node.Props["targetEntityIndex"] == "2" &&
@ -4286,10 +4301,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
MessagePack.MessagePackSerializer.Serialize(run));
RequireEqual("target-entity", restored.ShotReceipts.Single().GuidanceMode,
"guided presentation levers must survive daemon checkpoint restart");
var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 4, Run = run },
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 4, Run = run });
var shot = Flatten(surface.Surface.Root).Single(node =>
node.Kind == "shot.receipt" && node.Props.TryGetValue("itemKey", out var itemKey) &&
itemKey == "test-launcher");
@ -4593,10 +4606,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
Math.Abs(value.ScalarValue - 20) < 0.000001 &&
Math.Abs(value.AuxiliaryValue - 10) < 0.000001),
"shield outcome must publish absorbed damage and authored heat contribution");
var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 1, Run = run },
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 1, Run = run });
Require(Flatten(surface.Surface.Root).Any(node => node.Kind == "feedback.event" &&
node.Props["eventKind"] == "shield.absorbed" &&
node.Props["scalarValue"] == "20" && node.Props["auxiliaryValue"] == "10"),
@ -4735,10 +4746,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
$"cells={string.Join(';', receipt.DamageCells.Select(cell => $"{cell.X},{cell.Y}"))}");
RequireNear(90, Stat(target, "hull"), 0.000001,
"a disconnected hull island must not divide or receive penetration damage beyond a gap");
var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build(
new AetheriaRuntimeDaemonFrameDocument { FrameId = receipt.FrameId, Run = run },
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = receipt.FrameId, Run = run });
var shot = Flatten(surface.Surface.Root).Single(node =>
node.Kind == "shot.receipt" && node.Props["itemKey"] == "gap-penetrator");
RequireEqual("1", shot.Props["damageCellCount"],
@ -4906,10 +4915,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
RequireEqual(1, run.GameEvents.Count(value => value.Kind == "equipment.destroyed" &&
value.TargetEntityIndex == target.EntityIndex && value.ItemKey == "damageable-weapon"),
"the durability crossing must publish exactly one generic equipment destruction fact");
var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 1, Run = run },
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
var surface = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 1, Run = run });
Require(Flatten(surface.Surface.Root).Any(node => node.Kind == "feedback.event" &&
node.Props["eventKind"] == "equipment.destroyed" &&
node.Props["itemKey"] == "damageable-weapon"),
@ -6333,10 +6340,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
$"projectiles={run.Zones[0].PhysicalPayloads.Count} task={run.AgentTasks.Single().Status}");
RequireEqual(AetheriaRuntimeAgentTaskStatuses.Completed, run.AgentTasks.Single().Status,
"attack task must complete when its target dies");
var shotSurface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 60, Run = run },
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
var shotSurface = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 60, Run = run });
Require(Flatten(shotSurface.Surface.Root).Any(node => node.Kind == "shot.receipt" &&
node.Props["itemKey"] == "test-lock-cannon" && node.Props["outcome"] == "hit" &&
node.Props["presentationKind"] == "bolt" &&
@ -8063,10 +8068,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
"Ymir contact must emit one projectile-identity impact event");
RequireEqual(0, run.GameEvents.Count(value => value.Kind == "entity.damaged" && value.TargetEntityIndex == target.EntityIndex),
"presentation projectile contact must not manufacture a damage event");
var feedback = AetheriaRuntimeDaemonGameSurfaceBuilder.Build(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 0, Run = run },
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
var feedback = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 0, Run = run });
Require(Flatten(feedback.Surface.Root).Any(node => node.Kind == "feedback.event" && node.Props["eventKind"] == "physical-payload.contact" && node.Props["subjectKey"] == "smoke-projectile"),
"Eve feedback must project authoritative projectile impact identity");
}
@ -8253,10 +8256,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
"collection feedback must publish cargo quantity before the transaction");
RequireEqual(0, run.PickupContactReceipts.Count,
"proximity collection must not mint obsolete Ymir-contact receipts");
var collectionFeedback = AetheriaRuntimeDaemonGameSurfaceBuilder.Build(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 3, Run = run },
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
var collectionFeedback = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 3, Run = run });
Require(Flatten(collectionFeedback.Surface.Root).Any(node => node.Kind == "feedback.event" &&
node.Props["eventKind"] == "pickup.collected" && node.Props["pickupIndex"] == "7" &&
node.Props["itemKey"] == salvage.ItemKey && node.Props["scalarValue"] == "1" &&
@ -8701,10 +8702,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
new AetheriaRuntimeDaemonTickOptions { WorldPhysics = fullPhysics, FrameId = 2, FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 0.2, Catalog = catalog, BuildPublications = false });
RequireEqual(1, full.GameEvents.Count(value => value.Kind == "pickup.rejected" && value.PickupIndex == 10),
"the outward kick must prevent an immediate second rejection event");
var feedback = AetheriaRuntimeDaemonGameSurfaceBuilder.Build(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 1, Run = full },
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
var feedback = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 1, Run = full });
Require(Flatten(feedback.Surface.Root).Any(node => node.Kind == "feedback.event" &&
node.Props["eventKind"] == "pickup.rejected" && node.Props["pickupIndex"] == "10" &&
node.Props["reason"] == "cargo-capacity" &&

View file

@ -65,6 +65,7 @@ var clientPump = RunClientCultMeshPumpAsync(cultMeshRudpHost, clientPumpCancella
var nextApiPublicationUtc = DateTimeOffset.UtcNow;
var ingressState = new AetheriaDaemonIngressState();
var hotState = new AetheriaHotEntityPublicationState();
var reactiveSurfaceState = new AetheriaReactiveSurfacePublicationState();
var firstTick = await TickAsync(node, options, worldPhysics, latestFrame, ingressState, buildPublications: false).ConfigureAwait(false);
ThrowIfClientPumpFaulted(clientPump);
latestFrame = firstTick.Frame;
@ -79,6 +80,7 @@ await PublishPreparedDocumentsAsync(
options,
physicsPersistence,
firstPublication,
reactiveSurfaceState,
publishTopology: true).ConfigureAwait(false);
await PublishHotEntityStateAsync(node, soaPublisher, hotState, firstTick.Frame, ingressState.Catalog).ConfigureAwait(false);
nextApiPublicationUtc = DateTimeOffset.UtcNow.Add(options.ApiPublicationInterval);
@ -138,6 +140,7 @@ while (!stopped.Task.IsCompleted)
options,
physicsPersistence,
publication,
reactiveSurfaceState,
publishTopology: false);
nextApiPublicationUtc = DateTimeOffset.UtcNow.Add(options.ApiPublicationInterval);
}
@ -396,6 +399,7 @@ static async Task PublishPreparedDocumentsAsync(
AetheriaDaemonHostOptions options,
AetheriaYmirPersistenceCoordinator physicsPersistence,
AetheriaPreparedPublication prepared,
AetheriaReactiveSurfacePublicationState reactiveSurfaceState,
bool publishTopology)
{
await physicsPersistence.PersistPrivateAsync(prepared.Physics).ConfigureAwait(false);
@ -403,7 +407,8 @@ static async Task PublishPreparedDocumentsAsync(
node,
options,
prepared.Publication,
publishTopology).ConfigureAwait(false);
publishTopology,
reactiveSurfaceState).ConfigureAwait(false);
if (publishTopology)
{
await PublishStateSurfacesAsync(node, options, prepared.Publication.Frame.PublishedAtUtc).ConfigureAwait(false);
@ -1550,7 +1555,8 @@ static async Task PublishDaemonApiDocumentsAsync(
AetheriaStateNode node,
AetheriaDaemonHostOptions options,
AetheriaRuntimeDaemonTickResult result,
bool publishTopology)
bool publishTopology,
AetheriaReactiveSurfacePublicationState? reactiveSurfaceState = null)
{
await node.MutableDocument<AetheriaRuntimeDaemonFrameDocument>(AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest)
.ReplaceAsync(result.Frame)
@ -1602,6 +1608,15 @@ static async Task PublishDaemonApiDocumentsAsync(
var activeMainMenuSurfaceId = string.IsNullOrWhiteSpace(mainMenuState?.ActiveSurfaceId)
? AetheriaRuntimeMainMenuCommands.RootSurfaceId
: mainMenuState.ActiveSurfaceId;
var reactiveGameSurface = AetheriaRuntimeSurfaceDocuments.ToPortableSurface(
AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(result.Frame));
if (reactiveSurfaceState?.Matches(reactiveGameSurface.Version) != true)
{
await node.MutableDocument<EveSurfaceDocument>(AetheriaRuntimeVerseRecordKeys.DaemonGameReactiveSurface)
.ReplaceAsync(reactiveGameSurface)
.ConfigureAwait(false);
reactiveSurfaceState?.Set(reactiveGameSurface.Version);
}
if (publishTopology)
{
var gameSurface = AetheriaRuntimeDaemonGameSurfaceBuilder.Build(
@ -3150,6 +3165,15 @@ internal sealed record AetheriaPreparedPublication(
AetheriaRuntimeDaemonTickResult Publication,
IReadOnlyList<AetheriaYmirZonePersistenceCapture> Physics);
internal sealed class AetheriaReactiveSurfacePublicationState
{
private long? _version;
public bool Matches(long version) => _version == version;
public void Set(long version) => _version = version;
}
internal sealed class AetheriaHotEntityPublicationState
{
private EveEntitySoaViewDocument? _layout;

View file

@ -42,8 +42,7 @@ namespace GameCult.Aetheria.State.Verse
frame.SimulationSettings,
catalog),
CockpitOverlay(entity, target, frame.SimulationSettings),
FeedbackStream(run, frame.FrameId),
ShotReceiptStream(run),
ReactiveGameplaySlot(),
GravityFieldSurface("aetheria.daemon.game.field"),
MainMenuOverlay("aetheria.daemon.game.main_menu", activeMainMenuSurfaceId),
Hidden(Node(
@ -187,6 +186,64 @@ namespace GameCult.Aetheria.State.Verse
.ToArray());
}
public static AetheriaRuntimeSurfaceDocument BuildReactiveGameplay(
AetheriaRuntimeDaemonFrameDocument frame)
{
frame ??= new AetheriaRuntimeDaemonFrameDocument();
var run = frame.Run ?? new AetheriaRuntimeRunCheckpointCommit();
var zone = FindCurrentZone(run);
var entity = FindCurrentEntity(run, zone);
var entityIndex = entity?.EntityIndex ?? -1;
var events = (run.GameEvents ?? Array.Empty<AetheriaRuntimeGameEventCommit>())
.Where(value => value != null &&
(entityIndex < 0 || value.SourceEntityIndex == entityIndex || value.TargetEntityIndex == entityIndex))
.ToArray();
var receipts = (run.ShotReceipts ?? Array.Empty<AetheriaRuntimeShotReceiptCommit>())
.Where(value => value != null &&
(entityIndex < 0 || value.SourceEntityIndex == entityIndex || value.TargetEntityIndex == entityIndex))
.ToArray();
var lastFrame = events.Select(value => value.FrameId)
.Concat(receipts.Select(value => value.FrameId))
.DefaultIfEmpty(0)
.Max();
var version = checked(lastFrame * 100000L + events.Length * 100L + receipts.Length);
return new AetheriaRuntimeSurfaceDocument(
AetheriaRuntimeProviderIdentity.ProviderId,
"game.daemon",
"Aetheria Pilot Reactive State",
version,
frame.PublishedAtUtc,
new AetheriaRuntimeSurfaceTree(
"aetheria.daemon.game.reactive",
Node(
"aetheria.daemon.game.reactive.root",
"layer.reactive",
Array.Empty<(string, string)>(),
FeedbackStream(events, frame.FrameId),
ShotReceiptStream(receipts)),
Array.Empty<AetheriaRuntimeSurfaceStyleToken>()),
Array.Empty<AetheriaRuntimeSurfaceCommandTemplate>());
}
private static AetheriaRuntimeSurfaceComponent ReactiveGameplaySlot() =>
new AetheriaRuntimeSurfaceComponent(
"aetheria.daemon.game.reactive",
"layer.reactive",
new Dictionary<string, string>(StringComparer.Ordinal),
Array.Empty<AetheriaRuntimeSurfaceComponent>(),
Array.Empty<GameCult.Mesh.CultMeshStateBindingDescriptor>(),
new[]
{
new AetheriaRuntimeEmbeddedDocumentSlot(
"pilot-reactive-state",
AetheriaRuntimeVerseRecordKeys.DaemonGameReactiveSurface.ToString(),
EveSurfaceDocument.SchemaId,
"reactive-state",
new GameCult.Mesh.CultMeshRouteHint(
GameCult.Mesh.CultMeshLocalityKind.SharedMemory,
"exact retained pilot presentation state"))
});
private static AetheriaRuntimeSurfaceCommandTemplate SurfaceCommand(
AetheriaRuntimeDaemonCommandKinds kind) =>
new AetheriaRuntimeSurfaceCommandTemplate(
@ -477,9 +534,11 @@ namespace GameCult.Aetheria.State.Verse
return (null, null);
}
private static AetheriaRuntimeSurfaceComponent FeedbackStream(AetheriaRuntimeRunCheckpointCommit run, long frameId)
private static AetheriaRuntimeSurfaceComponent FeedbackStream(
IReadOnlyList<AetheriaRuntimeGameEventCommit> sourceEvents,
long frameId)
{
var events = (run.GameEvents ?? Array.Empty<AetheriaRuntimeGameEventCommit>())
var events = (sourceEvents ?? Array.Empty<AetheriaRuntimeGameEventCommit>())
.Where(value => value != null)
.OrderBy(value => value.FrameId)
.ThenBy(value => value.EventId, StringComparer.Ordinal)
@ -518,9 +577,10 @@ namespace GameCult.Aetheria.State.Verse
return FormatNumber(after ? current : before);
}
private static AetheriaRuntimeSurfaceComponent ShotReceiptStream(AetheriaRuntimeRunCheckpointCommit run)
private static AetheriaRuntimeSurfaceComponent ShotReceiptStream(
IReadOnlyList<AetheriaRuntimeShotReceiptCommit> sourceReceipts)
{
var receipts = (run.ShotReceipts ?? Array.Empty<AetheriaRuntimeShotReceiptCommit>())
var receipts = (sourceReceipts ?? Array.Empty<AetheriaRuntimeShotReceiptCommit>())
.Where(value => value != null)
.OrderBy(value => value.FrameId)
.ThenBy(value => value.ShotId, StringComparer.Ordinal)

View file

@ -73,6 +73,9 @@ namespace GameCult.Aetheria.State.Verse
public static CultRecordKey DaemonGameSurface { get; } =
new CultRecordKey("eve:surface:aetheria.daemon.game");
public static CultRecordKey DaemonGameReactiveSurface { get; } =
new CultRecordKey("eve:surface:aetheria.daemon.game.reactive");
public static CultRecordKey StarbridgeCommanderSurface { get; } =
new CultRecordKey("eve:surface:aetheria.starbridge.commander");