Add ergonomic CultLib happy-path APIs

This commit is contained in:
Meta Vens 2026-05-09 13:32:30 +01:00
commit 80d0485d3c
9 changed files with 471 additions and 7 deletions

View file

@ -0,0 +1,90 @@
using System;
using System.Threading.Tasks;
namespace GameCult.Caching.MessagePack
{
/// <summary>
/// Options for opening a CultCache over the canonical single-file MessagePack backing store.
/// </summary>
public sealed class CultCacheOpenOptions
{
/// <summary>
/// Gets or sets the document registry to use. When omitted, the shared registry is used.
/// </summary>
public CultDocumentRegistry? Registry { get; set; }
/// <summary>
/// Gets or sets whether the cache should pull the existing snapshot during open.
/// </summary>
public bool PullOnOpen { get; set; } = true;
/// <summary>
/// Gets or sets whether the cache should flush attached stores when disposed.
/// </summary>
public bool FlushOnDispose { get; set; }
/// <summary>
/// Gets or sets whether the backing store should flush when disposed.
/// </summary>
public bool StoreFlushOnDispose { get; set; }
/// <summary>
/// Gets or sets an optional callback used to customize the cache before opening.
/// </summary>
public Action<CultCache>? ConfigureCache { get; set; }
/// <summary>
/// Gets or sets an optional callback used to customize the backing store before opening.
/// </summary>
public Action<SingleFileMessagePackBackingStore>? ConfigureStore { get; set; }
}
/// <summary>
/// Friendly MessagePack entrypoints for opening a CultCache with the canonical backing store.
/// </summary>
public static class CultCacheMessagePack
{
/// <summary>
/// Creates a cache with the canonical single-file MessagePack backing store attached.
/// </summary>
public static CultCache Create(string filePath, CultCacheOpenOptions? options = null)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("File path must be non-empty.", nameof(filePath));
}
options ??= new CultCacheOpenOptions();
var cache = new CultCache(options.Registry)
{
FlushAttachedStoresOnDispose = options.FlushOnDispose
};
options.ConfigureCache?.Invoke(cache);
var store = new SingleFileMessagePackBackingStore(filePath)
{
FlushOnDispose = options.StoreFlushOnDispose
};
options.ConfigureStore?.Invoke(store);
cache.AddBackingStore(store);
return cache;
}
/// <summary>
/// Creates a cache with the canonical single-file MessagePack backing store attached and optionally pulls the on-disk snapshot.
/// </summary>
public static async Task<CultCache> OpenAsync(string filePath, CultCacheOpenOptions? options = null)
{
options ??= new CultCacheOpenOptions();
var cache = Create(filePath, options);
if (options.PullOnOpen)
{
await cache.PullAllBackingStoresAsync().ConfigureAwait(false);
}
return cache;
}
}
}

View file

@ -1266,6 +1266,15 @@ namespace GameCult.Caching
}
}
/// <summary>
/// Flushes all attached backing stores.
/// </summary>
public Task FlushAsync(bool soft = false)
{
FlushAllBackingStores(soft);
return Task.CompletedTask;
}
/// <summary>
/// Flushes one attached backing store.
/// </summary>
@ -1293,6 +1302,15 @@ namespace GameCult.Caching
FlushAllBackingStores(soft);
}
/// <summary>
/// Flushes attached backing stores at a lifecycle boundary such as shutdown or assembly reload.
/// </summary>
public Task PrepareForReloadOrShutdownAsync(bool soft = false)
{
PrepareForReloadOrShutdown(soft);
return Task.CompletedTask;
}
/// <summary>
/// Adds or replaces a typed document and returns its record handle.
/// </summary>
@ -1306,6 +1324,14 @@ namespace GameCult.Caching
return new CultRecordHandle<T>(stored.Key);
}
/// <summary>
/// Adds or replaces a typed document and returns its record handle.
/// </summary>
public Task<CultRecordHandle<T>> UpsertAsync<T>(T document, CultRecordHandle<T>? handle = null)
{
return AddAsync(document, handle);
}
/// <summary>
/// Gets the record handle for a document instance, if it is tracked.
/// </summary>
@ -1335,6 +1361,15 @@ namespace GameCult.Caching
return Get(key) as T;
}
/// <summary>
/// Tries to get a typed document by record key.
/// </summary>
public bool TryGet<T>(CultRecordKey key, out T? document) where T : class
{
document = Get<T>(key);
return document != null;
}
/// <summary>
/// Gets all cached documents assignable to the requested type.
/// </summary>
@ -1371,6 +1406,15 @@ namespace GameCult.Caching
return null;
}
/// <summary>
/// Tries to get a typed document by its CultName value.
/// </summary>
public bool TryGetByName<T>(string name, out T? document) where T : class
{
document = GetByName<T>(name);
return document != null;
}
/// <summary>
/// Gets a typed document by an indexed value.
/// </summary>
@ -1385,6 +1429,15 @@ namespace GameCult.Caching
return null;
}
/// <summary>
/// Tries to get a typed document by an indexed value.
/// </summary>
public bool TryGetByIndex<T>(string alias, string value, out T? document) where T : class
{
document = GetByIndex<T>(alias, value);
return document != null;
}
/// <summary>
/// Resolves a typed document reference against this cache.
/// </summary>
@ -1404,6 +1457,15 @@ namespace GameCult.Caching
}
}
/// <summary>
/// Removes a document by typed handle.
/// </summary>
public Task DeleteAsync<T>(CultRecordHandle<T> handle)
{
Remove(handle);
return Task.CompletedTask;
}
/// <summary>
/// Disposes attached disposable backing stores.
/// </summary>

View file

@ -121,19 +121,28 @@ Each persisted record carries:
- `storedAt`
- `payload`
## Typical Startup
## Happy Path
```csharp
using GameCult.Caching;
using GameCult.Caching.MessagePack;
var cache = new CultCache();
var store = new SingleFileMessagePackBackingStore("Data.msgpack");
var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack");
cache.AddBackingStore(store);
await cache.PullAllBackingStoresAsync();
var handle = await cache.UpsertAsync(new ItemData
{
Name = "Potion",
Category = "Consumable",
Value = 50
});
await cache.FlushAsync();
```
If you want the explicit assembly rite, the lower-level `CultCache` +
`SingleFileMessagePackBackingStore` path is still there. The helper is just the
blessed front door.
## Persistence discipline
Persistence stays manual by default. Mutations mark the cache and attached
@ -142,11 +151,15 @@ that felt convenient in the moment.
Useful surfaces:
- `CultCacheMessagePack.OpenAsync(path)`
- `cache.IsDirty`
- `store.IsDirty`
- `cache.UpsertAsync(document)`
- `cache.FlushAllBackingStores()`
- `cache.FlushAsync()`
- `cache.FlushBackingStore(store)`
- `cache.PrepareForReloadOrShutdown()`
- `cache.PrepareForReloadOrShutdownAsync()`
- `cache.FlushAttachedStoresOnDispose`
- `store.FlushOnDispose`

View file

@ -164,6 +164,14 @@ namespace GameCult.Networking
});
}
/// <summary>
/// Adds a listener for a specific message type.
/// </summary>
public void On<T>(Action<T> callback) where T : Message
{
AddMessageListener(callback);
}
/// <summary>
/// Removes a previously registered listener for a specific message type.
/// </summary>
@ -178,6 +186,14 @@ namespace GameCult.Networking
}
}
/// <summary>
/// Removes a previously registered listener for a specific message type.
/// </summary>
public void Off<T>(Action<T> callback) where T : Message
{
RemoveMessageListener(callback);
}
/// <summary>
/// Sends a login request.
/// </summary>

View file

@ -0,0 +1,162 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
namespace GameCult.Networking
{
/// <summary>
/// Options for creating a local CultNet host over CultCache and the canonical MessagePack store.
/// </summary>
public sealed class CultNetHostOptions
{
/// <summary>
/// Gets or sets the cache-open options used for the underlying CultCache.
/// </summary>
public CultCacheOpenOptions? CacheOptions { get; set; }
/// <summary>
/// Gets or sets the server security configuration. When omitted, development defaults are used.
/// </summary>
public ServerSecurityOptions? Security { get; set; }
/// <summary>
/// Gets or sets whether the server should start immediately after creation.
/// </summary>
public bool StartServer { get; set; } = true;
/// <summary>
/// Gets or sets an optional callback used to customize the server before start.
/// </summary>
public Action<Server>? ConfigureServer { get; set; }
}
/// <summary>
/// Convenience wrapper for a locally hosted CultNet server over a durable CultCache.
/// </summary>
public sealed class CultNetHost : IDisposable
{
internal CultNetHost(CultCache cache, SingleFileMessagePackBackingStore? store, Server server)
{
Cache = cache;
Store = store;
Server = server;
}
/// <summary>
/// Gets the durable cache used by the host.
/// </summary>
public CultCache Cache { get; }
/// <summary>
/// Gets the canonical single-file MessagePack store, when one is attached.
/// </summary>
public SingleFileMessagePackBackingStore? Store { get; }
/// <summary>
/// Gets the underlying CultNet server.
/// </summary>
public Server Server { get; }
/// <summary>
/// Starts the server.
/// </summary>
public void Start()
{
Server.Start();
}
/// <summary>
/// Flushes the durable cache.
/// </summary>
public Task FlushAsync(bool soft = false)
{
return Cache.FlushAsync(soft);
}
/// <summary>
/// Stops the server and disposes the host resources.
/// </summary>
public void Dispose()
{
Server.Dispose();
Cache.Dispose();
}
}
/// <summary>
/// Friendly local entrypoints for CultNet host/client creation.
/// </summary>
public static class CultNetLocal
{
/// <summary>
/// Creates a local host with a durable CultCache and the canonical MessagePack backing store.
/// </summary>
public static async Task<CultNetHost> CreateHostAsync(string cachePath, CultNetHostOptions? options = null)
{
if (string.IsNullOrWhiteSpace(cachePath))
{
throw new ArgumentException("Cache path must be non-empty.", nameof(cachePath));
}
options ??= new CultNetHostOptions();
var cache = await CultCacheMessagePack.OpenAsync(cachePath, options.CacheOptions).ConfigureAwait(false);
var store = cache.BackingStores.OfType<SingleFileMessagePackBackingStore>().FirstOrDefault();
var server = new Server(cache, options.Security ?? ServerSecurityOptions.Development());
options.ConfigureServer?.Invoke(server);
var host = new CultNetHost(cache, store, server);
if (options.StartServer)
{
host.Start();
}
return host;
}
/// <summary>
/// Creates and starts a local host with development-friendly defaults.
/// </summary>
public static Task<CultNetHost> StartHostAsync(string cachePath, CultNetHostOptions? options = null)
{
options ??= new CultNetHostOptions();
options.StartServer = true;
return CreateHostAsync(cachePath, options);
}
/// <summary>
/// Creates a client configured for local CultNet use.
/// </summary>
public static Client CreateClient(ClientSecurityOptions? security = null, Action<Client>? configureClient = null)
{
var client = new Client(security ?? ClientSecurityOptions.Development());
configureClient?.Invoke(client);
return client;
}
/// <summary>
/// Creates and connects a client to the specified host.
/// </summary>
public static Client ConnectClient(
string host = "localhost",
int port = 3075,
ClientSecurityOptions? security = null,
Action<Client>? configureClient = null)
{
var client = CreateClient(security, configureClient);
client.Connect(host, port);
return client;
}
/// <summary>
/// Creates and connects a client to a local development host.
/// </summary>
public static Client ConnectLocal(
ClientSecurityOptions? security = null,
Action<Client>? configureClient = null)
{
return ConnectClient("localhost", 3075, security, configureClient);
}
}
}

View file

@ -183,6 +183,20 @@ Use `ServerSecurityOptions.Development()` only for local development and tests.
## Basic Server Usage
Quickest local-host path:
```csharp
using GameCult.Networking;
var host = await CultNetLocal.StartHostAsync("Players.msgpack");
host.Server.On<ChatMessage>(message =>
{
host.Server.Logger.LogInfo($"Chat: {message.Text}");
});
```
Lower-level explicit path:
```csharp
using GameCult.Caching;
using GameCult.Logging;
@ -194,7 +208,7 @@ var server = new Server(cache)
Logger = new ConsoleLogger()
};
server.AddMessageListener<ChatMessage>(message =>
server.On<ChatMessage>(message =>
{
server.Logger.LogInfo($"Chat: {message.Text}");
});
@ -204,6 +218,18 @@ server.Start();
## Basic Client Usage
Quickest local-client path:
```csharp
using GameCult.Networking;
var client = CultNetLocal.ConnectLocal();
client.On<ChatMessage>(message => Console.WriteLine(message.Text));
client.Login("user@example.com", "correct horse battery staple");
```
Lower-level explicit path:
```csharp
using GameCult.Logging;
using GameCult.Networking;
@ -214,7 +240,7 @@ var client = new Client(new ClientSecurityOptions("<matching-connection-key>"))
};
client.OnError += error => client.Logger.LogError($"Client error: {error}");
client.AddMessageListener<ChatMessage>(message => client.Logger.LogInfo($"Chat: {message.Text}"));
client.On<ChatMessage>(message => client.Logger.LogInfo($"Chat: {message.Text}"));
client.Connect("localhost", 3075);
client.Login("user@example.com", "correct horse battery staple");

View file

@ -111,6 +111,14 @@ namespace GameCult.Networking
});
}
/// <summary>
/// Adds a listener for a specific authenticated message type.
/// </summary>
public void On<T>(Action<T> callback) where T : Message
{
AddMessageListener(callback);
}
/// <summary>
/// Removes a previously registered listener for a specific message type.
/// </summary>
@ -125,6 +133,14 @@ namespace GameCult.Networking
}
}
/// <summary>
/// Removes a previously registered listener for a specific authenticated message type.
/// </summary>
public void Off<T>(Action<T> callback) where T : Message
{
RemoveMessageListener(callback);
}
/// <summary>
/// Stops the underlying LiteNetLib server.
/// </summary>

View file

@ -1,3 +1,4 @@
#nullable enable
using System;
using System.IO;
using System.Linq;
@ -92,6 +93,35 @@ namespace GameCult.Caching.Tests
}
}
[Test]
public async Task CultCacheMessagePack_OpenAsync_Creates_Usable_Durable_Cache()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack");
try
{
var cache = await CultCacheMessagePack.OpenAsync(filePath);
var handle = await cache.UpsertAsync(new NamedTestEntry
{
Name = "open",
Value = "magic"
});
await cache.FlushAsync();
cache.Dispose();
var reopened = await CultCacheMessagePack.OpenAsync(filePath);
Assert.That(reopened.TryGet(handle.Key, out NamedTestEntry? loaded), Is.True);
Assert.That(loaded!.Value, Is.EqualTo("magic"));
}
finally
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
}
[Test]
public async Task CultCache_FlushOnDispose_Persists_When_Enabled()
{

View file

@ -1,5 +1,6 @@
#nullable enable
using System;
using System.IO;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
@ -334,6 +335,54 @@ namespace GameCult.Networking.Tests
Assert.That(developmentServer.LogSensitivePayloads, Is.True);
}
[Test]
public async Task CultNetLocal_CreateHostAsync_Wires_Durable_Cache_Without_Starting_Server()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultnet-host-{Guid.NewGuid():N}.msgpack");
try
{
var host = await CultNetLocal.CreateHostAsync(filePath, new CultNetHostOptions
{
StartServer = false
});
Assert.That(host.Cache, Is.Not.Null);
Assert.That(host.Store, Is.Not.Null);
Assert.That(host.Server, Is.Not.Null);
await host.Cache.UpsertAsync(new PlayerData
{
PlayerId = Guid.NewGuid(),
Email = "host@example.test",
PasswordHash = "hash",
Username = "HostUser"
});
await host.FlushAsync();
host.Dispose();
var reopened = await CultCacheMessagePack.OpenAsync(filePath);
Assert.That(reopened.GetByName<PlayerData>("HostUser"), Is.Not.Null);
}
finally
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
}
[Test]
public void CultNetLocal_CreateClient_Uses_Development_Defaults()
{
var client = CultNetLocal.CreateClient();
Assert.That(client, Is.Not.Null);
Assert.That(client.LogSensitivePayloads, Is.False);
Assert.That(client.ReconnectState, Is.EqualTo(ClientReconnectState.Idle));
}
private sealed class EnvironmentVariableScope : IDisposable
{
private readonly (string Name, string? Value)[] _originalValues;