diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 1a402d63b..bcda19208 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -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(); diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index eb0945b6e..305e90ca0 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -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(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; diff --git a/Aetheria.Unity/Packages/manifest.json b/Aetheria.Unity/Packages/manifest.json index 28e607b92..3d75c7c99 100644 --- a/Aetheria.Unity/Packages/manifest.json +++ b/Aetheria.Unity/Packages/manifest.json @@ -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" } } diff --git a/Aetheria.Unity/Packages/packages-lock.json b/Aetheria.Unity/Packages/packages-lock.json index bbda9fc14..c859dd532 100644 --- a/Aetheria.Unity/Packages/packages-lock.json +++ b/Aetheria.Unity/Packages/packages-lock.json @@ -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", diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs index 808104267..12f848a73 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs @@ -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()) .Where(value => value != null && + value.FrameId > eventsAfterFrame && (entityIndex < 0 || value.SourceEntityIndex == entityIndex || value.TargetEntityIndex == entityIndex)) .ToArray(); var receipts = (run.ShotReceipts ?? Array.Empty()) .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(); + 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()), Array.Empty()); } @@ -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); diff --git a/docs/aetheria-current-codebase-model.md b/docs/aetheria-current-codebase-model.md index 9e1767c43..9144ca7be 100644 --- a/docs/aetheria-current-codebase-model.md +++ b/docs/aetheria-current-codebase-model.md @@ -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 diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 821f3fbb5..4b40754f9 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -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,