diff --git a/.voidbot/state/aetheria.cc b/.voidbot/state/aetheria.cc index be4a5113b..32a5e1888 100644 Binary files a/.voidbot/state/aetheria.cc and b/.voidbot/state/aetheria.cc differ diff --git a/Aetheria.State.Unity.Smoke/Program.cs b/Aetheria.State.Unity.Smoke/Program.cs index bdf102b75..f9b74740d 100644 --- a/Aetheria.State.Unity.Smoke/Program.cs +++ b/Aetheria.State.Unity.Smoke/Program.cs @@ -217,23 +217,34 @@ try throw new InvalidOperationException("Runtime state commit log did not read the queued player settings command."); } - await using var commitNode = await AetheriaStateNode.OpenAsync(commitSmokeStatePath, "aetheria-unity-runtime-commit-smoke"); - var report = await AetheriaRuntimeCommitLogApplier.ApplyPendingAsync(commitNode); - var settings = await commitNode.GetPlayerSettingsAsync(); - var run = await commitNode.GetRunStateAsync(new("global:aetheria.run_state.smoke-run.v1")); - var zone = await commitNode.GetZoneStateAsync(new("global:aetheria.run_state.smoke-run.zone.0.v1")); - var entity = await commitNode.GetEntitySnapshotAsync(new("global:aetheria.run_state.smoke-run.zone.0.entity.0.v1")); - if (report.AppliedPlayerSettings != 1 || - report.AppliedRunCheckpoints != 1 || - settings?.PlayerName != "Unity smoke" || - settings.Input.ActionBarInputs.Length != 1 || - run?.ZoneKeys.Length != 1 || - zone?.EntityKeys.Length != 1 || - entity?.Equipment.Length != 1 || - entity.WeaponGroups.Length != 1 || - AetheriaRuntimeStateCommitLog.ReadPending(commitSmokeStatePath).Count != 0) + var typedNodeApplied = false; + await using (var commitNode = await AetheriaStateNode.OpenAsync(commitSmokeStatePath, "aetheria-unity-runtime-commit-smoke")) { - throw new InvalidOperationException("Runtime state commit log did not apply queued settings/run snapshots through the typed state node."); + var report = await AetheriaRuntimeCommitLogApplier.ApplyPendingAsync(commitNode); + var settings = await commitNode.GetPlayerSettingsAsync(); + var run = await commitNode.GetRunStateAsync(new("global:aetheria.run_state.smoke-run.v1")); + var zone = await commitNode.GetZoneStateAsync(new("global:aetheria.run_state.smoke-run.zone.0.v1")); + var entity = await commitNode.GetEntitySnapshotAsync(new("global:aetheria.run_state.smoke-run.zone.0.entity.0.v1")); + typedNodeApplied = report.AppliedPlayerSettings == 1 && + report.AppliedRunCheckpoints == 1 && + settings?.PlayerName == "Unity smoke" && + settings.Input.ActionBarInputs.Length == 1 && + run?.ZoneKeys.Length == 1 && + zone?.EntityKeys.Length == 1 && + entity?.Equipment.Length == 1 && + entity.WeaponGroups.Length == 1 && + AetheriaRuntimeStateCommitLog.ReadPending(commitSmokeStatePath).Count == 0; + } + + var packageSettings = AetheriaRuntimeCatalogStore.ReadPlayerSettings(commitSmokeStatePath); + if (!typedNodeApplied || + packageSettings?.PlayerName != "Unity smoke" || + packageSettings.TutorialPassed != true || + packageSettings.ActionBarInputs.Count != 1) + { + throw new InvalidOperationException( + "Runtime state commit log did not apply queued settings/run snapshots through the typed state node. " + + $"typedNodeApplied={typedNodeApplied}, packageSettings={(packageSettings == null ? "null" : $"{packageSettings.PlayerName}/{packageSettings.TutorialPassed}/{packageSettings.ActionBarInputs.Count}")}"); } var eveCommand = AetheriaRuntimeEveCommandLog.QueueCommand( @@ -260,7 +271,10 @@ try } finally { - Directory.Delete(commitSmokeDirectory, true); + if (Environment.GetEnvironmentVariable("AETHERIA_KEEP_SMOKE_STATE") == "1") + Console.Error.WriteLine($"Kept smoke state: {commitSmokeDirectory}"); + else + Directory.Delete(commitSmokeDirectory, true); } Console.WriteLine($"Aetheria Unity runtime catalog smoke passed: {statePath}"); diff --git a/Aetheria.State/README.md b/Aetheria.State/README.md index 66e227e92..820b08359 100644 --- a/Aetheria.State/README.md +++ b/Aetheria.State/README.md @@ -126,10 +126,11 @@ It opens `aetheria-world.cc`, emits immutable catalog read models for trade, equipment, behavior, hardpoint, manufacturer, corporation, and name-file queries, exposes typed item shape masks for layout and fitting consumers, and exposes typed interior masks and hardpoints for equipment/cargo layout -consumers, exposes typed behavior payload read models, and can read the -published Eve catalog surface. It does not deserialize legacy runtime projection -objects and does not write state. Unity can use this as the first package -boundary once CultLib/Eve runtime packaging is available. +consumers, exposes typed behavior payload read models, 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 state. +Unity can use this as the first package boundary once CultLib/Eve runtime +packaging is available. `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/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 5a61f68ec..901460412 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -45,7 +45,7 @@ public class ActionGameManager : MonoBehaviour { get { - return _runtimePlayerSettings ??= CreateDefaultRuntimePlayerSettings(); + return _runtimePlayerSettings ??= LoadRuntimePlayerSettings(); } } @@ -76,6 +76,70 @@ public class ActionGameManager : MonoBehaviour return settings; } + private static RuntimePlayerSettings LoadRuntimePlayerSettings() + { + var settings = CreateDefaultRuntimePlayerSettings(); + try + { + var stored = AetheriaRuntimeCatalogStore.ReadPlayerSettings(RuntimeStateFilePath); + if (stored == null) + return settings; + + ApplyRuntimePlayerSettings(settings, stored); + Debug.Log("Loaded Aetheria Verse player settings from typed state."); + } + catch (FileNotFoundException) + { + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to load Aetheria Verse player settings from typed state; using defaults: {ex}"); + } + + return settings; + } + + private static void ApplyRuntimePlayerSettings( + RuntimePlayerSettings settings, + AetheriaRuntimePlayerSettingsSnapshot stored) + { + if (!string.IsNullOrWhiteSpace(stored.PlayerName)) + settings.Name = stored.PlayerName; + + settings.TutorialPassed = stored.TutorialPassed; + + settings.HashedStoryFiles.Clear(); + foreach (var storyFileHash in stored.StoryFileHashes) + { + if (!string.IsNullOrWhiteSpace(storyFileHash.StoryPath)) + settings.HashedStoryFiles[storyFileHash.StoryPath] = storyFileHash.Hash; + } + + if (Enum.TryParse(stored.TemperatureUnit, out TemperatureUnit temperatureUnit)) + settings.GameplaySettings.TemperatureUnit = temperatureUnit; + settings.GameplaySettings.SignificantDigits = Math.Max(0, stored.SignificantDigits); + + if (Enum.TryParse(stored.NebulaQuality, out Quality nebulaQuality)) + settings.GraphicsSettings.NebulaQuality = nebulaQuality; + settings.GraphicsSettings.ShowAsteroidsInMinimap = stored.ShowAsteroidsInMinimap; + + settings.InputSettings.InputActionMap.Clear(); + foreach (var binding in stored.BindingOverrides) + { + if (string.IsNullOrWhiteSpace(binding.ActionName) || string.IsNullOrWhiteSpace(binding.BindingPath) || binding.BindingIndex < 0) + continue; + + settings.InputSettings.InputActionMap[(binding.ActionName, binding.BindingIndex)] = binding.BindingPath; + } + + settings.InputSettings.ActionBarInputs.Clear(); + foreach (var input in stored.ActionBarInputs) + { + if (!string.IsNullOrWhiteSpace(input) && !settings.InputSettings.ActionBarInputs.Contains(input)) + settings.InputSettings.ActionBarInputs.Add(input); + } + } + public static Galaxy CurrentGalaxy; public static bool IsTutorial; public static AetheriaRuntimeCatalogSnapshot RuntimeCatalog { get; private set; } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs index a8ad05ff7..be6455028 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs @@ -394,4 +394,65 @@ namespace GameCult.Aetheria.State.Unity public IReadOnlyList SampleNames { get; } public IReadOnlyList Names { get; } } + + public sealed class AetheriaRuntimePlayerSettingsSnapshot + { + public AetheriaRuntimePlayerSettingsSnapshot( + string playerName, + bool tutorialPassed, + IReadOnlyList storyFileHashes, + string temperatureUnit, + int significantDigits, + string nebulaQuality, + bool showAsteroidsInMinimap, + IReadOnlyList bindingOverrides, + IReadOnlyList actionBarInputs) + { + PlayerName = playerName; + TutorialPassed = tutorialPassed; + StoryFileHashes = storyFileHashes; + TemperatureUnit = temperatureUnit; + SignificantDigits = significantDigits; + NebulaQuality = nebulaQuality; + ShowAsteroidsInMinimap = showAsteroidsInMinimap; + BindingOverrides = bindingOverrides; + ActionBarInputs = actionBarInputs; + } + + public string PlayerName { get; } + public bool TutorialPassed { get; } + public IReadOnlyList StoryFileHashes { get; } + public string TemperatureUnit { get; } + public int SignificantDigits { get; } + public string NebulaQuality { get; } + public bool ShowAsteroidsInMinimap { get; } + public IReadOnlyList BindingOverrides { get; } + public IReadOnlyList ActionBarInputs { get; } + } + + public sealed class AetheriaRuntimeStoryFileHash + { + public AetheriaRuntimeStoryFileHash(string storyPath, string hash) + { + StoryPath = storyPath; + Hash = hash; + } + + public string StoryPath { get; } + public string Hash { get; } + } + + public sealed class AetheriaRuntimeInputBindingOverride + { + public AetheriaRuntimeInputBindingOverride(string actionName, int bindingIndex, string bindingPath) + { + ActionName = actionName; + BindingIndex = bindingIndex; + BindingPath = bindingPath; + } + + public string ActionName { get; } + public int BindingIndex { get; } + public string BindingPath { get; } + } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogStore.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogStore.cs index dad1e6bea..eff3fe35f 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogStore.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogStore.cs @@ -16,6 +16,8 @@ namespace GameCult.Aetheria.State.Unity private const string CorporationSchema = "aetheria.corporation"; private const string NameFileSchema = "aetheria.name_file"; private const string EveSurfaceSchema = "gamecult.eve.surface"; + private const string PlayerSettingsSchema = "aetheria.player_settings"; + private const string PlayerSettingsKey = "global:aetheria.player_settings.v1"; public static AetheriaRuntimeCatalogSnapshot OpenReadOnly(string stateFilePath) { @@ -64,6 +66,26 @@ namespace GameCult.Aetheria.State.Unity return surfaces; } + public static AetheriaRuntimePlayerSettingsSnapshot? ReadPlayerSettings(string stateFilePath) + { + if (string.IsNullOrWhiteSpace(stateFilePath)) + throw new ArgumentException("State file path must be non-empty.", nameof(stateFilePath)); + + var catalog = ReadSchemaCatalog(stateFilePath); + AetheriaRuntimePlayerSettingsSnapshot? settings = null; + foreach (var record in ReadRecords(stateFilePath)) + { + if (record.Key != PlayerSettingsKey || + !catalog.TryGetValue(record.SchemaId, out var schemaName) || + schemaName != PlayerSettingsSchema) + continue; + + settings = ReadPlayerSettingsPayload(record.Payload); + } + + return settings; + } + private static Dictionary ReadSchemaCatalog(string stateFilePath) { if (!File.Exists(stateFilePath)) @@ -122,7 +144,7 @@ namespace GameCult.Aetheria.State.Unity private static PersistedRecord ReadPersistedRecord(ref MessagePackReader reader) { var fieldCount = reader.ReadArrayHeader(); - if (fieldCount > 0) reader.Skip(); + var key = fieldCount > 0 ? ReadString(ref reader) : ""; var schemaId = fieldCount > 1 ? ReadString(ref reader) : ""; if (fieldCount > 2) reader.Skip(); var payload = Array.Empty(); @@ -134,7 +156,7 @@ namespace GameCult.Aetheria.State.Unity } for (var field = 4; field < fieldCount; field++) reader.Skip(); - return new PersistedRecord(schemaId, payload); + return new PersistedRecord(key, schemaId, payload); } private static AetheriaRuntimeCatalogItem ReadItem(byte[] payload) @@ -263,6 +285,30 @@ namespace GameCult.Aetheria.State.Unity return new EveSurfaceDocument(type, schema, providerId, providerKind, title, version, updatedAtUtc, surface, commands); } + private static AetheriaRuntimePlayerSettingsSnapshot ReadPlayerSettingsPayload(byte[] payload) + { + var reader = new MessagePackReader(payload); + var fields = reader.ReadArrayHeader(); + SkipFields(ref reader, fields, 0, 3); + var playerName = ReadFieldString(ref reader, fields, 3); + var tutorialPassed = ReadFieldBool(ref reader, fields, 4); + var storyFileHashes = ReadFieldStoryFileHashes(ref reader, fields, 5); + var gameplay = ReadFieldPlayerGameplaySettings(ref reader, fields, 6); + var graphics = ReadFieldPlayerGraphicsSettings(ref reader, fields, 7); + var input = ReadFieldPlayerInputSettings(ref reader, fields, 8); + SkipRemaining(ref reader, fields, 9); + return new AetheriaRuntimePlayerSettingsSnapshot( + playerName, + tutorialPassed, + storyFileHashes, + gameplay.TemperatureUnit, + gameplay.SignificantDigits, + graphics.NebulaQuality, + graphics.ShowAsteroidsInMinimap, + input.BindingOverrides, + input.ActionBarInputs); + } + private static string ReadFieldString(ref MessagePackReader reader, int fields, int index) { return index >= fields ? "" : ReadString(ref reader); @@ -273,6 +319,11 @@ namespace GameCult.Aetheria.State.Unity return index >= fields ? 0 : reader.ReadInt32(); } + private static bool ReadFieldBool(ref MessagePackReader reader, int fields, int index) + { + return index < fields && reader.ReadBoolean(); + } + private static long ReadFieldInt64(ref MessagePackReader reader, int fields, int index) { return index >= fields ? 0 : reader.ReadInt64(); @@ -293,6 +344,71 @@ namespace GameCult.Aetheria.State.Unity return values; } + private static IReadOnlyList ReadFieldStoryFileHashes(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return Array.Empty(); + var count = reader.ReadArrayHeader(); + var hashes = new AetheriaRuntimeStoryFileHash[count]; + for (var hash = 0; hash < count; hash++) + { + var hashFields = reader.ReadArrayHeader(); + var storyPath = ReadFieldString(ref reader, hashFields, 0); + var value = ReadFieldString(ref reader, hashFields, 1); + SkipRemaining(ref reader, hashFields, 2); + hashes[hash] = new AetheriaRuntimeStoryFileHash(storyPath, value); + } + + return hashes; + } + + private static PlayerGameplaySettings ReadFieldPlayerGameplaySettings(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return new PlayerGameplaySettings("Celsius", 3); + var gameplayFields = reader.ReadArrayHeader(); + var temperatureUnit = ReadFieldString(ref reader, gameplayFields, 0); + var significantDigits = ReadFieldInt32(ref reader, gameplayFields, 1); + SkipRemaining(ref reader, gameplayFields, 2); + return new PlayerGameplaySettings(temperatureUnit, significantDigits); + } + + private static PlayerGraphicsSettings ReadFieldPlayerGraphicsSettings(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return new PlayerGraphicsSettings("Normal", false); + var graphicsFields = reader.ReadArrayHeader(); + var nebulaQuality = ReadFieldString(ref reader, graphicsFields, 0); + var showAsteroidsInMinimap = ReadFieldBool(ref reader, graphicsFields, 1); + SkipRemaining(ref reader, graphicsFields, 2); + return new PlayerGraphicsSettings(nebulaQuality, showAsteroidsInMinimap); + } + + private static PlayerInputSettings ReadFieldPlayerInputSettings(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return new PlayerInputSettings(Array.Empty(), Array.Empty()); + var inputFields = reader.ReadArrayHeader(); + var bindings = ReadFieldInputBindings(ref reader, inputFields, 0); + var actionBarInputs = ReadFieldStringArray(ref reader, inputFields, 1); + SkipRemaining(ref reader, inputFields, 2); + return new PlayerInputSettings(bindings, actionBarInputs); + } + + private static IReadOnlyList ReadFieldInputBindings(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return Array.Empty(); + var count = reader.ReadArrayHeader(); + var bindings = new AetheriaRuntimeInputBindingOverride[count]; + for (var binding = 0; binding < count; binding++) + { + var bindingFields = reader.ReadArrayHeader(); + var actionName = ReadFieldString(ref reader, bindingFields, 0); + var bindingIndex = ReadFieldInt32(ref reader, bindingFields, 1); + var bindingPath = ReadFieldString(ref reader, bindingFields, 2); + SkipRemaining(ref reader, bindingFields, 3); + bindings[binding] = new AetheriaRuntimeInputBindingOverride(actionName, bindingIndex, bindingPath); + } + + return bindings; + } + private static IReadOnlyList ReadFieldShapeCells(ref MessagePackReader reader, int fields, int index) { return index >= fields ? Array.Empty() : ReadShapeCells(ref reader); @@ -582,15 +698,59 @@ namespace GameCult.Aetheria.State.Unity private readonly struct PersistedRecord { - public PersistedRecord(string schemaId, byte[] payload) + public PersistedRecord(string key, string schemaId, byte[] payload) { + Key = key; SchemaId = schemaId; Payload = payload; } + public string Key { get; } + public string SchemaId { get; } public byte[] Payload { get; } } + + private readonly struct PlayerGameplaySettings + { + public PlayerGameplaySettings(string temperatureUnit, int significantDigits) + { + TemperatureUnit = temperatureUnit; + SignificantDigits = significantDigits; + } + + public string TemperatureUnit { get; } + + public int SignificantDigits { get; } + } + + private readonly struct PlayerGraphicsSettings + { + public PlayerGraphicsSettings(string nebulaQuality, bool showAsteroidsInMinimap) + { + NebulaQuality = nebulaQuality; + ShowAsteroidsInMinimap = showAsteroidsInMinimap; + } + + public string NebulaQuality { get; } + + public bool ShowAsteroidsInMinimap { get; } + } + + private readonly struct PlayerInputSettings + { + public PlayerInputSettings( + IReadOnlyList bindingOverrides, + IReadOnlyList actionBarInputs) + { + BindingOverrides = bindingOverrides; + ActionBarInputs = actionBarInputs; + } + + public IReadOnlyList BindingOverrides { get; } + + public IReadOnlyList ActionBarInputs { get; } + } } } diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 043006068..e66535951 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -78,6 +78,8 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. `AetheriaInput` class is the edge consumer of typed binding overrides, not the durable owner. Binding drag/drop and action-bar remapping both queue the same typed player-settings commit after mutating the runtime projection. + Unity boot reads typed player settings back through the package-owned + CultCache reader before falling back to defaults. - `Aetheria.State` now exposes typed node put/get ports for run state, zone state, and entity snapshots. The state smoke writes a run referencing a zone, a zone referencing an entity snapshot, and an entity snapshot carrying