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;