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); var zoneKeys = new List(); foreach (var zone in command.ZoneSnapshots) { var zoneKey = ZoneKey(run.RunId, zone.ZoneIndex); var entityKeys = new List(); foreach (var entity in zone.Entities) { var entityKey = EntityKey(run.RunId, zone.ZoneIndex, entity.EntityIndex); entity.Snapshot.ChildEntityKeys = entity.Snapshot.ChildEntityKeys .Select(key => key.StartsWith("pending:entity:", StringComparison.Ordinal) && int.TryParse(key["pending:entity:".Length..], out var childIndex) ? EntityKey(run.RunId, zone.ZoneIndex, childIndex).ToString() : key) .ToArray(); await node.PutEntitySnapshotAsync(entityKey, entity.Snapshot).ConfigureAwait(false); entityKeys.Add(entityKey.ToString()); } zone.State.EntityKeys = entityKeys.ToArray(); await node.PutZoneStateAsync(zoneKey, zone.State).ConfigureAwait(false); zoneKeys.Add(zoneKey.ToString()); } if (zoneKeys.Count > 0) run.ZoneKeys = zoneKeys.ToArray(); 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, out var zoneSnapshots), zoneSnapshots, 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, out AetheriaPendingZoneSnapshot[] zoneSnapshots) { var fields = reader.ReadArrayHeader(); var run = 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 }; zoneSnapshots = ReadZoneSnapshots(ref reader, fields, 7); return run; } private static AetheriaPendingZoneSnapshot[] ReadZoneSnapshots(ref MessagePackReader reader, int fields, int index) { if (index >= fields) return []; var count = reader.ReadArrayHeader(); var zones = new AetheriaPendingZoneSnapshot[count]; for (var item = 0; item < count; item++) { var itemFields = reader.ReadArrayHeader(); var zoneIndex = ReadFieldInt32(ref reader, itemFields, 0); var state = new AetheriaZoneState { Name = ReadFieldString(ref reader, itemFields, 1), Position = new AetheriaVector2 { X = ReadFieldDouble(ref reader, itemFields, 2), Y = ReadFieldDouble(ref reader, itemFields, 3) }, AdjacentZoneIndices = ReadIntArray(ref reader, itemFields, 4), FactionIndices = ReadIntArray(ref reader, itemFields, 5), OwnerFactionIndex = ReadFieldInt32(ref reader, itemFields, 6) }; zones[item] = new AetheriaPendingZoneSnapshot( zoneIndex, state, ReadEntitySnapshots(ref reader, itemFields, 7)); } return zones; } private static AetheriaPendingEntitySnapshot[] ReadEntitySnapshots(ref MessagePackReader reader, int fields, int index) { if (index >= fields) return []; var count = reader.ReadArrayHeader(); var entities = new AetheriaPendingEntitySnapshot[count]; for (var item = 0; item < count; item++) { var itemFields = reader.ReadArrayHeader(); var entityIndex = ReadFieldInt32(ref reader, itemFields, 0); var snapshot = new AetheriaEntitySnapshot { Name = ReadFieldString(ref reader, itemFields, 1), Kind = ReadFieldString(ref reader, itemFields, 2), Position = new AetheriaVector3 { X = ReadFieldDouble(ref reader, itemFields, 3), Y = ReadFieldDouble(ref reader, itemFields, 4), Z = ReadFieldDouble(ref reader, itemFields, 5) }, Direction = new AetheriaVector2 { X = ReadFieldDouble(ref reader, itemFields, 6), Y = ReadFieldDouble(ref reader, itemFields, 7) }, FactionKey = ReferenceKey("aetheria.corporation", ReadFieldString(ref reader, itemFields, 8)), HullItemKey = ReferenceKey("aetheria.item_definition", ReadFieldString(ref reader, itemFields, 9)), Equipment = ReadEntityItemSlots(ref reader, itemFields, 10), CargoBays = ReadEntityItemSlots(ref reader, itemFields, 11), DockingBays = ReadEntityItemSlots(ref reader, itemFields, 12), ChildEntityKeys = ReadIntArray(ref reader, itemFields, 13) .Select(childIndex => $"pending:entity:{childIndex}") .ToArray(), WeaponGroups = ReadWeaponGroups(ref reader, itemFields, 14) }; entities[item] = new AetheriaPendingEntitySnapshot(entityIndex, snapshot); } return entities; } private static AetheriaEntityItemSlot[] ReadEntityItemSlots(ref MessagePackReader reader, int fields, int index) { var slots = ReadItemSlots(ref reader, fields, index); return slots .Select(slot => new AetheriaEntityItemSlot { Position = slot.Position, ItemKey = slot.Item.ItemKey }) .ToArray(); } private static AetheriaWeaponGroupSnapshot[] ReadWeaponGroups(ref MessagePackReader reader, int fields, int index) { return ReadIntArrayArray(ref reader, fields, index) .Select(group => new AetheriaWeaponGroupSnapshot { EquipmentIndices = group }) .ToArray(); } 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 CultRecordKey ZoneKey(string runId, int zoneIndex) { return new CultRecordKey($"global:aetheria.run_state.{StableToken(runId)}.zone.{zoneIndex}.v1"); } private static CultRecordKey EntityKey(string runId, int zoneIndex, int entityIndex) { return new CultRecordKey($"global:aetheria.run_state.{StableToken(runId)}.zone.{zoneIndex}.entity.{entityIndex}.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, AetheriaPendingZoneSnapshot[] zoneSnapshots) { Kind = kind; CommandId = commandId; CreatedAtUtc = createdAtUtc; Path = path; PlayerSettings = playerSettings; LoadoutTemplate = loadoutTemplate; RunState = runState; ZoneSnapshots = zoneSnapshots; } 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, AetheriaPendingZoneSnapshot[] zoneSnapshots, string path) { return new PendingCommand(kind, commandId, createdAtUtc, path, null, null, run, zoneSnapshots); } 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; } public AetheriaPendingZoneSnapshot[] ZoneSnapshots { get; } } private readonly struct AetheriaPendingZoneSnapshot { public AetheriaPendingZoneSnapshot( int zoneIndex, AetheriaZoneState state, AetheriaPendingEntitySnapshot[] entities) { ZoneIndex = zoneIndex; State = state; Entities = entities; } public int ZoneIndex { get; } public AetheriaZoneState State { get; } public AetheriaPendingEntitySnapshot[] Entities { get; } } private readonly struct AetheriaPendingEntitySnapshot { public AetheriaPendingEntitySnapshot(int entityIndex, AetheriaEntitySnapshot snapshot) { EntityIndex = entityIndex; Snapshot = snapshot; } public int EntityIndex { get; } public AetheriaEntitySnapshot Snapshot { get; } } }