Start typed state rebuild
This commit is contained in:
parent
ab2c294464
commit
89e91ff4d7
16 changed files with 476 additions and 0 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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.
13
Aetheria.State.Smoke/Aetheria.State.Smoke.csproj
Normal file
13
Aetheria.State.Smoke/Aetheria.State.Smoke.csproj
Normal 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>
|
||||
70
Aetheria.State.Smoke/Program.cs
Normal file
70
Aetheria.State.Smoke/Program.cs
Normal 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}");
|
||||
16
Aetheria.State/Aetheria.State.csproj
Normal file
16
Aetheria.State/Aetheria.State.csproj
Normal 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>
|
||||
45
Aetheria.State/AetheriaDocumentRegistry.cs
Normal file
45
Aetheria.State/AetheriaDocumentRegistry.cs
Normal 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)
|
||||
];
|
||||
}
|
||||
106
Aetheria.State/AetheriaStateNode.cs
Normal file
106
Aetheria.State/AetheriaStateNode.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
16
Aetheria.State/AetheriaStatePaths.cs
Normal file
16
Aetheria.State/AetheriaStatePaths.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
20
Aetheria.State/Documents/AetheriaCorporation.cs
Normal file
20
Aetheria.State/Documents/AetheriaCorporation.cs
Normal 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; } = "";
|
||||
}
|
||||
33
Aetheria.State/Documents/AetheriaItemDefinition.cs
Normal file
33
Aetheria.State/Documents/AetheriaItemDefinition.cs
Normal 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; } = [];
|
||||
}
|
||||
39
Aetheria.State/Documents/AetheriaMigrationLedger.cs
Normal file
39
Aetheria.State/Documents/AetheriaMigrationLedger.cs
Normal 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; }
|
||||
}
|
||||
25
Aetheria.State/Documents/AetheriaPlayerProfile.cs
Normal file
25
Aetheria.State/Documents/AetheriaPlayerProfile.cs
Normal 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; } = "";
|
||||
}
|
||||
23
Aetheria.State/Documents/AetheriaRuntimeSession.cs
Normal file
23
Aetheria.State/Documents/AetheriaRuntimeSession.cs
Normal 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; } = "";
|
||||
}
|
||||
27
Aetheria.State/Documents/AetheriaWorldState.cs
Normal file
27
Aetheria.State/Documents/AetheriaWorldState.cs
Normal 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; } = "";
|
||||
}
|
||||
18
Aetheria.State/Migration/LegacyMigrationBoundary.cs
Normal file
18
Aetheria.State/Migration/LegacyMigrationBoundary.cs
Normal 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
21
Aetheria.State/README.md
Normal 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.
|
||||
Loading…
Reference in a new issue