From 80d0485d3c1e05ef0e03c25f1add25a3d69b204d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 9 May 2026 13:32:30 +0100 Subject: [PATCH] Add ergonomic CultLib happy-path APIs --- .../CultCacheMessagePack.cs | 90 ++++++++++ src/GameCult.Caching/CultCache.cs | 62 +++++++ src/GameCult.Caching/README.md | 23 ++- src/GameCult.Networking/Client.cs | 16 ++ src/GameCult.Networking/CultNetLocal.cs | 162 ++++++++++++++++++ src/GameCult.Networking/README.md | 30 +++- src/GameCult.Networking/Server.cs | 16 ++ .../BackingStoreTests.cs | 30 ++++ .../NetworkingTests.cs | 49 ++++++ 9 files changed, 471 insertions(+), 7 deletions(-) create mode 100644 src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs create mode 100644 src/GameCult.Networking/CultNetLocal.cs diff --git a/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs new file mode 100644 index 00000000..7e32c799 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultCacheMessagePack.cs @@ -0,0 +1,90 @@ +using System; +using System.Threading.Tasks; + +namespace GameCult.Caching.MessagePack +{ + /// + /// Options for opening a CultCache over the canonical single-file MessagePack backing store. + /// + public sealed class CultCacheOpenOptions + { + /// + /// Gets or sets the document registry to use. When omitted, the shared registry is used. + /// + public CultDocumentRegistry? Registry { get; set; } + + /// + /// Gets or sets whether the cache should pull the existing snapshot during open. + /// + public bool PullOnOpen { get; set; } = true; + + /// + /// Gets or sets whether the cache should flush attached stores when disposed. + /// + public bool FlushOnDispose { get; set; } + + /// + /// Gets or sets whether the backing store should flush when disposed. + /// + public bool StoreFlushOnDispose { get; set; } + + /// + /// Gets or sets an optional callback used to customize the cache before opening. + /// + public Action? ConfigureCache { get; set; } + + /// + /// Gets or sets an optional callback used to customize the backing store before opening. + /// + public Action? ConfigureStore { get; set; } + } + + /// + /// Friendly MessagePack entrypoints for opening a CultCache with the canonical backing store. + /// + public static class CultCacheMessagePack + { + /// + /// Creates a cache with the canonical single-file MessagePack backing store attached. + /// + 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; + } + + /// + /// Creates a cache with the canonical single-file MessagePack backing store attached and optionally pulls the on-disk snapshot. + /// + public static async Task OpenAsync(string filePath, CultCacheOpenOptions? options = null) + { + options ??= new CultCacheOpenOptions(); + var cache = Create(filePath, options); + if (options.PullOnOpen) + { + await cache.PullAllBackingStoresAsync().ConfigureAwait(false); + } + + return cache; + } + } +} diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index a2e33c7f..0f1d4ed6 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -1266,6 +1266,15 @@ namespace GameCult.Caching } } + /// + /// Flushes all attached backing stores. + /// + public Task FlushAsync(bool soft = false) + { + FlushAllBackingStores(soft); + return Task.CompletedTask; + } + /// /// Flushes one attached backing store. /// @@ -1293,6 +1302,15 @@ namespace GameCult.Caching FlushAllBackingStores(soft); } + /// + /// Flushes attached backing stores at a lifecycle boundary such as shutdown or assembly reload. + /// + public Task PrepareForReloadOrShutdownAsync(bool soft = false) + { + PrepareForReloadOrShutdown(soft); + return Task.CompletedTask; + } + /// /// Adds or replaces a typed document and returns its record handle. /// @@ -1306,6 +1324,14 @@ namespace GameCult.Caching return new CultRecordHandle(stored.Key); } + /// + /// Adds or replaces a typed document and returns its record handle. + /// + public Task> UpsertAsync(T document, CultRecordHandle? handle = null) + { + return AddAsync(document, handle); + } + /// /// Gets the record handle for a document instance, if it is tracked. /// @@ -1335,6 +1361,15 @@ namespace GameCult.Caching return Get(key) as T; } + /// + /// Tries to get a typed document by record key. + /// + public bool TryGet(CultRecordKey key, out T? document) where T : class + { + document = Get(key); + return document != null; + } + /// /// Gets all cached documents assignable to the requested type. /// @@ -1371,6 +1406,15 @@ namespace GameCult.Caching return null; } + /// + /// Tries to get a typed document by its CultName value. + /// + public bool TryGetByName(string name, out T? document) where T : class + { + document = GetByName(name); + return document != null; + } + /// /// Gets a typed document by an indexed value. /// @@ -1385,6 +1429,15 @@ namespace GameCult.Caching return null; } + /// + /// Tries to get a typed document by an indexed value. + /// + public bool TryGetByIndex(string alias, string value, out T? document) where T : class + { + document = GetByIndex(alias, value); + return document != null; + } + /// /// Resolves a typed document reference against this cache. /// @@ -1404,6 +1457,15 @@ namespace GameCult.Caching } } + /// + /// Removes a document by typed handle. + /// + public Task DeleteAsync(CultRecordHandle handle) + { + Remove(handle); + return Task.CompletedTask; + } + /// /// Disposes attached disposable backing stores. /// diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index 9b87598d..1e72b1dd 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -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` diff --git a/src/GameCult.Networking/Client.cs b/src/GameCult.Networking/Client.cs index 3adf7090..c58357f8 100644 --- a/src/GameCult.Networking/Client.cs +++ b/src/GameCult.Networking/Client.cs @@ -164,6 +164,14 @@ namespace GameCult.Networking }); } + /// + /// Adds a listener for a specific message type. + /// + public void On(Action callback) where T : Message + { + AddMessageListener(callback); + } + /// /// Removes a previously registered listener for a specific message type. /// @@ -178,6 +186,14 @@ namespace GameCult.Networking } } + /// + /// Removes a previously registered listener for a specific message type. + /// + public void Off(Action callback) where T : Message + { + RemoveMessageListener(callback); + } + /// /// Sends a login request. /// diff --git a/src/GameCult.Networking/CultNetLocal.cs b/src/GameCult.Networking/CultNetLocal.cs new file mode 100644 index 00000000..49c3d62a --- /dev/null +++ b/src/GameCult.Networking/CultNetLocal.cs @@ -0,0 +1,162 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +namespace GameCult.Networking +{ + /// + /// Options for creating a local CultNet host over CultCache and the canonical MessagePack store. + /// + public sealed class CultNetHostOptions + { + /// + /// Gets or sets the cache-open options used for the underlying CultCache. + /// + public CultCacheOpenOptions? CacheOptions { get; set; } + + /// + /// Gets or sets the server security configuration. When omitted, development defaults are used. + /// + public ServerSecurityOptions? Security { get; set; } + + /// + /// Gets or sets whether the server should start immediately after creation. + /// + public bool StartServer { get; set; } = true; + + /// + /// Gets or sets an optional callback used to customize the server before start. + /// + public Action? ConfigureServer { get; set; } + } + + /// + /// Convenience wrapper for a locally hosted CultNet server over a durable CultCache. + /// + public sealed class CultNetHost : IDisposable + { + internal CultNetHost(CultCache cache, SingleFileMessagePackBackingStore? store, Server server) + { + Cache = cache; + Store = store; + Server = server; + } + + /// + /// Gets the durable cache used by the host. + /// + public CultCache Cache { get; } + + /// + /// Gets the canonical single-file MessagePack store, when one is attached. + /// + public SingleFileMessagePackBackingStore? Store { get; } + + /// + /// Gets the underlying CultNet server. + /// + public Server Server { get; } + + /// + /// Starts the server. + /// + public void Start() + { + Server.Start(); + } + + /// + /// Flushes the durable cache. + /// + public Task FlushAsync(bool soft = false) + { + return Cache.FlushAsync(soft); + } + + /// + /// Stops the server and disposes the host resources. + /// + public void Dispose() + { + Server.Dispose(); + Cache.Dispose(); + } + } + + /// + /// Friendly local entrypoints for CultNet host/client creation. + /// + public static class CultNetLocal + { + /// + /// Creates a local host with a durable CultCache and the canonical MessagePack backing store. + /// + public static async Task 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().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; + } + + /// + /// Creates and starts a local host with development-friendly defaults. + /// + public static Task StartHostAsync(string cachePath, CultNetHostOptions? options = null) + { + options ??= new CultNetHostOptions(); + options.StartServer = true; + return CreateHostAsync(cachePath, options); + } + + /// + /// Creates a client configured for local CultNet use. + /// + public static Client CreateClient(ClientSecurityOptions? security = null, Action? configureClient = null) + { + var client = new Client(security ?? ClientSecurityOptions.Development()); + configureClient?.Invoke(client); + return client; + } + + /// + /// Creates and connects a client to the specified host. + /// + public static Client ConnectClient( + string host = "localhost", + int port = 3075, + ClientSecurityOptions? security = null, + Action? configureClient = null) + { + var client = CreateClient(security, configureClient); + client.Connect(host, port); + return client; + } + + /// + /// Creates and connects a client to a local development host. + /// + public static Client ConnectLocal( + ClientSecurityOptions? security = null, + Action? configureClient = null) + { + return ConnectClient("localhost", 3075, security, configureClient); + } + } +} diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md index a1c39de2..e2a4f522 100644 --- a/src/GameCult.Networking/README.md +++ b/src/GameCult.Networking/README.md @@ -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(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(message => +server.On(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(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("")) }; client.OnError += error => client.Logger.LogError($"Client error: {error}"); -client.AddMessageListener(message => client.Logger.LogInfo($"Chat: {message.Text}")); +client.On(message => client.Logger.LogInfo($"Chat: {message.Text}")); client.Connect("localhost", 3075); client.Login("user@example.com", "correct horse battery staple"); diff --git a/src/GameCult.Networking/Server.cs b/src/GameCult.Networking/Server.cs index f9232328..1d703185 100644 --- a/src/GameCult.Networking/Server.cs +++ b/src/GameCult.Networking/Server.cs @@ -111,6 +111,14 @@ namespace GameCult.Networking }); } + /// + /// Adds a listener for a specific authenticated message type. + /// + public void On(Action callback) where T : Message + { + AddMessageListener(callback); + } + /// /// Removes a previously registered listener for a specific message type. /// @@ -125,6 +133,14 @@ namespace GameCult.Networking } } + /// + /// Removes a previously registered listener for a specific authenticated message type. + /// + public void Off(Action callback) where T : Message + { + RemoveMessageListener(callback); + } + /// /// Stops the underlying LiteNetLib server. /// diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 145fcfcd..a2781a08 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -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() { diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index fc0e8c58..e636030b 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -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("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;