diff --git a/.gitignore b/.gitignore index bead77e1d..50ae0b2e1 100644 --- a/.gitignore +++ b/.gitignore @@ -97,6 +97,10 @@ Economy.Server/bin/ Economy.Server/obj/ Economy.Shared/bin/ Economy.Shared/obj/ +Aetheria.State/bin/ +Aetheria.State/obj/ +Aetheria.State.Smoke/bin/ +Aetheria.State.Smoke/obj/ # JetBrains Rider .idea folder .idea/ diff --git a/.voidbot/state/aetheria.cc b/.voidbot/state/aetheria.cc index 168f00ca4..1c0d74e9a 100644 Binary files a/.voidbot/state/aetheria.cc and b/.voidbot/state/aetheria.cc differ diff --git a/Aetheria.State.Smoke/Aetheria.State.Smoke.csproj b/Aetheria.State.Smoke/Aetheria.State.Smoke.csproj new file mode 100644 index 000000000..ca788873d --- /dev/null +++ b/Aetheria.State.Smoke/Aetheria.State.Smoke.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + enable + latest + enable + + + + + + diff --git a/Aetheria.State.Smoke/Program.cs b/Aetheria.State.Smoke/Program.cs new file mode 100644 index 000000000..d184ca608 --- /dev/null +++ b/Aetheria.State.Smoke/Program.cs @@ -0,0 +1,70 @@ +using Aetheria.State; +using Aetheria.State.Documents; +using Aetheria.State.Migration; +using GameCult.Caching; + +var root = args.Length > 0 ? args[0] : Directory.GetCurrentDirectory(); +var statePath = AetheriaStatePaths.ResolveDefaultStatePath(root); +var now = DateTimeOffset.UtcNow.ToString("O"); +var itemKey = new CultRecordKey("item:smoke-aether-drive"); + +await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-state-smoke")) +{ + await node.PutWorldAsync(new AetheriaWorldState + { + Name = "Aetheria", + WorldId = "aetheria", + SchemaEpoch = 1, + CreatedAtUtc = now, + UpdatedAtUtc = now + }); + + await node.PutItemDefinitionAsync( + itemKey, + new AetheriaItemDefinition + { + Name = "Smoke Aether Drive", + Category = "ship-module", + LegacyId = "smoke:aether-drive", + Description = "Typed CultCache smoke document for the rebuild spine.", + Mass = 12.5, + Volume = 4.0, + Tags = ["smoke", "state-spine"] + }); + + await node.PutMigrationLedgerAsync(new AetheriaMigrationLedger + { + Source = LegacyMigrationBoundary.LegacyGameDataFile, + SourceFingerprint = "smoke", + LastMigrationAtUtc = now, + Counts = + [ + new AetheriaMigrationCount + { + DocumentType = "aetheria.item_definition.v1", + Count = 1 + } + ], + Notes = ["Smoke proves the new state owner can write, flush, reopen, and read without old JSON/Rethink authority."] + }); + + await node.FlushAsync(); +} + +await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aetheria-state-smoke-reopen")) +{ + var world = await reopened.GetWorldAsync(); + var item = await reopened.GetItemDefinitionAsync(itemKey); + + if (world?.WorldId != "aetheria") + { + throw new InvalidOperationException("World state did not survive flush/reopen."); + } + + if (item?.Name != "Smoke Aether Drive") + { + throw new InvalidOperationException("Item definition did not survive flush/reopen."); + } +} + +Console.WriteLine($"Aetheria typed state smoke passed: {statePath}"); diff --git a/Aetheria.State/Aetheria.State.csproj b/Aetheria.State/Aetheria.State.csproj new file mode 100644 index 000000000..103d58879 --- /dev/null +++ b/Aetheria.State/Aetheria.State.csproj @@ -0,0 +1,16 @@ + + + netstandard2.1 + enable + latest + enable + Typed CultCache and CultMesh state spine for Aetheria. + + + + + + + + + diff --git a/Aetheria.State/AetheriaDocumentRegistry.cs b/Aetheria.State/AetheriaDocumentRegistry.cs new file mode 100644 index 000000000..f6e9359e5 --- /dev/null +++ b/Aetheria.State/AetheriaDocumentRegistry.cs @@ -0,0 +1,45 @@ +using Aetheria.State.Documents; +using GameCult.Caching; +using GameCult.Networking; + +namespace Aetheria.State; + +public static class AetheriaDocumentRegistry +{ + public static CultDocumentRegistry CreateCultCacheRegistry() + { + var registry = new CultDocumentRegistry(); + foreach (var documentType in DocumentTypes) + { + registry.GetRequired(documentType); + } + + return registry; + } + + public static CultNetDocumentRegistry CreateCultNetRegistry(CultDocumentRegistry? cacheRegistry = null) + { + var registry = cacheRegistry ?? CreateCultCacheRegistry(); + return new CultNetDocumentRegistry( + registry, + new[] + { + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry) + }); + } + + public static IReadOnlyList DocumentTypes { get; } = + [ + typeof(AetheriaWorldState), + typeof(AetheriaItemDefinition), + typeof(AetheriaCorporation), + typeof(AetheriaPlayerProfile), + typeof(AetheriaRuntimeSession), + typeof(AetheriaMigrationLedger) + ]; +} diff --git a/Aetheria.State/AetheriaStateNode.cs b/Aetheria.State/AetheriaStateNode.cs new file mode 100644 index 000000000..2e40ec927 --- /dev/null +++ b/Aetheria.State/AetheriaStateNode.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Aetheria.State.Documents; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using GameCult.Mesh; +using GameCult.Networking; + +namespace Aetheria.State; + +public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable +{ + private readonly CultMeshNode _node; + + private AetheriaStateNode(string statePath, CultMeshNode node) + { + StatePath = statePath; + _node = node; + } + + public string StatePath { get; } + + public CultCache Cache => _node.Cache; + + public CultNetDatabase Database => _node.Database; + + public static async Task OpenAsync( + string statePath, + string runtimeId = "aetheria-local", + bool startServer = false) + { + if (string.IsNullOrWhiteSpace(statePath)) + { + throw new ArgumentException("State path must be non-empty.", nameof(statePath)); + } + + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(statePath)) ?? "."); + + var cacheRegistry = AetheriaDocumentRegistry.CreateCultCacheRegistry(); + var node = await CultMesh.CreateNodeAsync( + statePath, + new CultMeshNodeOptions + { + StartServer = startServer, + EnableDurableShardLogs = true, + CacheOptions = new CultCacheOpenOptions + { + Registry = cacheRegistry, + PullOnOpen = true, + StoreFlushOnDispose = true, + UseDirectoryStore = true + }, + DatabaseOptions = new CultNetDatabaseOptions + { + RuntimeId = runtimeId, + DocumentRegistry = AetheriaDocumentRegistry.CreateCultNetRegistry(cacheRegistry) + } + }).ConfigureAwait(false); + + return new AetheriaStateNode(statePath, node); + } + + public Task> PutWorldAsync(AetheriaWorldState world) + { + return Database.PutAsync(new CultRecordKey("global:aetheria.world_state.v1"), world); + } + + public Task GetWorldAsync() + { + return Database.GetAsync(new CultRecordKey("global:aetheria.world_state.v1")); + } + + public Task> PutMigrationLedgerAsync(AetheriaMigrationLedger ledger) + { + return Database.PutAsync(new CultRecordKey("global:aetheria.migration_ledger.v1"), ledger); + } + + public Task> PutItemDefinitionAsync( + CultRecordKey key, + AetheriaItemDefinition item) + { + return Database.PutAsync(key, item); + } + + public Task GetItemDefinitionAsync(CultRecordKey key) + { + return Database.GetAsync(key); + } + + public Task FlushAsync(bool soft = false) + { + return _node.FlushAsync(soft); + } + + public void Dispose() + { + _node.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await FlushAsync().ConfigureAwait(false); + Dispose(); + } +} diff --git a/Aetheria.State/AetheriaStatePaths.cs b/Aetheria.State/AetheriaStatePaths.cs new file mode 100644 index 000000000..2f5f96dea --- /dev/null +++ b/Aetheria.State/AetheriaStatePaths.cs @@ -0,0 +1,16 @@ +using System.IO; + +namespace Aetheria.State; + +public static class AetheriaStatePaths +{ + public const string DefaultStateFileName = "aetheria-world.cc"; + + public static string ResolveDefaultStatePath(string? projectRoot = null) + { + var root = string.IsNullOrWhiteSpace(projectRoot) + ? Directory.GetCurrentDirectory() + : projectRoot!; + return Path.Combine(root, "GameData", DefaultStateFileName); + } +} diff --git a/Aetheria.State/Documents/AetheriaCorporation.cs b/Aetheria.State/Documents/AetheriaCorporation.cs new file mode 100644 index 000000000..58b5b9d4a --- /dev/null +++ b/Aetheria.State/Documents/AetheriaCorporation.cs @@ -0,0 +1,20 @@ +using GameCult.Caching; +using MessagePack; + +namespace Aetheria.State.Documents; + +[CultDocument("aetheria.corporation", "aetheria.corporation.v1")] +[MessagePackObject] +public sealed class AetheriaCorporation +{ + [Key(0)] + [CultName] + public string Name { get; set; } = ""; + + [Key(1)] + [CultIndex("legacyId")] + public string LegacyId { get; set; } = ""; + + [Key(2)] + public string Description { get; set; } = ""; +} diff --git a/Aetheria.State/Documents/AetheriaItemDefinition.cs b/Aetheria.State/Documents/AetheriaItemDefinition.cs new file mode 100644 index 000000000..14d1dd8cb --- /dev/null +++ b/Aetheria.State/Documents/AetheriaItemDefinition.cs @@ -0,0 +1,33 @@ +using GameCult.Caching; +using MessagePack; + +namespace Aetheria.State.Documents; + +[CultDocument("aetheria.item_definition", "aetheria.item_definition.v1")] +[MessagePackObject] +public sealed class AetheriaItemDefinition +{ + [Key(0)] + [CultName] + public string Name { get; set; } = ""; + + [Key(1)] + [CultIndex("category")] + public string Category { get; set; } = "uncategorized"; + + [Key(2)] + [CultIndex("legacyId")] + public string LegacyId { get; set; } = ""; + + [Key(3)] + public string Description { get; set; } = ""; + + [Key(4)] + public double Mass { get; set; } + + [Key(5)] + public double Volume { get; set; } + + [Key(6)] + public string[] Tags { get; set; } = []; +} diff --git a/Aetheria.State/Documents/AetheriaMigrationLedger.cs b/Aetheria.State/Documents/AetheriaMigrationLedger.cs new file mode 100644 index 000000000..9fa1a5973 --- /dev/null +++ b/Aetheria.State/Documents/AetheriaMigrationLedger.cs @@ -0,0 +1,39 @@ +using GameCult.Caching; +using MessagePack; + +namespace Aetheria.State.Documents; + +[CultDocument("aetheria.migration_ledger", "aetheria.migration_ledger.v1")] +[CultGlobal] +[MessagePackObject] +public sealed class AetheriaMigrationLedger +{ + [Key(0)] + [CultName] + public string Name { get; set; } = "Aetheria legacy migration ledger"; + + [Key(1)] + public string Source { get; set; } = ""; + + [Key(2)] + public string SourceFingerprint { get; set; } = ""; + + [Key(3)] + public string LastMigrationAtUtc { get; set; } = ""; + + [Key(4)] + public AetheriaMigrationCount[] Counts { get; set; } = []; + + [Key(5)] + public string[] Notes { get; set; } = []; +} + +[MessagePackObject] +public sealed class AetheriaMigrationCount +{ + [Key(0)] + public string DocumentType { get; set; } = ""; + + [Key(1)] + public int Count { get; set; } +} diff --git a/Aetheria.State/Documents/AetheriaPlayerProfile.cs b/Aetheria.State/Documents/AetheriaPlayerProfile.cs new file mode 100644 index 000000000..8dbf87908 --- /dev/null +++ b/Aetheria.State/Documents/AetheriaPlayerProfile.cs @@ -0,0 +1,25 @@ +using GameCult.Caching; +using MessagePack; + +namespace Aetheria.State.Documents; + +[CultDocument("aetheria.player_profile", "aetheria.player_profile.v1")] +[MessagePackObject] +public sealed class AetheriaPlayerProfile +{ + [Key(0)] + [CultName] + public string Username { get; set; } = ""; + + [Key(1)] + [CultIndex("legacyId")] + public string LegacyId { get; set; } = ""; + + [Key(2)] + [CultIndex("email")] + public string Email { get; set; } = ""; + + [Key(3)] + [CultReference(typeof(AetheriaCorporation))] + public string CorporationKey { get; set; } = ""; +} diff --git a/Aetheria.State/Documents/AetheriaRuntimeSession.cs b/Aetheria.State/Documents/AetheriaRuntimeSession.cs new file mode 100644 index 000000000..8ad1a83f7 --- /dev/null +++ b/Aetheria.State/Documents/AetheriaRuntimeSession.cs @@ -0,0 +1,23 @@ +using GameCult.Caching; +using MessagePack; + +namespace Aetheria.State.Documents; + +[CultDocument("aetheria.runtime_session", "aetheria.runtime_session.v1")] +[MessagePackObject] +public sealed class AetheriaRuntimeSession +{ + [Key(0)] + [CultName] + public string RuntimeId { get; set; } = ""; + + [Key(1)] + [CultIndex("role")] + public string Role { get; set; } = "local"; + + [Key(2)] + public string StartedAtUtc { get; set; } = ""; + + [Key(3)] + public string LastSeenAtUtc { get; set; } = ""; +} diff --git a/Aetheria.State/Documents/AetheriaWorldState.cs b/Aetheria.State/Documents/AetheriaWorldState.cs new file mode 100644 index 000000000..ed56ec046 --- /dev/null +++ b/Aetheria.State/Documents/AetheriaWorldState.cs @@ -0,0 +1,27 @@ +using GameCult.Caching; +using MessagePack; + +namespace Aetheria.State.Documents; + +[CultDocument("aetheria.world_state", "aetheria.world_state.v1")] +[CultGlobal] +[MessagePackObject] +public sealed class AetheriaWorldState +{ + [Key(0)] + [CultName] + public string Name { get; set; } = "Aetheria"; + + [Key(1)] + [CultIndex("worldId")] + public string WorldId { get; set; } = "aetheria"; + + [Key(2)] + public int SchemaEpoch { get; set; } = 1; + + [Key(3)] + public string CreatedAtUtc { get; set; } = ""; + + [Key(4)] + public string UpdatedAtUtc { get; set; } = ""; +} diff --git a/Aetheria.State/Migration/LegacyMigrationBoundary.cs b/Aetheria.State/Migration/LegacyMigrationBoundary.cs new file mode 100644 index 000000000..2337bab00 --- /dev/null +++ b/Aetheria.State/Migration/LegacyMigrationBoundary.cs @@ -0,0 +1,18 @@ +namespace Aetheria.State.Migration; + +public static class LegacyMigrationBoundary +{ + public const string LegacyCacheNamespace = "Assets/Scripts/ServerShared/CultCache"; + public const string LegacyRethinkNamespace = "Assets/Scripts/ServerShared/NIH/RethinkDb"; + public const string LegacyGameDataFile = "GameData/AetherDB.msgpack"; + + public static string[] ForbiddenLiveAuthorities { get; } = + [ + "DatabaseEntry", + "DatabaseCache", + "JsonKnownTypes", + "Newtonsoft.Json", + "RethinkDb.Driver", + "RethinkTableAttribute" + ]; +} diff --git a/Aetheria.State/README.md b/Aetheria.State/README.md new file mode 100644 index 000000000..ccf6d1ae0 --- /dev/null +++ b/Aetheria.State/README.md @@ -0,0 +1,21 @@ +# Aetheria.State + +`Aetheria.State` is the replacement owner for durable Aetheria game state. + +It uses modern `GameCult.Caching`, `GameCult.Caching.MessagePack`, and +`GameCult.Mesh`. It does not reference the old `Assets/Scripts/ServerShared` +tree, RethinkDB, JsonKnownTypes, or Newtonsoft.Json. + +The first live state file is `GameData/aetheria-world.cc`. Legacy files such as +`GameData/AetherDB.msgpack` are migration inputs only. + +Current rebuild notes: + +- `MessagePack` 3.1.4 is pulled transitively through CultLib and is currently + flagged by NuGet advisory `GHSA-hv8m-jj95-wg3x`; update CultLib's + `GameCult.Caching.MessagePack` package ownership rather than adding a local + Aetheria override. +- `CultRecordRef` should become the persisted reference shape once the + MessagePack source-generator path handles the GameCult formatter cleanly. + Until then, v1 profile documents store explicit record-key strings at the + persistence boundary.