From be6d7b3b253fa3ef0bddc5b28bf471b65417b6b4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 16 Jun 2026 18:09:15 +0100 Subject: [PATCH] Publish player settings as a provider-owned Eve surface --- .voidbot/state/aetheria.cc | Bin 188901 -> 190733 bytes Aetheria.State.ApplyPending/Program.cs | 13 ++ Aetheria.State.Smoke/Program.cs | 121 +++++++----- Aetheria.State.Verify/Program.cs | 67 +++++++ Aetheria.State/AetheriaEveCommandBridge.cs | 90 ++++++++- .../AetheriaOperationsSurfaceProjector.cs | 1 + .../AetheriaPlayerSettingsSurfaceProjector.cs | 177 ++++++++++++++++++ .../AetheriaProviderAdvertisementProjector.cs | 35 ++++ .../AetheriaRuntimeCommitLogApplier.cs | 8 +- Aetheria.State/AetheriaStateNode.cs | 10 + .../AetheriaEveCommandDrainStatus.cs | 3 + Aetheria.State/README.md | 30 +-- Economy.Server/Program.cs | 17 +- docs/aetheria-perfect-machine-map.md | 32 ++-- 14 files changed, 534 insertions(+), 70 deletions(-) create mode 100644 Aetheria.State/AetheriaPlayerSettingsSurfaceProjector.cs diff --git a/.voidbot/state/aetheria.cc b/.voidbot/state/aetheria.cc index 3baabdc07bf55b829f2177d78c0d61752eeed404..4c7eea6b08df06ae7bf08dc1b6705ef0dba1ba6a 100644 GIT binary patch delta 2483 zcmbW3?`s@I7{}dhu{Ehguralibf7lHl09>m^il(Yg;rZc`zr;FM4j84yB)eav&_ui zHEOvReIb2OYguH$ciKur4Ym>? zKF{-gJ~O|4(|h6j-Wyl!E#ng-`zK1{g;KFNb;;_FzqQZzw!e*zjwZM7*gN8AgUA<- zjXi4?Zr-@lzI%KA>5cEdaqi;Tg^OpculL4tl?$!vF~J+ON`%9kj8xH~20@Z2MOj@U zM&OI$h@7EPp$e&SL6GtY;(irHFP=KmSt>o^`^d!%mZ2)~iGr=JPcZXm3ZJ?@roqz9 z11R+I44jpp;F$9wQNu2QKO>3MOsIGAAi%7ODuPz)1l4JSNK}u1J~i0FC|oTWWZ+17 zTu?QGLaaPQnN$Q<5w9Uo(12WW)d678Ito`6VUlvT^xdB~ung3JFr7tg25=SLjkoB&h2VVT$Z>rUdu35-6IM<3>Ud z26c{<;>=0a@g@;Eimog<6AEI<#FBP@>x);&8HFJC5KD=q$0O(op%6%*fJYEV$Ly}R z%t}oe60n8_Pz3>jA>*|7nJjb~)I(TqqO8&C!$y(MB?JKD2 zhavnfT*J(THk}|f`O8D-++{&(OS|Ib`jhc@m7e)OYqNdD^y6OLeyS!yPz4*Et}GnV zAWoJ^ynJ%IKRxMDnHB<9t6(2YbR8%zq&wft{npMuj4D2Lv4Sp}rlqdSmuXjK7L=euE^eEZ#iFLFUtOuGP0=_&bWn6Gt+i^1Vsp?>$ zO`N4kJy#sO(9)B|vXRj{!7xBJ;M70LXo#SF(G8jv{W1ryW)+M-n2u%eGjrpUI(BD} z2FL3#qMDE#d}QXoE_hQS!gy5oK%#*GYH$}GBJair~7)2nBY`7zGTg z0i#mp=)BTlOAC-<2U0f()BpZgiZvi6K;QM!O>hg{?~-Dqi4>6dv|LG~OaBW!j^B-* zb@UD=eJ@$to@-J~^z^bwxnqpgzE{@fPW&&R8Sj9mKfW^h*yAhA+6|YtUVGV~M6SaU zf*hqb|6hHjit92d351FwWyLxwL3Kv8g-> zxZ1Zb`FzpZ(fRSJJuti$tL7r=75JP@7gyXS@Csz>Tgl*oT2~# delta 1186 zcmY*ZJ8KkC6m}*-e4$NzBIZD}P|UhX%){7dVvvA}Pn4kIotcx}Yi8~p@4d6hR9mF5 zQj{&m(idnXg^yxE5DTpo5$%+yg;EW$WsY3=FxZPl;N z?nzOhCaKbsOkhaWU`tUEgVAu5s*f|l=%Fy);GYnNn-rw_540M%D2=hS(2#S+stC9u z6=8Z1xB-RN64XR`I2T6L)i$?`B5oR;%{i7}FE9}-ONI&ydB6;kkupNiux-s)xp{dK zw{eYH+W^{8STfs1LcwDxY++PU5)lQ^m_!0eBCy)s3p*&BJ!ff5a`fCJ@K#DR2g4Aw z;q)6T)YRLM(;nj`#z?Ujv}{$dd=p$#yapyJ*c&GbAw?QX#km`8$Qa03L0T4lJYrI2TJz%V!{vm&%@0*4%g!nq%A}avlcYDP%J+TP6Y`@}&^8 z6o_TW{gf2SX&xf@GC<2l5Yfrl6l`{c#;6R>BWDez<2@F(s7Yf2LS{d|tnZN9Red=w z>GKc?f|t@P+Kq4ZSPSh4o3S-QndPQzBLSVLY3AGXX-m{SgBZ8GoIo0_n9#9Q2DD$s zi0x}BZ;FYT;-~1tBryLwkY9lnO7k$%c*Pl66gr8lVovi~84=A@GOY_wnDEO|oyKXs zw4!_dfx_u8JvDi8YyIsl=lO>9i&)_S3LkC7o$T3jXH#}4a|-oq_nhCI*(v6BWebm- zT^o!2qx<_t`$h@_gTqq~4J#4f_WI99&LwAl(W@_d&WAng9!D6_vXHQy*RL!%^(zbR zL~r(P$?aLQd?ZF{A3L_R&p>~E2JFpv)qWf(Ht)sCtn0fQHJj;>4AlN~Y|id~cdPB< S{rPdJkNt38um0zCc 0 ? "rejected" : "ok" @@ -67,6 +68,7 @@ var completedSession = new AetheriaRuntimeSession }; await node.PutRuntimeSessionAsync(completedSession); await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(commitStatus, eveStatus, completedSession)); +await PublishPlayerSettingsSurfaceAsync(node, now); await node.PutProviderAdvertisementAsync(AetheriaProviderAdvertisementProjector.Build(statePath, now)); await node.FlushAsync(); @@ -79,6 +81,7 @@ Console.WriteLine($"Eve commands accepted: {eveReport.AcceptedPaths.Length}"); 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}"); if (!deleteApplied) Console.WriteLine("Applied command files were kept because --keep was supplied."); @@ -97,3 +100,13 @@ static int CountPendingEveCommands(string statePath) ? Directory.EnumerateFiles(pendingDirectory, "*.cc").Count() : 0; } + +static async Task PublishPlayerSettingsSurfaceAsync(AetheriaStateNode node, string updatedAtUtc) +{ + var settings = await node.GetPlayerSettingsAsync().ConfigureAwait(false) ?? new AetheriaPlayerSettings(); + var publishedAtUtc = string.IsNullOrWhiteSpace(settings.LastUpdatedAtUtc) + ? updatedAtUtc + : settings.LastUpdatedAtUtc; + await node.PutPlayerSettingsSurfaceAsync( + AetheriaPlayerSettingsSurfaceProjector.Build(settings, publishedAtUtc)).ConfigureAwait(false); +} diff --git a/Aetheria.State.Smoke/Program.cs b/Aetheria.State.Smoke/Program.cs index ea8eaecf5..e1fa9c6c7 100644 --- a/Aetheria.State.Smoke/Program.cs +++ b/Aetheria.State.Smoke/Program.cs @@ -119,46 +119,6 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s drainStatus, runtimeSession: await node.GetRuntimeSessionAsync("smoke-runtime"))); - AetheriaRuntimeEveCommandLog.QueueCommand( - statePath, - new EveSurfaceCommandRequest( - AetheriaProviderAdvertisementProjector.ProviderId, - AetheriaCatalogSurfaceProjector.SurfaceId, - "aetheria.catalog.refresh", - new Dictionary { ["source"] = "state-smoke" }, - DateTimeOffset.UtcNow, - "aetheria-state-smoke")); - AetheriaRuntimeEveCommandLog.QueueCommand( - statePath, - new EveSurfaceCommandRequest( - AetheriaProviderAdvertisementProjector.ProviderId, - AetheriaCatalogSurfaceProjector.SurfaceId, - "aetheria.catalog.unknown", - new Dictionary { ["source"] = "state-smoke" }, - DateTimeOffset.UtcNow, - "aetheria-state-smoke")); - var eveCommandReport = await AetheriaEveCommandBridge.ApplyPendingAsync(node); - var eveCommandStatus = new AetheriaEveCommandDrainStatus - { - RuntimeId = "smoke-runtime", - StatePath = statePath, - LastPollAtUtc = now, - LastAcceptedAtUtc = now, - PendingBeforeApply = 2, - CommandsAccepted = eveCommandReport.AcceptedPaths.Length, - CommandsRejected = eveCommandReport.RejectedCommands, - AppliedCatalogRefreshes = eveCommandReport.AppliedCatalogRefreshes, - AppliedOperationsRefreshes = eveCommandReport.AppliedOperationsRefreshes, - LastRejectedCommand = eveCommandReport.LastRejectedCommand, - LastRejectedReason = eveCommandReport.LastRejectedReason, - Status = eveCommandReport.RejectedCommands > 0 ? "rejected" : "ok" - }; - await node.PutEveCommandDrainStatusAsync(eveCommandStatus); - await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build( - drainStatus, - eveCommandStatus, - await node.GetRuntimeSessionAsync("smoke-runtime"))); - await node.PutLoadoutTemplateAsync(loadoutKey, new AetheriaLoadoutTemplate { Name = "Smoke Aether Runner", @@ -515,6 +475,69 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s ActionBarInputs = ["/1", "/leftButton"] } }); + await node.PutPlayerSettingsSurfaceAsync( + AetheriaPlayerSettingsSurfaceProjector.Build( + await node.GetPlayerSettingsAsync(), + now)); + + AetheriaRuntimeEveCommandLog.QueueCommand( + statePath, + new EveSurfaceCommandRequest( + AetheriaProviderAdvertisementProjector.ProviderId, + AetheriaCatalogSurfaceProjector.SurfaceId, + "aetheria.catalog.refresh", + new Dictionary { ["source"] = "state-smoke" }, + DateTimeOffset.UtcNow, + "aetheria-state-smoke")); + AetheriaRuntimeEveCommandLog.QueueCommand( + statePath, + new EveSurfaceCommandRequest( + AetheriaProviderAdvertisementProjector.ProviderId, + AetheriaPlayerSettingsSurfaceProjector.SurfaceId, + "aetheria.player_settings.gameplay.significant_digits.increment", + new Dictionary { ["source"] = "state-smoke" }, + DateTimeOffset.UtcNow, + "aetheria-state-smoke")); + AetheriaRuntimeEveCommandLog.QueueCommand( + statePath, + new EveSurfaceCommandRequest( + AetheriaProviderAdvertisementProjector.ProviderId, + AetheriaPlayerSettingsSurfaceProjector.SurfaceId, + "aetheria.player_settings.graphics.show_asteroids.toggle", + new Dictionary { ["source"] = "state-smoke" }, + DateTimeOffset.UtcNow, + "aetheria-state-smoke")); + AetheriaRuntimeEveCommandLog.QueueCommand( + statePath, + new EveSurfaceCommandRequest( + AetheriaProviderAdvertisementProjector.ProviderId, + AetheriaCatalogSurfaceProjector.SurfaceId, + "aetheria.catalog.unknown", + new Dictionary { ["source"] = "state-smoke" }, + DateTimeOffset.UtcNow, + "aetheria-state-smoke")); + var eveCommandReport = await AetheriaEveCommandBridge.ApplyPendingAsync(node); + var eveCommandStatus = new AetheriaEveCommandDrainStatus + { + RuntimeId = "smoke-runtime", + StatePath = statePath, + LastPollAtUtc = now, + LastAcceptedAtUtc = now, + PendingBeforeApply = 4, + CommandsAccepted = eveCommandReport.AcceptedPaths.Length, + CommandsRejected = eveCommandReport.RejectedCommands, + AppliedCatalogRefreshes = eveCommandReport.AppliedCatalogRefreshes, + AppliedOperationsRefreshes = eveCommandReport.AppliedOperationsRefreshes, + AppliedPlayerSettingsCommands = eveCommandReport.AppliedPlayerSettingsCommands, + LastRejectedCommand = eveCommandReport.LastRejectedCommand, + LastRejectedReason = eveCommandReport.LastRejectedReason, + Status = eveCommandReport.RejectedCommands > 0 ? "rejected" : "ok" + }; + await node.PutEveCommandDrainStatusAsync(eveCommandStatus); + await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build( + drainStatus, + eveCommandStatus, + await node.GetRuntimeSessionAsync("smoke-runtime"))); await node.FlushAsync(); } @@ -530,6 +553,7 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether var drainStatus = await reopened.GetRuntimeCommitDrainStatusAsync(); var eveCommandStatus = await reopened.GetEveCommandDrainStatusAsync(); var operationsSurface = await reopened.GetOperationsSurfaceAsync(); + var playerSettingsSurface = await reopened.GetPlayerSettingsSurfaceAsync(); var advertisement = await reopened.GetProviderAdvertisementAsync(); var runtimeSession = await reopened.GetRuntimeSessionAsync("smoke-runtime"); var playerSettings = await reopened.GetPlayerSettingsAsync(); @@ -571,9 +595,10 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether if (drainStatus?.RuntimeId != "smoke-runtime" || drainStatus.CommandsApplied != 1 || - eveCommandStatus?.CommandsAccepted != 1 || + eveCommandStatus?.CommandsAccepted != 3 || eveCommandStatus.CommandsRejected != 1 || eveCommandStatus.AppliedCatalogRefreshes != 1 || + eveCommandStatus.AppliedPlayerSettingsCommands != 2 || !eveCommandStatus.LastRejectedReason.Contains("not advertised", StringComparison.Ordinal) || operationsSurface?.Surface.Id != AetheriaOperationsSurfaceProjector.SurfaceId || !operationsSurface.Surface.Root.Children.Any(child => child.Id == "aetheria.operations.eveCommandDrain") || @@ -583,10 +608,12 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether } if (advertisement?.ProviderId != AetheriaProviderAdvertisementProjector.ProviderId || - advertisement.Surfaces.Length < 2 || + advertisement.Surfaces.Length < 3 || !advertisement.Surfaces.Any(surface => surface.SurfaceId == AetheriaCatalogSurfaceProjector.SurfaceId) || !advertisement.Surfaces.Any(surface => surface.SurfaceId == AetheriaOperationsSurfaceProjector.SurfaceId) || + !advertisement.Surfaces.Any(surface => surface.SurfaceId == AetheriaPlayerSettingsSurfaceProjector.SurfaceId) || !advertisement.Schemas.Contains("aetheria.runtime_session.v1") || + !advertisement.Commands.Any(command => command.Command == "aetheria.player_settings.graphics.show_asteroids.toggle") || !advertisement.Schemas.Contains(AetheriaEveCommandBridge.CommandSchema)) { throw new InvalidOperationException("Aetheria Eve provider advertisement did not survive flush/reopen."); @@ -603,14 +630,22 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether playerSettings.PlayerName != "Smoke Pilot" || !playerSettings.TutorialPassed || playerSettings.StoryFileHashes.Length != 1 || - playerSettings.Gameplay.SignificantDigits != 4 || + playerSettings.Gameplay.SignificantDigits != 5 || playerSettings.Graphics.NebulaQuality != "High" || + playerSettings.Graphics.ShowAsteroidsInMinimap || playerSettings.Input.BindingOverrides.Length != 1 || playerSettings.Input.ActionBarInputs.Length != 2) { throw new InvalidOperationException("Player settings did not survive flush/reopen."); } + if (playerSettingsSurface?.Surface.Id != AetheriaPlayerSettingsSurfaceProjector.SurfaceId || + !playerSettingsSurface.Surface.Root.Children.Any(child => child.Id == "aetheria.playerSettings.gameplay") || + !playerSettingsSurface.Surface.Root.Children.Any(child => child.Id == "aetheria.playerSettings.graphics")) + { + throw new InvalidOperationException("Player settings Eve surface did not survive flush/reopen."); + } + if (loadout?.RootEntity.Hull.ItemKey != AetheriaCatalogKeys.ItemDefinitionFromLegacyId(itemLegacyId).ToString() || loadout.RootEntity.Equipment.Length != 1 || loadout.RootEntity.Equipment[0].Item.Enabled || diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 286fce20a..60f7a9009 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -29,6 +29,7 @@ RequireEveRuntimeBootstrap(root); RequireNoRendererLocalConsole(root); RequireNoRendererLocalDebugPanels(root); RequireMainMenuSettingsCommit(root); +RequirePlayerSettingsEveSurface(root); RequireMainMenuContinueRunState(root); RequirePropertiesPanelReadOnlyInspector(root); RequireRuntimeSimulationTuningCommits(root); @@ -1840,6 +1841,72 @@ static void RequireMainMenuSettingsCommit(string root) } } +static void RequirePlayerSettingsEveSurface(string root) +{ + var projectorPath = Path.Combine(root, "Aetheria.State", "AetheriaPlayerSettingsSurfaceProjector.cs"); + var bridgePath = Path.Combine(root, "Aetheria.State", "AetheriaEveCommandBridge.cs"); + var providerPath = Path.Combine(root, "Aetheria.State", "AetheriaProviderAdvertisementProjector.cs"); + var serverPath = Path.Combine(root, "Economy.Server", "Program.cs"); + + if (!File.Exists(projectorPath)) + { + throw new InvalidOperationException("Player settings Eve surface projector is missing."); + } + + var projector = File.ReadAllText(projectorPath); + var requiredProjectorSymbols = new[] + { + "SurfaceId = \"aetheria.player_settings\"", + "aetheria.player_settings.gameplay.temperature_unit.cycle", + "aetheria.player_settings.gameplay.significant_digits.increment", + "aetheria.player_settings.graphics.nebula_quality.cycle", + "aetheria.player_settings.graphics.show_asteroids.toggle" + }; + + var missingProjectorSymbols = requiredProjectorSymbols + .Where(symbol => !projector.Contains(symbol, StringComparison.Ordinal)) + .ToArray(); + + if (missingProjectorSymbols.Length > 0) + { + throw new InvalidOperationException( + "Player settings Eve surface projector is missing required typed controls: " + + string.Join(", ", missingProjectorSymbols)); + } + + var bridge = File.Exists(bridgePath) + ? File.ReadAllText(bridgePath) + : throw new InvalidOperationException("Player settings Eve command bridge is missing."); + var provider = File.Exists(providerPath) + ? File.ReadAllText(providerPath) + : throw new InvalidOperationException("Player settings provider advertisement projector is missing."); + var server = File.Exists(serverPath) + ? File.ReadAllText(serverPath) + : throw new InvalidOperationException("Economy.Server program is missing."); + + if (!bridge.Contains("AppliedPlayerSettingsCommands", StringComparison.Ordinal) || + !bridge.Contains("ApplyPlayerSettingsCommandAsync", StringComparison.Ordinal) || + !bridge.Contains("PutPlayerSettingsSurfaceAsync", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Eve command bridge no longer owns the typed player-settings surface mutation path."); + } + + if (!provider.Contains("AetheriaPlayerSettingsSurfaceProjector.SurfaceId", StringComparison.Ordinal) || + !provider.Contains("aetheria.player_settings.refresh", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Provider advertisement no longer publishes the player-settings Eve surface and commands."); + } + + if (!server.Contains("PublishPlayerSettingsSurfaceAsync", StringComparison.Ordinal) || + !server.Contains("PutPlayerSettingsSurfaceAsync", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Economy.Server no longer republishes the provider-owned player-settings Eve surface."); + } +} + static void RequireMainMenuContinueRunState(string root) { var mainMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "MainMenu.cs"); diff --git a/Aetheria.State/AetheriaEveCommandBridge.cs b/Aetheria.State/AetheriaEveCommandBridge.cs index cc199a2f0..8c36b3c84 100644 --- a/Aetheria.State/AetheriaEveCommandBridge.cs +++ b/Aetheria.State/AetheriaEveCommandBridge.cs @@ -7,6 +7,7 @@ public sealed class AetheriaEveCommandApplyReport { public int AppliedCatalogRefreshes { get; set; } public int AppliedOperationsRefreshes { get; set; } + public int AppliedPlayerSettingsCommands { get; set; } public int RejectedCommands { get; set; } public string[] AcceptedPaths { get; set; } = []; public string[] RejectedPaths { get; set; } = []; @@ -66,6 +67,15 @@ public static class AetheriaEveCommandBridge AetheriaOperationsSurfaceProjector.Build(commitStatus, eveStatus)).ConfigureAwait(false); report.AppliedOperationsRefreshes++; break; + case "aetheria.player_settings.refresh": + case "aetheria.player_settings.gameplay.temperature_unit.cycle": + case "aetheria.player_settings.gameplay.significant_digits.decrement": + case "aetheria.player_settings.gameplay.significant_digits.increment": + case "aetheria.player_settings.graphics.nebula_quality.cycle": + case "aetheria.player_settings.graphics.show_asteroids.toggle": + await ApplyPlayerSettingsCommandAsync(node, command).ConfigureAwait(false); + report.AppliedPlayerSettingsCommands++; + break; } accepted.Add(path); @@ -101,7 +111,85 @@ public static class AetheriaEveCommandBridge return (string.Equals(surfaceId, AetheriaCatalogSurfaceProjector.SurfaceId, StringComparison.Ordinal) && string.Equals(command, "aetheria.catalog.refresh", StringComparison.Ordinal)) || (string.Equals(surfaceId, AetheriaOperationsSurfaceProjector.SurfaceId, StringComparison.Ordinal) && - string.Equals(command, "aetheria.operations.refresh", StringComparison.Ordinal)); + string.Equals(command, "aetheria.operations.refresh", StringComparison.Ordinal)) || + (string.Equals(surfaceId, AetheriaPlayerSettingsSurfaceProjector.SurfaceId, StringComparison.Ordinal) && + KnownPlayerSettingsCommand(command)); + } + + private static bool KnownPlayerSettingsCommand(string command) + { + return string.Equals(command, "aetheria.player_settings.refresh", StringComparison.Ordinal) || + string.Equals(command, "aetheria.player_settings.gameplay.temperature_unit.cycle", StringComparison.Ordinal) || + string.Equals(command, "aetheria.player_settings.gameplay.significant_digits.decrement", StringComparison.Ordinal) || + string.Equals(command, "aetheria.player_settings.gameplay.significant_digits.increment", StringComparison.Ordinal) || + string.Equals(command, "aetheria.player_settings.graphics.nebula_quality.cycle", StringComparison.Ordinal) || + string.Equals(command, "aetheria.player_settings.graphics.show_asteroids.toggle", StringComparison.Ordinal); + } + + private static async Task ApplyPlayerSettingsCommandAsync( + AetheriaStateNode node, + AetheriaRuntimeEveCommandDocument command) + { + var settings = await node.GetPlayerSettingsAsync().ConfigureAwait(false) ?? new AetheriaPlayerSettings(); + settings.Gameplay ??= new AetheriaPlayerGameplaySettings(); + settings.Graphics ??= new AetheriaPlayerGraphicsSettings(); + var persistSettings = false; + + switch (command.Command) + { + case "aetheria.player_settings.gameplay.temperature_unit.cycle": + settings.Gameplay.TemperatureUnit = Cycle( + settings.Gameplay.TemperatureUnit, + "Kelvin", + "Celsius", + "Fahrenheit"); + persistSettings = true; + break; + case "aetheria.player_settings.gameplay.significant_digits.decrement": + settings.Gameplay.SignificantDigits = Math.Max(0, settings.Gameplay.SignificantDigits - 1); + persistSettings = true; + break; + case "aetheria.player_settings.gameplay.significant_digits.increment": + if (settings.Gameplay.SignificantDigits < int.MaxValue) + settings.Gameplay.SignificantDigits++; + persistSettings = true; + break; + case "aetheria.player_settings.graphics.nebula_quality.cycle": + settings.Graphics.NebulaQuality = Cycle( + settings.Graphics.NebulaQuality, + "Low", + "Normal", + "High", + "Ultra"); + persistSettings = true; + break; + case "aetheria.player_settings.graphics.show_asteroids.toggle": + settings.Graphics.ShowAsteroidsInMinimap = !settings.Graphics.ShowAsteroidsInMinimap; + persistSettings = true; + break; + } + + if (persistSettings) + { + settings.LastUpdatedAtUtc = command.IssuedAtUtc; + await node.PutPlayerSettingsAsync(settings).ConfigureAwait(false); + } + + await node.PutPlayerSettingsSurfaceAsync( + AetheriaPlayerSettingsSurfaceProjector.Build(settings, command.IssuedAtUtc)) + .ConfigureAwait(false); + } + + private static string Cycle(string current, params string[] values) + { + if (values.Length == 0) + return current; + + var index = Array.FindIndex(values, value => string.Equals(value, current, StringComparison.Ordinal)); + if (index < 0) + return values[0]; + + return values[(index + 1) % values.Length]; } private static void RecordRejection( diff --git a/Aetheria.State/AetheriaOperationsSurfaceProjector.cs b/Aetheria.State/AetheriaOperationsSurfaceProjector.cs index ed08ae252..e683c1de6 100644 --- a/Aetheria.State/AetheriaOperationsSurfaceProjector.cs +++ b/Aetheria.State/AetheriaOperationsSurfaceProjector.cs @@ -58,6 +58,7 @@ public static class AetheriaOperationsSurfaceProjector Metric("eveCommandDrain.rejected", "Commands Rejected", (eveCommandStatus?.CommandsRejected ?? 0).ToString()), 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.failures", "Consecutive Failures", (eveCommandStatus?.ConsecutiveFailures ?? 0).ToString()), Row( "eveCommandDrain.last", diff --git a/Aetheria.State/AetheriaPlayerSettingsSurfaceProjector.cs b/Aetheria.State/AetheriaPlayerSettingsSurfaceProjector.cs new file mode 100644 index 000000000..ba8da6947 --- /dev/null +++ b/Aetheria.State/AetheriaPlayerSettingsSurfaceProjector.cs @@ -0,0 +1,177 @@ +using System.Linq; +using Aetheria.State.Documents; + +namespace Aetheria.State; + +public static class AetheriaPlayerSettingsSurfaceProjector +{ + public const string SurfaceKey = "eve:surface:aetheria.player_settings"; + public const string SurfaceId = "aetheria.player_settings"; + + public static EveSurfaceState Build( + AetheriaPlayerSettings? settings, + string updatedAtUtc, + long version = 1) + { + settings ??= new AetheriaPlayerSettings(); + var gameplay = settings.Gameplay ?? new AetheriaPlayerGameplaySettings(); + var graphics = settings.Graphics ?? new AetheriaPlayerGraphicsSettings(); + var publishedAtUtc = !string.IsNullOrWhiteSpace(settings.LastUpdatedAtUtc) + ? settings.LastUpdatedAtUtc + : updatedAtUtc; + + return new EveSurfaceState + { + ProviderId = "aetheria", + ProviderKind = "game.runtime", + Title = "Aetheria Player Settings", + Version = version, + UpdatedAtUtc = publishedAtUtc, + Surface = new EveSurface + { + Id = SurfaceId, + Root = Node( + "aetheria.playerSettings.root", + "surface", + [], + Node( + "aetheria.playerSettings.summary", + "card", + [("title", "Player Settings")], + Row( + "playerSettings.summary.values", + ("playerName", settings.PlayerName), + ("tutorialPassed", settings.TutorialPassed ? "Yes" : "No"), + ("activeRun", settings.ActiveRunKey)), + Text( + "playerSettings.summary.note", + "Input remapping still lives on the runtime-owned screen until Eve grows typed rebinding controls.")), + Node( + "aetheria.playerSettings.gameplay", + "card", + [("title", "Gameplay")], + Metric("playerSettings.gameplay.temperatureUnit", "Temperature Unit", gameplay.TemperatureUnit), + ButtonRow( + "playerSettings.gameplay.temperatureUnit.buttons", + Button( + "playerSettings.gameplay.temperatureUnit.cycle", + "Cycle Temperature Unit", + "aetheria.player_settings.gameplay.temperature_unit.cycle")), + Metric( + "playerSettings.gameplay.significantDigits", + "Significant Digits", + gameplay.SignificantDigits.ToString()), + ButtonRow( + "playerSettings.gameplay.significantDigits.buttons", + Button( + "playerSettings.gameplay.significantDigits.decrement", + "Digits -", + "aetheria.player_settings.gameplay.significant_digits.decrement"), + Button( + "playerSettings.gameplay.significantDigits.increment", + "Digits +", + "aetheria.player_settings.gameplay.significant_digits.increment"))), + Node( + "aetheria.playerSettings.graphics", + "card", + [("title", "Graphics")], + Metric("playerSettings.graphics.nebulaQuality", "Nebula Quality", graphics.NebulaQuality), + ButtonRow( + "playerSettings.graphics.nebulaQuality.buttons", + Button( + "playerSettings.graphics.nebulaQuality.cycle", + "Cycle Nebula Quality", + "aetheria.player_settings.graphics.nebula_quality.cycle")), + Metric( + "playerSettings.graphics.showAsteroids", + "Show Asteroids In Minimap", + graphics.ShowAsteroidsInMinimap ? "Enabled" : "Disabled"), + ButtonRow( + "playerSettings.graphics.showAsteroids.buttons", + Button( + "playerSettings.graphics.showAsteroids.toggle", + graphics.ShowAsteroidsInMinimap ? "Disable Minimap Asteroids" : "Enable Minimap Asteroids", + "aetheria.player_settings.graphics.show_asteroids.toggle")))) + }, + Commands = + [ + new EveCommandTemplate + { + Command = "aetheria.player_settings.refresh", + Label = "Refresh", + Transport = "cultmesh" + }, + new EveCommandTemplate + { + Command = "aetheria.player_settings.gameplay.temperature_unit.cycle", + Label = "Cycle Temperature Unit", + Transport = "cultmesh" + }, + new EveCommandTemplate + { + Command = "aetheria.player_settings.gameplay.significant_digits.decrement", + Label = "Digits -", + Transport = "cultmesh" + }, + new EveCommandTemplate + { + Command = "aetheria.player_settings.gameplay.significant_digits.increment", + Label = "Digits +", + Transport = "cultmesh" + }, + new EveCommandTemplate + { + Command = "aetheria.player_settings.graphics.nebula_quality.cycle", + Label = "Cycle Nebula Quality", + Transport = "cultmesh" + }, + new EveCommandTemplate + { + Command = "aetheria.player_settings.graphics.show_asteroids.toggle", + Label = "Toggle Minimap Asteroids", + Transport = "cultmesh" + } + ] + }; + } + + private static EveSurfaceComponent Metric(string id, string label, string value) + { + return Node(id, "metric", [("label", label), ("value", value)]); + } + + private static EveSurfaceComponent Text(string id, string value) + { + return Node(id, "text", [("value", value)]); + } + + private static EveSurfaceComponent Button(string id, string label, string command) + { + return Node(id, "control.button", [("label", label), ("command", command)]); + } + + private static EveSurfaceComponent ButtonRow(string id, params EveSurfaceComponent[] children) + { + return Node(id, "row", [], children); + } + + private static EveSurfaceComponent Row(string id, params (string Key, string Value)[] props) + { + return Node(id, "row", props); + } + + private static EveSurfaceComponent Node( + string id, + string kind, + (string Key, string Value)[] props, + params EveSurfaceComponent[] children) + { + return new EveSurfaceComponent + { + Id = id, + Kind = kind, + Props = props.ToDictionary(prop => prop.Key, prop => prop.Value), + Children = children + }; + } +} diff --git a/Aetheria.State/AetheriaProviderAdvertisementProjector.cs b/Aetheria.State/AetheriaProviderAdvertisementProjector.cs index 38f43df9c..c148f6e68 100644 --- a/Aetheria.State/AetheriaProviderAdvertisementProjector.cs +++ b/Aetheria.State/AetheriaProviderAdvertisementProjector.cs @@ -64,6 +64,11 @@ public static class AetheriaProviderAdvertisementProjector { SurfaceId = AetheriaOperationsSurfaceProjector.SurfaceId, Key = AetheriaOperationsSurfaceProjector.SurfaceKey + }, + new EveProviderSurfaceRef + { + SurfaceId = AetheriaPlayerSettingsSurfaceProjector.SurfaceId, + Key = AetheriaPlayerSettingsSurfaceProjector.SurfaceKey } ], Commands = @@ -77,6 +82,36 @@ public static class AetheriaProviderAdvertisementProjector { Command = "aetheria.operations.refresh", Summary = "Refresh operations projection" + }, + new EveProviderCommandRef + { + Command = "aetheria.player_settings.refresh", + Summary = "Refresh player settings projection" + }, + new EveProviderCommandRef + { + Command = "aetheria.player_settings.gameplay.temperature_unit.cycle", + Summary = "Cycle the typed player temperature unit" + }, + new EveProviderCommandRef + { + Command = "aetheria.player_settings.gameplay.significant_digits.decrement", + Summary = "Decrease typed player significant digits" + }, + new EveProviderCommandRef + { + Command = "aetheria.player_settings.gameplay.significant_digits.increment", + Summary = "Increase typed player significant digits" + }, + new EveProviderCommandRef + { + Command = "aetheria.player_settings.graphics.nebula_quality.cycle", + Summary = "Cycle typed player nebula quality" + }, + new EveProviderCommandRef + { + Command = "aetheria.player_settings.graphics.show_asteroids.toggle", + Summary = "Toggle typed player minimap asteroid visibility" } ] }; diff --git a/Aetheria.State/AetheriaRuntimeCommitLogApplier.cs b/Aetheria.State/AetheriaRuntimeCommitLogApplier.cs index 5d4fab371..ddd9fd598 100644 --- a/Aetheria.State/AetheriaRuntimeCommitLogApplier.cs +++ b/Aetheria.State/AetheriaRuntimeCommitLogApplier.cs @@ -38,8 +38,12 @@ public static class AetheriaRuntimeCommitLogApplier switch (command.Kind) { case "player_settings": - await node.PutPlayerSettingsAsync( - ToPlayerSettings(command.PlayerSettings ?? throw MissingPayload(command), command.CreatedAtUtc)) + var settings = ToPlayerSettings( + command.PlayerSettings ?? throw MissingPayload(command), + command.CreatedAtUtc); + await node.PutPlayerSettingsAsync(settings).ConfigureAwait(false); + await node.PutPlayerSettingsSurfaceAsync( + AetheriaPlayerSettingsSurfaceProjector.Build(settings, command.CreatedAtUtc)) .ConfigureAwait(false); report.AppliedPlayerSettings++; break; diff --git a/Aetheria.State/AetheriaStateNode.cs b/Aetheria.State/AetheriaStateNode.cs index b5d23e903..7f82964be 100644 --- a/Aetheria.State/AetheriaStateNode.cs +++ b/Aetheria.State/AetheriaStateNode.cs @@ -183,6 +183,16 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable return Database.GetAsync(new CultRecordKey(AetheriaOperationsSurfaceProjector.SurfaceKey)); } + public Task> PutPlayerSettingsSurfaceAsync(EveSurfaceState surface) + { + return Database.PutAsync(new CultRecordKey(AetheriaPlayerSettingsSurfaceProjector.SurfaceKey), surface); + } + + public Task GetPlayerSettingsSurfaceAsync() + { + return Database.GetAsync(new CultRecordKey(AetheriaPlayerSettingsSurfaceProjector.SurfaceKey)); + } + public Task> PutProviderAdvertisementAsync( EveProviderAdvertisementState advertisement) { diff --git a/Aetheria.State/Documents/AetheriaEveCommandDrainStatus.cs b/Aetheria.State/Documents/AetheriaEveCommandDrainStatus.cs index ab2d06399..4e1a2345e 100644 --- a/Aetheria.State/Documents/AetheriaEveCommandDrainStatus.cs +++ b/Aetheria.State/Documents/AetheriaEveCommandDrainStatus.cs @@ -54,4 +54,7 @@ public sealed class AetheriaEveCommandDrainStatus [Key(14)] public string Status { get; set; } = "ok"; + + [Key(15)] + public int AppliedPlayerSettingsCommands { get; set; } } diff --git a/Aetheria.State/README.md b/Aetheria.State/README.md index 6030d9fe9..65bf4eec2 100644 --- a/Aetheria.State/README.md +++ b/Aetheria.State/README.md @@ -53,8 +53,9 @@ Unity Eve surfaces also emit typed `gamecult.eve.command.v1` documents under `GameData/aetheria-world.cc.eve.pending`. `AetheriaEveCommandBridge` is the 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. Renderer callbacks do not accept commands -or mutate game state locally. +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. `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 @@ -319,9 +320,12 @@ allegiance edges so they cannot reclaim catalog lookup authority. `Aetheria.State.Smoke` writes and reopens full typed player settings plus a typed loadout template in addition to world, catalog, run state, zone state, and -an entity snapshot. It also writes and reopens `AetheriaRuntimeSession` and -checks that the Eve provider advertises the runtime-session schema. The run -smoke proves a typed run can reference a typed zone, the zone can preserve +an entity snapshot. It also writes and reopens `AetheriaRuntimeSession`, the +provider-owned `aetheria.player_settings` Eve surface, and player-settings Eve +commands that mutate typed significant-digit and minimap-asteroid settings +through the provider bridge. The smoke checks that the Eve provider advertises +the runtime-session schema and player-settings command surface. The run smoke +proves a typed run can reference a typed zone, the zone can preserve orbit/body rows and reference typed entity snapshots, and entity snapshots can preserve equipment slots, weapon groups, and simulation stat grids without reviving `PlayerSettings.msgpack`, `.loadout`, or `.zone` serialization. @@ -374,17 +378,19 @@ dotnet run --project .\Economy.Server\Economy.Server.csproj -- --apply-pending-o ``` The server publishes `AetheriaRuntimeCommitDrainStatus`, -`AetheriaEveCommandDrainStatus`, and the `aetheria.operations` Eve surface after -drain attempts, so pending depth, accepted/applied/rejected counts, failures, -and timestamps are typed state rather than console-only status. It also +`AetheriaEveCommandDrainStatus`, the `aetheria.operations` Eve surface, and the +typed `aetheria.player_settings` Eve surface after drain attempts, so pending +depth, accepted/applied/rejected counts, failures, timestamps, and player +settings controls are typed state rather than console-only status. It also publishes `gamecult.eve.provider_advertisement.v1` for the `aetheria` provider, -advertising the catalog and operations surfaces plus the typed schemas witnessed -by the local `.cc` state file. +advertising the catalog, operations, and player-settings surfaces plus the +typed schemas witnessed by the local `.cc` state file. `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, and -provider advertisement after applying pending files: +used. It also republishes the typed drain status, operations surface, +player-settings surface, and provider advertisement after applying pending +files: ```powershell dotnet run --project .\Aetheria.State.ApplyPending\Aetheria.State.ApplyPending.csproj -- . diff --git a/Economy.Server/Program.cs b/Economy.Server/Program.cs index 45e444dc0..0baf75f1d 100644 --- a/Economy.Server/Program.cs +++ b/Economy.Server/Program.cs @@ -156,6 +156,7 @@ internal static class Program CommandsRejected = report.RejectedCommands, AppliedCatalogRefreshes = report.AppliedCatalogRefreshes, AppliedOperationsRefreshes = report.AppliedOperationsRefreshes, + AppliedPlayerSettingsCommands = report.AppliedPlayerSettingsCommands, LastRejectedCommand = report.LastRejectedCommand, LastRejectedReason = report.LastRejectedReason, ConsecutiveFailures = 0, @@ -174,7 +175,8 @@ internal static class Program $"accepted={report.AcceptedPaths.Length}, " + $"rejected={report.RejectedCommands}, " + $"catalogRefreshes={report.AppliedCatalogRefreshes}, " + - $"operationsRefreshes={report.AppliedOperationsRefreshes}"); + $"operationsRefreshes={report.AppliedOperationsRefreshes}, " + + $"playerSettings={report.AppliedPlayerSettingsCommands}"); } catch (Exception ex) { @@ -219,11 +221,24 @@ internal static class Program var runtimeSession = await node.GetRuntimeSessionAsync(RuntimeId).ConfigureAwait(false); await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(commitStatus, eveStatus, runtimeSession)) .ConfigureAwait(false); + await PublishPlayerSettingsSurfaceAsync(node, updatedAtUtc).ConfigureAwait(false); await node.PutProviderAdvertisementAsync( AetheriaProviderAdvertisementProjector.Build(node.StatePath, updatedAtUtc)).ConfigureAwait(false); await node.FlushAsync().ConfigureAwait(false); } + private static async Task PublishPlayerSettingsSurfaceAsync( + AetheriaStateNode node, + string updatedAtUtc) + { + var settings = await node.GetPlayerSettingsAsync().ConfigureAwait(false) ?? new AetheriaPlayerSettings(); + var publishedAtUtc = string.IsNullOrWhiteSpace(settings.LastUpdatedAtUtc) + ? updatedAtUtc + : settings.LastUpdatedAtUtc; + await node.PutPlayerSettingsSurfaceAsync( + AetheriaPlayerSettingsSurfaceProjector.Build(settings, publishedAtUtc)).ConfigureAwait(false); + } + private static async Task PublishRuntimeSessionAsync( AetheriaStateNode node, string startedAtUtc, diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 12c7f0139..1e1a30ced 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -483,11 +483,15 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. - `AetheriaRuntimeCommitDrainStatus` and the `aetheria.operations` Eve surface publish pending-drain health, pending depth, applied counts, failures, and timestamps as typed state. Console logs are notification-only. +- `AetheriaPlayerSettingsSurfaceProjector` now emits a provider-owned + `aetheria.player_settings` Eve surface from canonical `AetheriaPlayerSettings` + state. Gameplay and graphics settings are exposed as typed read models plus + narrow typed command buttons, not renderer-local fields. - `AetheriaProviderAdvertisementProjector` publishes `gamecult.eve.provider_advertisement.v1` for the `aetheria` provider, - advertising the catalog and operations surfaces, command boundaries, schemas, - and `.cc` witness path. This is the discovery map for Odin/Eve, not a health - page. + advertising the catalog, operations, and player-settings surfaces, command + boundaries, schemas, and `.cc` witness path. This is the discovery map for + Odin/Eve, not a health page. - `AetheriaCatalogSurfaceProjector` now emits the first provider-owned Eve surface from typed catalog state. The importer materializes a `gamecult.eve.surface.v1` catalog operator document at @@ -636,9 +640,10 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. path queues `gamecult.eve.command.v1` envelopes under `aetheria-world.cc.eve.pending` for the provider-owned command bridge instead of accepting renderer-local command effects. `AetheriaEveCommandBridge` - currently accepts the advertised catalog/operations refresh commands, - republishes provider-owned surfaces, rejects unknown/unadvertised commands, - and records `AetheriaEveCommandDrainStatus` as typed state. + currently accepts the advertised catalog/operations refresh commands plus the + first provider-owned player-settings mutation commands, republishes + provider-owned surfaces, rejects unknown/unadvertised commands, and records + `AetheriaEveCommandDrainStatus` as typed state. ## Invariants @@ -1443,8 +1448,10 @@ First Aetheria surfaces to publish: commands. `AetheriaRuntimePendingCultCacheStore` owns the temporary envelope writer/reader until the Unity package can use generated CultCache serializers directly. - - Extend the command bridge beyond refresh commands as gameplay/editor Eve - surfaces acquire provider-owned handlers. + - Done: extend the command bridge beyond refresh commands for the first + mutating settings surface; player-settings Eve commands now mutate + canonical `AetheriaPlayerSettings` and republish the provider-owned + `aetheria.player_settings` surface. - Done: wire the presenter into runtime through `AetheriaEveRuntimeBootstrap` so the operations surface mounts as a UI Toolkit surface after scene load. - Done: delete the concrete uGUI debug console path: `ConsoleView`, @@ -1546,6 +1553,8 @@ First Aetheria surfaces to publish: operations surface. - Done: publish Eve command-drain health as typed CultCache state and include it in the operations surface. + - Done: publish provider-owned player-settings controls as a typed Eve + surface and advertise their command boundary through the provider ad. - Done: publish an Eve provider advertisement so Odin/Eve can discover Aetheria surfaces and command boundaries through typed state. - Done: stop legacy catalog pull/read paths from writing entries back to @@ -1840,9 +1849,10 @@ First Aetheria surfaces to publish: `InputLayout` rows/columns. The dead commented Ink `ToJson` write path and checked-in `ansi104.json` display file have been removed from live source. - `Aetheria.State.Smoke` proves the provider-owned Eve command bridge drains - `gamecult.eve.command.v1` envelopes, accepts advertised refresh commands, - rejects unknown commands, persists `AetheriaEveCommandDrainStatus`, and exposes - the Eve command drain through the operations surface. + `gamecult.eve.command.v1` envelopes, accepts advertised refresh commands plus + player-settings mutation commands, rejects unknown commands, persists + `AetheriaEveCommandDrainStatus`, and exposes the Eve command drain through the + operations surface while preserving the `aetheria.player_settings` surface. - Unity play smoke proves runtime UI reads from Eve surfaces and sends commands through the shared state service. - UI Toolkit lowering parity compares Aetheria surfaces against the Eve browser