From d9b762f429056676877d00955dc617e4d423b429 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 15 Jul 2026 03:21:28 +0100 Subject: [PATCH] Lower provider-authored combat presentation --- .../PlayMode/GenericWorldCaptureTests.cs | 149 ++++++++-- .../PlayMode/GenericWorldCaptureTests.cs | 149 ++++++++-- .../org.gamecult.eve.unity-scene/README.md | 8 + .../EveUnityCultMeshLiveProviderTransport.cs | 36 +++ .../EveUnityCultMeshPlayableWorldProvider.cs | 12 +- .../Runtime/EveUnityCombatPresentation.cs | 98 +++++++ .../EveUnityCombatPresentation.cs.meta | 2 + .../EveUnityCombatPresentationRenderer.cs | 262 ++++++++++++++++++ ...EveUnityCombatPresentationRenderer.cs.meta | 2 + .../Runtime/EveUnityPlayableWorldCameraRig.cs | 49 +++- .../EveUnityPlayableWorldClientHost.cs | 6 + .../EveUnityPlayableWorldInputDriver.cs | 38 ++- .../Runtime/EveUnityShotReceiptPresenter.cs | 6 + .../Editor/EveUnityCombatPresentationTests.cs | 83 ++++++ .../EveUnityCombatPresentationTests.cs.meta | 2 + .../EveUnitySceneSurfaceLowererTests.cs | 42 +++ .../EveUnityShotReceiptPresenterTests.cs | 6 +- .../eve-runtime-capability.json | 5 +- .../org.gamecult.eve.unity-scene/package.json | 2 +- scripts/run-aetheria-daemon-world-witness.ps1 | 17 +- scripts/run-interactive-aetheria.ps1 | 121 ++++++++ 21 files changed, 1019 insertions(+), 76 deletions(-) create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs.meta create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs create mode 100644 packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs.meta create mode 100644 scripts/run-interactive-aetheria.ps1 diff --git a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 344a044..088d8a4 100644 --- a/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/ReleaseConsumerProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -190,6 +190,10 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests WitnessReceipt movementReceipt = null; WitnessReceipt focusReceipt = null; WitnessReceipt actionReceipt = null; + EveUnityShotReceipt observedShot = null; + EveUnityCombatPresentation observedCombat = null; + float shieldRatioBefore = 0f; + float hullRatioBefore = 0f; long initialVersion = 0; float movementDistance = 0f; @@ -231,6 +235,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests provider, provider); host.Connect(); + host.ShotAvailable += shot => observedShot = shot; runtime = host.Runtime; Assert.That(runtime, Is.Not.Null); Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty); @@ -248,12 +253,6 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var playerId = runtime.ActiveWorld.PlayerEntityId; var playerFact = host.PresentedEntities.CurrentGeneration.Entities .Single(entity => entity.EntityId == playerId); - var targetEntityId = host.PresentedEntities.CurrentGeneration.Entities - .Where(entity => entity.EntityId != playerId) - .Select(entity => entity.EntityId) - .FirstOrDefault(); - Assert.That(targetEntityId, Is.Not.Null.And.Not.Empty, - "The advertised live generation has no explicit targeting candidate."); var playerPrefab = provider.ResolvePrefab(new EveUnityPlayableWorldAssetBinding( playerFact.AssetRef, playerFact.EntityKind, @@ -292,13 +291,27 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(movementDistance, Is.GreaterThan(0.01f)); movementReceipt = WitnessReceipt.From("movement", runtime.LastReceipt); + var releaseVersion = runtime.ActiveVersion; + var release = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f); + var releaseDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < releaseDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != release.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, release.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, releaseVersion); + var movementVersion = runtime.ActiveVersion; - var focus = runtime.SubmitTargetIntent(playerId, targetEntityId); + var focus = runtime.SubmitFocusIntent(playerId); var focusDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < focusDeadline && (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != focus.CommandId || !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || - runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + string.IsNullOrWhiteSpace(EveUnityCombatPresentation.Find(runtime.ActiveProjection)?.SelectedTargetEntityId))) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); @@ -311,12 +324,49 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests movementVersion); Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); focusReceipt = WitnessReceipt.From("targeting", runtime.LastReceipt); + observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection); + Assert.That(observedCombat, Is.Not.Null); + Assert.That(observedCombat.TargetVisible, Is.True); + Assert.That(observedCombat.TargetHostile, Is.True); + var targetEntityId = observedCombat.SelectedTargetEntityId; + var targetEntityIndex = observedCombat.SelectedTargetEntityIndex; + Assert.That(targetEntityId, Is.Not.Empty); + Assert.That(host.PresentedEntities.CurrentGeneration.Entities.Any(entity => entity.EntityId == targetEntityId), Is.True, + "The provider-selected combat target is absent from the advertised SoA generation."); + shieldRatioBefore = observedCombat.ShieldRatio; + hullRatioBefore = observedCombat.HullRatio; + + var targetFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == targetEntityId); + playerFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == playerId); + var aim = targetFact.Position - playerFact.Position; + aim.y = 0; + Assert.That(aim.sqrMagnitude, Is.GreaterThan(0.0001f)); + aim.Normalize(); + var steerVersion = runtime.ActiveVersion; + var steer = runtime.SubmitMoveVectorIntent(playerId, aim.x, aim.z, 0.25f); + var steerDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < steerDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != steer.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, steer.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, steerVersion); var focusVersion = runtime.ActiveVersion; - var action = runtime.SubmitActionIntent(playerId, "0"); + var inputDriver = root.GetComponent() ?? + root.AddComponent(); + inputDriver.Host = host; + var action = inputDriver.SubmitPrimaryAction(); + Assert.That(action, Is.Not.Null, + "The advertised input capability did not bind a primary action for this generic client."); var actionDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < actionDeadline && - (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action.CommandId || + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action!.CommandId || !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) { @@ -325,13 +375,38 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests } AssertReconciledProviderReceipt( runtime.LastReceipt, - action.CommandId, + action!.CommandId, provider.Selection.ProviderId, provider.Selection.SurfaceId, focusVersion); Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); actionReceipt = WitnessReceipt.From("action", runtime.LastReceipt); + var shotDeadline = Time.realtimeSinceStartup + 15f; + while (Time.realtimeSinceStartup < shotDeadline && observedShot == null) + { + yield return new WaitForSecondsRealtime(0.05f); + runtime.Refresh(); + } + Assert.That(observedShot, Is.Not.Null, + "A reconciled fire request never reached daemon-owned lock acquisition and shot resolution."); + observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection); + Assert.That(observedShot.TargetEntityIndex, Is.EqualTo(targetEntityIndex)); + Assert.That(observedShot.LockQuality, Is.GreaterThan(0.99)); + var trajectories = root.GetComponent(); + var combatRenderer = root.GetComponent(); + Assert.That(trajectories, Is.Not.Null); + Assert.That(trajectories.ActiveTrajectoryCount, Is.GreaterThan(0)); + Assert.That(combatRenderer, Is.Not.Null); + combatRenderer.RefreshNow(); + if (observedCombat != null && !string.IsNullOrWhiteSpace(observedCombat.SelectedTargetEntityId)) + { + Assert.That(combatRenderer.ReticleVisible, Is.True); + Assert.That(combatRenderer.LockVisible, Is.True); + } + if (observedShot.Hit && (observedShot.AppliedDamage > 0 || observedShot.ShieldAbsorbedDamage > 0)) + Assert.That(combatRenderer.HitMarkerVisible, Is.True); + var light = lightObject.AddComponent(); light.type = LightType.Directional; light.intensity = 1.4f; @@ -340,17 +415,6 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(0.035f, 0.055f, 0.08f, 1f); camera.cullingMask = -1; - foreach (var entityMarker in root.GetComponentsInChildren()) - { - foreach (var renderer in entityMarker.GetComponentsInChildren()) - { - renderer.material.color = entityMarker.Controllable - ? new Color(0.18f, 0.75f, 1f) - : entityMarker.EntityKind == "enemy" - ? new Color(1f, 0.25f, 0.2f) - : new Color(1f, 0.78f, 0.15f); - } - } var playerMarker = FindMarker(root, playerId); var playerRenderers = playerMarker.GetComponentsInChildren(); Assert.That(playerRenderers, Is.Not.Empty, "The provider-authored player prefab has no renderable visual."); @@ -387,6 +451,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests File.WriteAllBytes(capturePath, pixels.EncodeToPNG()); Assert.That(new FileInfo(capturePath).Length, Is.GreaterThan(1024)); + mapRenderers = mapRenderers.Where(renderer => renderer != null).ToList(); + Assert.That(mapRenderers, Is.Not.Empty, + "All advertised map-channel renderers were destroyed before map capture."); var mapBounds = mapRenderers[0].bounds; foreach (var renderer in mapRenderers) mapBounds.Encapsulate(renderer.bounds); @@ -420,7 +487,13 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests (mapCamera.cullingMask & (1 << mapLayer)) != 0, movementReceipt, focusReceipt, - actionReceipt); + actionReceipt, + observedShot, + shieldRatioBefore, + observedCombat?.ShieldRatio ?? shieldRatioBefore, + hullRatioBefore, + observedCombat?.HullRatio ?? hullRatioBefore, + (float)observedShot.LockQuality); } finally { @@ -451,7 +524,13 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests bool mapCameraIncludesMapChannel, WitnessReceipt movement, WitnessReceipt targeting, - WitnessReceipt action) + WitnessReceipt action, + EveUnityShotReceipt shot, + float shieldRatioBefore, + float shieldRatioAfter, + float hullRatioBefore, + float hullRatioAfter, + float lockProgress) { var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH"); if (string.IsNullOrWhiteSpace(path)) return; @@ -462,6 +541,17 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests movement = movement != null, targeting = targeting != null, action = action != null, + combatPresentation = shot != null, + shotId = shot?.ShotId ?? "", + shotHit = shot?.Hit ?? false, + impactKind = shot?.ImpactKind ?? "", + appliedDamage = (float)(shot?.AppliedDamage ?? 0), + shieldAbsorbedDamage = (float)(shot?.ShieldAbsorbedDamage ?? 0), + shieldRatioBefore = shieldRatioBefore, + shieldRatioAfter = shieldRatioAfter, + hullRatioBefore = hullRatioBefore, + hullRatioAfter = hullRatioAfter, + lockProgress = lockProgress, initialVersion = initialVersion, finalVersion = finalVersion, movementDistance = movementDistance, @@ -485,6 +575,17 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public bool movement; public bool targeting; public bool action; + public bool combatPresentation; + public string shotId; + public bool shotHit; + public string impactKind; + public float appliedDamage; + public float shieldAbsorbedDamage; + public float shieldRatioBefore; + public float shieldRatioAfter; + public float hullRatioBefore; + public float hullRatioAfter; + public float lockProgress; public long initialVersion; public long finalVersion; public float movementDistance; diff --git a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs index 344a044..088d8a4 100644 --- a/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs +++ b/TestProject/Assets/Tests/PlayMode/GenericWorldCaptureTests.cs @@ -190,6 +190,10 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests WitnessReceipt movementReceipt = null; WitnessReceipt focusReceipt = null; WitnessReceipt actionReceipt = null; + EveUnityShotReceipt observedShot = null; + EveUnityCombatPresentation observedCombat = null; + float shieldRatioBefore = 0f; + float hullRatioBefore = 0f; long initialVersion = 0; float movementDistance = 0f; @@ -231,6 +235,7 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests provider, provider); host.Connect(); + host.ShotAvailable += shot => observedShot = shot; runtime = host.Runtime; Assert.That(runtime, Is.Not.Null); Assert.That(runtime.ActiveWorld.PlayerEntityId, Is.Not.Empty); @@ -248,12 +253,6 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests var playerId = runtime.ActiveWorld.PlayerEntityId; var playerFact = host.PresentedEntities.CurrentGeneration.Entities .Single(entity => entity.EntityId == playerId); - var targetEntityId = host.PresentedEntities.CurrentGeneration.Entities - .Where(entity => entity.EntityId != playerId) - .Select(entity => entity.EntityId) - .FirstOrDefault(); - Assert.That(targetEntityId, Is.Not.Null.And.Not.Empty, - "The advertised live generation has no explicit targeting candidate."); var playerPrefab = provider.ResolvePrefab(new EveUnityPlayableWorldAssetBinding( playerFact.AssetRef, playerFact.EntityKind, @@ -292,13 +291,27 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests Assert.That(movementDistance, Is.GreaterThan(0.01f)); movementReceipt = WitnessReceipt.From("movement", runtime.LastReceipt); + var releaseVersion = runtime.ActiveVersion; + var release = runtime.SubmitMoveVectorIntent(playerId, 0f, 0f, 0f); + var releaseDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < releaseDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != release.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, release.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, releaseVersion); + var movementVersion = runtime.ActiveVersion; - var focus = runtime.SubmitTargetIntent(playerId, targetEntityId); + var focus = runtime.SubmitFocusIntent(playerId); var focusDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < focusDeadline && (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != focus.CommandId || !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || - runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) + runtime.ActiveVersion < runtime.LastReceipt.SourceVersion || + string.IsNullOrWhiteSpace(EveUnityCombatPresentation.Find(runtime.ActiveProjection)?.SelectedTargetEntityId))) { yield return new WaitForSecondsRealtime(0.1f); runtime.Refresh(); @@ -311,12 +324,49 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests movementVersion); Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); focusReceipt = WitnessReceipt.From("targeting", runtime.LastReceipt); + observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection); + Assert.That(observedCombat, Is.Not.Null); + Assert.That(observedCombat.TargetVisible, Is.True); + Assert.That(observedCombat.TargetHostile, Is.True); + var targetEntityId = observedCombat.SelectedTargetEntityId; + var targetEntityIndex = observedCombat.SelectedTargetEntityIndex; + Assert.That(targetEntityId, Is.Not.Empty); + Assert.That(host.PresentedEntities.CurrentGeneration.Entities.Any(entity => entity.EntityId == targetEntityId), Is.True, + "The provider-selected combat target is absent from the advertised SoA generation."); + shieldRatioBefore = observedCombat.ShieldRatio; + hullRatioBefore = observedCombat.HullRatio; + + var targetFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == targetEntityId); + playerFact = host.PresentedEntities.CurrentGeneration.Entities + .Single(entity => entity.EntityId == playerId); + var aim = targetFact.Position - playerFact.Position; + aim.y = 0; + Assert.That(aim.sqrMagnitude, Is.GreaterThan(0.0001f)); + aim.Normalize(); + var steerVersion = runtime.ActiveVersion; + var steer = runtime.SubmitMoveVectorIntent(playerId, aim.x, aim.z, 0.25f); + var steerDeadline = Time.realtimeSinceStartup + 12f; + while (Time.realtimeSinceStartup < steerDeadline && + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != steer.CommandId || + !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase))) + { + yield return new WaitForSecondsRealtime(0.1f); + runtime.Refresh(); + } + AssertReconciledProviderReceipt(runtime.LastReceipt, steer.CommandId, + provider.Selection.ProviderId, provider.Selection.SurfaceId, steerVersion); var focusVersion = runtime.ActiveVersion; - var action = runtime.SubmitActionIntent(playerId, "0"); + var inputDriver = root.GetComponent() ?? + root.AddComponent(); + inputDriver.Host = host; + var action = inputDriver.SubmitPrimaryAction(); + Assert.That(action, Is.Not.Null, + "The advertised input capability did not bind a primary action for this generic client."); var actionDeadline = Time.realtimeSinceStartup + 12f; while (Time.realtimeSinceStartup < actionDeadline && - (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action.CommandId || + (runtime.LastReceipt == null || runtime.LastReceipt.CommandId != action!.CommandId || !string.Equals(runtime.LastReceipt.State, "reconciled", StringComparison.OrdinalIgnoreCase) || runtime.ActiveVersion < runtime.LastReceipt.SourceVersion)) { @@ -325,13 +375,38 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests } AssertReconciledProviderReceipt( runtime.LastReceipt, - action.CommandId, + action!.CommandId, provider.Selection.ProviderId, provider.Selection.SurfaceId, focusVersion); Assert.That(runtime.ActiveVersion, Is.GreaterThanOrEqualTo(runtime.LastReceipt.SourceVersion)); actionReceipt = WitnessReceipt.From("action", runtime.LastReceipt); + var shotDeadline = Time.realtimeSinceStartup + 15f; + while (Time.realtimeSinceStartup < shotDeadline && observedShot == null) + { + yield return new WaitForSecondsRealtime(0.05f); + runtime.Refresh(); + } + Assert.That(observedShot, Is.Not.Null, + "A reconciled fire request never reached daemon-owned lock acquisition and shot resolution."); + observedCombat = EveUnityCombatPresentation.Find(runtime.ActiveProjection); + Assert.That(observedShot.TargetEntityIndex, Is.EqualTo(targetEntityIndex)); + Assert.That(observedShot.LockQuality, Is.GreaterThan(0.99)); + var trajectories = root.GetComponent(); + var combatRenderer = root.GetComponent(); + Assert.That(trajectories, Is.Not.Null); + Assert.That(trajectories.ActiveTrajectoryCount, Is.GreaterThan(0)); + Assert.That(combatRenderer, Is.Not.Null); + combatRenderer.RefreshNow(); + if (observedCombat != null && !string.IsNullOrWhiteSpace(observedCombat.SelectedTargetEntityId)) + { + Assert.That(combatRenderer.ReticleVisible, Is.True); + Assert.That(combatRenderer.LockVisible, Is.True); + } + if (observedShot.Hit && (observedShot.AppliedDamage > 0 || observedShot.ShieldAbsorbedDamage > 0)) + Assert.That(combatRenderer.HitMarkerVisible, Is.True); + var light = lightObject.AddComponent(); light.type = LightType.Directional; light.intensity = 1.4f; @@ -340,17 +415,6 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = new Color(0.035f, 0.055f, 0.08f, 1f); camera.cullingMask = -1; - foreach (var entityMarker in root.GetComponentsInChildren()) - { - foreach (var renderer in entityMarker.GetComponentsInChildren()) - { - renderer.material.color = entityMarker.Controllable - ? new Color(0.18f, 0.75f, 1f) - : entityMarker.EntityKind == "enemy" - ? new Color(1f, 0.25f, 0.2f) - : new Color(1f, 0.78f, 0.15f); - } - } var playerMarker = FindMarker(root, playerId); var playerRenderers = playerMarker.GetComponentsInChildren(); Assert.That(playerRenderers, Is.Not.Empty, "The provider-authored player prefab has no renderable visual."); @@ -387,6 +451,9 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests File.WriteAllBytes(capturePath, pixels.EncodeToPNG()); Assert.That(new FileInfo(capturePath).Length, Is.GreaterThan(1024)); + mapRenderers = mapRenderers.Where(renderer => renderer != null).ToList(); + Assert.That(mapRenderers, Is.Not.Empty, + "All advertised map-channel renderers were destroyed before map capture."); var mapBounds = mapRenderers[0].bounds; foreach (var renderer in mapRenderers) mapBounds.Encapsulate(renderer.bounds); @@ -420,7 +487,13 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests (mapCamera.cullingMask & (1 << mapLayer)) != 0, movementReceipt, focusReceipt, - actionReceipt); + actionReceipt, + observedShot, + shieldRatioBefore, + observedCombat?.ShieldRatio ?? shieldRatioBefore, + hullRatioBefore, + observedCombat?.HullRatio ?? hullRatioBefore, + (float)observedShot.LockQuality); } finally { @@ -451,7 +524,13 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests bool mapCameraIncludesMapChannel, WitnessReceipt movement, WitnessReceipt targeting, - WitnessReceipt action) + WitnessReceipt action, + EveUnityShotReceipt shot, + float shieldRatioBefore, + float shieldRatioAfter, + float hullRatioBefore, + float hullRatioAfter, + float lockProgress) { var path = Environment.GetEnvironmentVariable("EVEUNITY_WITNESS_FACTS_PATH"); if (string.IsNullOrWhiteSpace(path)) return; @@ -462,6 +541,17 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests movement = movement != null, targeting = targeting != null, action = action != null, + combatPresentation = shot != null, + shotId = shot?.ShotId ?? "", + shotHit = shot?.Hit ?? false, + impactKind = shot?.ImpactKind ?? "", + appliedDamage = (float)(shot?.AppliedDamage ?? 0), + shieldAbsorbedDamage = (float)(shot?.ShieldAbsorbedDamage ?? 0), + shieldRatioBefore = shieldRatioBefore, + shieldRatioAfter = shieldRatioAfter, + hullRatioBefore = hullRatioBefore, + hullRatioAfter = hullRatioAfter, + lockProgress = lockProgress, initialVersion = initialVersion, finalVersion = finalVersion, movementDistance = movementDistance, @@ -485,6 +575,17 @@ namespace GameCult.EveUnity.GenericClient.PlayModeTests public bool movement; public bool targeting; public bool action; + public bool combatPresentation; + public string shotId; + public bool shotHit; + public string impactKind; + public float appliedDamage; + public float shieldAbsorbedDamage; + public float shieldRatioBefore; + public float shieldRatioAfter; + public float hullRatioBefore; + public float hullRatioAfter; + public float lockProgress; public long initialVersion; public long finalVersion; public float movementDistance; diff --git a/packages/org.gamecult.eve.unity-scene/README.md b/packages/org.gamecult.eve.unity-scene/README.md index e94e088..27f94bd 100644 --- a/packages/org.gamecult.eve.unity-scene/README.md +++ b/packages/org.gamecult.eve.unity-scene/README.md @@ -35,6 +35,14 @@ Retained provider feedback is lowered without becoming Unity authority. The generic `EveUnityShotTrajectoryRenderer` renders provider-authored origin, endpoint, duration, and hit outcome, then expires the visual locally. It never uses Unity collision to infer damage or report a hit back to the provider. +Providers may publish a non-spatial `combat.presentation` component beside the +SoA body view. `EveUnityCombatPresentationRenderer` lowers its selected target, +contact visibility, lock progress, meter ratios, semantic roles, and timing +into reticle, lock, shield/hull, and hit-marker visuals. Hit markers require a +new deduplicated shot receipt from the advertised controlled source to the +advertised selection with authoritative applied or shield-absorbed damage. +SoA remains the only transform authority; the presentation component never +duplicates bodies or computes combat outcomes. The reusable `LightningCompute` renderer and its shader/material bundle live in this runtime package with their original Unity GUIDs. Provider asset bundles may therefore reference the effect without importing Aetheria gameplay code. diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs index 7eb8f74..3c43f78 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshLiveProviderTransport.cs @@ -25,6 +25,7 @@ namespace GameCult.Eve.UnityScene { typeof(EveProviderAdvertisementDocument), typeof(EveSurfaceDocument), + typeof(EveInputCapabilityDocument), typeof(EveSurfaceCommandRequest), typeof(EveCommandReceiptDocument), typeof(EveAssetCatalogDocument), @@ -86,6 +87,7 @@ namespace GameCult.Eve.UnityScene _bodyResolver = bodyResolver; CurrentSurfaceDocument = EmptySurfaceDocument(); CurrentAssetManifestDocument = EmptyAssetManifest(); + CurrentInputCapability = new EveInputCapabilityDocument(); } public string TransportKind => "eve-cultmesh-remote-replica"; @@ -98,6 +100,8 @@ namespace GameCult.Eve.UnityScene public EveUnityPlayableWorldAssetManifestDocument CurrentAssetManifestDocument { get; private set; } + public EveInputCapabilityDocument CurrentInputCapability { get; private set; } + public event Action? SurfaceDocumentAvailable; public event Action? AssetManifestDocumentAvailable; @@ -132,9 +136,41 @@ namespace GameCult.Eve.UnityScene EnsureOpen(); ResolveAdvertisement(forceRefresh: true); RefreshSurface(); + RefreshInputCapability(); RefreshAssetCatalog(); } + private void RefreshInputCapability() + { + var recordRef = FindComponentProp( + CurrentSurfaceDocument.SurfaceDocument.Surface.Root, + "inputCapability"); + if (string.IsNullOrWhiteSpace(recordRef)) + throw new InvalidOperationException("The advertised playable world does not publish an input capability record."); + CurrentInputCapability = _snapshot! + .FetchDocumentsAsync( + recordKeys: new[] { recordRef }, + schemaIds: new[] { EveInputCapabilityDocument.SchemaId }) + .GetAwaiter() + .GetResult() + .FirstOrDefault() + ?? throw new InvalidOperationException($"The advertised input capability '{recordRef}' was not available."); + } + + private static string FindComponentProp(EveSurfaceComponent component, string key) + { + var value = component.GetProp(key); + if (!string.IsNullOrWhiteSpace(value)) + return value; + foreach (var child in component.Children) + { + value = FindComponentProp(child, key); + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + return ""; + } + public void PumpLiveEvents() { PollCurrentEntityView(); diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs index a91c832..a2f6ea3 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/CultMesh/EveUnityCultMeshPlayableWorldProvider.cs @@ -18,7 +18,8 @@ namespace GameCult.Eve.UnityScene IEveUnityProviderRefreshSource, IEveUnityEntitySoaViewDocumentSource, IEveUnityCameraRenderPolicySource, - IEveUnityNativeAssetProvider + IEveUnityNativeAssetProvider, + IEveUnityInputCapabilitySource { [SerializeField] private string rendezvousEndpoint = ""; [SerializeField] private string providerFilter = ""; @@ -40,6 +41,15 @@ namespace GameCult.Eve.UnityScene public EveUnitySceneProviderSurfaceDocument CurrentDocument => Bridge.CurrentSurfaceDocument; + public EveInputCapabilityDocument CurrentInputCapability + { + get + { + EnsureTransport(); + return _transport!.CurrentInputCapability; + } + } + public event Action? DocumentAvailable { add { Bridge.DocumentAvailable += value; } diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs new file mode 100644 index 0000000..d3879af --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityCombatPresentation + { + public EveUnityCombatPresentation(EveUnitySceneNode node) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + ControlledEntityId = Get(node, "controlledEntityId"); + ControlledEntityIndex = Int(node, "controlledEntityIndex", -1); + SelectedTargetEntityId = Get(node, "selectedTargetEntityId"); + SelectedTargetEntityIndex = Int(node, "selectedTargetEntityIndex", -1); + TargetVisible = Bool(node, "targetVisible"); + TargetHostile = Bool(node, "targetHostile"); + ContactInformation = Ratio(node, "contactInformation"); + ShieldRatio = Ratio(node, "shieldRatio"); + HullRatio = Ratio(node, "hullRatio"); + LockProgress = Ratio(node, "lockProgress"); + ReticleRole = Get(node, "reticleRole"); + LockRole = Get(node, "lockRole"); + ShieldMeterRole = Get(node, "shieldMeterRole"); + HullMeterRole = Get(node, "hullMeterRole"); + HitMarkerRole = Get(node, "hitMarkerRole"); + LockDisplayThreshold = Ratio(node, "lockDisplayThreshold", 0.01f); + HitMarkerDurationSeconds = Math.Max(0.01f, Number(node, "hitMarkerDurationSeconds", 0.25f)); + RadialFillMinimum = Ratio(node, "radialFillMinimum", 0.25f); + RadialFillMaximum = Ratio(node, "radialFillMaximum", 0.75f); + } + + public string ControlledEntityId { get; } + public int ControlledEntityIndex { get; } + public string SelectedTargetEntityId { get; } + public int SelectedTargetEntityIndex { get; } + public bool TargetVisible { get; } + public bool TargetHostile { get; } + public float ContactInformation { get; } + public float ShieldRatio { get; } + public float HullRatio { get; } + public float LockProgress { get; } + public string ReticleRole { get; } + public string LockRole { get; } + public string ShieldMeterRole { get; } + public string HullMeterRole { get; } + public string HitMarkerRole { get; } + public float LockDisplayThreshold { get; } + public float HitMarkerDurationSeconds { get; } + public float RadialFillMinimum { get; } + public float RadialFillMaximum { get; } + + public float RadialFill(float ratio) => + RadialFillMinimum + (RadialFillMaximum - RadialFillMinimum) * Math.Max(0, Math.Min(1, ratio)); + + public bool PresentsHit(EveUnityShotReceipt receipt) => + receipt != null && + receipt.SourceEntityIndex == ControlledEntityIndex && + receipt.TargetEntityIndex == SelectedTargetEntityIndex && + receipt.Hit && + (receipt.AppliedDamage > 0 || receipt.ShieldAbsorbedDamage > 0); + + public static EveUnityCombatPresentation? Find(EveUnitySceneProjection? projection) + { + if (projection == null) return null; + return Find(projection.Root); + } + + private static EveUnityCombatPresentation? Find(EveUnitySceneNode node) + { + if (string.Equals(node.ComponentKind, "combat.presentation", StringComparison.Ordinal)) + return new EveUnityCombatPresentation(node); + foreach (var child in node.Children) + { + var found = Find(child); + if (found != null) return found; + } + return null; + } + + private static string Get(EveUnitySceneNode node, string key) => + node.Props.TryGetValue(key, out var value) ? value ?? "" : ""; + + private static int Int(EveUnitySceneNode node, string key, int fallback) => + int.TryParse(Get(node, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback; + + private static bool Bool(EveUnitySceneNode node, string key) => + bool.TryParse(Get(node, key), out var value) && value; + + private static float Number(EveUnitySceneNode node, string key, float fallback = 0) => + float.TryParse(Get(node, key), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : fallback; + + private static float Ratio(EveUnitySceneNode node, string key, float fallback = 0) => + Math.Max(0, Math.Min(1, Number(node, key, fallback))); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs.meta new file mode 100644 index 0000000..e0219d9 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e15acab8e9094b18b6dbd3abea0e055d diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs new file mode 100644 index 0000000..6b0cd5b --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs @@ -0,0 +1,262 @@ +using System; +using UnityEngine; + +#nullable enable + +namespace GameCult.Eve.UnityScene +{ + public sealed class EveUnityCombatPresentationRenderer : MonoBehaviour + { + [SerializeField] private Color reticleColor = new Color(0.2f, 0.9f, 1f, 0.9f); + [SerializeField] private Color lockColor = new Color(1f, 0.7f, 0.15f, 1f); + [SerializeField] private Color shieldColor = new Color(0.15f, 0.65f, 1f, 1f); + [SerializeField] private Color hullColor = new Color(1f, 0.35f, 0.2f, 1f); + [SerializeField] private Color hitColor = Color.white; + [SerializeField] private float width = 0.12f; + + private EveUnityPlayableWorldClientHost? _host; + private Transform? _visualRoot; + private LineRenderer? _reticle; + private LineRenderer? _lock; + private LineRenderer? _shield; + private LineRenderer? _hull; + private LineRenderer? _hitA; + private LineRenderer? _hitB; + private Material? _material; + private float _hitUntil; + private EveUnityCombatPresentation? _lastPresentation; + private Vector3 _lastCenter; + private Vector3 _lastRight; + private Vector3 _lastUp; + private float _lastRadius; + private bool _hasLastTarget; + + public EveUnityCombatPresentation? Current { get; private set; } + public bool ReticleVisible => _reticle != null && _reticle.enabled; + public bool LockVisible => _lock != null && _lock.enabled; + public bool HitMarkerVisible => _hitA != null && _hitA.enabled; + + public void Bind(EveUnityPlayableWorldClientHost host) + { + if (host == null) throw new ArgumentNullException(nameof(host)); + if (ReferenceEquals(_host, host)) return; + Unbind(); + _host = host; + _host.ShotAvailable += OnShotAvailable; + } + + public void Unbind() + { + if (_host != null) _host.ShotAvailable -= OnShotAvailable; + _host = null; + Current = null; + SetVisible(false); + } + + public void RefreshNow() + { + Current = EveUnityCombatPresentation.Find(_host?.ActiveProjection); + if (Current == null || string.IsNullOrWhiteSpace(Current.SelectedTargetEntityId)) + { + PresentRetainedHit(); + return; + } + + var marker = FindMarker(Current.SelectedTargetEntityId); + if (marker == null) + { + PresentRetainedHit(); + return; + } + + _lastPresentation = Current; + EnsureVisuals(); + PresentationAxes(out var right, out var up, out var towardCamera, out var cullingMask); + var visualBounds = VisualBounds(marker.transform, cullingMask); + var center = visualBounds?.center ?? (marker.transform.position + Vector3.up * 0.25f); + var radius = Math.Max(0.8f, visualBounds?.extents.magnitude ?? marker.transform.lossyScale.magnitude * 0.8f); + center += towardCamera * 0.1f; + _lastCenter = center; + _lastRight = right; + _lastUp = up; + _lastRadius = radius; + _hasLastTarget = true; + Arc(_reticle!, center, right, up, radius * 1.35f, 1f, 0f); + _reticle!.enabled = Current.TargetVisible; + + var lockVisible = Current.TargetVisible && Current.TargetHostile && + Current.LockProgress > Current.LockDisplayThreshold; + var noise = (1f - Current.LockProgress) * radius * 0.2f; + var frequency = Mathf.Lerp(0.01f, 10f, Mathf.Pow(Current.LockProgress, 4f)); + var noisyCenter = center + + right * ((Mathf.PerlinNoise(Time.unscaledTime * frequency, 0.37f) - 0.5f) * noise) + + up * ((Mathf.PerlinNoise(0.73f, Time.unscaledTime * frequency) - 0.5f) * noise); + var spin = Time.unscaledTime * Mathf.Lerp(5f, 720f, Current.LockProgress * Current.LockProgress); + Arc(_lock!, noisyCenter, right, up, radius * 1.55f, Current.LockProgress, spin); + _lock!.enabled = lockVisible; + + Arc(_shield!, center, right, up, radius * 1.78f, Current.RadialFill(Current.ShieldRatio), -90f); + Arc(_hull!, center, right, up, radius * 1.98f, Current.RadialFill(Current.HullRatio), -90f); + _shield!.enabled = Current.TargetVisible; + _hull!.enabled = Current.TargetVisible; + + var hitVisible = Time.unscaledTime < _hitUntil; + Cross(_hitA!, _hitB!, center, right, up, radius * 0.55f); + _hitA!.enabled = hitVisible; + _hitB!.enabled = hitVisible; + } + + private void PresentRetainedHit() + { + if (!_hasLastTarget || Time.unscaledTime >= _hitUntil) + { + SetVisible(false); + return; + } + EnsureVisuals(); + _reticle!.enabled = _lock!.enabled = _shield!.enabled = _hull!.enabled = false; + Cross(_hitA!, _hitB!, _lastCenter, _lastRight, _lastUp, _lastRadius * 0.55f); + _hitA!.enabled = _hitB!.enabled = true; + } + + private void LateUpdate() => RefreshNow(); + + private void OnShotAvailable(EveUnityShotReceipt receipt) + { + Current = EveUnityCombatPresentation.Find(_host?.ActiveProjection); + var presentation = Current; + if (presentation?.PresentsHit(receipt) != true) + presentation = _lastPresentation; + if (presentation?.PresentsHit(receipt) == true) + _hitUntil = Time.unscaledTime + presentation.HitMarkerDurationSeconds; + } + + private EveUnityPlayableWorldEntityMarker? FindMarker(string entityId) + { + if (_host == null) return null; + foreach (var marker in _host.SceneRoot.GetComponentsInChildren(true)) + if (string.Equals(marker.EntityId, entityId, StringComparison.Ordinal)) return marker; + return null; + } + + private void EnsureVisuals() + { + if (_visualRoot != null) return; + var root = new GameObject("Eve combat presentation"); + root.transform.SetParent(transform, false); + _visualRoot = root.transform; + _reticle = Line("Selected target reticle", reticleColor, width); + _lock = Line("Target lock", lockColor, width * 1.15f); + _shield = Line("Target shield", shieldColor, width * 0.75f); + _hull = Line("Target hull", hullColor, width * 0.75f); + _hitA = Line("Hit marker A", hitColor, width * 1.3f); + _hitB = Line("Hit marker B", hitColor, width * 1.3f); + } + + private LineRenderer Line(string name, Color color, float lineWidth) + { + var child = new GameObject(name); + child.transform.SetParent(_visualRoot, false); + var line = child.AddComponent(); + line.useWorldSpace = true; + line.loop = false; + line.material = SharedMaterial(); + line.startColor = line.endColor = color; + line.startWidth = line.endWidth = Math.Max(0.01f, lineWidth); + line.numCapVertices = 2; + return line; + } + + private Material SharedMaterial() + { + if (_material != null) return _material; + var shader = Shader.Find("Sprites/Default") ?? Shader.Find("Universal Render Pipeline/Unlit"); + if (shader == null) throw new InvalidOperationException("EveUnity requires an unlit shader for combat presentation."); + _material = new Material(shader) { name = "EveUnity combat presentation" }; + return _material; + } + + private static void PresentationAxes(out Vector3 right, out Vector3 up, out Vector3 towardCamera, out int cullingMask) + { + Camera? selected = Camera.main; + if (selected == null) + { + foreach (var candidate in Camera.allCameras) + { + if (!candidate.isActiveAndEnabled || candidate.orthographic) + continue; + selected = candidate; + break; + } + } + right = selected == null ? Vector3.right : selected.transform.right; + up = selected == null ? Vector3.up : selected.transform.up; + towardCamera = selected == null ? Vector3.back : -selected.transform.forward; + cullingMask = selected == null ? -1 : selected.cullingMask; + } + + private static Bounds? VisualBounds(Transform root, int cullingMask) + { + Bounds? bounds = null; + foreach (var renderer in root.GetComponentsInChildren(includeInactive: false)) + { + if (renderer == null || !renderer.enabled) + continue; + if ((cullingMask & (1 << renderer.gameObject.layer)) == 0) + continue; + if (renderer is LineRenderer || renderer is TrailRenderer || + string.Equals(renderer.GetType().Name, "ParticleSystemRenderer", StringComparison.Ordinal)) + continue; + if (bounds.HasValue) + { + var combined = bounds.Value; + combined.Encapsulate(renderer.bounds); + bounds = combined; + } + else + { + bounds = renderer.bounds; + } + } + return bounds; + } + + private static void Arc(LineRenderer line, Vector3 center, Vector3 right, Vector3 up, float radius, float fill, float rotationDegrees) + { + fill = Mathf.Clamp01(fill); + var segments = Math.Max(2, Mathf.CeilToInt(64 * fill) + 1); + line.positionCount = segments; + for (var index = 0; index < segments; index++) + { + var progress = segments <= 1 ? 0 : index / (float)(segments - 1); + var angle = (rotationDegrees + progress * 360f * fill) * Mathf.Deg2Rad; + line.SetPosition(index, center + right * (Mathf.Cos(angle) * radius) + up * (Mathf.Sin(angle) * radius)); + } + } + + private static void Cross(LineRenderer a, LineRenderer b, Vector3 center, Vector3 right, Vector3 up, float radius) + { + a.positionCount = b.positionCount = 2; + a.SetPosition(0, center - right * radius - up * radius); + a.SetPosition(1, center + right * radius + up * radius); + b.SetPosition(0, center - right * radius + up * radius); + b.SetPosition(1, center + right * radius - up * radius); + } + + private void SetVisible(bool visible) + { + if (_reticle != null) _reticle.enabled = visible; + if (_lock != null) _lock.enabled = visible; + if (_shield != null) _shield.enabled = visible; + if (_hull != null) _hull.enabled = visible; + if (_hitA != null) _hitA.enabled = visible && Time.unscaledTime < _hitUntil; + if (_hitB != null) _hitB.enabled = visible && Time.unscaledTime < _hitUntil; + } + + private void OnDestroy() + { + Unbind(); + if (_material != null) Destroy(_material); + } + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs.meta b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs.meta new file mode 100644 index 0000000..25e78e2 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5c953d99a7454322b2935b878295b357 diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs index fafe3c9..4cf6d8f 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldCameraRig.cs @@ -71,7 +71,8 @@ namespace GameCult.Eve.UnityScene } var bounds = CalculateVisualBounds( player, - cameraComponent != null ? cameraComponent.cullingMask : -1); + cameraComponent != null ? cameraComponent.cullingMask : -1, + FindSelectedTarget(resolvedHost)); var target = bounds?.center ?? player.position; var radius = bounds?.extents.magnitude ?? 0f; var verticalFov = cameraComponent != null ? cameraComponent.fieldOfView : 60f; @@ -91,29 +92,47 @@ namespace GameCult.Eve.UnityScene return true; } - private static Bounds? CalculateVisualBounds(Transform player, int cullingMask) + private static Bounds? CalculateVisualBounds(Transform player, int cullingMask, Transform? selectedTarget) { - var renderers = player.GetComponentsInChildren(includeInactive: false); Bounds? bounds = null; - foreach (var visual in renderers) + foreach (var root in selectedTarget == null + ? new[] { player } + : new[] { player, selectedTarget! }) { - if (visual == null || !visual.enabled || - (cullingMask & (1 << visual.gameObject.layer)) == 0) - continue; - if (bounds.HasValue) + foreach (var visual in root.GetComponentsInChildren(includeInactive: false)) { - var combined = bounds.Value; - combined.Encapsulate(visual.bounds); - bounds = combined; - } - else - { - bounds = visual.bounds; + if (visual == null || !visual.enabled || + (cullingMask & (1 << visual.gameObject.layer)) == 0) + continue; + if (visual is LineRenderer || visual is TrailRenderer || + string.Equals(visual.GetType().Name, "ParticleSystemRenderer", System.StringComparison.Ordinal)) + continue; + if (bounds.HasValue) + { + var combined = bounds.Value; + combined.Encapsulate(visual.bounds); + bounds = combined; + } + else + { + bounds = visual.bounds; + } } } return bounds; } + private static Transform? FindSelectedTarget(EveUnityPlayableWorldClientHost host) + { + var combat = EveUnityCombatPresentation.Find(host.ActiveProjection); + if (combat == null || string.IsNullOrWhiteSpace(combat.SelectedTargetEntityId)) + return null; + if (host.PresentedEntities?.CurrentGeneration != null && + host.PresentedEntities.TryGetByEntityId(combat.SelectedTargetEntityId, out var presented)) + return presented.Transform; + return null; + } + private void LateUpdate() { if (driveInLateUpdate) diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs index 60fefac..1323c80 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldClientHost.cs @@ -33,6 +33,9 @@ namespace GameCult.Eve.UnityScene public EveUnitySceneCommandReceipt? LastReceipt => Runtime?.LastReceipt; public IEveUnityPresentedEntityRegistry? PresentedEntities => Runtime?.PresentedEntities; + public EveInputCapabilityDocument? InputCapability => + (providerSurfaceDocuments as IEveUnityInputCapabilitySource)?.CurrentInputCapability; + public long ActiveVersion => Runtime?.ActiveVersion ?? 0; public event Action? FeedbackAvailable; @@ -89,6 +92,9 @@ namespace GameCult.Eve.UnityScene var feedbackEffects = GetComponent(); if (feedbackEffects == null) feedbackEffects = gameObject.AddComponent(); feedbackEffects.Bind(this, Runtime.GameObjectAssetProvider); + var combatPresentation = GetComponent(); + if (combatPresentation == null) combatPresentation = gameObject.AddComponent(); + combatPresentation.Bind(this); var thermal = GetComponent(); var thermalHud = GetComponent(); if (thermalHud == null) thermalHud = gameObject.AddComponent(); diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs index 03850e1..c321815 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldInputDriver.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using GameCult.Eve.Surface; using UnityEngine; @@ -6,6 +7,11 @@ using UnityEngine; namespace GameCult.Eve.UnityScene { + public interface IEveUnityInputCapabilitySource + { + EveInputCapabilityDocument CurrentInputCapability { get; } + } + public sealed class EveUnityPlayableWorldInputDriver : MonoBehaviour { [SerializeField] private EveUnityPlayableWorldClientHost? host; @@ -71,15 +77,41 @@ namespace GameCult.Eve.UnityScene issuedAt); } - public EveSurfaceCommandRequest? SubmitPrimaryAction(string actionId = "primary") + public EveSurfaceCommandRequest? SubmitPrimaryAction(string actionId = "") { var resolvedHost = ResolveHost(); if (resolvedHost?.ActiveWorld == null) return null; + var resolvedActionId = string.IsNullOrWhiteSpace(actionId) + ? ResolvePrimaryActionId(resolvedHost.InputCapability) + : actionId; + if (string.IsNullOrWhiteSpace(resolvedActionId)) + return null; + return resolvedHost.SubmitActionIntent( resolvedHost.ActiveWorld.PlayerEntityId, - string.IsNullOrWhiteSpace(actionId) ? "primary" : actionId); + resolvedActionId); + } + + public static string ResolvePrimaryActionId(EveInputCapabilityDocument? capability) + { + if (capability == null) + return ""; + var profile = (capability.DefaultProfiles ?? Array.Empty()) + .FirstOrDefault(value => string.Equals(value.DeviceClass, "keyboard-mouse", StringComparison.Ordinal)) + ?? (capability.DefaultProfiles ?? Array.Empty()).FirstOrDefault(); + var binding = (profile?.Bindings ?? Array.Empty()) + .FirstOrDefault(value => (value.Gesture?.Controls ?? Array.Empty()) + .Any(control => string.Equals(control, "mouse.primary", StringComparison.Ordinal))) + ?? (profile?.Bindings ?? Array.Empty()).FirstOrDefault(value => value.ActionBar); + if (binding == null) + return ""; + var action = (capability.Actions ?? Array.Empty()) + .FirstOrDefault(value => string.Equals(value.ActionId, binding.ActionId, StringComparison.Ordinal)); + return action != null && string.Equals(action.Availability, "available", StringComparison.OrdinalIgnoreCase) + ? action.ActionId + : ""; } private void Update() @@ -91,7 +123,7 @@ namespace GameCult.Eve.UnityScene SubmitMoveVectorInput(Input.GetAxisRaw(horizontalAxis), Input.GetAxisRaw(verticalAxis)); if (!string.IsNullOrWhiteSpace(actionButton) && Input.GetButtonDown(actionButton)) - SubmitPrimaryAction(actionButton); + SubmitPrimaryAction(); } private EveUnityPlayableWorldClientHost? ResolveHost() diff --git a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs index 5f853ce..9bd38fe 100644 --- a/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs +++ b/packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs @@ -38,6 +38,9 @@ namespace GameCult.Eve.UnityScene ItemKey = Get(node, "itemKey"); ImpactKind = Get(node, "impactKind"); PresentationIntensity = Math.Max(0, Double(node, "presentationIntensity", 1)); + AppliedDamage = Math.Max(0, Double(node, "appliedDamage", 0)); + ShieldAbsorbedDamage = Math.Max(0, Double(node, "shieldAbsorbedDamage", 0)); + LockQuality = Math.Max(0, Math.Min(1, Double(node, "lockQuality", 0))); } public string ShotId { get; } @@ -54,6 +57,9 @@ namespace GameCult.Eve.UnityScene public string ItemKey { get; } public string ImpactKind { get; } public double PresentationIntensity { get; } + public double AppliedDamage { get; } + public double ShieldAbsorbedDamage { get; } + public double LockQuality { get; } private static string Get(EveUnitySceneNode node, string key) => node.Props.TryGetValue(key, out var value) ? value ?? "" : ""; diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs new file mode 100644 index 0000000..8a06221 --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; + +#nullable enable + +namespace GameCult.Eve.UnityScene.Tests +{ + public sealed class EveUnityCombatPresentationTests + { + [Test] + public void ProjectionPreservesProviderSelectionLockMetersAndFossilTiming() + { + var projection = Projection(Combat()); + var combat = EveUnityCombatPresentation.Find(projection); + + Assert.That(combat, Is.Not.Null); + Assert.That(combat!.ControlledEntityIndex, Is.EqualTo(4)); + Assert.That(combat.SelectedTargetEntityId, Is.EqualTo("run.zone.0.entity.9")); + Assert.That(combat.TargetVisible, Is.True); + Assert.That(combat.TargetHostile, Is.True); + Assert.That(combat.LockProgress, Is.EqualTo(0.6f).Within(0.0001f)); + Assert.That(combat.RadialFill(0), Is.EqualTo(0.25f).Within(0.0001f)); + Assert.That(combat.RadialFill(1), Is.EqualTo(0.75f).Within(0.0001f)); + Assert.That(combat.HitMarkerDurationSeconds, Is.EqualTo(0.25f)); + } + + [Test] + public void HitMarkerRequiresControlledSourceSelectedTargetAndAuthoritativeDamage() + { + var combat = new EveUnityCombatPresentation(Combat()); + + Assert.That(combat.PresentsHit(Shot(4, 9, true, 12, 0)), Is.True); + Assert.That(combat.PresentsHit(Shot(3, 9, true, 12, 0)), Is.False); + Assert.That(combat.PresentsHit(Shot(4, 8, true, 12, 0)), Is.False); + Assert.That(combat.PresentsHit(Shot(4, 9, false, 0, 0)), Is.False); + Assert.That(combat.PresentsHit(Shot(4, 9, true, 0, 0)), Is.False); + Assert.That(combat.PresentsHit(Shot(4, 9, true, 0, 12)), Is.True); + } + + private static EveUnitySceneProjection Projection(params EveUnitySceneNode[] children) => + new EveUnitySceneProjection("provider", "surface", "world", "commands", "receipt", "provider", null, + Node("root", "surface", children)); + + private static EveUnitySceneNode Combat() => Node("combat", "combat.presentation", new Dictionary(StringComparer.Ordinal) + { + ["controlledEntityId"] = "run.zone.0.entity.4", + ["controlledEntityIndex"] = "4", + ["selectedTargetEntityId"] = "run.zone.0.entity.9", + ["selectedTargetEntityIndex"] = "9", + ["targetVisible"] = "true", + ["targetHostile"] = "true", + ["contactInformation"] = "0.8", + ["shieldRatio"] = "0.4", + ["hullRatio"] = "0.9", + ["lockProgress"] = "0.6", + ["hitMarkerDurationSeconds"] = "0.25", + ["radialFillMinimum"] = "0.25", + ["radialFillMaximum"] = "0.75" + }); + + private static EveUnityShotReceipt Shot(int source, int target, bool hit, double damage, double shield) => + new EveUnityShotReceipt(Node("shot", "shot.receipt", new Dictionary(StringComparer.Ordinal) + { + ["shotId"] = Guid.NewGuid().ToString("N"), + ["sourceEntityIndex"] = source.ToString(), + ["targetEntityIndex"] = target.ToString(), + ["hit"] = hit.ToString(), + ["appliedDamage"] = damage.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["shieldAbsorbedDamage"] = shield.ToString(System.Globalization.CultureInfo.InvariantCulture) + })); + + private static EveUnitySceneNode Node(string id, string kind, params EveUnitySceneNode[] children) => + Node(id, kind, Empty(), children); + + private static EveUnitySceneNode Node(string id, string kind, IReadOnlyDictionary props, params EveUnitySceneNode[] children) => + new EveUnitySceneNode(id, kind, "scene-node", props, Empty(), Empty(), 0, 0, + Array.Empty(), null, children); + + private static IReadOnlyDictionary Empty() => + new Dictionary(StringComparer.Ordinal); + } +} diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs.meta b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs.meta new file mode 100644 index 0000000..6a4265c --- /dev/null +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 30c8f313c1ea4b43858ed212909b7ce9 diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs index 6b3d0ff..4fe1fe4 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs @@ -978,6 +978,48 @@ namespace GameCult.Eve.UnityScene.Tests } } + [Test] + public void PlayableWorldInputDriverUsesAdvertisedPrimaryBinding() + { + var capability = new EveInputCapabilityDocument + { + Actions = new[] + { + new EveInputActionDocument + { + ActionId = "weapon-group.0.fire", + Availability = "available" + } + }, + DefaultProfiles = new[] + { + new EveInputProfileDocument + { + ProfileId = "keyboard-mouse", + DeviceClass = "keyboard-mouse", + Bindings = new[] + { + new EveInputBindingDocument + { + ActionId = "weapon-group.0.fire", + Gesture = new EveInputGestureDocument + { + Controls = new[] { "mouse.primary" } + }, + ActionBar = true + } + } + } + } + }; + + Assert.That( + EveUnityPlayableWorldInputDriver.ResolvePrimaryActionId(capability), + Is.EqualTo("weapon-group.0.fire")); + capability.Actions[0].Availability = "unavailable"; + Assert.That(EveUnityPlayableWorldInputDriver.ResolvePrimaryActionId(capability), Is.Empty); + } + [Test] public void PlayableWorldCameraRigFollowsAdvertisedPlayerEntityWithoutProviderTypes() { diff --git a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs index 98beaa5..02920dd 100644 --- a/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs +++ b/packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityShotReceiptPresenterTests.cs @@ -34,6 +34,8 @@ namespace GameCult.Eve.UnityScene.Tests Assert.That(emitted[0].ItemKey, Is.EqualTo("test-cannon")); Assert.That(emitted[0].ImpactKind, Is.EqualTo("shield")); Assert.That(emitted[0].PresentationIntensity, Is.EqualTo(9)); + Assert.That(emitted[0].AppliedDamage, Is.EqualTo(12)); + Assert.That(emitted[0].ShieldAbsorbedDamage, Is.EqualTo(8)); Assert.That(presenter.Apply(next), Is.Zero); } @@ -69,7 +71,9 @@ namespace GameCult.Eve.UnityScene.Tests ["presentationKind"] = "test-laser", ["itemKey"] = "test-cannon", ["impactKind"] = "shield", - ["presentationIntensity"] = "9" + ["presentationIntensity"] = "9", + ["appliedDamage"] = "12", + ["shieldAbsorbedDamage"] = "8" }, Empty(), Empty(), 0, 0, Array.Empty(), null, Array.Empty()); diff --git a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json index 122968b..a7a7dd1 100644 --- a/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json +++ b/packages/org.gamecult.eve.unity-scene/eve-runtime-capability.json @@ -114,6 +114,8 @@ "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityFeedbackPresenter.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotReceiptPresenter.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityShotTrajectoryRenderer.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentation.cs", + "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityCombatPresentationRenderer.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityProviderRefreshSource.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityPlayableWorldPresenter.cs", "packages/org.gamecult.eve.unity-scene/Runtime/EveUnityGameObjectPlayableWorldSceneSink.cs", @@ -121,7 +123,8 @@ "packages/org.gamecult.eve.unity-scene/Runtime/SaiVisualNovelUnitySceneProjectionAdapter.cs", "packages/org.gamecult.eve.unity-scene/Runtime/NornGraphUnitySceneProjectionAdapter.cs", "packages/org.gamecult.eve.unity-scene/Runtime/TeXMathUnitySceneProjectionAdapter.cs", - "packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs" + "packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnitySceneSurfaceLowererTests.cs", + "packages/org.gamecult.eve.unity-scene/Tests/Editor/EveUnityCombatPresentationTests.cs" ] } ], diff --git a/packages/org.gamecult.eve.unity-scene/package.json b/packages/org.gamecult.eve.unity-scene/package.json index 7d51868..b2c1856 100644 --- a/packages/org.gamecult.eve.unity-scene/package.json +++ b/packages/org.gamecult.eve.unity-scene/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.eve.unity-scene", "displayName": "Eve Unity Scene Lowering", - "version": "0.3.18", + "version": "0.3.19", "unity": "2021.3", "description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.", "dependencies": { diff --git a/scripts/run-aetheria-daemon-world-witness.ps1 b/scripts/run-aetheria-daemon-world-witness.ps1 index 2fe6b32..5c58fdb 100644 --- a/scripts/run-aetheria-daemon-world-witness.ps1 +++ b/scripts/run-aetheria-daemon-world-witness.ps1 @@ -3,6 +3,7 @@ param( [string] $AetheriaRoot = "E:\Projects\Aetheria", [string] $CultLibRoot = "E:\Projects\CultLib", [string] $EveUnityRoot = "E:\Projects\EveUnity", + [string] $YmirRoot = "E:\Projects\Ymir", [string] $ClientProject = "ReleaseConsumerProject", [int] $Port = 3076, [string] $OutputDirectory = "artifacts\aetheria-daemon", @@ -31,7 +32,7 @@ $statePath = Join-Path $outputRoot "aetheria-witness-state.cc" $daemonProject = Join-Path $AetheriaRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj" $importProject = Join-Path $AetheriaRoot "Aetheria.State.Import\Aetheria.State.Import.csproj" -foreach ($required in @($UnityExe, (Join-Path $repoRoot $projectRoot), $daemonProject, $importProject)) { +foreach ($required in @($UnityExe, (Join-Path $repoRoot $projectRoot), $daemonProject, $importProject, (Join-Path $YmirRoot "src\Ymir.Core\Ymir.Core.csproj"))) { if (-not (Test-Path -LiteralPath $required)) { throw "Required world witness path not found: $required" } } $canonicalWitnessTest = Join-Path $repoRoot "TestProject\Assets\Tests\PlayMode\GenericWorldCaptureTests.cs" @@ -76,7 +77,7 @@ foreach ($ephemeralPath in @( $importArguments = @( "run", "--project", $importProject, - "-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot", + "-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot", "-p:YmirRoot=$YmirRoot", "--", $AetheriaRoot, $statePath ) $importLogPath = Join-Path $outputRoot "aetheria-import.log" @@ -114,7 +115,7 @@ if (-not $SkipAssetBundleBuild) { $daemonArguments = @( "run", "--project", $daemonProject, - "-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot", + "-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot", "-p:YmirRoot=$YmirRoot", "--", "--root", $AetheriaRoot, "--state", $statePath, @@ -135,13 +136,13 @@ Write-Host "Poll: Select-String -Path '$daemonLogPath' -Pattern 'Aetheria client try { $ready = $false - for ($attempt = 0; $attempt -lt 60; $attempt++) { + for ($attempt = 0; $attempt -lt 240; $attempt++) { if ($daemon.HasExited) { throw "Aetheria daemon exited before publishing CultMesh. See $daemonLogPath" } if ((Test-Path $daemonLogPath) -and (Select-String -Path $daemonLogPath -Pattern "Aetheria client CultMesh endpoint: rudp://127.0.0.1:$Port" -Quiet)) { $ready = $true; break } Start-Sleep -Milliseconds 500 } - if (-not $ready) { throw "Aetheria daemon did not open CultMesh port $Port. See $daemonLogPath" } + if (-not $ready) { throw "Aetheria daemon did not open CultMesh port $Port within 120 seconds. See $daemonLogPath" } $env:EVEUNITY_RENDEZVOUS_ENDPOINT = "rudp://127.0.0.1:$Port" $env:EVEUNITY_PROVIDER_ID = "aetheria" @@ -177,6 +178,10 @@ try { } } $facts = Get-Content -LiteralPath $factsPath -Raw | ConvertFrom-Json + if (-not $facts.combatPresentation -or [string]::IsNullOrWhiteSpace($facts.shotId) -or + [double]$facts.lockProgress -le 0.99) { + throw "Live witness did not observe daemon-owned selection, completed lock, and a resolved shot." + } $releaseLock = Get-Content -LiteralPath (Join-Path $repoRoot "ReleaseConsumerProject\Packages\packages-lock.json") -Raw | ConvertFrom-Json $releasedPackageClient = $projectRoot -eq "ReleaseConsumerProject" $sceneLock = $releaseLock.dependencies.'org.gamecult.eve.unity-scene' @@ -265,7 +270,7 @@ try { sizeBytes = $resultsArtifact.Length } ) - authority = "released-generic-runtime-observes-provider-advertisement-assets-command-receipts-and-republished-surface-versions" + authority = "released-generic-runtime-observes-provider-assets-authoritative-combat-receipts-and-camera-channel-separation" } $witness | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $witnessPath -Encoding UTF8 $stateWitnessPath = Join-Path $outputRoot "runtime-witness.$observedCacheState.json" diff --git a/scripts/run-interactive-aetheria.ps1 b/scripts/run-interactive-aetheria.ps1 new file mode 100644 index 0000000..3dae7e9 --- /dev/null +++ b/scripts/run-interactive-aetheria.ps1 @@ -0,0 +1,121 @@ +param( + [string] $UnityExe = "C:\Program Files\Unity\Hub\Editor\6000.4.2f1\Editor\Unity.exe", + [string] $AetheriaRoot = "E:\Projects\Aetheria", + [string] $CultLibRoot = "E:\Projects\CultLib-codex-cultmesh-reliability", + [string] $EveUnityRoot = "E:\Projects\EveUnity", + [string] $YmirRoot = "E:\Projects\Ymir", + [int] $Port = 3076, + [string] $OutputDirectory = "artifacts\interactive", + [switch] $SkipBuild +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$project = Join-Path $root "ReleaseConsumerProject" +$outputRoot = if ([IO.Path]::IsPathRooted($OutputDirectory)) { $OutputDirectory } else { Join-Path $root $OutputDirectory } +$buildLog = Join-Path $outputRoot "build.log" +$bundleBuildLog = Join-Path $outputRoot "asset-bundle-build.log" +$daemonLog = Join-Path $outputRoot "daemon.log" +$importLog = Join-Path $outputRoot "import.log" +$importErrorLog = Join-Path $outputRoot "import.error.log" +$state = Join-Path $outputRoot "aetheria.cc" +$replica = Join-Path $outputRoot "eve-unity-replica.cc" +$assetCache = Join-Path $outputRoot "asset-cache" +$exe = Join-Path $project "Build\Windows\EveUnity.exe" +$importProject = Join-Path $AetheriaRoot "Aetheria.State.Import\Aetheria.State.Import.csproj" +$daemonProject = Join-Path $AetheriaRoot "Aetheria.State.Daemon\Aetheria.State.Daemon.csproj" + +foreach ($required in @($UnityExe, $project, $importProject, $daemonProject, (Join-Path $YmirRoot "src\Ymir.Core\Ymir.Core.csproj"))) { + if (-not (Test-Path -LiteralPath $required)) { throw "Required interactive witness path not found: $required" } +} +if (Get-NetUDPEndpoint -LocalPort $Port -ErrorAction SilentlyContinue) { + throw "UDP port $Port is already occupied." +} +New-Item -ItemType Directory -Force $outputRoot, $assetCache | Out-Null +foreach ($ephemeralPath in @($state, "$state.records", "$state.cultmesh", $replica, "$replica.records", "$replica.cultmesh")) { + $resolved = [IO.Path]::GetFullPath($ephemeralPath) + if (-not $resolved.StartsWith([IO.Path]::GetFullPath($outputRoot), [StringComparison]::OrdinalIgnoreCase)) { + throw "Interactive cleanup escaped its artifact directory: $resolved" + } + if (Test-Path -LiteralPath $resolved) { Remove-Item -LiteralPath $resolved -Recurse -Force } +} + +if (-not $SkipBuild -or -not (Test-Path $exe)) { + $build = Start-Process $UnityExe -ArgumentList @("-batchmode", "-quit", "-projectPath", $project, "-executeMethod", "GenericEveUnityBuild.BuildWindows", "-logFile", $buildLog) -PassThru -WindowStyle Hidden + Write-Host "Build PID: $($build.Id)" + Write-Host "Build log: $buildLog" + if (-not $build.WaitForExit(300000)) { Stop-Process $build.Id -Force; throw "EveUnity build timed out." } + if ($build.ExitCode -ne 0 -or -not (Test-Path $exe) -or (Select-String $buildLog -Pattern "Scripts have compiler errors|Build completed with a result of 'Failed'" -Quiet)) { Get-Content $buildLog -Tail 120; throw "EveUnity build failed." } +} + +if (-not $SkipBuild) { + $bundleBuilder = Start-Process $UnityExe -ArgumentList @( + "-batchmode", "-quit", "-projectPath", ".", + "-executeMethod", "Aetheria.Editor.EveAssetBundleBuilder.BuildWindows", + "-logFile", $bundleBuildLog + ) -WorkingDirectory $AetheriaRoot -PassThru -WindowStyle Hidden + Write-Host "AssetBundle build PID: $($bundleBuilder.Id)" + Write-Host "AssetBundle build log: $bundleBuildLog" + if (-not $bundleBuilder.WaitForExit(240000)) { Stop-Process $bundleBuilder.Id -Force; throw "Aetheria AssetBundle build timed out." } + if ($bundleBuilder.ExitCode -ne 0) { Get-Content $bundleBuildLog -Tail 120; throw "Aetheria AssetBundle build failed." } +} + +$importArguments = @( + "run", "--project", $importProject, + "-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot", "-p:YmirRoot=$YmirRoot", + "--", $AetheriaRoot, $state +) +$previousErrorActionPreference = $ErrorActionPreference +try { + $ErrorActionPreference = "Continue" + & dotnet @importArguments 1> $importLog 2> $importErrorLog + $importExitCode = $LASTEXITCODE +} +finally { + $ErrorActionPreference = $previousErrorActionPreference +} +if ($importExitCode -ne 0) { throw "Aetheria state import failed. See $importErrorLog" } + +$daemon = Start-Process dotnet -ArgumentList @( + "run", "--project", $daemonProject, + "-p:CultLibRoot=$CultLibRoot", "-p:EveUnityRoot=$EveUnityRoot", "-p:YmirRoot=$YmirRoot", + "--", + "--root", $AetheriaRoot, + "--state", $state, + "--client-cultmesh-host", "127.0.0.1", + "--client-cultmesh-advertise-host", "127.0.0.1", + "--client-cultmesh-port", $Port, + "--tick-interval-ms", 250, + "--fixed-delta-ms", 20, + "--no-odin-announcements" +) -PassThru -WindowStyle Hidden -RedirectStandardOutput $daemonLog -RedirectStandardError "$daemonLog.error" +Write-Host "Daemon PID: $($daemon.Id)" +Write-Host "Daemon log: $daemonLog" +try { + $ready = $false + for ($i=0; $i -lt 240; $i++) { + if ($daemon.HasExited) { throw "Aetheria daemon exited. See $daemonLog" } + if ((Test-Path $daemonLog) -and + (Select-String $daemonLog -Pattern "Aetheria client CultMesh endpoint: rudp://127.0.0.1:$Port" -Quiet)) { + $ready = $true + break + } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw "Aetheria daemon did not open CultMesh port $Port within 120 seconds. See $daemonLog" } + + $env:EVEUNITY_RENDEZVOUS_ENDPOINT = "rudp://127.0.0.1:$Port" + $env:EVEUNITY_PROVIDER_ID = "aetheria" + $env:EVEUNITY_REPLICA_PATH = $replica + $env:EVEUNITY_ASSET_CACHE_PATH = $assetCache + Remove-Item Env:EVEUNITY_SURFACE_ID -ErrorAction SilentlyContinue + $client = Start-Process $exe -ArgumentList "-force-d3d11" -PassThru + Write-Host "Client PID: $($client.Id)" + Write-Host "Asset cache: $assetCache" + Write-Host "Close the EveUnity window when finished." + $client.WaitForExit() +} +finally { + if ($null -ne $daemon -and -not $daemon.HasExited) { Stop-Process $daemon.Id -Force } + Remove-Item Env:EVEUNITY_RENDEZVOUS_ENDPOINT, Env:EVEUNITY_PROVIDER_ENDPOINT, Env:EVEUNITY_PROVIDER_ID, Env:EVEUNITY_SURFACE_ID, Env:EVEUNITY_REPLICA_PATH, Env:EVEUNITY_ASSET_CACHE_PATH -ErrorAction SilentlyContinue +}