From a04df8a585ae2f5d4e2d3870a6d40fa0b2bf7177 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 17 Jun 2026 14:29:41 +0100 Subject: [PATCH] Wire replica sync into client target shell --- .../Aetheria.State.Unity.csproj | 1 + Aetheria.State.Verify/Program.cs | 58 +++++- Aetheria.State/README.md | 4 + Assets/Scripts/UI/MainMenu.cs | 43 +++++ .../AetheriaRuntimeClientTargetCommands.cs | 4 +- .../AetheriaRuntimeClientTargetStore.cs | 4 + ...theriaRuntimeClientTargetSurfaceBuilder.cs | 26 ++- .../Runtime/AetheriaRuntimeStateBoot.cs | 12 +- .../AetheriaRuntimeVerseReplicaBridge.cs | 166 ++++++++++++++++++ 9 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseReplicaBridge.cs diff --git a/Aetheria.State.Unity/Aetheria.State.Unity.csproj b/Aetheria.State.Unity/Aetheria.State.Unity.csproj index d1784fdd5..e40e74ad4 100644 --- a/Aetheria.State.Unity/Aetheria.State.Unity.csproj +++ b/Aetheria.State.Unity/Aetheria.State.Unity.csproj @@ -17,6 +17,7 @@ + diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 2cf01dede..6eff6000a 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -3878,6 +3878,7 @@ static void RequireClientTargetBootAuthority(string root) var bootPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeStateBoot.cs"); var clientTargetStorePath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeClientTargetStore.cs"); var verseDiscoveryPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeVerseDiscovery.cs"); + var replicaBridgePath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeVerseReplicaBridge.cs"); var bootstrapPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.eve-runtime", "Runtime", "AetheriaEveRuntimeBootstrap.cs"); var presenterPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.eve-runtime", "Runtime", "AetheriaEveSurfacePresenter.cs"); var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs"); @@ -3890,6 +3891,7 @@ static void RequireClientTargetBootAuthority(string root) bootPath, clientTargetStorePath, verseDiscoveryPath, + replicaBridgePath, bootstrapPath, presenterPath, actionGameManagerPath, @@ -3912,6 +3914,7 @@ static void RequireClientTargetBootAuthority(string root) var boot = File.ReadAllText(bootPath); var clientTargetStore = File.ReadAllText(clientTargetStorePath); var verseDiscovery = File.ReadAllText(verseDiscoveryPath); + var replicaBridge = File.ReadAllText(replicaBridgePath); var bootstrap = File.ReadAllText(bootstrapPath); var presenter = File.ReadAllText(presenterPath); var actionGameManager = File.ReadAllText(actionGameManagerPath); @@ -3922,7 +3925,8 @@ static void RequireClientTargetBootAuthority(string root) "AetheriaRuntimeClientTargetStore.cs", "AetheriaRuntimeVerseDiscovery.cs", "AetheriaRuntimeStateBoundary.cs", - "AetheriaRuntimeStateBoot.cs" + "AetheriaRuntimeStateBoot.cs", + "AetheriaRuntimeVerseReplicaBridge.cs" }; var missingUnityFacadeSymbols = requiredUnityFacadeSymbols .Where(symbol => !unityFacadeProject.Contains(symbol, StringComparison.Ordinal)) @@ -3967,6 +3971,8 @@ static void RequireClientTargetBootAuthority(string root) "LastDiscoveryAtUtc", "LastDiscoveryError", "ReplicaStateFilePath", + "LastReplicaSyncAtUtc", + "LastReplicaSyncError", "ReadOrInitialize", "CreateDefault", "Write(string clientTargetPath, AetheriaRuntimeClientTargetDocument document)", @@ -3994,6 +4000,8 @@ static void RequireClientTargetBootAuthority(string root) "LastDiscoveryAtUtc", "LastDiscoveryError", "ReplicaStateFilePath", + "LastReplicaSyncAtUtc", + "LastReplicaSyncError", "TargetLabel", "AetheriaRuntimeClientTargetStore.ReadOrInitialize", "AetheriaRuntimeStateBoundary.ResolveStatePathOverride()", @@ -4032,6 +4040,25 @@ static void RequireClientTargetBootAuthority(string root) string.Join(", ", missingVerseDiscoverySymbols)); } + var requiredReplicaBridgeSymbols = new[] + { + "public static class AetheriaRuntimeVerseReplicaBridge", + "AetheriaRuntimeVerseReplicaSyncResult", + "Aetheria.State.Replica", + "Sync(", + "ProcessStartInfo", + "AetheriaRuntimeStateBoundary.GetReplicaStateFilePath" + }; + var missingReplicaBridgeSymbols = requiredReplicaBridgeSymbols + .Where(symbol => !replicaBridge.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingReplicaBridgeSymbols.Length > 0) + { + throw new InvalidOperationException( + "Runtime Verse replica bridge is incomplete: " + + string.Join(", ", missingReplicaBridgeSymbols)); + } + var requiredActionGameManagerSymbols = new[] { "AetheriaRuntimeStateBoot.Inspect(GameDataDirectory)", @@ -4082,6 +4109,8 @@ static void RequireClientTargetBootAuthority(string root) "stateBoot.LastDiscoveryAtUtc", "stateBoot.LastDiscoveryError", "stateBoot.ReplicaStateFilePath", + "stateBoot.LastReplicaSyncAtUtc", + "stateBoot.LastReplicaSyncError", "\"Client Target\"", "\"Transport\"", "\"Target Source\"" @@ -4232,6 +4261,7 @@ static void RequireVerseSettingsShellAndBridge(string root) var verseHostCommandsPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeVerseHostCommands.cs"); var clientTargetStorePath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeClientTargetStore.cs"); var verseDiscoveryPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeVerseDiscovery.cs"); + var replicaBridgePath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeVerseReplicaBridge.cs"); var mainMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "MainMenu.cs"); var commandBridgePath = Path.Combine(root, "Aetheria.State", "AetheriaEveCommandBridge.cs"); var drainStatusPath = Path.Combine(root, "Aetheria.State", "Documents", "AetheriaEveCommandDrainStatus.cs"); @@ -4248,6 +4278,7 @@ static void RequireVerseSettingsShellAndBridge(string root) verseHostCommandsPath, clientTargetStorePath, verseDiscoveryPath, + replicaBridgePath, mainMenuPath, commandBridgePath, drainStatusPath, @@ -4274,6 +4305,7 @@ static void RequireVerseSettingsShellAndBridge(string root) var verseHostCommands = File.ReadAllText(verseHostCommandsPath); var clientTargetStore = File.ReadAllText(clientTargetStorePath); var verseDiscovery = File.ReadAllText(verseDiscoveryPath); + var replicaBridge = File.ReadAllText(replicaBridgePath); var mainMenu = File.ReadAllText(mainMenuPath); var commandBridge = File.ReadAllText(commandBridgePath); var drainStatus = File.ReadAllText(drainStatusPath); @@ -4285,7 +4317,8 @@ static void RequireVerseSettingsShellAndBridge(string root) { "AetheriaRuntimeClientTargetCommands.cs", "AetheriaRuntimeClientTargetSurfaceBuilder.cs", - "AetheriaRuntimeVerseDiscovery.cs" + "AetheriaRuntimeVerseDiscovery.cs", + "AetheriaRuntimeVerseReplicaBridge.cs" }; var missingUnityFacadeSymbols = requiredUnityFacadeSymbols .Where(symbol => !unityFacadeProject.Contains(symbol, StringComparison.Ordinal)) @@ -4314,6 +4347,7 @@ static void RequireVerseSettingsShellAndBridge(string root) "SetDiscoveryEndpoints", "DiscoverVerses", "SelectDiscoveredVerse", + "SyncReplica", "IsKnown" }; var missingClientTargetCommandSymbols = requiredClientTargetCommandSymbols @@ -4351,10 +4385,12 @@ static void RequireVerseSettingsShellAndBridge(string root) "\"Verse Discovery\"", "\"Daemon Verse Host\"", "\"Replica State File\"", + "\"Replica Sync\"", "AetheriaRuntimeClientTargetCommands.SetStateFilePath", "AetheriaRuntimeClientTargetCommands.SetDiscoveryEndpoints", "AetheriaRuntimeClientTargetCommands.DiscoverVerses", "AetheriaRuntimeClientTargetCommands.SelectDiscoveredVerse", + "AetheriaRuntimeClientTargetCommands.SyncReplica", "AetheriaRuntimeVerseHostCommands.CycleVisibility", "AetheriaRuntimeVerseHostCommands.Refresh" }; @@ -4390,6 +4426,23 @@ static void RequireVerseSettingsShellAndBridge(string root) string.Join(", ", missingVerseDiscoverySymbols)); } + var requiredReplicaBridgeSymbols = new[] + { + "public static class AetheriaRuntimeVerseReplicaBridge", + "ProcessStartInfo", + "Aetheria.State.Replica", + "Sync(" + }; + var missingReplicaBridgeSymbols = requiredReplicaBridgeSymbols + .Where(symbol => !replicaBridge.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingReplicaBridgeSymbols.Length > 0) + { + throw new InvalidOperationException( + "Verse replica bridge no longer exposes the sync transport used by the Unity shell: " + + string.Join(", ", missingReplicaBridgeSymbols)); + } + var requiredMainMenuSymbols = new[] { "VerseSettingsShellSurfaceId", @@ -4402,6 +4455,7 @@ static void RequireVerseSettingsShellAndBridge(string root) "TryQueueVerseHostCommand(", "AetheriaRuntimeClientTargetStore.Update(", "AetheriaRuntimeVerseDiscovery.RefreshClientTarget(", + "AetheriaRuntimeVerseReplicaBridge.Sync(", "ParseDiscoveryEndpoints(", "new EveSurfaceCommandRequest(", "AetheriaRuntimeVerseHostCommands.SurfaceId" diff --git a/Aetheria.State/README.md b/Aetheria.State/README.md index c1e86d769..3b29ab614 100644 --- a/Aetheria.State/README.md +++ b/Aetheria.State/README.md @@ -423,6 +423,10 @@ truth. The selected remote client target resolves to that replica path. The daemon still owns accepted state; the replica is only the local read surface Unity lowers from. +The Unity Verse-settings shell can also trigger the same one-shot sync through +the `Aetheria.State.Replica` bridge when the repo-local tool is available. That +keeps target selection local while the replica organ owns daemon transport. + `Aetheria.State.ApplyPending` is the smaller local operator applicator for queued Unity runtime commits and Eve commands when the server host is not being used. It also republishes the typed drain status, operations surface, diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index bee0d1c53..704e44f1d 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -458,6 +458,8 @@ public class MainMenu : MonoBehaviour stateBoot.DiscoveredVerses ?? Array.Empty(), stateBoot.LastDiscoveryAtUtc, stateBoot.LastDiscoveryError, + stateBoot.LastReplicaSyncAtUtc, + stateBoot.LastReplicaSyncError, stateBoot.TargetSource, stateBoot.SupportsLocalStateFileRead, stateBoot.FailureMessage, @@ -626,6 +628,41 @@ public class MainMenu : MonoBehaviour return true; } + if (string.Equals(command, AetheriaRuntimeClientTargetCommands.SyncReplica, StringComparison.Ordinal)) + { + var target = AetheriaRuntimeClientTargetStore.ReadOrInitialize(clientTargetPath, defaultStateFilePath); + var syncedAtUtc = DateTime.UtcNow.ToString("O"); + try + { + var result = AetheriaRuntimeVerseReplicaBridge.Sync(gameDataDirectory, target); + AetheriaRuntimeClientTargetStore.Update( + clientTargetPath, + defaultStateFilePath, + document => + { + document.ReplicaStateFilePath = result.ReplicaStateFilePath; + document.LastReplicaSyncAtUtc = syncedAtUtc; + document.LastReplicaSyncError = ""; + document.UpdatedAtUtc = syncedAtUtc; + }); + } + catch (Exception ex) + { + AetheriaRuntimeClientTargetStore.Update( + clientTargetPath, + defaultStateFilePath, + document => + { + document.LastReplicaSyncAtUtc = syncedAtUtc; + document.LastReplicaSyncError = ex.Message ?? ex.GetType().Name; + document.UpdatedAtUtc = syncedAtUtc; + }); + throw; + } + + return true; + } + AetheriaRuntimeClientTargetStore.Update( clientTargetPath, defaultStateFilePath, @@ -637,15 +674,19 @@ public class MainMenu : MonoBehaviour document.TargetKind = string.Equals(document.TargetKind, AetheriaRuntimeClientTargetKinds.CultMeshVerse, StringComparison.Ordinal) ? AetheriaRuntimeClientTargetKinds.StateFile : AetheriaRuntimeClientTargetKinds.CultMeshVerse; + document.LastReplicaSyncError = ""; break; case var _ when string.Equals(command, AetheriaRuntimeClientTargetCommands.SetTitle, StringComparison.Ordinal): document.Title = ReadPayloadValue(payload, "value"); break; case var _ when string.Equals(command, AetheriaRuntimeClientTargetCommands.SetVerseId, StringComparison.Ordinal): document.VerseId = ReadPayloadValue(payload, "value"); + document.ReplicaStateFilePath = AetheriaRuntimeStateBoundary.GetReplicaStateFilePath(gameDataDirectory, document.VerseId); + document.LastReplicaSyncError = ""; break; case var _ when string.Equals(command, AetheriaRuntimeClientTargetCommands.SetCultMeshAddress, StringComparison.Ordinal): document.CultMeshAddress = ReadPayloadValue(payload, "value"); + document.LastReplicaSyncError = ""; break; case var _ when string.Equals(command, AetheriaRuntimeClientTargetCommands.SetStateFilePath, StringComparison.Ordinal): document.StateFilePath = ReadPayloadValue(payload, "value"); @@ -659,8 +700,10 @@ public class MainMenu : MonoBehaviour document.Title = ReadPayloadValue(payload, "title"); document.VerseId = ReadPayloadValue(payload, "verseId"); document.CultMeshAddress = ReadPayloadValue(payload, "cultMeshAddress"); + document.ReplicaStateFilePath = AetheriaRuntimeStateBoundary.GetReplicaStateFilePath(gameDataDirectory, document.VerseId); document.DiscoveryEndpoints = ParseDiscoveryEndpoints(ReadPayloadValue(payload, "discoveryEndpoints")); document.LastDiscoveryError = ""; + document.LastReplicaSyncError = ""; break; } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetCommands.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetCommands.cs index 4b5925034..bb20fd522 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetCommands.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetCommands.cs @@ -12,6 +12,7 @@ namespace GameCult.Aetheria.State.Unity public const string SetDiscoveryEndpoints = "aetheria.client_target.discovery_endpoints.set"; public const string DiscoverVerses = "aetheria.client_target.discovery.refresh"; public const string SelectDiscoveredVerse = "aetheria.client_target.discovery.select"; + public const string SyncReplica = "aetheria.client_target.replica.sync"; public static bool IsKnown(string command) { @@ -23,7 +24,8 @@ namespace GameCult.Aetheria.State.Unity command == SetStateFilePath || command == SetDiscoveryEndpoints || command == DiscoverVerses || - command == SelectDiscoveredVerse; + command == SelectDiscoveredVerse || + command == SyncReplica; } } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetStore.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetStore.cs index d55630f3e..650c9060b 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetStore.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetStore.cs @@ -34,6 +34,8 @@ namespace GameCult.Aetheria.State.Unity [Key(9)] public string LastDiscoveryAtUtc { get; set; } = ""; [Key(10)] public string LastDiscoveryError { get; set; } = ""; [Key(11)] public string ReplicaStateFilePath { get; set; } = ""; + [Key(12)] public string LastReplicaSyncAtUtc { get; set; } = ""; + [Key(13)] public string LastReplicaSyncError { get; set; } = ""; } [MessagePackObject] @@ -96,6 +98,8 @@ namespace GameCult.Aetheria.State.Unity document.LastDiscoveryAtUtc ??= ""; document.LastDiscoveryError ??= ""; document.ReplicaStateFilePath = NormalizeReplicaStateFilePath(document, clientTargetPath, document.ReplicaStateFilePath); + document.LastReplicaSyncAtUtc ??= ""; + document.LastReplicaSyncError ??= ""; foreach (var verse in document.DiscoveredVerses) { if (verse == null) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetSurfaceBuilder.cs index b51b40a40..c08fcb7ed 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetSurfaceBuilder.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetSurfaceBuilder.cs @@ -17,6 +17,8 @@ namespace GameCult.Aetheria.State.Unity IReadOnlyList discoveredVerses, string lastDiscoveryAtUtc, string lastDiscoveryError, + string lastReplicaSyncAtUtc, + string lastReplicaSyncError, string targetSource, bool supportsLocalStateFileRead, string bootFailureMessage, @@ -36,6 +38,8 @@ namespace GameCult.Aetheria.State.Unity DiscoveredVerses = discoveredVerses ?? Array.Empty(); LastDiscoveryAtUtc = lastDiscoveryAtUtc ?? ""; LastDiscoveryError = lastDiscoveryError ?? ""; + LastReplicaSyncAtUtc = lastReplicaSyncAtUtc ?? ""; + LastReplicaSyncError = lastReplicaSyncError ?? ""; TargetSource = targetSource ?? ""; SupportsLocalStateFileRead = supportsLocalStateFileRead; BootFailureMessage = bootFailureMessage ?? ""; @@ -56,6 +60,8 @@ namespace GameCult.Aetheria.State.Unity public IReadOnlyList DiscoveredVerses { get; } public string LastDiscoveryAtUtc { get; } public string LastDiscoveryError { get; } + public string LastReplicaSyncAtUtc { get; } + public string LastReplicaSyncError { get; } public string TargetSource { get; } public bool SupportsLocalStateFileRead { get; } public string BootFailureMessage { get; } @@ -98,6 +104,8 @@ namespace GameCult.Aetheria.State.Unity "", "", "", + "", + "", supportsLocalStateFileRead: true, bootFailureMessage: "", hostTitle: "", @@ -114,11 +122,12 @@ namespace GameCult.Aetheria.State.Unity ? "Make Private" : "Make Public"; var lastDiscoveryLabel = string.IsNullOrWhiteSpace(state.LastDiscoveryAtUtc) ? "never" : state.LastDiscoveryAtUtc; + var lastReplicaSyncLabel = string.IsNullOrWhiteSpace(state.LastReplicaSyncAtUtc) ? "never" : state.LastReplicaSyncAtUtc; var summaryNote = !state.SupportsLocalStateFileRead && !string.IsNullOrWhiteSpace(state.BootFailureMessage) ? state.BootFailureMessage : string.Equals(state.TargetSource, "state-path-override", StringComparison.Ordinal) ? "AETHERIA_STATE_PATH is overriding the persisted client target. Update the environment if you want boot to follow the saved target again." - : "Client target edits persist in aetheria-client.cc. Verse discovery and selection mutate the same typed owner. Verse visibility changes queue provider-owned Eve commands for the daemon bridge."; + : "Client target edits persist in aetheria-client.cc. Verse discovery and selection mutate the same typed owner. Verse visibility changes queue provider-owned Eve commands for the daemon bridge. Remote Verse targets hydrate a cache-only local replica before Unity reads them."; var discoveryChildren = new List { @@ -217,6 +226,13 @@ namespace GameCult.Aetheria.State.Unity "aetheria.clientTarget.target.replicaStateFilePath", "Replica State File", state.TargetReplicaStateFilePath), + Metric( + "aetheria.clientTarget.target.replicaSyncAt", + "Replica Sync", + lastReplicaSyncLabel), + Text( + "aetheria.clientTarget.target.replicaSyncError", + state.LastReplicaSyncError), ButtonRow( "aetheria.clientTarget.target.actions", Button( @@ -225,6 +241,10 @@ namespace GameCult.Aetheria.State.Unity ? "Use Local State File" : "Use CultMesh Verse", AetheriaRuntimeClientTargetCommands.CycleTargetKind), + Button( + "aetheria.clientTarget.target.syncReplica", + "Sync Replica", + AetheriaRuntimeClientTargetCommands.SyncReplica), Button( "aetheria.clientTarget.target.refresh", "Refresh", @@ -297,6 +317,10 @@ namespace GameCult.Aetheria.State.Unity AetheriaRuntimeClientTargetCommands.SelectDiscoveredVerse, "Select Discovered Verse", "unity-uitoolkit"), + new AetheriaRuntimeSurfaceCommandTemplate( + AetheriaRuntimeClientTargetCommands.SyncReplica, + "Sync Replica", + "unity-uitoolkit"), new AetheriaRuntimeSurfaceCommandTemplate( AetheriaRuntimeVerseHostCommands.CycleVisibility, "Toggle Visibility", diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStateBoot.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStateBoot.cs index 508df3d98..38d21a950 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStateBoot.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStateBoot.cs @@ -21,7 +21,9 @@ namespace GameCult.Aetheria.State.Unity string[] discoveryEndpoints, AetheriaRuntimeDiscoveredVerse[] discoveredVerses, string lastDiscoveryAtUtc, - string lastDiscoveryError) + string lastDiscoveryError, + string lastReplicaSyncAtUtc, + string lastReplicaSyncError) { ClientTargetPath = clientTargetPath; TargetKind = targetKind; @@ -38,6 +40,8 @@ namespace GameCult.Aetheria.State.Unity DiscoveredVerses = discoveredVerses ?? Array.Empty(); LastDiscoveryAtUtc = lastDiscoveryAtUtc ?? ""; LastDiscoveryError = lastDiscoveryError ?? ""; + LastReplicaSyncAtUtc = lastReplicaSyncAtUtc ?? ""; + LastReplicaSyncError = lastReplicaSyncError ?? ""; } public string ClientTargetPath { get; } @@ -55,6 +59,8 @@ namespace GameCult.Aetheria.State.Unity public AetheriaRuntimeDiscoveredVerse[] DiscoveredVerses { get; } public string LastDiscoveryAtUtc { get; } public string LastDiscoveryError { get; } + public string LastReplicaSyncAtUtc { get; } + public string LastReplicaSyncError { get; } public string TargetLabel => string.IsNullOrWhiteSpace(Title) @@ -134,7 +140,9 @@ namespace GameCult.Aetheria.State.Unity target.DiscoveryEndpoints?.Where(value => !string.IsNullOrWhiteSpace(value)).ToArray() ?? Array.Empty(), target.DiscoveredVerses ?? Array.Empty(), target.LastDiscoveryAtUtc ?? "", - target.LastDiscoveryError ?? ""); + target.LastDiscoveryError ?? "", + target.LastReplicaSyncAtUtc ?? "", + target.LastReplicaSyncError ?? ""); } private static string BuildTargetLabel(string title, string verseId) diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseReplicaBridge.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseReplicaBridge.cs new file mode 100644 index 000000000..cbbe1847a --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseReplicaBridge.cs @@ -0,0 +1,166 @@ +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace GameCult.Aetheria.State.Unity +{ + public readonly struct AetheriaRuntimeVerseReplicaSyncResult + { + public AetheriaRuntimeVerseReplicaSyncResult( + string replicaStateFilePath, + string standardOutput, + string standardError) + { + ReplicaStateFilePath = replicaStateFilePath ?? ""; + StandardOutput = standardOutput ?? ""; + StandardError = standardError ?? ""; + } + + public string ReplicaStateFilePath { get; } + public string StandardOutput { get; } + public string StandardError { get; } + } + + public static class AetheriaRuntimeVerseReplicaBridge + { + public static AetheriaRuntimeVerseReplicaSyncResult Sync( + DirectoryInfo gameDataDirectory, + AetheriaRuntimeClientTargetDocument target, + TimeSpan? timeout = null) + { + if (gameDataDirectory == null) throw new ArgumentNullException(nameof(gameDataDirectory)); + if (target == null) throw new ArgumentNullException(nameof(target)); + + var endpoint = target.CultMeshAddress ?? ""; + if (string.IsNullOrWhiteSpace(endpoint)) + throw new InvalidOperationException("Cannot sync a Verse replica without a CultMesh endpoint."); + + var verseId = string.IsNullOrWhiteSpace(target.VerseId) ? "unknown-verse" : target.VerseId; + var replicaStateFilePath = string.IsNullOrWhiteSpace(target.ReplicaStateFilePath) + ? AetheriaRuntimeStateBoundary.GetReplicaStateFilePath(gameDataDirectory, verseId) + : Path.GetFullPath(target.ReplicaStateFilePath); + var repoRoot = gameDataDirectory.Parent?.FullName; + if (string.IsNullOrWhiteSpace(repoRoot)) + throw new InvalidOperationException("Cannot resolve the Aetheria repo root from the GameData directory."); + + Directory.CreateDirectory(Path.GetDirectoryName(replicaStateFilePath) ?? gameDataDirectory.FullName); + + var startInfo = BuildStartInfo(repoRoot, endpoint, replicaStateFilePath, verseId); + using var process = new Process { StartInfo = startInfo }; + var output = new StringBuilder(); + var error = new StringBuilder(); + + process.OutputDataReceived += (_, eventArgs) => + { + if (!string.IsNullOrWhiteSpace(eventArgs.Data)) + output.AppendLine(eventArgs.Data); + }; + process.ErrorDataReceived += (_, eventArgs) => + { + if (!string.IsNullOrWhiteSpace(eventArgs.Data)) + error.AppendLine(eventArgs.Data); + }; + + if (!process.Start()) + throw new InvalidOperationException("Failed to start the Aetheria Verse replica sync process."); + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + var waitTimeout = timeout ?? TimeSpan.FromSeconds(90); + if (!process.WaitForExit((int)Math.Ceiling(waitTimeout.TotalMilliseconds))) + { + try + { + process.Kill(); + } + catch + { + } + + throw new TimeoutException( + $"Timed out waiting {waitTimeout.TotalSeconds:0} seconds for the Aetheria Verse replica sync process."); + } + + process.WaitForExit(); + var outputText = output.ToString().Trim(); + var errorText = error.ToString().Trim(); + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(errorText) + ? (string.IsNullOrWhiteSpace(outputText) + ? $"Aetheria Verse replica sync exited with code {process.ExitCode}." + : outputText) + : errorText); + } + + return new AetheriaRuntimeVerseReplicaSyncResult(replicaStateFilePath, outputText, errorText); + } + + private static ProcessStartInfo BuildStartInfo( + string repoRoot, + string endpoint, + string replicaStateFilePath, + string verseId) + { + var executablePath = Path.Combine(repoRoot, "Aetheria.State.Replica", "bin", "Debug", "net10.0", "Aetheria.State.Replica.exe"); + if (File.Exists(executablePath)) + { + return CreateStartInfo( + executablePath, + BuildArguments("sync", "--endpoint", endpoint, "--replica", replicaStateFilePath, "--verse-id", verseId), + repoRoot); + } + + var dllPath = Path.Combine(repoRoot, "Aetheria.State.Replica", "bin", "Debug", "net10.0", "Aetheria.State.Replica.dll"); + if (File.Exists(dllPath)) + { + return CreateStartInfo( + "dotnet", + BuildArguments(dllPath, "sync", "--endpoint", endpoint, "--replica", replicaStateFilePath, "--verse-id", verseId), + repoRoot); + } + + var projectPath = Path.Combine(repoRoot, "Aetheria.State.Replica", "Aetheria.State.Replica.csproj"); + if (!File.Exists(projectPath)) + throw new FileNotFoundException("Cannot find the Aetheria.State.Replica project.", projectPath); + + return CreateStartInfo( + "dotnet", + BuildArguments("run", "--project", projectPath, "--", "sync", "--endpoint", endpoint, "--replica", replicaStateFilePath, "--verse-id", verseId), + repoRoot); + } + + private static ProcessStartInfo CreateStartInfo(string fileName, string arguments, string workingDirectory) + { + return new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + } + + private static string BuildArguments(params string[] values) + { + return string.Join(" ", values.Select(QuoteArgument)); + } + + private static string QuoteArgument(string value) + { + var text = value ?? ""; + if (text.Length == 0) + return "\"\""; + + if (text.IndexOfAny(new[] { ' ', '\t', '\n', '\r', '"' }) < 0) + return text; + + return "\"" + text.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } + } +}