Publish player settings as a provider-owned Eve surface

This commit is contained in:
Meta Vens 2026-06-16 18:09:15 +01:00
commit be6d7b3b25
14 changed files with 534 additions and 70 deletions

Binary file not shown.

View file

@ -51,6 +51,7 @@ var eveStatus = new AetheriaEveCommandDrainStatus
CommandsRejected = eveReport.RejectedCommands,
AppliedCatalogRefreshes = eveReport.AppliedCatalogRefreshes,
AppliedOperationsRefreshes = eveReport.AppliedOperationsRefreshes,
AppliedPlayerSettingsCommands = eveReport.AppliedPlayerSettingsCommands,
LastRejectedCommand = eveReport.LastRejectedCommand,
LastRejectedReason = eveReport.LastRejectedReason,
Status = eveReport.RejectedCommands > 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);
}

View file

@ -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<string, string> { ["source"] = "state-smoke" },
DateTimeOffset.UtcNow,
"aetheria-state-smoke"));
AetheriaRuntimeEveCommandLog.QueueCommand(
statePath,
new EveSurfaceCommandRequest(
AetheriaProviderAdvertisementProjector.ProviderId,
AetheriaCatalogSurfaceProjector.SurfaceId,
"aetheria.catalog.unknown",
new Dictionary<string, string> { ["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 = ["<Keyboard>/1", "<Mouse>/leftButton"]
}
});
await node.PutPlayerSettingsSurfaceAsync(
AetheriaPlayerSettingsSurfaceProjector.Build(
await node.GetPlayerSettingsAsync(),
now));
AetheriaRuntimeEveCommandLog.QueueCommand(
statePath,
new EveSurfaceCommandRequest(
AetheriaProviderAdvertisementProjector.ProviderId,
AetheriaCatalogSurfaceProjector.SurfaceId,
"aetheria.catalog.refresh",
new Dictionary<string, string> { ["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<string, string> { ["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<string, string> { ["source"] = "state-smoke" },
DateTimeOffset.UtcNow,
"aetheria-state-smoke"));
AetheriaRuntimeEveCommandLog.QueueCommand(
statePath,
new EveSurfaceCommandRequest(
AetheriaProviderAdvertisementProjector.ProviderId,
AetheriaCatalogSurfaceProjector.SurfaceId,
"aetheria.catalog.unknown",
new Dictionary<string, string> { ["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 ||

View file

@ -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");

View file

@ -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(

View file

@ -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",

View file

@ -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
};
}
}

View file

@ -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"
}
]
};

View file

@ -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;

View file

@ -183,6 +183,16 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
return Database.GetAsync<EveSurfaceState>(new CultRecordKey(AetheriaOperationsSurfaceProjector.SurfaceKey));
}
public Task<CultRecordHandle<EveSurfaceState>> PutPlayerSettingsSurfaceAsync(EveSurfaceState surface)
{
return Database.PutAsync(new CultRecordKey(AetheriaPlayerSettingsSurfaceProjector.SurfaceKey), surface);
}
public Task<EveSurfaceState?> GetPlayerSettingsSurfaceAsync()
{
return Database.GetAsync<EveSurfaceState>(new CultRecordKey(AetheriaPlayerSettingsSurfaceProjector.SurfaceKey));
}
public Task<CultRecordHandle<EveProviderAdvertisementState>> PutProviderAdvertisementAsync(
EveProviderAdvertisementState advertisement)
{

View file

@ -54,4 +54,7 @@ public sealed class AetheriaEveCommandDrainStatus
[Key(14)]
public string Status { get; set; } = "ok";
[Key(15)]
public int AppliedPlayerSettingsCommands { get; set; }
}

View file

@ -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 -- .

View file

@ -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,

View file

@ -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