Stream bounded reactive gameplay state
This commit is contained in:
parent
bd89cb4c85
commit
68b91b2196
7 changed files with 105 additions and 22 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue