Start typed state rebuild

This commit is contained in:
Meta Vens 2026-06-13 00:31:22 +01:00
commit 89e91ff4d7
16 changed files with 476 additions and 0 deletions

4
.gitignore vendored
View file

@ -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/

Binary file not shown.

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Aetheria.State\Aetheria.State.csproj" />
</ItemGroup>
</Project>

View file

@ -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}");

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Description>Typed CultCache and CultMesh state spine for Aetheria.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\CultLib\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\..\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="..\..\CultLib\src\GameCult.Mesh\GameCult.Mesh.csproj" />
<ProjectReference Include="..\..\CultLib\src\GameCult.Networking\GameCult.Networking.csproj" />
</ItemGroup>
</Project>

View file

@ -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<AetheriaWorldState>(registry),
CultNetDocumentBinding.ForDocument<AetheriaItemDefinition>(registry),
CultNetDocumentBinding.ForDocument<AetheriaCorporation>(registry),
CultNetDocumentBinding.ForDocument<AetheriaPlayerProfile>(registry),
CultNetDocumentBinding.ForDocument<AetheriaRuntimeSession>(registry),
CultNetDocumentBinding.ForDocument<AetheriaMigrationLedger>(registry)
});
}
public static IReadOnlyList<Type> DocumentTypes { get; } =
[
typeof(AetheriaWorldState),
typeof(AetheriaItemDefinition),
typeof(AetheriaCorporation),
typeof(AetheriaPlayerProfile),
typeof(AetheriaRuntimeSession),
typeof(AetheriaMigrationLedger)
];
}

View file

@ -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<AetheriaStateNode> 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<CultRecordHandle<AetheriaWorldState>> PutWorldAsync(AetheriaWorldState world)
{
return Database.PutAsync(new CultRecordKey("global:aetheria.world_state.v1"), world);
}
public Task<AetheriaWorldState?> GetWorldAsync()
{
return Database.GetAsync<AetheriaWorldState>(new CultRecordKey("global:aetheria.world_state.v1"));
}
public Task<CultRecordHandle<AetheriaMigrationLedger>> PutMigrationLedgerAsync(AetheriaMigrationLedger ledger)
{
return Database.PutAsync(new CultRecordKey("global:aetheria.migration_ledger.v1"), ledger);
}
public Task<CultRecordHandle<AetheriaItemDefinition>> PutItemDefinitionAsync(
CultRecordKey key,
AetheriaItemDefinition item)
{
return Database.PutAsync(key, item);
}
public Task<AetheriaItemDefinition?> GetItemDefinitionAsync(CultRecordKey key)
{
return Database.GetAsync<AetheriaItemDefinition>(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();
}
}

View file

@ -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);
}
}

View file

@ -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; } = "";
}

View file

@ -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; } = [];
}

View file

@ -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; }
}

View file

@ -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; } = "";
}

View file

@ -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; } = "";
}

View file

@ -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; } = "";
}

View file

@ -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"
];
}

21
Aetheria.State/README.md Normal file
View file

@ -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<T>` 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.