Stream bounded reactive gameplay state

This commit is contained in:
Meta Vens 2026-07-19 11:17:13 +01:00
commit 68b91b2196
7 changed files with 105 additions and 22 deletions

View file

@ -822,6 +822,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
InputCapabilityPublishesConsumableActionBarState,
InputCapabilityPublishesHeldWeaponGroupLever,
InputCapabilityPublishesNativeTargetAndInteractControls,
ReactiveGameplayPublishesOnlyNewEventFacts,
ReflectorUsesSharedStellarLightFieldWithoutAccumulating,
SensorPingIsActorScopedEnergyGatedAndReconnectable,
TurretControllerAcquiresAimsAndTriggersExactWeapons,
@ -3974,7 +3975,11 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
new AetheriaRuntimeDaemonFrameDocument { FrameId = 12, Run = run },
new AetheriaRuntimeDaemonHealthDocument(),
AetheriaRuntimeDaemonCommandBoundaryDocument.Create("daemon"));
var combat = Flatten(surface.Surface.Root).Single(node => node.Kind == "combat.presentation");
var reactiveSurface = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 12, Run = run });
Require(!Flatten(surface.Surface.Root).Any(node => node.Kind == "combat.presentation"),
"the topology surface must not retain dynamic combat presentation authority");
var combat = Flatten(reactiveSurface.Surface.Root).Single(node => node.Kind == "combat.presentation");
var world = Flatten(surface.Surface.Root).Single(node => node.Kind == "world.scene3d");
var aim = Flatten(surface.Surface.Root).Single(node => node.Kind == "aim.presentation");
Require(!string.IsNullOrWhiteSpace(world.Props["lookCommand"]) &&
@ -4107,6 +4112,12 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
$"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.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 22, Run = run });
var resetCombat = Flatten(resetSurface.Surface.Root).Single(node => node.Kind == "combat.presentation");
Require(resetSurface.Version == 22 &&
resetCombat.Props["selectedTargetEntityId"] == "" &&
resetCombat.Props["selectedTargetEntityIndex"] == "-1" &&
resetCombat.Props["targetVisible"] == "false",
"reactive combat presentation must advance with the daemon frame and clear an invalid target");
Require(Flatten(resetSurface.Surface.Root).Any(node => node.Kind == "feedback.event" &&
node.Props["eventKind"] == "weapon.lock.lost" &&
node.Props["targetEntityIndex"] == "2" &&
@ -8146,6 +8157,46 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
"Eve feedback must project authoritative projectile impact identity");
}
private static void ReactiveGameplayPublishesOnlyNewEventFacts()
{
var player = Entity(0, 0, "player");
var run = new AetheriaRuntimeRunCheckpointCommit
{
RunId = "reactive-delta-smoke",
CurrentZoneIndex = 0,
Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [player] }],
GameEvents =
[
new AetheriaRuntimeGameEventCommit { EventId = "old-event", Kind = "old", FrameId = 10, SourceEntityIndex = 0 },
new AetheriaRuntimeGameEventCommit { EventId = "new-event", Kind = "new", FrameId = 12, SourceEntityIndex = 0 }
],
ShotReceipts =
[
new AetheriaRuntimeShotReceiptCommit { ShotId = "old-shot", FrameId = 10, SourceEntityIndex = 0 },
new AetheriaRuntimeShotReceiptCommit { ShotId = "new-shot", FrameId = 12, SourceEntityIndex = 0 }
]
};
run.CurrentEntityKey = run.EntityRecordKey(0, player.EntityIndex);
var delta = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 12, Run = run },
eventsAfterFrame: 10);
var nodes = Flatten(delta.Surface.Root).ToArray();
Require(nodes.Count(node => node.Kind == "feedback.event") == 1 &&
nodes.Single(node => node.Kind == "feedback.event").Props["eventId"] == "new-event",
"reactive gameplay must publish only event facts newer than the previous delivery frame");
Require(nodes.Count(node => node.Kind == "shot.receipt") == 1 &&
nodes.Single(node => node.Kind == "shot.receipt").Props["shotId"] == "new-shot",
"reactive gameplay must not resend retained shot history in every surface update");
var empty = AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
new AetheriaRuntimeDaemonFrameDocument { FrameId = 13, Run = run },
eventsAfterFrame: 12);
Require(!Flatten(empty.Surface.Root).Any(node =>
node.Kind == "feedback.event" || node.Kind == "shot.receipt"),
"a later reactive update must carry current variables without replaying old event arrays");
}
private static void ProjectileContactCannotKill()
{
var (run, _, target) = Scenario();

View file

@ -1609,7 +1609,9 @@ static async Task PublishDaemonApiDocumentsAsync(
? AetheriaRuntimeMainMenuCommands.RootSurfaceId
: mainMenuState.ActiveSurfaceId;
var reactiveGameSurface = AetheriaRuntimeSurfaceDocuments.ToPortableSurface(
AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(result.Frame));
AetheriaRuntimeDaemonGameSurfaceBuilder.BuildReactiveGameplay(
result.Frame,
reactiveSurfaceState?.LastPublishedFrame ?? -1));
if (reactiveSurfaceState?.Matches(reactiveGameSurface.Version) != true)
{
await node.MutableDocument<EveSurfaceDocument>(AetheriaRuntimeVerseRecordKeys.DaemonGameReactiveSurface)
@ -3169,6 +3171,8 @@ internal sealed class AetheriaReactiveSurfacePublicationState
{
private long? _version;
public long LastPublishedFrame => _version ?? -1;
public bool Matches(long version) => _version == version;
public void Set(long version) => _version = version;

View file

@ -7,9 +7,9 @@
"com.unity.modules.physics": "1.0.0",
"com.unity.modules.uielements": "1.0.0",
"com.unity.render-pipelines.universal": "17.4.0",
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.27",
"org.gamecult.cultlib": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.28",
"org.gamecult.eve.plugin-fields": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3",
"org.gamecult.eve.surface": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.surface#eveunity-surface-v0.2.4",
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.85"
"org.gamecult.eve.unity-scene": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.86"
}
}

View file

@ -127,11 +127,11 @@
}
},
"org.gamecult.cultlib": {
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.27",
"version": "https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.28",
"depth": 0,
"source": "git",
"dependencies": {},
"hash": "f3d008023e1c7c41c38354850824dc72f987603d"
"hash": "5587f5c6d1f81bda4ba7b492e4cc348fc54007bf"
},
"org.gamecult.eve.plugin-fields": {
"version": "https://github.com/GameCult/EvePlugins.git?path=/plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields#eve-plugin-fields-unity-v0.2.3",
@ -152,18 +152,18 @@
"hash": "e08fa08335f99e9edddeb706912eecfad07cb281"
},
"org.gamecult.eve.unity-scene": {
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.85",
"version": "https://github.com/GameCult/EveUnity.git?path=/packages/org.gamecult.eve.unity-scene#eveunity-scene-v0.3.86",
"depth": 0,
"source": "git",
"dependencies": {
"org.gamecult.cultlib": "1.0.27",
"org.gamecult.cultlib": "1.0.28",
"org.gamecult.eve.plugin-fields": "0.2.3",
"org.gamecult.eve.surface": "0.2.4",
"com.unity.mathematics": "1.3.2",
"com.unity.render-pipelines.universal": "17.4.0",
"com.unity.modules.particlesystem": "1.0.0"
},
"hash": "e9a25ffc38f799f0fc30f456175dd9539ada8d9a"
"hash": "c417b5543c3f112497fd9958c5febb7c626738d4"
},
"com.unity.modules.assetbundle": {
"version": "1.0.0",

View file

@ -187,7 +187,8 @@ namespace GameCult.Aetheria.State.Verse
}
public static AetheriaRuntimeSurfaceDocument BuildReactiveGameplay(
AetheriaRuntimeDaemonFrameDocument frame)
AetheriaRuntimeDaemonFrameDocument frame,
long eventsAfterFrame = -1)
{
frame ??= new AetheriaRuntimeDaemonFrameDocument();
var run = frame.Run ?? new AetheriaRuntimeRunCheckpointCommit();
@ -196,21 +197,32 @@ namespace GameCult.Aetheria.State.Verse
var entityIndex = entity?.EntityIndex ?? -1;
var events = (run.GameEvents ?? Array.Empty<AetheriaRuntimeGameEventCommit>())
.Where(value => value != null &&
value.FrameId > eventsAfterFrame &&
(entityIndex < 0 || value.SourceEntityIndex == entityIndex || value.TargetEntityIndex == entityIndex))
.ToArray();
var receipts = (run.ShotReceipts ?? Array.Empty<AetheriaRuntimeShotReceiptCommit>())
.Where(value => value != null &&
value.FrameId > eventsAfterFrame &&
(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 reactiveChildren = new List<AetheriaRuntimeSurfaceComponent>();
if (entity != null)
{
var combat = CombatPresentation(
run,
zone,
PlayableWorldEntityId(run, zone, run.CurrentEntityKey),
frame.SimulationSettings);
if (combat != null)
reactiveChildren.Add(combat);
}
reactiveChildren.Add(FeedbackStream(events, frame.FrameId));
reactiveChildren.Add(ShotReceiptStream(receipts));
return new AetheriaRuntimeSurfaceDocument(
AetheriaRuntimeProviderIdentity.ProviderId,
"game.daemon",
"Aetheria Pilot Reactive State",
lastFrame,
frame.FrameId,
frame.PublishedAtUtc,
new AetheriaRuntimeSurfaceTree(
"aetheria.daemon.game.reactive",
@ -218,8 +230,7 @@ namespace GameCult.Aetheria.State.Verse
"aetheria.daemon.game.reactive.root",
"layer.reactive",
Array.Empty<(string, string)>(),
FeedbackStream(events, frame.FrameId),
ShotReceiptStream(receipts)),
reactiveChildren.ToArray()),
Array.Empty<AetheriaRuntimeSurfaceStyleToken>()),
Array.Empty<AetheriaRuntimeSurfaceCommandTemplate>());
}
@ -962,9 +973,6 @@ namespace GameCult.Aetheria.State.Verse
.Select(entity => PlayableEntityPresentation(
entity, run, zone, currentEntityKey, simulationSettings, catalog))
.ToList();
var combatPresentation = CombatPresentation(run, zone, playerEntityId, simulationSettings);
if (combatPresentation != null)
presentationChildren.Insert(0, combatPresentation);
var tractorPresentation = TractorPresentation(run, zone, playerEntityId);
if (tractorPresentation != null)
presentationChildren.Insert(0, tractorPresentation);

View file

@ -107,6 +107,14 @@ under Unity `Assets/Scripts/ServerShared/Behaviors`.
pilot input capability; then the pilot and sector-map Eve surfaces; then the
daemon SoA view, CultMesh body publication, and Eve entity view; finally the
remaining topology surfaces when topology publication is requested.
- **Reactive combat state:** the retained pilot topology advertises an embedded
reactive-surface slot. `combat.presentation` exists only in that
frame-versioned fragment alongside feedback and shot receipts; the topology
cannot retain an older selection, visibility, lock, or target-meter value.
Current combat variables are republished at the fragment version; feedback
events and shot receipts include only facts newer than the previously
published fragment. Their retained daemon arrays remain persistence/history,
not a growing payload retransmitted to every client.
- **Derived state:** pilot/map surfaces and input capability are projections of
the prepared authoritative frame and catalog. SoA documents are bulk render
projections; they do not own command acceptance, beam feedback, or gameplay
@ -116,8 +124,11 @@ under Unity `Assets/Scripts/ServerShared/Behaviors`.
facts. No client or renderer may repair delayed control state from a newer
entity slab.
- **Shared path:** initial publication and ordinary prepared-frame publication
both call `PublishDaemonApiDocumentsAsync`, so the same ordering applies to
boot and live ticks.
both call `PublishDaemonApiDocumentsAsync`. The live loop publishes hot SoA
bodies every simulation tick and periodic control/reactive documents on the
prepared-publication cadence, so those planes are not one atomic generation.
Consumers must use each document's version; a static topology value may not
impersonate newer reactive state.
- **Cut line:** bulk SoA publication no longer precedes the pilot/map control
surfaces on the ordered state stream.
- **Verification layer:** daemon tests should assert record order at the

View file

@ -294,6 +294,15 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted.
`weapon.lock.started`, `weapon.lock.acquired`, and `weapon.lock.lost` events
with prior/current progress. Eve projects those events generically while the
continuous combat presentation remains the reticle's state owner.
`combat.presentation` lives only in the frame-versioned reactive Eve surface
mounted through the pilot topology's embedded-document slot. The retained
topology advertises that slot and cannot publish a second, stale combat node.
Selection, visibility, hostility, lock progress, and target meters are derived
from the same daemon frame as feedback and shot receipts; generic lowerers
compose that provider-owned fragment without acquiring combat semantics.
The fragment carries current combat variables plus only feedback and shot
facts newer than its previous published frame. Retained event and receipt
arrays are daemon history and never become an ever-growing reactive snapshot.
Sensor, radiator, reactor, and capacitor behaviors publish typed state rows
for ping state, radiator temperature/throughput, reactor draw/load, and
capacitor charge/capacity/efficiency. Aether drives publish typed axis,