From 89e91ff4d7d933206279bc93f73f0ca82e8f280c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 13 Jun 2026 00:31:22 +0100 Subject: [PATCH] Start typed state rebuild --- .gitignore | 4 + .voidbot/state/aetheria.cc | Bin 8361 -> 9542 bytes .../Aetheria.State.Smoke.csproj | 13 +++ Aetheria.State.Smoke/Program.cs | 70 ++++++++++++ Aetheria.State/Aetheria.State.csproj | 16 +++ Aetheria.State/AetheriaDocumentRegistry.cs | 45 ++++++++ Aetheria.State/AetheriaStateNode.cs | 106 ++++++++++++++++++ Aetheria.State/AetheriaStatePaths.cs | 16 +++ .../Documents/AetheriaCorporation.cs | 20 ++++ .../Documents/AetheriaItemDefinition.cs | 33 ++++++ .../Documents/AetheriaMigrationLedger.cs | 39 +++++++ .../Documents/AetheriaPlayerProfile.cs | 25 +++++ .../Documents/AetheriaRuntimeSession.cs | 23 ++++ .../Documents/AetheriaWorldState.cs | 27 +++++ .../Migration/LegacyMigrationBoundary.cs | 18 +++ Aetheria.State/README.md | 21 ++++ 16 files changed, 476 insertions(+) create mode 100644 Aetheria.State.Smoke/Aetheria.State.Smoke.csproj create mode 100644 Aetheria.State.Smoke/Program.cs create mode 100644 Aetheria.State/Aetheria.State.csproj create mode 100644 Aetheria.State/AetheriaDocumentRegistry.cs create mode 100644 Aetheria.State/AetheriaStateNode.cs create mode 100644 Aetheria.State/AetheriaStatePaths.cs create mode 100644 Aetheria.State/Documents/AetheriaCorporation.cs create mode 100644 Aetheria.State/Documents/AetheriaItemDefinition.cs create mode 100644 Aetheria.State/Documents/AetheriaMigrationLedger.cs create mode 100644 Aetheria.State/Documents/AetheriaPlayerProfile.cs create mode 100644 Aetheria.State/Documents/AetheriaRuntimeSession.cs create mode 100644 Aetheria.State/Documents/AetheriaWorldState.cs create mode 100644 Aetheria.State/Migration/LegacyMigrationBoundary.cs create mode 100644 Aetheria.State/README.md 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 168f00ca4d27a3c5fc6c690ac9b5c7f7534e679b..1c0d74e9a5b544bab7463bdacdcea71160e21b32 100644 GIT binary patch delta 2089 zcmaJ?O>7%Q7$rY~(o#XyByiy8L!cIrvyStlrYWjunt~{8h?*iq1W4oE&-Re@%rY}; z$4KA<7p`b8+#9Gs9Ex&)18^%Rgg6p}fIBBH91(AJ*N!7aTrBPE`1{`XzW3gKe(8s+ zcW#ZJoL^X(Ketj_3eGJqY<)a7o!uS(=-_a9ZZ1F66W!6n#r*Kt{h!%99|OIT8xV%cF-!(SpG>5*1_(x7RRO^3}UW=DOG!~@|9-9R;| zw%1lGbk%D0io!TI0mAn+w9Z|pmDLRw+;%`Awc7v7)8$LPrljjtZC;p6s+?>yIFWHo zsl_ZFZC%(tUs2BVX4!~>u>!Bh;F$?i1I}FBqa=-EOPw?ZpQ0W!ff_5CydlDES_o>v zA~_=f!ePd^cVT2JVe0(4I-bFDnfY3XcjWWdS*qhgr14s5M?H2}5O`{0D_G}nh)@7K z>f@ePPGhIp*yB_EpVoQDZ?_O!BCI9b(kUp&s|ZrU7~LIkP~}KEke3M8D(V~-R)w~i z6NN>tE~jr7M2)hM4`*94A%iAtFf~+P>Oa<)V<=|8 z2)Dr^VPIoq#5VXssIaBY4Q|-q?wQ(KKK9Lh{SR+Jz5CBX-g@OQ-0I=SAhg~XyW3Jl zpIM!nkZ;?x-4>>|zY4vS5-z+^SZ`pYfj_)SM-1XsuvDV7K>_5M{{xcSa~%%L{yhFj z|AJSNcTsgHYiv2fz%W4ts&W--GJbplG~(2@sIF5L0dUdmyQ6xo4g7cHpl4r(_9a+$ zy*En34p9)>a*-2s1pL?OV2 zHgy@n>V@$|>abWq!u<3I!`{g*jP_P#KH2;C!G(z@i&keM?G(G&+$M)bl$}3yrvKJ}qB39edzQkOg@XON5_WlyjixRF z8XB=A8V=ev44Zk)MAG;?KGF!~xX+as5J!g*a4_ko_CQ{LHJ_ilMmr>6E(veCf`ozn zye~R<2LN4^>E4Aqo*4a@k&UcG6>SdI)G|tbEj9P55A`JwXIzznsD+%+54yexVYzZBEyw@WwT{weO;Ut zi8Fg3q_2u|h{@-yw_x_>RONxu;@Qs=Gb3N*TJf3waq8O>!&}R>?C*((Gd*!{c4s;s dy}X=%zAw&vFmedI`1oZ=MXCC@wCS=9gy| z{L*wWEsmc}KMvgOE0(h2M4All9`AL|q|@Vtba?2(z$u+x8rr*jZw0B2RtdO)wu+Kq zQ^>l~2(fSuWZgJ~$OLG0`fCU}LI<$k#7WcA7{Inx)Q0Uc+%hI?D1y+mc$lt%aKLjx z8Xk_CfP}3f`A=miRCMsUi|Xno2?J13Y%GD${~4aX93AO+5UK`-Z7{ax*F=Oe3CYJM z`boxTGEwx0jt68Qw&*ej%oc%pj|BO&OW%R+1o-9&f2z7SBu)2IS^Qel7n{v{sm>8R#WWD9h z3|F%02pBMP3#8auX1*p!RD{EIO}0I?#X%|En8c11J#Y_NbroW+P9`?uy zs94i-Xs0&9govLjIkp#3FQEU|}s|8*mtMRawUf7THD4F2iz6Gsog|(uboHoGm-8Axp@cX$Ay2*6bN z=1LL5#a#2>+g%2-oU;EyXkG-ng;n=>e|7lXk&~~msRGmzv)Y6G-P1FJ>HWgeneL_) z7mGW4Bx>$a`nB*a{dvyoZDDaSogORnt?JBDzQ1bheP3~Qfs6eMJ^QEY%lMtXuDMe7 S>;CQYAC8W6!Ao=VJHG*LHFGNf 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.