diff --git a/.voidbot/state/aetheria.cc b/.voidbot/state/aetheria.cc index 57fc1b3c7..ac74ae404 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 8fe8dd497..b0d279608 100644 --- a/Aetheria.State.Unity.Smoke/Program.cs +++ b/Aetheria.State.Unity.Smoke/Program.cs @@ -11,6 +11,9 @@ await using var client = await AetheriaRuntimeCatalogClient.OpenAsync(statePath) var catalog = client.ReadCatalog(); var packageCatalog = AetheriaRuntimeCatalogStore.OpenReadOnly(statePath); var surface = await client.ReadCatalogSurfaceAsync(); +var commitSmokeDirectory = Path.Combine(Path.GetTempPath(), "aetheria-state-unity-smoke", Guid.NewGuid().ToString("N")); +var commitSmokeStatePath = Path.Combine(commitSmokeDirectory, "aetheria-world.cc"); +Directory.CreateDirectory(commitSmokeDirectory); if (catalog.Items.Count != 115) { @@ -131,6 +134,41 @@ if (surface?.Schema != "gamecult.eve.surface.v1" || throw new InvalidOperationException("Runtime catalog client did not read the typed Eve surface."); } +try +{ + var commit = AetheriaRuntimeStateCommitLog.QueuePlayerSettings( + commitSmokeStatePath, + new AetheriaRuntimePlayerSettingsCommit + { + PlayerName = "Unity smoke", + TutorialPassed = true, + ActionBarInputs = new[] { "/1" } + }); + var pending = AetheriaRuntimeStateCommitLog.ReadPending(commitSmokeStatePath); + if (pending.Count != 1 || + pending[0].Kind != AetheriaRuntimeCommitKind.PlayerSettings || + pending[0].Schema != AetheriaRuntimeStateCommitLog.CommitSchema || + pending[0].Path != commit.Path) + { + 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(); + if (report.AppliedPlayerSettings != 1 || + settings?.PlayerName != "Unity smoke" || + settings.Input.ActionBarInputs.Length != 1 || + AetheriaRuntimeStateCommitLog.ReadPending(commitSmokeStatePath).Count != 0) + { + throw new InvalidOperationException("Runtime state commit log did not apply queued settings through the typed state node."); + } +} +finally +{ + Directory.Delete(commitSmokeDirectory, true); +} + Console.WriteLine($"Aetheria Unity runtime catalog smoke passed: {statePath}"); Console.WriteLine($"Items/trade/equipment: {catalog.Items.Count}/{catalog.TradeItems.Count}/{catalog.EquipmentItems.Count}"); Console.WriteLine($"Shape mask sample: {shaped.Name} {shaped.ShapeWidth}x{shaped.ShapeHeight}/{shaped.ShapeCells.Count}"); @@ -138,3 +176,4 @@ Console.WriteLine($"Interior/hardpoint sample: {interior.Name} {interior.Interio Console.WriteLine($"Behavior payload sample: {behaviorHost.Name} {behaviorPayload.Kind}/{behaviorPayload.Fields.Count}"); Console.WriteLine($"Behavior sample: {behaviorKind}"); Console.WriteLine($"Eve surface: {surface.Surface.Id}"); +Console.WriteLine("Runtime state commit log smoke: player settings command queued, applied, and cleared"); diff --git a/Aetheria.State.Unity/Aetheria.State.Unity.csproj b/Aetheria.State.Unity/Aetheria.State.Unity.csproj index 20570ddc7..df7dd6848 100644 --- a/Aetheria.State.Unity/Aetheria.State.Unity.csproj +++ b/Aetheria.State.Unity/Aetheria.State.Unity.csproj @@ -10,6 +10,7 @@ + diff --git a/Aetheria.State/AetheriaRuntimeCommitLogApplier.cs b/Aetheria.State/AetheriaRuntimeCommitLogApplier.cs new file mode 100644 index 000000000..d70db4b43 --- /dev/null +++ b/Aetheria.State/AetheriaRuntimeCommitLogApplier.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Aetheria.State.Documents; +using GameCult.Caching; +using MessagePack; + +namespace Aetheria.State; + +public sealed class AetheriaRuntimeCommitApplyReport +{ + public int AppliedPlayerSettings { get; set; } + public int AppliedLoadoutTemplates { get; set; } + public int AppliedRunCheckpoints { get; set; } + public string[] AppliedPaths { get; set; } = []; +} + +public static class AetheriaRuntimeCommitLogApplier +{ + private const string CommitSchema = "gamecult.aetheria.runtime_commit.v1"; + + public static async Task ApplyPendingAsync( + AetheriaStateNode node, + bool deleteApplied = true) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + + var pendingDirectory = node.StatePath + ".pending"; + var report = new AetheriaRuntimeCommitApplyReport(); + var applied = new List(); + + if (!Directory.Exists(pendingDirectory)) + return report; + + foreach (var path in Directory.EnumerateFiles(pendingDirectory, "*.cc").OrderBy(path => path, StringComparer.Ordinal)) + { + var command = ReadCommand(path); + switch (command.Kind) + { + case "player_settings": + await node.PutPlayerSettingsAsync(command.PlayerSettings ?? throw MissingPayload(command)).ConfigureAwait(false); + report.AppliedPlayerSettings++; + break; + case "loadout_template": + var loadout = command.LoadoutTemplate ?? throw MissingPayload(command); + await node.PutLoadoutTemplateAsync(LoadoutKey(loadout.Name), loadout).ConfigureAwait(false); + report.AppliedLoadoutTemplates++; + break; + case "run_checkpoint": + var run = command.RunState ?? throw MissingPayload(command); + await node.PutRunStateAsync(RunKey(run.RunId), run).ConfigureAwait(false); + report.AppliedRunCheckpoints++; + break; + default: + throw new InvalidDataException($"Unknown Aetheria runtime commit kind '{command.Kind}' in {path}."); + } + + applied.Add(path); + if (deleteApplied) + File.Delete(path); + } + + await node.FlushAsync().ConfigureAwait(false); + report.AppliedPaths = applied.ToArray(); + return report; + } + + private static PendingCommand ReadCommand(string path) + { + var reader = new MessagePackReader(File.ReadAllBytes(path)); + var fields = reader.ReadArrayHeader(); + var schema = fields > 0 ? ReadString(ref reader) : ""; + if (!string.Equals(schema, CommitSchema, StringComparison.Ordinal)) + throw new InvalidDataException($"Unexpected Aetheria runtime commit schema '{schema}' in {path}."); + + var kind = fields > 1 ? ReadString(ref reader) : ""; + var commandId = fields > 2 ? ReadString(ref reader) : ""; + var createdAtUtc = fields > 3 ? ReadString(ref reader) : ""; + if (fields > 4) + { + var command = kind switch + { + "player_settings" => PendingCommand.ForPlayerSettings( + kind, + commandId, + createdAtUtc, + ReadPlayerSettings(ref reader, createdAtUtc), + path), + "loadout_template" => PendingCommand.ForLoadoutTemplate( + kind, + commandId, + createdAtUtc, + ReadLoadoutTemplate(ref reader, createdAtUtc), + path), + "run_checkpoint" => PendingCommand.ForRunState( + kind, + commandId, + createdAtUtc, + ReadRunState(ref reader, createdAtUtc), + path), + _ => throw new InvalidDataException($"Unknown Aetheria runtime commit kind '{kind}' in {path}.") + }; + + for (var field = 5; field < fields; field++) + reader.Skip(); + + return command; + } + + return PendingCommand.Empty(kind, commandId, createdAtUtc, path); + } + + private static AetheriaPlayerSettings ReadPlayerSettings(ref MessagePackReader reader, string updatedAtUtc) + { + var fields = reader.ReadArrayHeader(); + return new AetheriaPlayerSettings + { + PlayerName = ReadFieldString(ref reader, fields, 0), + TutorialPassed = ReadFieldBool(ref reader, fields, 1), + StoryFileHashes = ReadStoryFileHashes(ref reader, fields, 2), + Gameplay = new AetheriaPlayerGameplaySettings + { + TemperatureUnit = ReadFieldString(ref reader, fields, 3), + SignificantDigits = ReadFieldInt32(ref reader, fields, 4) + }, + Graphics = new AetheriaPlayerGraphicsSettings + { + NebulaQuality = ReadFieldString(ref reader, fields, 5), + ShowAsteroidsInMinimap = ReadFieldBool(ref reader, fields, 6) + }, + Input = new AetheriaPlayerInputSettings + { + BindingOverrides = ReadBindings(ref reader, fields, 7), + ActionBarInputs = ReadStringArray(ref reader, fields, 8) + }, + LastUpdatedAtUtc = updatedAtUtc + }; + } + + private static AetheriaLoadoutTemplate ReadLoadoutTemplate(ref MessagePackReader reader, string updatedAtUtc) + { + var fields = reader.ReadArrayHeader(); + return new AetheriaLoadoutTemplate + { + Name = ReadFieldString(ref reader, fields, 0), + OwnerPlayerKey = ReadFieldString(ref reader, fields, 1), + RootEntity = fields > 2 ? ReadEntityLoadout(ref reader) : new AetheriaEntityLoadout(), + CreatedAtUtc = updatedAtUtc, + UpdatedAtUtc = updatedAtUtc + }; + } + + private static AetheriaEntityLoadout ReadEntityLoadout(ref MessagePackReader reader) + { + var fields = reader.ReadArrayHeader(); + return new AetheriaEntityLoadout + { + Name = ReadFieldString(ref reader, fields, 0), + Kind = ReadFieldString(ref reader, fields, 1), + FactionKey = ReferenceKey("aetheria.corporation", ReadFieldString(ref reader, fields, 2)), + Hull = fields > 3 ? ReadLoadoutItem(ref reader) : new AetheriaLoadoutItem(), + Equipment = ReadItemSlots(ref reader, fields, 4), + CargoBays = ReadItemSlots(ref reader, fields, 5), + DockingBays = ReadItemSlots(ref reader, fields, 6), + CargoContents = ReadCargoBays(ref reader, fields, 7), + DockingBayContents = ReadCargoBays(ref reader, fields, 8), + DockingBayAssignments = ReadIntArray(ref reader, fields, 9), + WeaponGroups = ReadIntArrayArray(ref reader, fields, 10), + Children = ReadChildren(ref reader, fields, 11) + }; + } + + private static AetheriaLoadoutItem ReadLoadoutItem(ref MessagePackReader reader) + { + var fields = reader.ReadArrayHeader(); + return new AetheriaLoadoutItem + { + ItemKey = ReferenceKey("aetheria.item_definition", ReadFieldString(ref reader, fields, 0)), + Quality = ReadFieldDouble(ref reader, fields, 1), + Durability = ReadFieldDouble(ref reader, fields, 2), + Quantity = ReadFieldInt32(ref reader, fields, 3) + }; + } + + private static AetheriaRunState ReadRunState(ref MessagePackReader reader, string updatedAtUtc) + { + var fields = reader.ReadArrayHeader(); + return new AetheriaRunState + { + RunId = ReadFieldString(ref reader, fields, 0), + IsTutorial = ReadFieldBool(ref reader, fields, 1), + EntranceZoneIndex = ReadFieldInt32(ref reader, fields, 2), + ExitZoneIndex = ReadFieldInt32(ref reader, fields, 3), + CurrentZoneIndex = ReadFieldInt32(ref reader, fields, 4), + CurrentZoneEntityIndex = ReadFieldInt32(ref reader, fields, 5), + DiscoveredZoneIndices = ReadIntArray(ref reader, fields, 6), + UpdatedAtUtc = updatedAtUtc + }; + } + + private static AetheriaStoryFileHash[] ReadStoryFileHashes(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return []; + var count = reader.ReadArrayHeader(); + var hashes = new AetheriaStoryFileHash[count]; + for (var item = 0; item < count; item++) + { + var itemFields = reader.ReadArrayHeader(); + hashes[item] = new AetheriaStoryFileHash + { + StoryPath = ReadFieldString(ref reader, itemFields, 0), + Hash = ReadFieldString(ref reader, itemFields, 1) + }; + } + + return hashes; + } + + private static AetheriaInputBindingOverride[] ReadBindings(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return []; + var count = reader.ReadArrayHeader(); + var bindings = new AetheriaInputBindingOverride[count]; + for (var item = 0; item < count; item++) + { + var itemFields = reader.ReadArrayHeader(); + bindings[item] = new AetheriaInputBindingOverride + { + ActionName = ReadFieldString(ref reader, itemFields, 0), + BindingIndex = ReadFieldInt32(ref reader, itemFields, 1), + BindingPath = ReadFieldString(ref reader, itemFields, 2) + }; + } + + return bindings; + } + + private static AetheriaLoadoutItemSlot[] ReadItemSlots(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return []; + var count = reader.ReadArrayHeader(); + var slots = new AetheriaLoadoutItemSlot[count]; + for (var item = 0; item < count; item++) + { + var itemFields = reader.ReadArrayHeader(); + slots[item] = new AetheriaLoadoutItemSlot + { + Position = new AetheriaGridCoord + { + X = ReadFieldInt32(ref reader, itemFields, 0), + Y = ReadFieldInt32(ref reader, itemFields, 1) + }, + Item = itemFields > 2 ? ReadLoadoutItem(ref reader) : new AetheriaLoadoutItem() + }; + } + + return slots; + } + + private static AetheriaCargoBayLoadout[] ReadCargoBays(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return []; + var count = reader.ReadArrayHeader(); + var bays = new AetheriaCargoBayLoadout[count]; + for (var item = 0; item < count; item++) + { + bays[item] = new AetheriaCargoBayLoadout + { + Items = ReadItemSlots(ref reader, 1, 0) + }; + } + + return bays; + } + + private static AetheriaEntityLoadout[] ReadChildren(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return []; + var count = reader.ReadArrayHeader(); + var children = new AetheriaEntityLoadout[count]; + for (var item = 0; item < count; item++) + children[item] = ReadEntityLoadout(ref reader); + return children; + } + + private static int[][] ReadIntArrayArray(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return []; + var count = reader.ReadArrayHeader(); + var arrays = new int[count][]; + for (var item = 0; item < count; item++) + arrays[item] = ReadIntArray(ref reader, 1, 0); + return arrays; + } + + private static string[] ReadStringArray(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return []; + var count = reader.ReadArrayHeader(); + var values = new string[count]; + for (var item = 0; item < count; item++) + values[item] = ReadString(ref reader); + return values; + } + + private static int[] ReadIntArray(ref MessagePackReader reader, int fields, int index) + { + if (index >= fields) return []; + var count = reader.ReadArrayHeader(); + var values = new int[count]; + for (var item = 0; item < count; item++) + values[item] = reader.ReadInt32(); + return values; + } + + private static string ReadFieldString(ref MessagePackReader reader, int fields, int index) + { + return index >= fields ? "" : ReadString(ref reader); + } + + private static int ReadFieldInt32(ref MessagePackReader reader, int fields, int index) + { + return index >= fields ? 0 : reader.ReadInt32(); + } + + private static double ReadFieldDouble(ref MessagePackReader reader, int fields, int index) + { + return index >= fields ? 0 : reader.ReadDouble(); + } + + private static bool ReadFieldBool(ref MessagePackReader reader, int fields, int index) + { + return index < fields && reader.ReadBoolean(); + } + + private static string ReadString(ref MessagePackReader reader) + { + return reader.ReadString() ?? ""; + } + + private static CultRecordKey LoadoutKey(string name) + { + return new CultRecordKey($"global:aetheria.loadout_template.{StableToken(name)}.v1"); + } + + private static CultRecordKey RunKey(string runId) + { + return new CultRecordKey($"global:aetheria.run_state.{StableToken(runId)}.v1"); + } + + private static string ReferenceKey(string documentName, string legacyId) + { + return string.IsNullOrWhiteSpace(legacyId) ? "" : $"{documentName}:legacy:{legacyId}"; + } + + private static string StableToken(string value) + { + var chars = (string.IsNullOrWhiteSpace(value) ? "unnamed" : value.Trim().ToLowerInvariant()) + .Select(ch => char.IsLetterOrDigit(ch) ? ch : '-') + .ToArray(); + var token = new string(chars).Trim('-'); + while (token.Contains("--", StringComparison.Ordinal)) + token = token.Replace("--", "-", StringComparison.Ordinal); + return string.IsNullOrWhiteSpace(token) ? "unnamed" : token; + } + + private static InvalidDataException MissingPayload(PendingCommand command) + { + return new InvalidDataException($"Aetheria runtime commit '{command.CommandId}' has no {command.Kind} payload."); + } + + private readonly struct PendingCommand + { + private PendingCommand( + string kind, + string commandId, + string createdAtUtc, + string path, + AetheriaPlayerSettings? playerSettings, + AetheriaLoadoutTemplate? loadoutTemplate, + AetheriaRunState? runState) + { + Kind = kind; + CommandId = commandId; + CreatedAtUtc = createdAtUtc; + Path = path; + PlayerSettings = playerSettings; + LoadoutTemplate = loadoutTemplate; + RunState = runState; + } + + public static PendingCommand Empty(string kind, string commandId, string createdAtUtc, string path) + { + return new PendingCommand(kind, commandId, createdAtUtc, path, null, null, null); + } + + public static PendingCommand ForPlayerSettings( + string kind, + string commandId, + string createdAtUtc, + AetheriaPlayerSettings settings, + string path) + { + return new PendingCommand(kind, commandId, createdAtUtc, path, settings, null, null); + } + + public static PendingCommand ForLoadoutTemplate( + string kind, + string commandId, + string createdAtUtc, + AetheriaLoadoutTemplate loadout, + string path) + { + return new PendingCommand(kind, commandId, createdAtUtc, path, null, loadout, null); + } + + public static PendingCommand ForRunState( + string kind, + string commandId, + string createdAtUtc, + AetheriaRunState run, + string path) + { + return new PendingCommand(kind, commandId, createdAtUtc, path, null, null, run); + } + + public string Kind { get; } + public string CommandId { get; } + public string CreatedAtUtc { get; } + public string Path { get; } + public AetheriaPlayerSettings? PlayerSettings { get; } + public AetheriaLoadoutTemplate? LoadoutTemplate { get; } + public AetheriaRunState? RunState { get; } + } +} diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 9166b5a6a..ca57b710d 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -36,6 +36,9 @@ public class ActionGameManager : MonoBehaviour get => _gameDataDirectory ??= new DirectoryInfo(Application.dataPath).Parent.CreateSubdirectory("GameData"); } + private static string _runtimeStateFilePath; + private static string RuntimeStateFilePath => + _runtimeStateFilePath ??= AetheriaRuntimeStateBoundary.GetStateFilePath(GameDataDirectory); private static PlayerSettings _playerSettings; public static PlayerSettings PlayerSettings @@ -48,7 +51,17 @@ public class ActionGameManager : MonoBehaviour public static void SavePlayerSettings() { - Debug.LogWarning("Player settings persistence belongs to the Verse state spine. The Unity client keeps settings in memory until Aetheria.State is available as a runtime package."); + try + { + var commit = AetheriaRuntimeStateCommitLog.QueuePlayerSettings( + RuntimeStateFilePath, + ProjectPlayerSettings(PlayerSettings)); + Debug.Log($"Queued Aetheria Verse player settings commit: {commit.Path}"); + } + catch (Exception ex) + { + Debug.LogError($"Failed to queue Aetheria Verse player settings commit: {ex}"); + } } private static PlayerSettings GetDefaultPlayerSettings() @@ -203,19 +216,170 @@ public class ActionGameManager : MonoBehaviour get => Settings.GameplaySettings.DefaultEntitySettings.Copy(); } + private static AetheriaRuntimePlayerSettingsCommit ProjectPlayerSettings(PlayerSettings settings) + { + return new AetheriaRuntimePlayerSettingsCommit + { + PlayerName = settings.Name, + TutorialPassed = settings.TutorialPassed, + StoryFileHashes = settings.HashedStoryFiles + .OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => new AetheriaRuntimeStoryFileHashCommit + { + StoryPath = pair.Key, + Hash = pair.Value + }) + .ToArray(), + TemperatureUnit = Enum.GetName(typeof(TemperatureUnit), settings.GameplaySettings.TemperatureUnit), + SignificantDigits = settings.GameplaySettings.SignificantDigits, + NebulaQuality = Enum.GetName(typeof(Quality), settings.GraphicsSettings.NebulaQuality), + ShowAsteroidsInMinimap = settings.GraphicsSettings.ShowAsteroidsInMinimap, + BindingOverrides = settings.InputSettings.InputActionMap + .OrderBy(pair => pair.Key.action, StringComparer.Ordinal) + .ThenBy(pair => pair.Key.binding) + .Select(pair => new AetheriaRuntimeInputBindingCommit + { + ActionName = pair.Key.action, + BindingIndex = pair.Key.binding, + BindingPath = pair.Value + }) + .ToArray(), + ActionBarInputs = settings.InputSettings.ActionBarInputs + .OrderBy(input => input, StringComparer.Ordinal) + .ToArray() + }; + } + + private AetheriaRuntimeLoadoutTemplateCommit ProjectLoadoutTemplate(EntityPack pack) + { + return new AetheriaRuntimeLoadoutTemplateCommit + { + Name = pack.Name ?? "", + OwnerPlayerKey = $"global:aetheria.player_settings.v1", + RootEntity = ProjectEntityLoadout(pack) + }; + } + + private AetheriaRuntimeEntityLoadoutCommit ProjectEntityLoadout(EntityPack pack) + { + return new AetheriaRuntimeEntityLoadoutCommit + { + Name = pack.Name ?? "", + Kind = pack is ShipPack ? "ship" : pack is OrbitalEntityPack ? "orbital" : "entity", + FactionLegacyId = pack.Faction == Guid.Empty ? "" : pack.Faction.ToString("D"), + Hull = ProjectLoadoutItem(pack.Hull), + Equipment = ProjectSlots(pack.Equipment), + CargoBays = ProjectSlots(pack.CargoBays), + DockingBays = ProjectSlots(pack.DockingBays), + CargoContents = ProjectCargoBays(pack.CargoContents), + DockingBayContents = ProjectCargoBays(pack.DockingBayContents), + DockingBayAssignments = pack.DockingBayAssignments ?? Array.Empty(), + WeaponGroups = pack.WeaponGroups?.Select(group => (IReadOnlyList)group).ToArray() ?? Array.Empty>(), + Children = pack.Children?.Select(ProjectEntityLoadout).ToArray() ?? Array.Empty() + }; + } + + private static AetheriaRuntimeLoadoutItemSlotCommit[] ProjectSlots((int2 position, EquippableItem item)[] slots) + { + return slots? + .Select(slot => new AetheriaRuntimeLoadoutItemSlotCommit + { + X = slot.position.x, + Y = slot.position.y, + Item = ProjectLoadoutItem(slot.item) + }) + .ToArray() ?? Array.Empty(); + } + + private static AetheriaRuntimeCargoBayLoadoutCommit[] ProjectCargoBays((int2 position, ItemInstance item)[][] bays) + { + return bays? + .Select(bay => new AetheriaRuntimeCargoBayLoadoutCommit + { + Items = bay? + .Select(slot => new AetheriaRuntimeLoadoutItemSlotCommit + { + X = slot.position.x, + Y = slot.position.y, + Item = ProjectLoadoutItem(slot.item) + }) + .ToArray() ?? Array.Empty() + }) + .ToArray() ?? Array.Empty(); + } + + private static AetheriaRuntimeLoadoutItemCommit ProjectLoadoutItem(ItemInstance item) + { + if (item == null) + return new AetheriaRuntimeLoadoutItemCommit(); + + return new AetheriaRuntimeLoadoutItemCommit + { + ItemLegacyId = item.Data?.LinkID.ToString("D") ?? "", + Quality = item is CraftedItemInstance crafted ? crafted.Quality : 1.0, + Durability = item is EquippableItem equippable ? equippable.Durability : 1.0, + Quantity = item is SimpleCommodity commodity ? commodity.Quantity : 1 + }; + } + + private AetheriaRuntimeRunCheckpointCommit ProjectRunCheckpoint() + { + return new AetheriaRuntimeRunCheckpointCommit + { + RunId = IsTutorial ? "tutorial" : "local", + IsTutorial = IsTutorial, + EntranceZoneIndex = ZoneIndex(CurrentGalaxy?.Entrance), + ExitZoneIndex = ZoneIndex(CurrentGalaxy?.Exit), + CurrentZoneIndex = ZoneIndex(Zone?.GalaxyZone), + CurrentZoneEntityIndex = Zone?.Entities?.IndexOf(CurrentEntity) ?? -1, + DiscoveredZoneIndices = CurrentGalaxy?.DiscoveredZones + .Select(ZoneIndex) + .Where(index => index >= 0) + .OrderBy(index => index) + .ToArray() ?? Array.Empty() + }; + } + + private static int ZoneIndex(GalaxyZone zone) + { + return CurrentGalaxy?.Zones == null || zone == null ? -1 : Array.IndexOf(CurrentGalaxy.Zones, zone); + } + public void SaveLoadout(EntityPack pack) { Loadouts.RemoveAll(loadout => loadout.Name == pack.Name); Loadouts.Add(pack); - Debug.LogWarning("Loadout persistence belongs to the Verse state spine. The Unity client keeps loadouts in memory until Aetheria.State is available as a runtime package."); + try + { + var commit = AetheriaRuntimeStateCommitLog.QueueLoadoutTemplate( + RuntimeStateFilePath, + ProjectLoadoutTemplate(pack)); + Debug.Log($"Queued Aetheria Verse loadout commit: {commit.Path}"); + } + catch (Exception ex) + { + Debug.LogError($"Failed to queue Aetheria Verse loadout commit: {ex}"); + } } - private void OnApplicationQuit() => WarnRunStatePersistencePending(); + private void OnApplicationQuit() => QueueRunCheckpoint("application-quit"); - private void WarnRunStatePersistencePending() + private void QueueRunCheckpoint(string reason) { - if (CurrentGalaxy != null) - Debug.LogWarning("Run persistence belongs to the Verse state spine. Legacy run serialization is disabled."); + if (CurrentGalaxy == null) + return; + + try + { + var commit = AetheriaRuntimeStateCommitLog.QueueRunCheckpoint( + RuntimeStateFilePath, + ProjectRunCheckpoint()); + Debug.Log($"Queued Aetheria Verse run checkpoint ({reason}): {commit.Path}"); + } + catch (Exception ex) + { + Debug.LogError($"Failed to queue Aetheria Verse run checkpoint ({reason}): {ex}"); + } } private void OnDisable() @@ -232,6 +396,7 @@ public class ActionGameManager : MonoBehaviour ConsoleController.MessageReceiver = this; var stateBoot = AetheriaRuntimeStateBoot.Inspect(GameDataDirectory); + _runtimeStateFilePath = stateBoot.StateFilePath; Debug.Log($"Aetheria typed state file: {stateBoot.StateFilePath}"); if (!stateBoot.StateFileExists) { @@ -628,7 +793,7 @@ public class ActionGameManager : MonoBehaviour ship.ExitWormhole(ZoneRenderer.WormholeInstances.Keys.First(w => w.Target == oldZone.GalaxyZone).Position, Settings.GameplaySettings.WormholeExitVelocity * ItemManager.Random.NextFloat2Direction()); CurrentEntity.Zone = Zone; - WarnRunStatePersistencePending(); + QueueRunCheckpoint("wormhole-transition"); }; } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStateCommitLog.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStateCommitLog.cs new file mode 100644 index 000000000..57735cfff --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStateCommitLog.cs @@ -0,0 +1,385 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using MessagePack; + +#nullable enable + +namespace GameCult.Aetheria.State.Unity +{ + public enum AetheriaRuntimeCommitKind + { + PlayerSettings, + LoadoutTemplate, + RunCheckpoint + } + + public sealed class AetheriaRuntimeCommitEnvelope + { + public AetheriaRuntimeCommitEnvelope( + string schema, + AetheriaRuntimeCommitKind kind, + string commandId, + string createdAtUtc, + string path) + { + Schema = schema; + Kind = kind; + CommandId = commandId; + CreatedAtUtc = createdAtUtc; + Path = path; + } + + public string Schema { get; } + public AetheriaRuntimeCommitKind Kind { get; } + public string CommandId { get; } + public string CreatedAtUtc { get; } + public string Path { get; } + } + + public sealed class AetheriaRuntimePlayerSettingsCommit + { + public string PlayerName { get; set; } = ""; + public bool TutorialPassed { get; set; } + public IReadOnlyList StoryFileHashes { get; set; } = Array.Empty(); + public string TemperatureUnit { get; set; } = "Celsius"; + public int SignificantDigits { get; set; } = 3; + public string NebulaQuality { get; set; } = "Normal"; + public bool ShowAsteroidsInMinimap { get; set; } + public IReadOnlyList BindingOverrides { get; set; } = Array.Empty(); + public IReadOnlyList ActionBarInputs { get; set; } = Array.Empty(); + } + + public sealed class AetheriaRuntimeStoryFileHashCommit + { + public string StoryPath { get; set; } = ""; + public string Hash { get; set; } = ""; + } + + public sealed class AetheriaRuntimeInputBindingCommit + { + public string ActionName { get; set; } = ""; + public int BindingIndex { get; set; } + public string BindingPath { get; set; } = ""; + } + + public sealed class AetheriaRuntimeLoadoutTemplateCommit + { + public string Name { get; set; } = ""; + public string OwnerPlayerKey { get; set; } = ""; + public AetheriaRuntimeEntityLoadoutCommit RootEntity { get; set; } = new AetheriaRuntimeEntityLoadoutCommit(); + } + + public sealed class AetheriaRuntimeEntityLoadoutCommit + { + public string Name { get; set; } = ""; + public string Kind { get; set; } = ""; + public string FactionLegacyId { get; set; } = ""; + public AetheriaRuntimeLoadoutItemCommit Hull { get; set; } = new AetheriaRuntimeLoadoutItemCommit(); + public IReadOnlyList Equipment { get; set; } = Array.Empty(); + public IReadOnlyList CargoBays { get; set; } = Array.Empty(); + public IReadOnlyList DockingBays { get; set; } = Array.Empty(); + public IReadOnlyList CargoContents { get; set; } = Array.Empty(); + public IReadOnlyList DockingBayContents { get; set; } = Array.Empty(); + public IReadOnlyList DockingBayAssignments { get; set; } = Array.Empty(); + public IReadOnlyList> WeaponGroups { get; set; } = Array.Empty>(); + public IReadOnlyList Children { get; set; } = Array.Empty(); + } + + public sealed class AetheriaRuntimeLoadoutItemCommit + { + public string ItemLegacyId { get; set; } = ""; + public double Quality { get; set; } = 1.0; + public double Durability { get; set; } = 1.0; + public int Quantity { get; set; } = 1; + } + + public sealed class AetheriaRuntimeLoadoutItemSlotCommit + { + public int X { get; set; } + public int Y { get; set; } + public AetheriaRuntimeLoadoutItemCommit Item { get; set; } = new AetheriaRuntimeLoadoutItemCommit(); + } + + public sealed class AetheriaRuntimeCargoBayLoadoutCommit + { + public IReadOnlyList Items { get; set; } = Array.Empty(); + } + + public sealed class AetheriaRuntimeRunCheckpointCommit + { + public string RunId { get; set; } = "local"; + public bool IsTutorial { get; set; } + public int EntranceZoneIndex { get; set; } = -1; + public int ExitZoneIndex { get; set; } = -1; + public int CurrentZoneIndex { get; set; } = -1; + public int CurrentZoneEntityIndex { get; set; } = -1; + public IReadOnlyList DiscoveredZoneIndices { get; set; } = Array.Empty(); + } + + public static class AetheriaRuntimeStateCommitLog + { + public const string CommitSchema = "gamecult.aetheria.runtime_commit.v1"; + + private delegate void WritePayload(ref MessagePackWriter writer); + + public static string GetPendingDirectory(string stateFilePath) + { + if (string.IsNullOrWhiteSpace(stateFilePath)) + throw new ArgumentException("State file path must be non-empty.", nameof(stateFilePath)); + + return stateFilePath + ".pending"; + } + + public static AetheriaRuntimeCommitEnvelope QueuePlayerSettings( + string stateFilePath, + AetheriaRuntimePlayerSettingsCommit settings) + { + if (settings == null) throw new ArgumentNullException(nameof(settings)); + return WriteCommit(stateFilePath, AetheriaRuntimeCommitKind.PlayerSettings, (ref MessagePackWriter writer) => WritePlayerSettings(ref writer, settings)); + } + + public static AetheriaRuntimeCommitEnvelope QueueLoadoutTemplate( + string stateFilePath, + AetheriaRuntimeLoadoutTemplateCommit loadout) + { + if (loadout == null) throw new ArgumentNullException(nameof(loadout)); + return WriteCommit(stateFilePath, AetheriaRuntimeCommitKind.LoadoutTemplate, (ref MessagePackWriter writer) => WriteLoadoutTemplate(ref writer, loadout)); + } + + public static AetheriaRuntimeCommitEnvelope QueueRunCheckpoint( + string stateFilePath, + AetheriaRuntimeRunCheckpointCommit run) + { + if (run == null) throw new ArgumentNullException(nameof(run)); + return WriteCommit(stateFilePath, AetheriaRuntimeCommitKind.RunCheckpoint, (ref MessagePackWriter writer) => WriteRunCheckpoint(ref writer, run)); + } + + public static IReadOnlyList ReadPending(string stateFilePath) + { + var pendingDirectory = GetPendingDirectory(stateFilePath); + if (!Directory.Exists(pendingDirectory)) + return Array.Empty(); + + return Directory + .EnumerateFiles(pendingDirectory, "*.cc") + .OrderBy(path => path, StringComparer.Ordinal) + .Select(ReadEnvelope) + .ToArray(); + } + + private static AetheriaRuntimeCommitEnvelope WriteCommit( + string stateFilePath, + AetheriaRuntimeCommitKind kind, + WritePayload writePayload) + { + var commandId = Guid.NewGuid().ToString("N"); + var createdAtUtc = DateTime.UtcNow.ToString("O"); + var pendingDirectory = GetPendingDirectory(stateFilePath); + Directory.CreateDirectory(pendingDirectory); + + var finalPath = Path.Combine( + pendingDirectory, + $"{createdAtUtc.Replace(':', '-')}.{KindToken(kind)}.{commandId}.cc"); + var tempPath = finalPath + ".tmp"; + + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(5); + writer.Write(CommitSchema); + writer.Write(KindToken(kind)); + writer.Write(commandId); + writer.Write(createdAtUtc); + writePayload(ref writer); + writer.Flush(); + + File.WriteAllBytes(tempPath, buffer.WrittenSpan.ToArray()); + if (File.Exists(finalPath)) + File.Delete(finalPath); + File.Move(tempPath, finalPath); + + return new AetheriaRuntimeCommitEnvelope(CommitSchema, kind, commandId, createdAtUtc, finalPath); + } + + private static AetheriaRuntimeCommitEnvelope ReadEnvelope(string path) + { + var reader = new MessagePackReader(File.ReadAllBytes(path)); + var fields = reader.ReadArrayHeader(); + var schema = fields > 0 ? ReadString(ref reader) : ""; + var kind = fields > 1 ? ParseKind(ReadString(ref reader)) : AetheriaRuntimeCommitKind.RunCheckpoint; + var commandId = fields > 2 ? ReadString(ref reader) : ""; + var createdAtUtc = fields > 3 ? ReadString(ref reader) : ""; + for (var field = 4; field < fields; field++) + reader.Skip(); + + return new AetheriaRuntimeCommitEnvelope(schema, kind, commandId, createdAtUtc, path); + } + + private static void WritePlayerSettings(ref MessagePackWriter writer, AetheriaRuntimePlayerSettingsCommit settings) + { + writer.WriteArrayHeader(9); + writer.Write(settings.PlayerName ?? ""); + writer.Write(settings.TutorialPassed); + WriteStoryHashes(ref writer, settings.StoryFileHashes); + writer.Write(settings.TemperatureUnit ?? ""); + writer.Write(settings.SignificantDigits); + writer.Write(settings.NebulaQuality ?? ""); + writer.Write(settings.ShowAsteroidsInMinimap); + WriteBindings(ref writer, settings.BindingOverrides); + WriteStrings(ref writer, settings.ActionBarInputs); + } + + private static void WriteStoryHashes(ref MessagePackWriter writer, IReadOnlyList? hashes) + { + hashes ??= Array.Empty(); + writer.WriteArrayHeader(hashes.Count); + foreach (var hash in hashes) + { + writer.WriteArrayHeader(2); + writer.Write(hash.StoryPath ?? ""); + writer.Write(hash.Hash ?? ""); + } + } + + private static void WriteBindings(ref MessagePackWriter writer, IReadOnlyList? bindings) + { + bindings ??= Array.Empty(); + writer.WriteArrayHeader(bindings.Count); + foreach (var binding in bindings) + { + writer.WriteArrayHeader(3); + writer.Write(binding.ActionName ?? ""); + writer.Write(binding.BindingIndex); + writer.Write(binding.BindingPath ?? ""); + } + } + + private static void WriteLoadoutTemplate(ref MessagePackWriter writer, AetheriaRuntimeLoadoutTemplateCommit loadout) + { + writer.WriteArrayHeader(3); + writer.Write(loadout.Name ?? ""); + writer.Write(loadout.OwnerPlayerKey ?? ""); + WriteEntityLoadout(ref writer, loadout.RootEntity); + } + + private static void WriteEntityLoadout(ref MessagePackWriter writer, AetheriaRuntimeEntityLoadoutCommit entity) + { + writer.WriteArrayHeader(12); + writer.Write(entity.Name ?? ""); + writer.Write(entity.Kind ?? ""); + writer.Write(entity.FactionLegacyId ?? ""); + WriteLoadoutItem(ref writer, entity.Hull); + WriteItemSlots(ref writer, entity.Equipment); + WriteItemSlots(ref writer, entity.CargoBays); + WriteItemSlots(ref writer, entity.DockingBays); + WriteCargoBays(ref writer, entity.CargoContents); + WriteCargoBays(ref writer, entity.DockingBayContents); + WriteInts(ref writer, entity.DockingBayAssignments); + WriteIntLists(ref writer, entity.WeaponGroups); + WriteChildren(ref writer, entity.Children); + } + + private static void WriteLoadoutItem(ref MessagePackWriter writer, AetheriaRuntimeLoadoutItemCommit item) + { + writer.WriteArrayHeader(4); + writer.Write(item.ItemLegacyId ?? ""); + writer.Write(item.Quality); + writer.Write(item.Durability); + writer.Write(item.Quantity); + } + + private static void WriteItemSlots(ref MessagePackWriter writer, IReadOnlyList? slots) + { + slots ??= Array.Empty(); + writer.WriteArrayHeader(slots.Count); + foreach (var slot in slots) + { + writer.WriteArrayHeader(3); + writer.Write(slot.X); + writer.Write(slot.Y); + WriteLoadoutItem(ref writer, slot.Item); + } + } + + private static void WriteCargoBays(ref MessagePackWriter writer, IReadOnlyList? cargoBays) + { + cargoBays ??= Array.Empty(); + writer.WriteArrayHeader(cargoBays.Count); + foreach (var bay in cargoBays) + WriteItemSlots(ref writer, bay.Items); + } + + private static void WriteChildren(ref MessagePackWriter writer, IReadOnlyList? children) + { + children ??= Array.Empty(); + writer.WriteArrayHeader(children.Count); + foreach (var child in children) + WriteEntityLoadout(ref writer, child); + } + + private static void WriteRunCheckpoint(ref MessagePackWriter writer, AetheriaRuntimeRunCheckpointCommit run) + { + writer.WriteArrayHeader(7); + writer.Write(run.RunId ?? ""); + writer.Write(run.IsTutorial); + writer.Write(run.EntranceZoneIndex); + writer.Write(run.ExitZoneIndex); + writer.Write(run.CurrentZoneIndex); + writer.Write(run.CurrentZoneEntityIndex); + WriteInts(ref writer, run.DiscoveredZoneIndices); + } + + private static void WriteStrings(ref MessagePackWriter writer, IReadOnlyList? values) + { + values ??= Array.Empty(); + writer.WriteArrayHeader(values.Count); + foreach (var value in values) + writer.Write(value ?? ""); + } + + private static void WriteInts(ref MessagePackWriter writer, IReadOnlyList? values) + { + values ??= Array.Empty(); + writer.WriteArrayHeader(values.Count); + foreach (var value in values) + writer.Write(value); + } + + private static void WriteIntLists(ref MessagePackWriter writer, IReadOnlyList>? values) + { + values ??= Array.Empty>(); + writer.WriteArrayHeader(values.Count); + foreach (var value in values) + WriteInts(ref writer, value); + } + + private static string KindToken(AetheriaRuntimeCommitKind kind) + { + return kind switch + { + AetheriaRuntimeCommitKind.PlayerSettings => "player_settings", + AetheriaRuntimeCommitKind.LoadoutTemplate => "loadout_template", + AetheriaRuntimeCommitKind.RunCheckpoint => "run_checkpoint", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) + }; + } + + private static AetheriaRuntimeCommitKind ParseKind(string token) + { + return token switch + { + "player_settings" => AetheriaRuntimeCommitKind.PlayerSettings, + "loadout_template" => AetheriaRuntimeCommitKind.LoadoutTemplate, + "run_checkpoint" => AetheriaRuntimeCommitKind.RunCheckpoint, + _ => throw new InvalidDataException($"Unknown Aetheria runtime commit kind '{token}'.") + }; + } + + private static string ReadString(ref MessagePackReader reader) + { + return reader.ReadString() ?? ""; + } + } +} diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 1615c14d9..a80f08516 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -30,8 +30,13 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. boot. When the typed state file exists, `ActionGameManager` opens a package-owned read-only runtime catalog snapshot from the `.cc` directory store before constructing the temporary legacy - `ItemManager`. Local run saves, loadouts, player settings files, zone files, and - generated keyboard layout caches no longer write bespoke durable files. The + `ItemManager`. Player settings saves, loadout saves, shutdown checkpoints, + and wormhole transition checkpoints now queue typed `.cc.pending` Verse + commit commands through the embedded runtime state package. `Aetheria.State` + owns applying those command envelopes into canonical `AetheriaPlayerSettings`, + `AetheriaLoadoutTemplate`, and `AetheriaRunState` documents through + `AetheriaStateNode`. Local run saves, loadouts, player settings files, zone + files, and generated keyboard layout caches no longer write bespoke durable files. The legacy `PlayerSettings` runtime object is no longer decorated as a MessagePack persistence shape. @@ -69,9 +74,9 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. replacement for bespoke `.loadout` files. It stores structured hull, equipment, cargo bay, docking bay, child-entity, assignment, and weapon-group state through record-key references and typed value slots instead of opaque - `EntityPack` serialization. Unity has not wired its save/loadout UI to this - document yet, so the old in-memory `EntityPack` path is still a runtime - construction helper, not durable authority. + `EntityPack` serialization. Unity's save/loadout UI now projects its + in-memory `EntityPack` into a typed Verse commit command; the in-memory list + remains a UI/session cache, not durable authority. - `ActionGameManager` opens `AetheriaRuntimeCatalogStore` over `aetheria-world.cc`, projects it through `AetheriaRuntimeItemCatalog`, and binds `DatabaseLinkBase` to that typed runtime item reader before gameplay @@ -147,7 +152,12 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. read path for .NET smokes. Neither is a simulation owner and neither writes state. `Aetheria.Shared.Unity` references this package directly so `Galaxy` can - consume typed name files without loading legacy `NameFile` documents. + consume typed name files without loading legacy `NameFile` documents. The + package also owns Unity's typed runtime commit log writer for settings, + loadout-template, and run-checkpoint command envelopes under + `aetheria-world.cc.pending`. This log is command-only: it cannot decide + canonical state, and the `Aetheria.State` node applicator deletes applied + commands after writing typed documents. - `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 @@ -287,12 +297,12 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. typed item catalog projection emits in-memory domain objects only; the old local save and editor catalog write paths have been cut. Migrated catalog documents are addressed through `AetheriaCatalogKeys` and `AetheriaStateNode` legacy-ID - methods, not importer-local string concatenation. Player settings and loadout - templates have typed state documents and node put/get paths; Unity's menu, - input screens, and save button still need to call them through the runtime - Verse package. Run, zone, and entity snapshot state also have typed node - put/get paths; Unity transition and shutdown paths still need to commit - through those ports. The old `SaveState`/`SaveZone` command surface is gone. + methods, not importer-local string concatenation. Player settings, loadout + templates, and lightweight run checkpoints have a shared Unity commit-log + primitive plus a typed state node applicator. Run, zone, and entity snapshot + state also have typed node put/get paths; full zone/entity runtime snapshot + projection still needs to move through the same command/apply spine. The old + `SaveState`/`SaveZone` command surface is gone. The catalog operator UI is emitted as a typed Eve surface document derived from the catalog snapshot. - Derived state: UI panels, generated keyboard layouts, editor rows, typed @@ -499,9 +509,10 @@ First Aetheria surfaces to publish: layout, DB inspector, and NameFile export writers. - Done: delete the old `SavedGame`/`SavedZone` runtime save DTO and loader. - Done: remove the stale Unity `SaveState`/`SaveZone` command names after - their bespoke serializers were deleted; shutdown and wormhole transitions - now emit only a Verse-persistence-pending warning until typed runtime - commit ports are wired. + their bespoke serializers were deleted. + - Done: replace warning-only player settings, loadout, shutdown, and + wormhole save paths with typed `.cc.pending` Verse commit commands and a + state-node applicator that writes canonical typed documents. - Done: stop legacy catalog pull/read paths from writing entries back to their source backing store. - Done: delete legacy catalog backing-store write/realtime APIs. @@ -539,7 +550,10 @@ First Aetheria surfaces to publish: the embedded package-owned read-only catalog opener, receiving the same package-owned runtime catalog read models without a MessagePack catalog cache, including typed item masks, interior masks, hardpoint definitions, - corporation/name-file links, and typed behavior payloads. + corporation/name-file links, and typed behavior payloads. It also proves the + Unity runtime state commit log can queue a player-settings command, the + `Aetheria.State` node can apply it into canonical typed state, and the + command is cleared after application. - 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