From fea05967f80d994d4e0fbb8afdeff8669aed20c6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 17 Jun 2026 11:24:36 +0100 Subject: [PATCH] Add typed verse settings shell and command bridge --- Aetheria.State.ApplyPending/Program.cs | 2 + .../Aetheria.State.Unity.csproj | 2 + Aetheria.State.Verify/Program.cs | 225 +++++++++++++++ Aetheria.State/Aetheria.State.csproj | 1 + Aetheria.State/AetheriaEveCommandBridge.cs | 50 +++- .../AetheriaOperationsSurfaceProjector.cs | 1 + .../AetheriaEveCommandDrainStatus.cs | 3 + Aetheria.State/README.md | 9 +- Assets/Scripts/UI/MainMenu.cs | 159 ++++++++++- Economy.Server/Program.cs | 4 +- .../AetheriaRuntimeClientTargetCommands.cs | 23 ++ .../AetheriaRuntimeClientTargetStore.cs | 13 + ...theriaRuntimeClientTargetSurfaceBuilder.cs | 263 ++++++++++++++++++ .../AetheriaRuntimeVerseHostCommands.cs | 15 + 14 files changed, 765 insertions(+), 5 deletions(-) create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetCommands.cs create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetSurfaceBuilder.cs create mode 100644 Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseHostCommands.cs diff --git a/Aetheria.State.ApplyPending/Program.cs b/Aetheria.State.ApplyPending/Program.cs index 30d80f62d..ae8a28177 100644 --- a/Aetheria.State.ApplyPending/Program.cs +++ b/Aetheria.State.ApplyPending/Program.cs @@ -53,6 +53,7 @@ var eveStatus = new AetheriaEveCommandDrainStatus AppliedCatalogRefreshes = eveReport.AppliedCatalogRefreshes, AppliedOperationsRefreshes = eveReport.AppliedOperationsRefreshes, AppliedPlayerSettingsCommands = eveReport.AppliedPlayerSettingsCommands, + AppliedVerseHostCommands = eveReport.AppliedVerseHostCommands, LastRejectedCommand = eveReport.LastRejectedCommand, LastRejectedReason = eveReport.LastRejectedReason, Status = eveReport.RejectedCommands > 0 ? "rejected" : "ok" @@ -89,6 +90,7 @@ Console.WriteLine($"Eve commands rejected: {eveReport.RejectedCommands}"); Console.WriteLine($"Eve catalog refreshes: {eveReport.AppliedCatalogRefreshes}"); Console.WriteLine($"Eve operations refreshes: {eveReport.AppliedOperationsRefreshes}"); Console.WriteLine($"Eve player settings commands: {eveReport.AppliedPlayerSettingsCommands}"); +Console.WriteLine($"Eve verse host commands: {eveReport.AppliedVerseHostCommands}"); if (!deleteApplied) Console.WriteLine("Applied command files were kept because --keep was supplied."); diff --git a/Aetheria.State.Unity/Aetheria.State.Unity.csproj b/Aetheria.State.Unity/Aetheria.State.Unity.csproj index efe2a08e4..ff839461a 100644 --- a/Aetheria.State.Unity/Aetheria.State.Unity.csproj +++ b/Aetheria.State.Unity/Aetheria.State.Unity.csproj @@ -11,6 +11,8 @@ + + diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index f9bdfcdf0..a60b81987 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -51,6 +51,7 @@ RequireNoDeadPopupShells(root); RequirePlayerSettingsEveSurface(root); RequireVerseHostSettingsAuthority(root); RequireClientTargetBootAuthority(root); +RequireVerseSettingsShellAndBridge(root); RequireMainMenuVerseHostProjection(root); RequireMainMenuContinueRunState(root); RequireDeadPropertiesPanelShellDeleted(root); @@ -538,6 +539,7 @@ Console.WriteLine("NameTools editor shell: the remaining name helper window lowe Console.WriteLine("Runtime state reader authority: Unity gameplay/UI read typed state through a shared runtime reader instead of direct store spelunking"); Console.WriteLine("Verse host authority: daemon-owned typed verse host settings now drive provider advertisement and operations telemetry"); Console.WriteLine("Client target boot authority: Unity boot resolves the active Verse through a typed client target instead of local path folklore"); +Console.WriteLine("Verse settings shell: client target edits and Verse-host visibility commands lower through typed Eve surfaces"); Console.WriteLine("Main-menu Verse projection: the Unity Eve shell lowers daemon-owned verse identity instead of ad-libbing local menu copy"); Console.WriteLine("Runtime simulation tuning authority: UI writes flow through gameplay checkpoint commits"); Console.WriteLine("Hull conductivity authority: inventory UI toggles flow through gameplay checkpoint commits"); @@ -2464,11 +2466,14 @@ static void RequireMainMenuSettingsShellUsesEveSurface(string root) { "RenderMenuSurface(", "BuildSettingsSurfaceDefinition()", + "BuildVerseSettingsSurfaceDefinition()", "BuildInputSettingsSurfaceDefinition(", "HandleSettingsSurfaceCommand(", + "HandleVerseSettingsSurfaceCommand(", "HandleInputSettingsSurfaceCommand(", "WithBackAction(", "ShowPlayerSettingsCommand", + "ShowVerseSettingsCommand", "ShowInputSettingsCommand", "BackToMainCommand", "BackToSettingsCommand" @@ -2488,6 +2493,7 @@ static void RequireMainMenuSettingsShellUsesEveSurface(string root) var forbiddenSymbols = new[] { "_nextMenu.panel.AddButton(\"Player Settings\"", + "_nextMenu.panel.AddButton(\"Verse\"", "_nextMenu.panel.AddButton(\"Input\"", "_nextMenu.panel.AddButton(\"Audio\"", "BuildAudioSettingsSurfaceDefinition(", @@ -4035,6 +4041,225 @@ static void RequireClientTargetBootAuthority(string root) } } +static void RequireVerseSettingsShellAndBridge(string root) +{ + var unityFacadeProjectPath = Path.Combine(root, "Aetheria.State.Unity", "Aetheria.State.Unity.csproj"); + var stateProjectPath = Path.Combine(root, "Aetheria.State", "Aetheria.State.csproj"); + var clientTargetCommandsPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeClientTargetCommands.cs"); + var clientTargetSurfaceBuilderPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeClientTargetSurfaceBuilder.cs"); + 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 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"); + var operationsProjectorPath = Path.Combine(root, "Aetheria.State", "AetheriaOperationsSurfaceProjector.cs"); + var applyPendingPath = Path.Combine(root, "Aetheria.State.ApplyPending", "Program.cs"); + var economyServerPath = Path.Combine(root, "Economy.Server", "Program.cs"); + + var requiredFiles = new[] + { + unityFacadeProjectPath, + stateProjectPath, + clientTargetCommandsPath, + clientTargetSurfaceBuilderPath, + verseHostCommandsPath, + clientTargetStorePath, + mainMenuPath, + commandBridgePath, + drainStatusPath, + operationsProjectorPath, + applyPendingPath, + economyServerPath + }; + + var missingFiles = requiredFiles + .Where(path => !File.Exists(path)) + .Select(path => Path.GetRelativePath(root, path)) + .ToArray(); + if (missingFiles.Length > 0) + { + throw new InvalidOperationException( + "Verse settings shell/bridge cannot be verified because required files are missing: " + + string.Join(", ", missingFiles)); + } + + var unityFacadeProject = File.ReadAllText(unityFacadeProjectPath); + var stateProject = File.ReadAllText(stateProjectPath); + var clientTargetCommands = File.ReadAllText(clientTargetCommandsPath); + var clientTargetSurfaceBuilder = File.ReadAllText(clientTargetSurfaceBuilderPath); + var verseHostCommands = File.ReadAllText(verseHostCommandsPath); + var clientTargetStore = File.ReadAllText(clientTargetStorePath); + var mainMenu = File.ReadAllText(mainMenuPath); + var commandBridge = File.ReadAllText(commandBridgePath); + var drainStatus = File.ReadAllText(drainStatusPath); + var operationsProjector = File.ReadAllText(operationsProjectorPath); + var applyPending = File.ReadAllText(applyPendingPath); + var economyServer = File.ReadAllText(economyServerPath); + + var requiredUnityFacadeSymbols = new[] + { + "AetheriaRuntimeClientTargetCommands.cs", + "AetheriaRuntimeClientTargetSurfaceBuilder.cs" + }; + var missingUnityFacadeSymbols = requiredUnityFacadeSymbols + .Where(symbol => !unityFacadeProject.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingUnityFacadeSymbols.Length > 0) + { + throw new InvalidOperationException( + "Aetheria.State.Unity.csproj does not compile the Verse settings shell files: " + + string.Join(", ", missingUnityFacadeSymbols)); + } + + if (!stateProject.Contains("AetheriaRuntimeVerseHostCommands.cs", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Aetheria.State.csproj does not compile the Verse-host Eve command contract."); + } + + var requiredClientTargetCommandSymbols = new[] + { + "SurfaceId = \"aetheria.client_target\"", + "CycleTargetKind", + "SetTitle", + "SetVerseId", + "SetCultMeshAddress", + "SetStateFilePath", + "IsKnown" + }; + var missingClientTargetCommandSymbols = requiredClientTargetCommandSymbols + .Where(symbol => !clientTargetCommands.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingClientTargetCommandSymbols.Length > 0) + { + throw new InvalidOperationException( + "Client-target command contract is incomplete: " + + string.Join(", ", missingClientTargetCommandSymbols)); + } + + var requiredVerseHostCommandSymbols = new[] + { + "SurfaceId = \"aetheria.verse_host_settings\"", + "CycleVisibility", + "Refresh", + "IsKnown" + }; + var missingVerseHostCommandSymbols = requiredVerseHostCommandSymbols + .Where(symbol => !verseHostCommands.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingVerseHostCommandSymbols.Length > 0) + { + throw new InvalidOperationException( + "Verse-host command contract is incomplete: " + + string.Join(", ", missingVerseHostCommandSymbols)); + } + + var requiredSurfaceBuilderSymbols = new[] + { + "public sealed class AetheriaRuntimeClientTargetSurfaceState", + "\"Client Target\"", + "\"Target Fields\"", + "\"Daemon Verse Host\"", + "AetheriaRuntimeClientTargetCommands.SetStateFilePath", + "AetheriaRuntimeVerseHostCommands.CycleVisibility", + "AetheriaRuntimeVerseHostCommands.Refresh" + }; + var missingSurfaceBuilderSymbols = requiredSurfaceBuilderSymbols + .Where(symbol => !clientTargetSurfaceBuilder.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingSurfaceBuilderSymbols.Length > 0) + { + throw new InvalidOperationException( + "Client-target Eve surface builder is incomplete: " + + string.Join(", ", missingSurfaceBuilderSymbols)); + } + + if (!clientTargetStore.Contains("Update(", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Client-target store no longer exposes typed update ownership for local target edits."); + } + + var requiredMainMenuSymbols = new[] + { + "VerseSettingsShellSurfaceId", + "ShowVerseSettingsCommand", + "ShowVerseSettingsSurface()", + "HandleVerseSettingsSurfaceCommand(EveSurfaceCommandRequest request)", + "BuildVerseSettingsSurfaceDefinition()", + "AetheriaRuntimeClientTargetSurfaceBuilder.Build(", + "TryCommitClientTargetCommand(", + "TryQueueVerseHostCommand(", + "AetheriaRuntimeClientTargetStore.Update(", + "new EveSurfaceCommandRequest(", + "AetheriaRuntimeVerseHostCommands.SurfaceId" + }; + var missingMainMenuSymbols = requiredMainMenuSymbols + .Where(symbol => !mainMenu.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingMainMenuSymbols.Length > 0) + { + throw new InvalidOperationException( + "MainMenu no longer owns the typed Verse settings shell handoff: " + + string.Join(", ", missingMainMenuSymbols)); + } + + var requiredBridgeSymbols = new[] + { + "AppliedVerseHostCommands", + "AetheriaRuntimeVerseHostCommands.Refresh", + "AetheriaRuntimeVerseHostCommands.CycleVisibility", + "ApplyVerseHostCommandAsync", + "PutVerseHostSettingsAsync", + "PutProviderAdvertisementAsync", + "AetheriaOperationsSurfaceProjector.Build(", + "AetheriaRuntimeVerseHostCommands.IsKnown(command)" + }; + var missingBridgeSymbols = requiredBridgeSymbols + .Where(symbol => !commandBridge.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingBridgeSymbols.Length > 0) + { + throw new InvalidOperationException( + "Aetheria Eve command bridge no longer routes Verse-host commands through the provider owner: " + + string.Join(", ", missingBridgeSymbols)); + } + + var requiredDrainSymbols = new[] + { + "AppliedVerseHostCommands" + }; + var missingDrainSymbols = requiredDrainSymbols + .Where(symbol => !drainStatus.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + if (missingDrainSymbols.Length > 0) + { + throw new InvalidOperationException( + "Aetheria Eve command drain status no longer records Verse-host command counts."); + } + + if (!operationsProjector.Contains("Verse Host Commands", StringComparison.Ordinal) || + !operationsProjector.Contains("AppliedVerseHostCommands", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Operations surface no longer projects Verse-host Eve command counts."); + } + + if (!applyPending.Contains("AppliedVerseHostCommands = eveReport.AppliedVerseHostCommands", StringComparison.Ordinal) || + !applyPending.Contains("Eve verse host commands: {eveReport.AppliedVerseHostCommands}", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Aetheria.State.ApplyPending no longer publishes Verse-host Eve command totals."); + } + + if (!economyServer.Contains("AppliedVerseHostCommands = report.AppliedVerseHostCommands", StringComparison.Ordinal) || + !economyServer.Contains("verseHost={report.AppliedVerseHostCommands}", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Economy.Server no longer publishes Verse-host Eve command totals."); + } +} + static void RequireMainMenuVerseHostProjection(string root) { var packageSnapshotPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeCatalogSnapshot.cs"); diff --git a/Aetheria.State/Aetheria.State.csproj b/Aetheria.State/Aetheria.State.csproj index be368c6f4..ac1c3d428 100644 --- a/Aetheria.State/Aetheria.State.csproj +++ b/Aetheria.State/Aetheria.State.csproj @@ -11,6 +11,7 @@ + diff --git a/Aetheria.State/AetheriaEveCommandBridge.cs b/Aetheria.State/AetheriaEveCommandBridge.cs index 7dc3b2fee..1084dc748 100644 --- a/Aetheria.State/AetheriaEveCommandBridge.cs +++ b/Aetheria.State/AetheriaEveCommandBridge.cs @@ -8,6 +8,7 @@ public sealed class AetheriaEveCommandApplyReport public int AppliedCatalogRefreshes { get; set; } public int AppliedOperationsRefreshes { get; set; } public int AppliedPlayerSettingsCommands { get; set; } + public int AppliedVerseHostCommands { get; set; } public int RejectedCommands { get; set; } public string[] AcceptedPaths { get; set; } = []; public string[] RejectedPaths { get; set; } = []; @@ -85,6 +86,11 @@ public static class AetheriaEveCommandBridge await ApplyPlayerSettingsCommandAsync(node, command).ConfigureAwait(false); report.AppliedPlayerSettingsCommands++; break; + case GameCult.Aetheria.State.Unity.AetheriaRuntimeVerseHostCommands.Refresh: + case GameCult.Aetheria.State.Unity.AetheriaRuntimeVerseHostCommands.CycleVisibility: + await ApplyVerseHostCommandAsync(node, command).ConfigureAwait(false); + report.AppliedVerseHostCommands++; + break; } accepted.Add(path); @@ -122,7 +128,9 @@ public static class AetheriaEveCommandBridge (string.Equals(surfaceId, AetheriaOperationsSurfaceProjector.SurfaceId, StringComparison.Ordinal) && string.Equals(command, "aetheria.operations.refresh", StringComparison.Ordinal)) || (string.Equals(surfaceId, AetheriaPlayerSettingsSurfaceProjector.SurfaceId, StringComparison.Ordinal) && - GameCult.Aetheria.State.Unity.AetheriaRuntimePlayerSettingsCommands.IsKnown(command)); + GameCult.Aetheria.State.Unity.AetheriaRuntimePlayerSettingsCommands.IsKnown(command)) || + (string.Equals(surfaceId, GameCult.Aetheria.State.Unity.AetheriaRuntimeVerseHostCommands.SurfaceId, StringComparison.Ordinal) && + GameCult.Aetheria.State.Unity.AetheriaRuntimeVerseHostCommands.IsKnown(command)); } private static async Task ApplyPlayerSettingsCommandAsync( @@ -185,6 +193,46 @@ public static class AetheriaEveCommandBridge .ConfigureAwait(false); } + private static async Task ApplyVerseHostCommandAsync( + AetheriaStateNode node, + AetheriaRuntimeEveCommandDocument command) + { + var settings = await node.GetVerseHostSettingsAsync().ConfigureAwait(false) ?? new AetheriaVerseHostSettings(); + var normalized = AetheriaVerseHostSettingsNormalizer.Normalize(settings); + var persistSettings = false; + + switch (command.Command) + { + case GameCult.Aetheria.State.Unity.AetheriaRuntimeVerseHostCommands.CycleVisibility: + normalized.Visibility = Cycle(normalized.Visibility, "private", "public"); + persistSettings = true; + break; + } + + if (persistSettings) + { + normalized.LastUpdatedAtUtc = command.IssuedAtUtc; + await node.PutVerseHostSettingsAsync(normalized).ConfigureAwait(false); + } + + var commitStatus = await node.GetRuntimeCommitDrainStatusAsync().ConfigureAwait(false) ?? + EmptyCommitDrainStatus(node.StatePath, command.IssuedAtUtc); + var eveStatus = await node.GetEveCommandDrainStatusAsync().ConfigureAwait(false) ?? + EmptyEveCommandDrainStatus(node.StatePath, command.IssuedAtUtc); + var runtimeSession = string.IsNullOrWhiteSpace(commitStatus.RuntimeId) + ? null + : await node.GetRuntimeSessionAsync(commitStatus.RuntimeId).ConfigureAwait(false); + + await node.PutOperationsSurfaceAsync( + AetheriaOperationsSurfaceProjector.Build( + commitStatus, + eveStatus, + normalized, + runtimeSession)).ConfigureAwait(false); + await node.PutProviderAdvertisementAsync( + AetheriaProviderAdvertisementProjector.Build(normalized, node.StatePath, command.IssuedAtUtc)).ConfigureAwait(false); + } + private static string Cycle(string current, params string[] values) { if (values.Length == 0) diff --git a/Aetheria.State/AetheriaOperationsSurfaceProjector.cs b/Aetheria.State/AetheriaOperationsSurfaceProjector.cs index c9635f122..52ba5b738 100644 --- a/Aetheria.State/AetheriaOperationsSurfaceProjector.cs +++ b/Aetheria.State/AetheriaOperationsSurfaceProjector.cs @@ -62,6 +62,7 @@ public static class AetheriaOperationsSurfaceProjector Metric("eveCommandDrain.catalogRefreshes", "Catalog Refreshes", (eveCommandStatus?.AppliedCatalogRefreshes ?? 0).ToString()), Metric("eveCommandDrain.operationsRefreshes", "Operations Refreshes", (eveCommandStatus?.AppliedOperationsRefreshes ?? 0).ToString()), Metric("eveCommandDrain.playerSettings", "Player Settings Commands", (eveCommandStatus?.AppliedPlayerSettingsCommands ?? 0).ToString()), + Metric("eveCommandDrain.verseHost", "Verse Host Commands", (eveCommandStatus?.AppliedVerseHostCommands ?? 0).ToString()), Metric("eveCommandDrain.failures", "Consecutive Failures", (eveCommandStatus?.ConsecutiveFailures ?? 0).ToString()), Row( "eveCommandDrain.last", diff --git a/Aetheria.State/Documents/AetheriaEveCommandDrainStatus.cs b/Aetheria.State/Documents/AetheriaEveCommandDrainStatus.cs index 4e1a2345e..d8ce33e67 100644 --- a/Aetheria.State/Documents/AetheriaEveCommandDrainStatus.cs +++ b/Aetheria.State/Documents/AetheriaEveCommandDrainStatus.cs @@ -57,4 +57,7 @@ public sealed class AetheriaEveCommandDrainStatus [Key(15)] public int AppliedPlayerSettingsCommands { get; set; } + + [Key(16)] + public int AppliedVerseHostCommands { get; set; } } diff --git a/Aetheria.State/README.md b/Aetheria.State/README.md index 6cefedd2e..529164cfa 100644 --- a/Aetheria.State/README.md +++ b/Aetheria.State/README.md @@ -56,8 +56,9 @@ Unity Eve surfaces also emit typed `gamecult.eve.command.v1` documents under provider-owned acceptance organ for those commands: it validates the provider, surface, and command template before running the narrow refresh handlers for the catalog and operations surfaces plus the typed player-settings mutation -handlers for gameplay/graphics Eve controls. Renderer callbacks do not accept -commands or mutate game state locally. +handlers for gameplay/graphics Eve controls and typed Verse-host visibility +commands. Renderer callbacks do not accept commands or mutate game state +locally. `AetheriaEveRuntimeBootstrap` mounts the `aetheria.operations` surface through a runtime-created `UIDocument` after scene load, with environment and command-line switches for diagnostics. UI Toolkit is now a live runtime lowering path, not @@ -316,6 +317,10 @@ can read typed player settings for Unity boot, and can read the published Eve catalog surface. It does not deserialize legacy runtime projection objects and does not write daemon-owned Verse state. Unity can use this as the first package boundary once CultLib/Eve runtime packaging is available. +Unity `MainMenu` now lowers a typed Verse settings shell over that split: +client-target edits persist locally in `aetheria-client.cc`, while Verse-host +visibility changes queue provider-owned Eve commands against the selected local +Verse state file. `Galaxy` now consumes this package-owned runtime catalog for faction selection and name generation, so generated sectors use typed corporation v2 and `aetheria.name_file.v2` records instead of legacy `Faction`/`NameFile` diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index cfb07175c..fbb14da60 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -28,12 +28,14 @@ public class MainMenu : MonoBehaviour private const string SettingsSurfaceId = "aetheria.main_menu.settings"; private const string InputSettingsSurfaceId = "aetheria.main_menu.input_settings"; private const string PlayerSettingsShellSurfaceId = "aetheria.main_menu.player_settings"; + private const string VerseSettingsShellSurfaceId = "aetheria.main_menu.verse_settings"; private const string ContinueRunCommand = "aetheria.main_menu.root.continue"; private const string NewGameCommand = "aetheria.main_menu.root.new_game"; private const string ShowSettingsCommand = "aetheria.main_menu.root.show_settings"; private const string QuitCommand = "aetheria.main_menu.root.quit"; private const string OpenRuntimeInputScreenCommand = "aetheria.main_menu.input_settings.open_runtime_screen"; private const string ShowPlayerSettingsCommand = "aetheria.main_menu.settings.show_player_settings"; + private const string ShowVerseSettingsCommand = "aetheria.main_menu.settings.show_verse_settings"; private const string ShowInputSettingsCommand = "aetheria.main_menu.settings.show_input_settings"; private const string BackToMainCommand = "aetheria.main_menu.settings.back_to_main"; private const string BackToSettingsCommand = "aetheria.main_menu.settings.back_to_settings"; @@ -275,6 +277,17 @@ public class MainMenu : MonoBehaviour HandlePlayerSettingsSurfaceCommand); } + private void ShowVerseSettingsSurface() + { + RenderMenuSurface( + WithBackAction( + ToEveSurfaceDocument(BuildVerseSettingsSurfaceDefinition()), + VerseSettingsShellSurfaceId, + BackToSettingsCommand, + "Back"), + HandleVerseSettingsSurfaceCommand); + } + private void RenderMenuSurface( EveSurfaceDocument document, Action commandHandler) @@ -315,6 +328,9 @@ public class MainMenu : MonoBehaviour case ShowPlayerSettingsCommand: ShowPlayerSettingsSurface(); return; + case ShowVerseSettingsCommand: + ShowVerseSettingsSurface(); + return; case ShowInputSettingsCommand: ShowInputSettings(); return; @@ -366,6 +382,29 @@ public class MainMenu : MonoBehaviour ShowPlayerSettingsSurface(); } + private void HandleVerseSettingsSurfaceCommand(EveSurfaceCommandRequest request) + { + if (string.Equals(request.Command, BackToSettingsCommand, StringComparison.Ordinal)) + { + ShowSettings(); + return; + } + + if (TryCommitClientTargetCommand(request.Command, request.Payload)) + { + ShowVerseSettingsSurface(); + return; + } + + if (TryQueueVerseHostCommand(request.Command, request.Payload)) + { + ShowVerseSettingsSurface(); + return; + } + + Debug.LogWarning($"Unknown verse-settings command: {request.Command}"); + } + private void HideMenuSurface() { if (_menuSurfaceDocument == null) @@ -403,6 +442,27 @@ public class MainMenu : MonoBehaviour DateTime.UtcNow.ToString("O"))); } + private static AetheriaRuntimeSurfaceDocument BuildVerseSettingsSurfaceDefinition() + { + var stateBoot = CurrentStateBoot(); + var verseHost = LatestVerseHostSettings(stateBoot); + return AetheriaRuntimeClientTargetSurfaceBuilder.Build( + new AetheriaRuntimeClientTargetSurfaceState( + stateBoot.TargetKind, + stateBoot.Title, + stateBoot.VerseId, + stateBoot.CultMeshAddress, + stateBoot.StateFilePath, + stateBoot.TargetSource, + stateBoot.SupportsLocalStateFileRead, + stateBoot.FailureMessage, + verseHost?.Title ?? stateBoot.Title, + verseHost?.VerseId ?? stateBoot.VerseId, + verseHost?.Visibility ?? "unknown", + verseHost?.CultMeshAddress ?? stateBoot.CultMeshAddress, + DateTime.UtcNow.ToString("O"))); + } + private static EveSurfaceDocument BuildMainSurfaceDefinition( AetheriaRuntimeStateBootReport stateBoot, AetheriaRuntimeRunStateSnapshot continueRun, @@ -505,6 +565,7 @@ public class MainMenu : MonoBehaviour new[] { new EveCommandTemplate(ShowPlayerSettingsCommand, "Player Settings", "unity-uitoolkit"), + new EveCommandTemplate(ShowVerseSettingsCommand, "Verse", "unity-uitoolkit"), new EveCommandTemplate(ShowInputSettingsCommand, "Input", "unity-uitoolkit"), new EveCommandTemplate(BackToMainCommand, "Back", "unity-uitoolkit") }, @@ -513,10 +574,11 @@ public class MainMenu : MonoBehaviour "Settings", Text( "aetheria.mainMenu.settings.note", - "Typed Verse settings already lower through Eve. Input rebinding now opens the runtime Eve input screen, and audio still has no typed surface."), + "Player settings, client target selection, and input rebinding all lower through typed Eve surfaces. Audio still has no typed owner."), ButtonRow( "aetheria.mainMenu.settings.actions", Button("aetheria.mainMenu.settings.playerSettings", "Player Settings", ShowPlayerSettingsCommand), + Button("aetheria.mainMenu.settings.verse", "Verse", ShowVerseSettingsCommand), Button("aetheria.mainMenu.settings.input", "Input", ShowInputSettingsCommand), Button("aetheria.mainMenu.settings.back", "Back", BackToMainCommand)))); } @@ -540,6 +602,101 @@ public class MainMenu : MonoBehaviour return true; } + private static bool TryCommitClientTargetCommand(string command, IReadOnlyDictionary payload) + { + if (!AetheriaRuntimeClientTargetCommands.IsKnown(command)) + return false; + + if (string.Equals(command, AetheriaRuntimeClientTargetCommands.Refresh, StringComparison.Ordinal)) + return true; + + try + { + var gameDataDirectory = ActionGameManager.GameDataDirectory; + var clientTargetPath = AetheriaRuntimeStateBoundary.GetClientTargetPath(gameDataDirectory); + var defaultStateFilePath = AetheriaRuntimeStateBoundary.GetStateFilePath(gameDataDirectory); + AetheriaRuntimeClientTargetStore.Update( + clientTargetPath, + defaultStateFilePath, + document => + { + switch (command) + { + case var _ when string.Equals(command, AetheriaRuntimeClientTargetCommands.CycleTargetKind, StringComparison.Ordinal): + document.TargetKind = string.Equals(document.TargetKind, AetheriaRuntimeClientTargetKinds.CultMeshVerse, StringComparison.Ordinal) + ? AetheriaRuntimeClientTargetKinds.StateFile + : AetheriaRuntimeClientTargetKinds.CultMeshVerse; + 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"); + break; + case var _ when string.Equals(command, AetheriaRuntimeClientTargetCommands.SetCultMeshAddress, StringComparison.Ordinal): + document.CultMeshAddress = ReadPayloadValue(payload, "value"); + break; + case var _ when string.Equals(command, AetheriaRuntimeClientTargetCommands.SetStateFilePath, StringComparison.Ordinal): + document.StateFilePath = ReadPayloadValue(payload, "value"); + break; + } + + if (string.IsNullOrWhiteSpace(document.TargetKind)) + document.TargetKind = AetheriaRuntimeClientTargetKinds.StateFile; + if (string.IsNullOrWhiteSpace(document.StateFilePath)) + document.StateFilePath = defaultStateFilePath; + document.UpdatedAtUtc = DateTime.UtcNow.ToString("O"); + }); + return true; + } + catch (Exception ex) + { + Debug.LogError($"Failed to update typed Aetheria client target: {ex}"); + return true; + } + } + + private static bool TryQueueVerseHostCommand(string command, IReadOnlyDictionary payload) + { + if (!AetheriaRuntimeVerseHostCommands.IsKnown(command)) + return false; + + var stateBoot = CurrentStateBoot(); + if (!stateBoot.SupportsLocalStateFileRead || !stateBoot.StateFileExists) + { + Debug.LogWarning( + $"Cannot queue Verse-host command '{command}' because the active target is not a readable local Verse state file."); + return true; + } + + try + { + var request = new EveSurfaceCommandRequest( + "aetheria", + AetheriaRuntimeVerseHostCommands.SurfaceId, + command, + payload?.ToDictionary(entry => entry.Key, entry => entry.Value ?? "", StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal), + DateTimeOffset.UtcNow, + "unity-main-menu"); + var envelope = AetheriaRuntimeEveCommandLog.QueueCommand(stateBoot.StateFilePath, request); + Debug.Log($"Queued Aetheria Verse-host Eve command: {envelope.Path}"); + return true; + } + catch (Exception ex) + { + Debug.LogError($"Failed to queue typed Aetheria Verse-host Eve command: {ex}"); + return true; + } + } + + private static string ReadPayloadValue(IReadOnlyDictionary payload, string key) + { + return payload != null && payload.TryGetValue(key, out var value) + ? value ?? "" + : ""; + } + private static EveSurfaceDocument BuildInputSettingsSurfaceDefinition(bool canOpenRuntimeInputScreen, bool inGame) { var commands = new List diff --git a/Economy.Server/Program.cs b/Economy.Server/Program.cs index d1c081102..6e0d38693 100644 --- a/Economy.Server/Program.cs +++ b/Economy.Server/Program.cs @@ -159,6 +159,7 @@ internal static class Program AppliedCatalogRefreshes = report.AppliedCatalogRefreshes, AppliedOperationsRefreshes = report.AppliedOperationsRefreshes, AppliedPlayerSettingsCommands = report.AppliedPlayerSettingsCommands, + AppliedVerseHostCommands = report.AppliedVerseHostCommands, LastRejectedCommand = report.LastRejectedCommand, LastRejectedReason = report.LastRejectedReason, ConsecutiveFailures = 0, @@ -178,7 +179,8 @@ internal static class Program $"rejected={report.RejectedCommands}, " + $"catalogRefreshes={report.AppliedCatalogRefreshes}, " + $"operationsRefreshes={report.AppliedOperationsRefreshes}, " + - $"playerSettings={report.AppliedPlayerSettingsCommands}"); + $"playerSettings={report.AppliedPlayerSettingsCommands}, " + + $"verseHost={report.AppliedVerseHostCommands}"); } catch (Exception ex) { diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetCommands.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetCommands.cs new file mode 100644 index 000000000..d48929e03 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetCommands.cs @@ -0,0 +1,23 @@ +namespace GameCult.Aetheria.State.Unity +{ + public static class AetheriaRuntimeClientTargetCommands + { + public const string SurfaceId = "aetheria.client_target"; + public const string Refresh = "aetheria.client_target.refresh"; + public const string CycleTargetKind = "aetheria.client_target.kind.cycle"; + public const string SetTitle = "aetheria.client_target.title.set"; + public const string SetVerseId = "aetheria.client_target.verse_id.set"; + public const string SetCultMeshAddress = "aetheria.client_target.cultmesh_address.set"; + public const string SetStateFilePath = "aetheria.client_target.state_file_path.set"; + + public static bool IsKnown(string command) + { + return command == Refresh || + command == CycleTargetKind || + command == SetTitle || + command == SetVerseId || + command == SetCultMeshAddress || + command == SetStateFilePath; + } + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetStore.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetStore.cs index 047b73a9e..58ae8b8e9 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetStore.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetStore.cs @@ -83,6 +83,19 @@ namespace GameCult.Aetheria.State.Unity MessagePackSerializer.Serialize(document)); } + public static AetheriaRuntimeClientTargetDocument Update( + string clientTargetPath, + string defaultStateFilePath, + Action mutate) + { + if (mutate == null) throw new ArgumentNullException(nameof(mutate)); + + var document = ReadOrInitialize(clientTargetPath, defaultStateFilePath); + mutate(document); + Write(clientTargetPath, document); + return document; + } + public static AetheriaRuntimeClientTargetDocument Read(string clientTargetPath) { return MessagePackSerializer.Deserialize( diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetSurfaceBuilder.cs new file mode 100644 index 000000000..df3994eca --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeClientTargetSurfaceBuilder.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GameCult.Aetheria.State.Unity +{ + public sealed class AetheriaRuntimeClientTargetSurfaceState + { + public AetheriaRuntimeClientTargetSurfaceState( + string targetKind, + string targetTitle, + string targetVerseId, + string targetCultMeshAddress, + string targetStateFilePath, + string targetSource, + bool supportsLocalStateFileRead, + string bootFailureMessage, + string hostTitle, + string hostVerseId, + string hostVisibility, + string hostCultMeshAddress, + string updatedAtUtc) + { + TargetKind = targetKind ?? ""; + TargetTitle = targetTitle ?? ""; + TargetVerseId = targetVerseId ?? ""; + TargetCultMeshAddress = targetCultMeshAddress ?? ""; + TargetStateFilePath = targetStateFilePath ?? ""; + TargetSource = targetSource ?? ""; + SupportsLocalStateFileRead = supportsLocalStateFileRead; + BootFailureMessage = bootFailureMessage ?? ""; + HostTitle = hostTitle ?? ""; + HostVerseId = hostVerseId ?? ""; + HostVisibility = hostVisibility ?? ""; + HostCultMeshAddress = hostCultMeshAddress ?? ""; + UpdatedAtUtc = updatedAtUtc ?? ""; + } + + public string TargetKind { get; } + public string TargetTitle { get; } + public string TargetVerseId { get; } + public string TargetCultMeshAddress { get; } + public string TargetStateFilePath { get; } + public string TargetSource { get; } + public bool SupportsLocalStateFileRead { get; } + public string BootFailureMessage { get; } + public string HostTitle { get; } + public string HostVerseId { get; } + public string HostVisibility { get; } + public string HostCultMeshAddress { get; } + public string UpdatedAtUtc { get; } + + public string TargetLabel => + string.IsNullOrWhiteSpace(TargetTitle) + ? (string.IsNullOrWhiteSpace(TargetVerseId) ? "Unknown Verse" : TargetVerseId) + : (string.IsNullOrWhiteSpace(TargetVerseId) || string.Equals(TargetTitle, TargetVerseId, StringComparison.Ordinal) + ? TargetTitle + : $"{TargetTitle} ({TargetVerseId})"); + + public string HostLabel => + string.IsNullOrWhiteSpace(HostTitle) + ? (string.IsNullOrWhiteSpace(HostVerseId) ? "Unknown Verse" : HostVerseId) + : (string.IsNullOrWhiteSpace(HostVerseId) || string.Equals(HostTitle, HostVerseId, StringComparison.Ordinal) + ? HostTitle + : $"{HostTitle} ({HostVerseId})"); + } + + public static class AetheriaRuntimeClientTargetSurfaceBuilder + { + public static AetheriaRuntimeSurfaceDocument Build( + AetheriaRuntimeClientTargetSurfaceState state, + long version = 1) + { + state ??= new AetheriaRuntimeClientTargetSurfaceState( + AetheriaRuntimeClientTargetKinds.StateFile, + "", + "", + "", + "", + "", + supportsLocalStateFileRead: true, + bootFailureMessage: "", + hostTitle: "", + hostVerseId: "", + hostVisibility: "", + hostCultMeshAddress: "", + updatedAtUtc: ""); + + var targetKindLabel = string.Equals(state.TargetKind, AetheriaRuntimeClientTargetKinds.CultMeshVerse, StringComparison.Ordinal) + ? "CultMesh Verse" + : "Local State File"; + var visibilityLabel = string.IsNullOrWhiteSpace(state.HostVisibility) ? "unknown" : state.HostVisibility; + var visibilityActionLabel = string.Equals(visibilityLabel, "public", StringComparison.OrdinalIgnoreCase) + ? "Make Private" + : "Make Public"; + 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 visibility changes queue typed Eve commands for the provider bridge."; + + return new AetheriaRuntimeSurfaceDocument( + providerId: "aetheria", + providerKind: "game.menu", + title: "Aetheria Verse Settings", + version: version, + updatedAtUtc: state.UpdatedAtUtc, + surface: new AetheriaRuntimeSurfaceTree( + AetheriaRuntimeClientTargetCommands.SurfaceId, + Node( + "aetheria.clientTarget.root", + "surface", + Array.Empty<(string Key, string Value)>(), + Node( + "aetheria.clientTarget.summary", + "card", + new[] { ("title", "Client Target") }, + Metric("aetheria.clientTarget.summary.target", "Target", state.TargetLabel), + Metric("aetheria.clientTarget.summary.transport", "Transport", targetKindLabel), + Metric("aetheria.clientTarget.summary.source", "Target Source", state.TargetSource), + Text("aetheria.clientTarget.summary.note", summaryNote)), + Node( + "aetheria.clientTarget.target", + "card", + new[] { ("title", "Target Fields") }, + TextInput( + "aetheria.clientTarget.target.title", + "Title", + state.TargetTitle, + AetheriaRuntimeClientTargetCommands.SetTitle), + TextInput( + "aetheria.clientTarget.target.verseId", + "Verse Id", + state.TargetVerseId, + AetheriaRuntimeClientTargetCommands.SetVerseId), + TextInput( + "aetheria.clientTarget.target.cultMeshAddress", + "CultMesh Address", + state.TargetCultMeshAddress, + AetheriaRuntimeClientTargetCommands.SetCultMeshAddress), + TextInput( + "aetheria.clientTarget.target.stateFilePath", + "State File Path", + state.TargetStateFilePath, + AetheriaRuntimeClientTargetCommands.SetStateFilePath), + ButtonRow( + "aetheria.clientTarget.target.actions", + Button( + "aetheria.clientTarget.target.cycleTransport", + string.Equals(state.TargetKind, AetheriaRuntimeClientTargetKinds.CultMeshVerse, StringComparison.Ordinal) + ? "Use Local State File" + : "Use CultMesh Verse", + AetheriaRuntimeClientTargetCommands.CycleTargetKind), + Button( + "aetheria.clientTarget.target.refresh", + "Refresh", + AetheriaRuntimeClientTargetCommands.Refresh))), + Node( + "aetheria.clientTarget.host", + "card", + new[] { ("title", "Daemon Verse Host") }, + Metric("aetheria.clientTarget.host.verse", "Verse", state.HostLabel), + Metric("aetheria.clientTarget.host.visibility", "Visibility", visibilityLabel), + Metric("aetheria.clientTarget.host.cultMesh", "CultMesh", state.HostCultMeshAddress), + ButtonRow( + "aetheria.clientTarget.host.actions", + Button( + "aetheria.clientTarget.host.toggleVisibility", + visibilityActionLabel, + AetheriaRuntimeVerseHostCommands.CycleVisibility), + Button( + "aetheria.clientTarget.host.refresh", + "Refresh Host", + AetheriaRuntimeVerseHostCommands.Refresh))), + Node( + "aetheria.clientTarget.host.noteCard", + "card", + new[] { ("title", "Ownership") }, + Text( + "aetheria.clientTarget.host.note", + "Client target edits are local. Visibility changes queue provider-owned Eve commands against the selected local Verse state file."))), + Array.Empty()), + commands: new[] + { + new AetheriaRuntimeSurfaceCommandTemplate( + AetheriaRuntimeClientTargetCommands.Refresh, + "Refresh", + "unity-uitoolkit"), + new AetheriaRuntimeSurfaceCommandTemplate( + AetheriaRuntimeClientTargetCommands.CycleTargetKind, + "Cycle Transport", + "unity-uitoolkit"), + new AetheriaRuntimeSurfaceCommandTemplate( + AetheriaRuntimeClientTargetCommands.SetTitle, + "Set Title", + "unity-uitoolkit"), + new AetheriaRuntimeSurfaceCommandTemplate( + AetheriaRuntimeClientTargetCommands.SetVerseId, + "Set Verse Id", + "unity-uitoolkit"), + new AetheriaRuntimeSurfaceCommandTemplate( + AetheriaRuntimeClientTargetCommands.SetCultMeshAddress, + "Set CultMesh Address", + "unity-uitoolkit"), + new AetheriaRuntimeSurfaceCommandTemplate( + AetheriaRuntimeClientTargetCommands.SetStateFilePath, + "Set State File Path", + "unity-uitoolkit"), + new AetheriaRuntimeSurfaceCommandTemplate( + AetheriaRuntimeVerseHostCommands.CycleVisibility, + "Toggle Visibility", + "cultmesh"), + new AetheriaRuntimeSurfaceCommandTemplate( + AetheriaRuntimeVerseHostCommands.Refresh, + "Refresh Host", + "cultmesh") + }); + } + + private static AetheriaRuntimeSurfaceComponent Metric(string id, string label, string value) + { + return Node(id, "metric", new[] { ("label", label), ("value", value ?? "") }); + } + + private static AetheriaRuntimeSurfaceComponent Text(string id, string value) + { + return Node(id, "text", new[] { ("value", value ?? "") }); + } + + private static AetheriaRuntimeSurfaceComponent Button(string id, string label, string command) + { + return Node(id, "control.button", new[] { ("label", label ?? ""), ("command", command ?? "") }); + } + + private static AetheriaRuntimeSurfaceComponent TextInput(string id, string label, string value, string command) + { + return Node( + id, + "control.text", + new[] { ("label", label ?? ""), ("value", value ?? ""), ("command", command ?? "") }); + } + + private static AetheriaRuntimeSurfaceComponent ButtonRow( + string id, + params AetheriaRuntimeSurfaceComponent[] children) + { + return Node(id, "row", Array.Empty<(string Key, string Value)>(), children); + } + + private static AetheriaRuntimeSurfaceComponent Node( + string id, + string kind, + IEnumerable<(string Key, string Value)> props, + params AetheriaRuntimeSurfaceComponent[] children) + { + return new AetheriaRuntimeSurfaceComponent( + id, + kind, + props.ToDictionary(prop => prop.Key, prop => prop.Value, StringComparer.Ordinal), + children ?? Array.Empty()); + } + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseHostCommands.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseHostCommands.cs new file mode 100644 index 000000000..023a1b40b --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseHostCommands.cs @@ -0,0 +1,15 @@ +namespace GameCult.Aetheria.State.Unity +{ + public static class AetheriaRuntimeVerseHostCommands + { + public const string SurfaceId = "aetheria.verse_host_settings"; + public const string Refresh = "aetheria.verse_host_settings.refresh"; + public const string CycleVisibility = "aetheria.verse_host_settings.visibility.cycle"; + + public static bool IsKnown(string command) + { + return command == Refresh || + command == CycleVisibility; + } + } +}