diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 0bb239f3d..94ef2664a 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -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" && diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 32bd4b092..eb0945b6e 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -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(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(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 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; diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs index 0d09e707a..e4d41fa4f 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs @@ -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()) + .Where(value => value != null && + (entityIndex < 0 || value.SourceEntityIndex == entityIndex || value.TargetEntityIndex == entityIndex)) + .ToArray(); + var receipts = (run.ShotReceipts ?? Array.Empty()) + .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()), + Array.Empty()); + } + + private static AetheriaRuntimeSurfaceComponent ReactiveGameplaySlot() => + new AetheriaRuntimeSurfaceComponent( + "aetheria.daemon.game.reactive", + "layer.reactive", + new Dictionary(StringComparer.Ordinal), + Array.Empty(), + Array.Empty(), + 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 sourceEvents, + long frameId) { - var events = (run.GameEvents ?? Array.Empty()) + var events = (sourceEvents ?? Array.Empty()) .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 sourceReceipts) { - var receipts = (run.ShotReceipts ?? Array.Empty()) + var receipts = (sourceReceipts ?? Array.Empty()) .Where(value => value != null) .OrderBy(value => value.FrameId) .ThenBy(value => value.ShotId, StringComparer.Ordinal) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs index 36f9d7ca0..daa1ef97e 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs @@ -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");