JSON backing store, I/O fixes, Networking Security

Introduced Newtonsoft.Json-backed persistence.
Improved backing-store reliability and serialization across the caching stack.
Improved networking security posture.

Key changes:
- Add new GameCult.Caching.NewtonsoftJson project with SingleFile/MultiFile backing stores and polymorphic known-type envelopes.
- Add MessagePack helper serialization (DatabaseEntrySerialization) and update MessagePack backing stores to use the shared options/resolver; add DatabaseLink formatter and generator/analyzer docs.
- Update CultLib.sln to include the new projects and new caching tests project.
- Improve CultCache persistence behavior: track pending store operations, wait for operations on PullAll, and centralize async push handling with error logging.
- Harden file I/O in multi-file/single-file stores: atomic temp-file writes, safe Replace/Move semantics, rename-safe deletes, and improved file-watcher subscription and debounce disposal.
- Add README files and documentation for several subprojects and packaging projects.

These changes add JSON persistence support, improve serialization consistency, and make on-disk operations more robust and observable.
This commit is contained in:
Meta Vens 2026-04-18 13:01:24 +01:00
commit 0bfc85a4f2
50 changed files with 3957 additions and 325 deletions

11
AGENTS.md Normal file
View file

@ -0,0 +1,11 @@
# AGENTS.md
## Repository Guidance
- Refrain from commenting on current changes in README files unless the user explicitly asks for that commentary.
- Treat clean API surface as a core project priority.
- Favor developer ergonomics in public APIs, examples, naming, defaults, and documentation.
- Prefer interfaces and behaviors that are predictable, easy to discover, and easy to integrate correctly.
- Keep public abstractions small and coherent; avoid exposing internal workflow complexity through the API when it can be encapsulated.
- When adding documentation or examples, optimize for practical usage by downstream developers.
- When proposing or implementing changes, consider how they affect usability, readability, and maintenance for library consumers.

View file

@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching", "src\Gam
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack", "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.NewtonsoftJson", "src\GameCult.Caching.NewtonsoftJson\GameCult.Caching.NewtonsoftJson.csproj", "{6C749A7B-A037-4AD4-A210-A411E7105D31}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Generator", "src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj", "{0E990C96-3808-450E-9349-A0C3262195ED}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Analyzers", "src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", "{DBE06954-EC85-4034-9049-72C7D27556DF}"
@ -21,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{39D316AB
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.Tests", "tests\GameCult.Networking.Tests\GameCult.Networking.Tests.csproj", "{E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.Tests", "tests\GameCult.Caching.Tests\GameCult.Caching.Tests.csproj", "{EB15435B-7147-4BD9-BB9C-B0A14318D3D5}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -42,6 +46,10 @@ Global
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|Any CPU.Build.0 = Release|Any CPU
{6C749A7B-A037-4AD4-A210-A411E7105D31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6C749A7B-A037-4AD4-A210-A411E7105D31}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6C749A7B-A037-4AD4-A210-A411E7105D31}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6C749A7B-A037-4AD4-A210-A411E7105D31}.Release|Any CPU.Build.0 = Release|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -50,6 +58,10 @@ Global
{E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81}.Release|Any CPU.Build.0 = Release|Any CPU
{EB15435B-7147-4BD9-BB9C-B0A14318D3D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EB15435B-7147-4BD9-BB9C-B0A14318D3D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EB15435B-7147-4BD9-BB9C-B0A14318D3D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EB15435B-7147-4BD9-BB9C-B0A14318D3D5}.Release|Any CPU.Build.0 = Release|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -63,8 +75,10 @@ Global
{72AEDD42-E881-4EB7-99A8-FD3B9667189C} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{9DDA917D-B4DB-4AC1-9B9A-4ECF06A5ABA1} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{6C749A7B-A037-4AD4-A210-A411E7105D31} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{EB15435B-7147-4BD9-BB9C-B0A14318D3D5} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{0E990C96-3808-450E-9349-A0C3262195ED} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{DBE06954-EC85-4034-9049-72C7D27556DF} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
EndGlobalSection

226
README.md
View file

@ -1,2 +1,226 @@
# CultLib
A collection of useful C# libraries
CultLib is a set of reusable C# libraries for game backends, game-adjacent services, and Unity-integrated tooling.
The libraries cover three main areas:
- logging primitives and implementations
- a typed in-memory cache with pluggable persistence
- LiteNetLib-based networking with encrypted credential exchange and signed session tokens
## Repository Scope
The solution includes:
- `GameCult.Logging`: common logging abstraction plus console and file implementations
- `GameCult.Caching`: `DatabaseEntry`-based cache, indexes, global entries, and backing-store abstractions
- `GameCult.Caching.MessagePack`: MessagePack-backed persistence for the cache
- `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-backed persistence for the cache
- `GameCult.Caching.MessagePack.Generator`: source generator for MessagePack formatters for cache models
- `GameCult.Caching.MessagePack.Analyzers`: packaging project that delivers the generator to consuming projects
- `GameCult.Networking`: encrypted login/register/verify flows and message dispatch over LiteNetLib
- `GameCult.Caching.Tests`: NUnit tests for cache and backing-store behavior
- `GameCult.Networking.Tests`: NUnit tests for networking behavior
- `GameCult.Unity`: Unity-side project and integrations used with the runtime libraries
## Repository Layout
```text
src/
GameCult.Logging/
GameCult.Caching/
GameCult.Caching.MessagePack/
GameCult.Caching.NewtonsoftJson/
GameCult.Caching.MessagePack.Generator/
GameCult.Caching.MessagePack.Analyzers/
GameCult.Networking/
GameCult.Unity/
tests/
GameCult.Caching.Tests/
GameCult.Networking.Tests/
```
## Build
```powershell
dotnet build CultLib.sln
```
## Test
```powershell
dotnet test CultLib.sln
```
## Common Concepts
### `DatabaseEntry`
The cache-centric libraries revolve around `DatabaseEntry`. Every entry has a stable `Guid` identifier and can optionally:
- expose a human-readable name through `INamedEntry`
- participate in generic indexes registered at runtime
- be treated as a global singleton entry through `GlobalSettingsAttribute`
Typical entry shape:
```csharp
using GameCult.Caching;
public class ItemData : DatabaseEntry, INamedEntry
{
public string Name = string.Empty;
public int Value;
public string EntryName
{
get => Name;
set => Name = value;
}
}
```
### `CultCache` and Backing Stores
`CultCache` is an in-memory index over `DatabaseEntry` objects. It can operate entirely in memory, or it can be attached to one or more backing stores for persistence and synchronization.
- the cache is the query surface
- backing stores are persistence adapters
- indexes and name maps are maintained inside the cache, not inside the store
### Important: Multiple Backing Stores
When multiple backing stores are added, behavior depends on how they are registered.
If a store is added with domain types:
```csharp
cache.AddBackingStore(playerStore, typeof(PlayerData));
cache.AddBackingStore(settingsStore, typeof(AppSettings));
```
then that store becomes the direct persistence target for those types.
If a store is added without domain types:
```csharp
cache.AddBackingStore(primaryStore);
cache.AddBackingStore(mirrorStore);
```
then the first generic store acts as the primary writable store for non-domain-specific entries. Additional generic stores subscribe to the existing stores and mirror their change events.
Implications:
- order matters for generic stores
- `AddAsync` writes to the type-specific store when one exists
- otherwise `AddAsync` writes to the first generic store
- later generic stores do not become co-primaries; they mirror earlier stores
- `PullAllBackingStoresAsync` pulls from every registered store
Recommended patterns:
- use one generic primary store if you want simple persistence
- use domain-specific stores when different entry types belong in different persistence layers
- treat additional generic stores as mirrors or downstream replicas, not independent write targets
## Example: Cache + Networking
```csharp
using GameCult.Caching;
using GameCult.Logging;
using GameCult.Networking;
var cache = new CultCache
{
Logger = new ConsoleLogger()
};
var server = new Server(cache)
{
Logger = new ConsoleLogger()
};
server.Start();
```
## Secrets and Runtime Configuration
`GameCult.Networking` uses two pieces of deployment configuration:
- `GAMECULT_CONNECTION_KEY`
- `GAMECULT_SESSION_SIGNING_SECRET`
Production guidance:
- set both values before starting the server
- treat `GAMECULT_CONNECTION_KEY` as shared client/server protocol configuration
- treat `GAMECULT_SESSION_SIGNING_SECRET` as a server-only secret
- generate high-entropy random values and store the server-side value in your platform's secret store
- do not rely on local-development defaults in production
- partial configuration is rejected at startup
Recommended value format:
- at least 32 random bytes per value
- encode as Base64 or Base64Url if you want a portable text representation
Example secret generation in PowerShell:
```powershell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```
Typical production setup patterns:
- inject `GAMECULT_SESSION_SIGNING_SECRET` and the server copy of `GAMECULT_CONNECTION_KEY` as environment variables from Docker secrets, Kubernetes secrets, systemd environment files, or your cloud secret manager
- ship the matching `GAMECULT_CONNECTION_KEY` to the client through a build-generated config asset, code-generated constants, or another explicit client configuration mechanism
- scope them per environment so development, staging, and production do not share values
- rotate `GAMECULT_SESSION_SIGNING_SECRET` operationally
- treat `GAMECULT_CONNECTION_KEY` as versioned protocol config; changing it requires a coordinated client/server rollout
Example server startup with strict environment validation:
```csharp
using GameCult.Caching;
using GameCult.Networking;
var cache = new CultCache();
var security = ServerSecurityOptions.FromEnvironment();
var server = new Server(cache, security);
```
Example local-development server startup:
```csharp
using GameCult.Caching;
using GameCult.Networking;
var cache = new CultCache();
var security = ServerSecurityOptions.Development();
var server = new Server(cache, security);
```
Example shipped-client startup with explicit client configuration:
```csharp
using GameCult.Networking;
var security = new ClientSecurityOptions("<matching-connection-key>");
var client = new Client(security);
```
## Project Docs
Each subproject has a local README with package-specific detail:
- [GameCult.Logging](src/GameCult.Logging/README.md)
- [GameCult.Caching](src/GameCult.Caching/README.md)
- [GameCult.Caching.MessagePack](src/GameCult.Caching.MessagePack/README.md)
- [GameCult.Caching.NewtonsoftJson](src/GameCult.Caching.NewtonsoftJson/README.md)
- [GameCult.Caching.MessagePack.Generator](src/GameCult.Caching.MessagePack.Generator/README.md)
- [GameCult.Caching.MessagePack.Analyzers](src/GameCult.Caching.MessagePack.Analyzers/README.md)
- [GameCult.Networking](src/GameCult.Networking/README.md)
- [GameCult.Unity](src/GameCult.Unity/README.md)
- [GameCult.Caching.Tests](tests/GameCult.Caching.Tests/README.md)
- [GameCult.Networking.Tests](tests/GameCult.Networking.Tests/README.md)

View file

@ -2,7 +2,10 @@
namespace GameCult.Caching.MessagePack.Analyzer
{
/// <summary>
/// Placeholder type for the analyzer packaging project.
/// </summary>
public class Class1
{
}
}
}

View file

@ -0,0 +1,25 @@
# GameCult.Caching.MessagePack.Analyzers
`GameCult.Caching.MessagePack.Analyzers` is the delivery mechanism for the MessagePack source generator used by the cache libraries.
## Scope
This project is primarily packaging infrastructure. It exists so consuming projects can receive generator output at compile time without treating the generator assembly as a normal runtime dependency.
## What It Does
- references the generator project
- arranges for the generator assembly to be packed under `analyzers/dotnet/cs`
- keeps generator assets out of the normal compile/runtime reference flow
## Practical Meaning
When a consuming project adds the analyzer package:
- the generator runs during compilation
- concrete `DatabaseEntry` types receive generated MessagePack formatters
- `GameCult.Caching.MessagePack` can then serialize those types without handwritten formatter code
## Current State
This project is a packaging host. It does not currently expose custom analyzer diagnostics of its own.

View file

@ -8,9 +8,16 @@ using Microsoft.CodeAnalysis.Text;
namespace GameCult.Caching.MessagePack.Generator
{
/// <summary>
/// Generates MessagePack formatters and resolver mappings for concrete <c>DatabaseEntry</c> types.
/// </summary>
[Generator]
public class DatabaseEntryMessagePackGenerator : IIncrementalGenerator
{
/// <summary>
/// Configures the incremental generator pipeline.
/// </summary>
/// <param name="ctx">The generator initialization context.</param>
public void Initialize(IncrementalGeneratorInitializationContext ctx)
{
// Select DatabaseEntry subclass declarations

View file

@ -0,0 +1,63 @@
# GameCult.Caching.MessagePack.Generator
`GameCult.Caching.MessagePack.Generator` is a Roslyn incremental source generator that emits MessagePack formatters for concrete `DatabaseEntry` subclasses.
## Scope
This project is infrastructure for the caching stack. Application code usually does not reference it directly.
Its job is to:
- find non-abstract subclasses of `DatabaseEntry`
- generate concrete `IMessagePackFormatter<T>` implementations
- generate resolver mappings consumed by `DatabaseEntryResolver`
## What It Generates
For each discovered concrete `DatabaseEntry` subtype, the generator emits:
- a formatter type
- a resolver mapping entry
Member ordering is:
1. `ID` first
2. members with `[Key(...)]`, ordered by key
3. remaining non-ignored instance fields and properties
That gives predictable binary layout while still supporting manual `[Key]` ordering.
## Example
Given:
```csharp
[MessagePackObject]
public class PlayerData : DatabaseEntry, INamedEntry
{
[Key(1)] public string Email = string.Empty;
[Key(2)] public string PasswordHash = string.Empty;
[Key(3)] public string Username = string.Empty;
[IgnoreMember]
public string EntryName
{
get => Username;
set => Username = value;
}
}
```
the generator emits the formatter plumbing needed for MessagePack serialization of `PlayerData` without manually implementing an `IMessagePackFormatter<PlayerData>`.
## When You Need To Think About This Package
Usually only when:
- formatter generation is not happening
- a cache-entry type is not serializing as expected
- you are debugging build-time source generation behavior
## Distribution
The generator is packaged for consumers through [../GameCult.Caching.MessagePack.Analyzers/README.md](../GameCult.Caching.MessagePack.Analyzers/README.md).

View file

@ -12,12 +12,20 @@ namespace GameCult.Caching.MessagePack
/// </summary>
public sealed class DatabaseEntryResolver : IFormatterResolver
{
/// <summary>
/// Shared singleton instance of the resolver.
/// </summary>
public static readonly DatabaseEntryResolver Instance = new DatabaseEntryResolver();
private readonly ConcurrentDictionary<Type, object> _formatterCache = new();
private DatabaseEntryResolver() { }
/// <summary>
/// Resolves a formatter for a cache entry type or a <see cref="DatabaseLink{T}"/>.
/// </summary>
/// <typeparam name="T">The requested serialized type.</typeparam>
/// <returns>The formatter if one is available; otherwise, <c>null</c>.</returns>
public IMessagePackFormatter<T>? GetFormatter<T>()
{
// 1. Check generated resolver mapping

View file

@ -0,0 +1,24 @@
using MessagePack;
using MessagePack.Resolvers;
namespace GameCult.Caching.MessagePack;
internal static class DatabaseEntrySerialization
{
public static readonly MessagePackSerializerOptions Options =
MessagePackSerializerOptions.Standard
.WithResolver(CompositeResolver.Create(
DatabaseEntryResolver.Instance,
StandardResolver.Instance))
.WithSecurity(MessagePackSecurity.UntrustedData);
public static byte[] Serialize<T>(T value)
{
return MessagePackSerializer.Serialize(value, Options);
}
public static T Deserialize<T>(byte[] payload)
{
return MessagePackSerializer.Deserialize<T>(payload, Options);
}
}

View file

@ -4,8 +4,13 @@ using MessagePack.Formatters;
namespace GameCult.Caching.MessagePack
{
public sealed class DatabaseLinkFormatter<T> : IMessagePackFormatter<DatabaseLink<T>> where T : DatabaseEntry
/// <summary>
/// Serializes <see cref="DatabaseLink{T}"/> values as their linked identifier only.
/// </summary>
/// <typeparam name="T">The target entry type.</typeparam>
public sealed class DatabaseLinkFormatter<T> : IMessagePackFormatter<DatabaseLink<T>?> where T : DatabaseEntry
{
/// <inheritdoc />
public void Serialize(ref MessagePackWriter writer, DatabaseLink<T>? value, MessagePackSerializerOptions options)
{
if (value == null) { writer.WriteNil(); return; }
@ -14,6 +19,7 @@ namespace GameCult.Caching.MessagePack
fmt.Serialize(ref writer, value.LinkID, options);
}
/// <inheritdoc />
public DatabaseLink<T>? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
if (reader.TryReadNil()) return null;

View file

@ -2,34 +2,53 @@ using MessagePack;
namespace GameCult.Caching.MessagePack;
/// <summary>
/// Persists all cache entries to a single MessagePack file.
/// </summary>
public class SingleFileMessagePackBackingStore : SingleFileBackingStore
{
/// <summary>
/// Initializes a single-file MessagePack backing store.
/// </summary>
/// <param name="filePath">The path to the backing file.</param>
public SingleFileMessagePackBackingStore(string filePath) : base(filePath) { }
/// <inheritdoc />
public override byte[] Serialize(DatabaseEntry[] entries)
{
return MessagePackSerializer.Serialize(entries);
return DatabaseEntrySerialization.Serialize(entries);
}
/// <inheritdoc />
public override DatabaseEntry[] Deserialize(byte[] data)
{
return MessagePackSerializer.Deserialize<DatabaseEntry[]>(data);
return DatabaseEntrySerialization.Deserialize<DatabaseEntry[]>(data);
}
}
/// <summary>
/// Persists each cache entry to its own MessagePack file.
/// </summary>
public class MultiFileMessagePackBackingStore : MultiFileBackingStore
{
/// <summary>
/// Initializes a multi-file MessagePack backing store rooted at the supplied directory.
/// </summary>
/// <param name="path">The root directory for stored entry files.</param>
public MultiFileMessagePackBackingStore(string path) : base(path) { }
/// <inheritdoc />
public override byte[] Serialize(DatabaseEntry entry)
{
return MessagePackSerializer.Serialize(entry);
return DatabaseEntrySerialization.Serialize(entry);
}
/// <inheritdoc />
public override DatabaseEntry Deserialize(byte[] data)
{
return MessagePackSerializer.Deserialize<DatabaseEntry>(data);
return DatabaseEntrySerialization.Deserialize<DatabaseEntry>(data);
}
/// <inheritdoc />
public override string Extension => "msgpack";
}
}

View file

@ -0,0 +1,115 @@
# GameCult.Caching.MessagePack
`GameCult.Caching.MessagePack` provides MessagePack-backed persistence for `GameCult.Caching`.
## Scope
The package focuses on:
- MessagePack storage for `DatabaseEntry` models
- single-file and multi-file store implementations
- support for generated formatters for concrete cache-entry types
- formatter support for `DatabaseLink<T>`
## Included Types
- `SingleFileMessagePackBackingStore`
- `MultiFileMessagePackBackingStore`
- `DatabaseEntryResolver`
- `DatabaseLinkFormatter<T>`
## Why The Generator Matters
`DatabaseEntry` is abstract. Concrete subclasses need MessagePack formatters to be serializable without hand-writing formatters for each model.
That is the role of:
- `GameCult.Caching.MessagePack.Generator`
- `GameCult.Caching.MessagePack.Analyzers`
In practice:
- this package gives you the stores and resolver support
- the generator package produces the actual concrete formatters
## Typical Usage
```csharp
using GameCult.Caching;
using GameCult.Caching.MessagePack;
var cache = new CultCache();
var store = new MultiFileMessagePackBackingStore("Data");
cache.AddBackingStore(store);
await cache.PullAllBackingStoresAsync();
```
## Single-File vs Multi-File
### `SingleFileMessagePackBackingStore`
Use this when you want one snapshot file containing all entries.
Use cases:
- compact settings data
- simple local persistence
- write-all-at-once workflows
```csharp
var store = new SingleFileMessagePackBackingStore("cache.msgpack");
cache.AddBackingStore(store);
```
### `MultiFileMessagePackBackingStore`
Use this when you want one file per entry.
Use cases:
- large datasets
- debugging persisted entries individually
- change observation through the multi-file base store
```csharp
var store = new MultiFileMessagePackBackingStore("Data");
cache.AddBackingStore(store, typeof(PlayerData));
```
## `DatabaseLink<T>`
`DatabaseLink<T>` is a lightweight reference type used by the cache layer. The MessagePack formatter serializes only the linked `Guid`, not the fully loaded target object.
That means:
- links stay compact on disk
- linked objects are resolved through the active `CultCache`
- deserialized links depend on `DatabaseLinkBase.Cache` pointing at the active cache
## Example Model
```csharp
using GameCult.Caching;
using MessagePack;
[MessagePackObject]
public class ItemData : DatabaseEntry, INamedEntry
{
[Key(1)] public string Name = string.Empty;
[Key(2)] public int Value;
[IgnoreMember]
public string EntryName
{
get => Name;
set => Name = value;
}
}
```
## Practical Notes
- For polymorphic `DatabaseEntry` serialization, use this package with the generator/analyzer package.
- The backing stores build on the semantics of `CultCache`, including primary-store routing and type-specific store routing.
- `MultiFileMessagePackBackingStore` inherits the rename-safe and atomic-write behavior of the hardened `MultiFileBackingStore` base class.

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Newtonsoft.Json backing stores for CultCache</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,238 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace GameCult.Caching.NewtonsoftJson;
/// <summary>
/// Persists all cache entries to a single JSON file using Newtonsoft.Json.
/// </summary>
public class SingleFileNewtonsoftJsonBackingStore : SingleFileBackingStore
{
private readonly KnownDatabaseEntryTypes _knownTypes = new();
/// <summary>
/// Initializes a single-file Newtonsoft.Json backing store.
/// </summary>
/// <param name="filePath">The path to the backing file.</param>
public SingleFileNewtonsoftJsonBackingStore(string filePath) : base(filePath)
{
}
/// <summary>
/// Registers a concrete <see cref="DatabaseEntry"/> type for serialization.
/// </summary>
/// <typeparam name="T">The entry type to register.</typeparam>
public void RegisterType<T>() where T : DatabaseEntry
{
_knownTypes.RegisterType(typeof(T));
}
/// <summary>
/// Registers all concrete <see cref="DatabaseEntry"/> types from the supplied assembly.
/// </summary>
/// <param name="assembly">The assembly to scan.</param>
public void RegisterAssembly(Assembly assembly)
{
_knownTypes.RegisterAssembly(assembly);
}
/// <summary>
/// Rebuilds the known-type map from the currently loaded assemblies.
/// </summary>
public void RefreshKnownTypes()
{
_knownTypes.RegisterLoadedAssemblies();
}
/// <inheritdoc />
public override byte[] Serialize(DatabaseEntry[] entries)
{
var serializer = JsonSerializer.Create(KnownDatabaseEntryTypes.SerializerSettings);
var payload = entries.Select(entry => _knownTypes.ToEnvelope(entry, serializer)).ToArray();
var json = JsonConvert.SerializeObject(payload, KnownDatabaseEntryTypes.SerializerSettings);
return Encoding.UTF8.GetBytes(json);
}
/// <inheritdoc />
public override DatabaseEntry[] Deserialize(byte[] data)
{
_knownTypes.RegisterLoadedAssemblies();
var json = Encoding.UTF8.GetString(data);
var serializer = JsonSerializer.Create(KnownDatabaseEntryTypes.SerializerSettings);
var payload = JsonConvert.DeserializeObject<DatabaseEntryEnvelope[]>(json, KnownDatabaseEntryTypes.SerializerSettings) ?? [];
return payload.Select(envelope => _knownTypes.FromEnvelope(envelope, serializer)).ToArray();
}
}
/// <summary>
/// Persists each cache entry to its own JSON file using Newtonsoft.Json.
/// </summary>
public class MultiFileNewtonsoftJsonBackingStore : MultiFileBackingStore
{
private readonly KnownDatabaseEntryTypes _knownTypes = new();
/// <summary>
/// Initializes a multi-file Newtonsoft.Json backing store rooted at the supplied directory.
/// </summary>
/// <param name="path">The root directory for stored entry files.</param>
public MultiFileNewtonsoftJsonBackingStore(string path) : base(path)
{
}
/// <summary>
/// Registers a concrete <see cref="DatabaseEntry"/> type for serialization.
/// </summary>
/// <typeparam name="T">The entry type to register.</typeparam>
public void RegisterType<T>() where T : DatabaseEntry
{
_knownTypes.RegisterType(typeof(T));
}
/// <summary>
/// Registers all concrete <see cref="DatabaseEntry"/> types from the supplied assembly.
/// </summary>
/// <param name="assembly">The assembly to scan.</param>
public void RegisterAssembly(Assembly assembly)
{
_knownTypes.RegisterAssembly(assembly);
}
/// <summary>
/// Rebuilds the known-type map from the currently loaded assemblies.
/// </summary>
public void RefreshKnownTypes()
{
_knownTypes.RegisterLoadedAssemblies();
}
/// <inheritdoc />
public override byte[] Serialize(DatabaseEntry entry)
{
var serializer = JsonSerializer.Create(KnownDatabaseEntryTypes.SerializerSettings);
var payload = _knownTypes.ToEnvelope(entry, serializer);
var json = JsonConvert.SerializeObject(payload, KnownDatabaseEntryTypes.SerializerSettings);
return Encoding.UTF8.GetBytes(json);
}
/// <inheritdoc />
public override DatabaseEntry Deserialize(byte[] data)
{
_knownTypes.RegisterLoadedAssemblies();
var json = Encoding.UTF8.GetString(data);
var serializer = JsonSerializer.Create(KnownDatabaseEntryTypes.SerializerSettings);
var payload = JsonConvert.DeserializeObject<DatabaseEntryEnvelope>(json, KnownDatabaseEntryTypes.SerializerSettings)
?? throw new JsonSerializationException("Failed to deserialize DatabaseEntry envelope.");
return _knownTypes.FromEnvelope(payload, serializer);
}
/// <inheritdoc />
public override string Extension => "json";
}
internal sealed class KnownDatabaseEntryTypes
{
internal static readonly JsonSerializerSettings SerializerSettings = new()
{
Formatting = Formatting.Indented
};
private readonly ConcurrentDictionary<string, Type> _discriminatorToType = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<Type, string> _typeToDiscriminator = new();
public KnownDatabaseEntryTypes()
{
RegisterLoadedAssemblies();
}
public void RegisterType(Type type)
{
if (!typeof(DatabaseEntry).IsAssignableFrom(type) || type.IsAbstract)
{
throw new ArgumentException($"Type '{type.FullName}' must be a non-abstract DatabaseEntry subtype.", nameof(type));
}
var discriminator = GetDiscriminator(type);
_discriminatorToType[discriminator] = type;
_typeToDiscriminator[type] = discriminator;
}
public void RegisterAssembly(Assembly assembly)
{
foreach (var type in SafeGetTypes(assembly).Where(IsConcreteDatabaseEntry))
{
RegisterType(type);
}
}
public void RegisterLoadedAssemblies()
{
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().Where(assembly => !assembly.IsDynamic))
{
RegisterAssembly(assembly);
}
}
public DatabaseEntryEnvelope ToEnvelope(DatabaseEntry entry, JsonSerializer serializer)
{
var type = entry.GetType();
if (!_typeToDiscriminator.TryGetValue(type, out var discriminator))
{
RegisterType(type);
discriminator = _typeToDiscriminator[type];
}
return new DatabaseEntryEnvelope
{
Type = discriminator,
Data = JObject.FromObject(entry, serializer)
};
}
public DatabaseEntry FromEnvelope(DatabaseEntryEnvelope envelope, JsonSerializer serializer)
{
if (!_discriminatorToType.TryGetValue(envelope.Type, out var type))
{
throw new JsonSerializationException($"Unknown DatabaseEntry discriminator '{envelope.Type}'. Register the containing assembly or entry type before deserializing.");
}
return (DatabaseEntry?)envelope.Data.ToObject(type, serializer)
?? throw new JsonSerializationException($"Failed to deserialize DatabaseEntry discriminator '{envelope.Type}'.");
}
private static bool IsConcreteDatabaseEntry(Type type)
{
return typeof(DatabaseEntry).IsAssignableFrom(type) && !type.IsAbstract;
}
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(type => type != null)!;
}
}
private static string GetDiscriminator(Type type)
{
return $"{type.Assembly.GetName().Name}:{type.FullName}";
}
}
internal sealed class DatabaseEntryEnvelope
{
public string Type { get; set; } = string.Empty;
public JObject Data { get; set; } = new();
}

View file

@ -0,0 +1,49 @@
# GameCult.Caching.NewtonsoftJson
`GameCult.Caching.NewtonsoftJson` provides human-readable JSON backing stores for `GameCult.Caching` using Newtonsoft.Json.
## Scope
The package provides:
- `SingleFileNewtonsoftJsonBackingStore`
- `MultiFileNewtonsoftJsonBackingStore`
- known-type polymorphic `DatabaseEntry` serialization through closed discriminators
## Usage
```csharp
using GameCult.Caching;
using GameCult.Caching.NewtonsoftJson;
var cache = new CultCache();
var store = new MultiFileNewtonsoftJsonBackingStore("Data");
cache.AddBackingStore(store);
await cache.PullAllBackingStoresAsync();
```
## Type Discovery
The JSON backing stores build a known-type map from the assemblies already loaded in the current `AppDomain`. In the common case, this means entry types defined in the consumer's application assembly work without extra configuration once that assembly is loaded.
The stores do not deserialize arbitrary CLR type names from JSON. Each file contains a closed discriminator plus the entry payload, and deserialization succeeds only when the discriminator matches a registered concrete `DatabaseEntry` subtype.
For less typical setups, the stores also expose:
- `RegisterType<T>()`
- `RegisterAssembly(Assembly assembly)`
- `RefreshKnownTypes()`
Example:
```csharp
var store = new MultiFileNewtonsoftJsonBackingStore("Data");
store.RegisterAssembly(typeof(MyCustomEntry).Assembly);
```
## Notes
- Files are indented JSON for inspection and editing.
- Polymorphic entry types are serialized with a closed discriminator map rather than open-ended type-name handling.
- `MultiFileNewtonsoftJsonBackingStore` builds on the base multi-file store behavior in `GameCult.Caching`, including rename-safe writes.

View file

@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using ConcurrentCollections;
using GameCult.Logging;
@ -69,11 +70,13 @@ namespace GameCult.Caching
private readonly ConcurrentDictionary<Type, DatabaseEntry?> _globals = new();
private readonly ConcurrentDictionary<Type, ConcurrentHashSet<DatabaseEntry>> _types = new();
private readonly ConcurrentDictionary<Type, ConcurrentDictionary<string, Guid>> _nameToIdMap = new();
private readonly ConcurrentDictionary<long, Task> _pendingStoreOperations = new();
// Generic indexes: Map (Type, FieldName) -> (Value -> ID). Allows registering indexes for any field
private readonly ConcurrentDictionary<(Type Type, string FieldName), ConcurrentDictionary<string, Guid>> _indexes = new();
// Cache getters per type (list of (field, getter) for that type only). Avoids scanning all indexes.
private readonly ConcurrentDictionary<Type, List<(string FieldName, Func<DatabaseEntry, string> Getter)>> _typeToGetters = new();
private ILogger _logger = new NullLogger();
private long _storeOperationId;
/// <summary>
/// Gets or sets the <see cref="ILogger"/> instance used for logging within the cache.
@ -161,7 +164,7 @@ namespace GameCult.Caching
foreach (var existingStore in _backingStores) store.SubscribeTo(existingStore);
_backingStores.Add(store);
}
store.EntryAdded.Subscribe(async entry =>
store.EntryAdded.Subscribe(entry => TrackStoreOperation(async () =>
{
try // Try-catch logs without swallowing. Added to EntryDeleted for consistency (even though sync)
{
@ -172,8 +175,8 @@ namespace GameCult.Caching
{
Logger.LogError($"EntryAdded processing failed for {entry?.ID}: {ex.Message}");
}
});
store.EntryUpdated.Subscribe(async entry =>
}));
store.EntryUpdated.Subscribe(entry => TrackStoreOperation(async () =>
{
try
{
@ -184,8 +187,8 @@ namespace GameCult.Caching
{
Logger.LogError($"EntryUpdated processing failed for {entry?.ID}: {ex.Message}");
}
});
store.EntryDeleted.Subscribe(entry =>
}));
store.EntryDeleted.Subscribe(entry => TrackStoreOperation(() =>
{
try
{
@ -196,7 +199,9 @@ namespace GameCult.Caching
{
Logger.LogError($"EntryDeleted processing failed for {entry?.ID}: {ex.Message}");
}
});
return Task.CompletedTask;
}));
store.Logger = Logger;
}
@ -212,6 +217,7 @@ namespace GameCult.Caching
.Concat(_storeTypes.Keys.Select(store => Task.Run(store.PullAll)))
.ToArray();
await Task.WhenAll(tasks);
await WaitForPendingStoreOperationsAsync();
}
/// <summary>
@ -231,6 +237,24 @@ namespace GameCult.Caching
var type = entry.GetType();
try
{
if (_typeStores.TryGetValue(type, out var store))
{
if (store != source)
await Task.Run(() => store.Push(entry));
}
else if (_backingStores.Any() && _backingStores.First() != source)
{
await Task.Run(() => _backingStores.First().Push(entry));
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to persist entry {entry.ID}: {ex.Message}");
throw;
}
// Pre-fetch existing to detect updates (for map/index cleanup)
if (!_entries.TryGetValue(entry.ID, out var existing)) existing = null;
@ -300,19 +324,6 @@ namespace GameCult.Caching
}
}
// Async file write
try
{
if (_typeStores.TryGetValue(type, out var store))
await Task.Run(() => store.Push(entry));
else if (_backingStores.Any() && _backingStores.First() != source)
await Task.Run(() => _backingStores.First().Push(entry));
}
catch (Exception ex)
{
// Log error but don't revert cache update to ensure consistency
Logger.LogError($"Failed to persist entry {entry.ID}: {ex.Message}");
}
}
/// <summary>
@ -555,6 +566,32 @@ namespace GameCult.Caching
foreach (var store in _storeTypes.Keys)
if (store is IDisposable d) d.Dispose();
}
private void TrackStoreOperation(Func<Task> operationFactory)
{
var operationId = Interlocked.Increment(ref _storeOperationId);
var operationTask = Task.Run(operationFactory);
_pendingStoreOperations[operationId] = operationTask;
_ = operationTask.ContinueWith(
(_, state) => _pendingStoreOperations.TryRemove((long)state!, out _),
operationId,
TaskScheduler.Default);
}
private async Task WaitForPendingStoreOperationsAsync()
{
while (true)
{
var pendingOperations = _pendingStoreOperations.Values.ToArray();
if (pendingOperations.Length == 0)
{
return;
}
await Task.WhenAll(pendingOperations);
}
}
}
/// <summary>
@ -654,7 +691,7 @@ namespace GameCult.Caching
}
/// <inheritdoc/>
public void Dispose()
public virtual void Dispose()
{
EntryAdded?.Dispose();
EntryDeleted?.Dispose();
@ -697,6 +734,7 @@ namespace GameCult.Caching
protected readonly ConcurrentDictionary<string, Guid> _filePathToIdMap = new();
private FileSystemWatcher? watcher;
private Subject<string>? _changedSubject;
private IDisposable? _changedSubscription;
/// <summary>
/// Serializes a <see cref="DatabaseEntry"/> into a byte array for file storage.
@ -797,7 +835,14 @@ namespace GameCult.Caching
Entries[entry.ID] = entry;
var directory = EntryTypeDirectories[type];
var filePath = Path.Combine(directory.FullName, GetFileName(entry));
File.WriteAllBytes(filePath, Serialize(entry));
if (TryGetExistingPath(entry.ID, out var existingPath) &&
!string.Equals(existingPath, filePath, StringComparison.OrdinalIgnoreCase))
{
TryDeleteFile(existingPath);
_filePathToIdMap.TryRemove(existingPath, out _);
}
WriteAllBytesAtomic(filePath, Serialize(entry));
_filePathToIdMap[filePath] = entry.ID;
}
@ -812,14 +857,12 @@ namespace GameCult.Caching
{
if (Entries.ContainsKey(entry.ID))
{
var type = entry.GetType();
Entries.TryRemove(entry.ID, out _);
var directory = EntryTypeDirectories[type];
var filePath = Path.Combine(directory.FullName, GetFileName(entry));
var filePath = TryGetExistingPath(entry.ID, out var existingPath)
? existingPath
: Path.Combine(EntryTypeDirectories[entry.GetType()].FullName, GetFileName(entry));
_filePathToIdMap.TryRemove(filePath, out _);
var file = new FileInfo(filePath);
if (file.Exists)
file.Delete();
TryDeleteFile(filePath);
}
}
@ -847,7 +890,7 @@ namespace GameCult.Caching
public void ObserveChanges()
{
if (watcher != null) // Guard against re-creation
Dispose();
StopObservingChanges();
watcher = new FileSystemWatcher(RootDirectory.FullName, $"*.{Extension}")
{
@ -858,7 +901,7 @@ namespace GameCult.Caching
// Use R3 to throttle events if file writes are bursty
_changedSubject = new Subject<string>(); // For paths
_changedSubject.Debounce(TimeSpan.FromMilliseconds(100)).Subscribe(HandleFileChange);
_changedSubscription = _changedSubject.Debounce(TimeSpan.FromMilliseconds(100)).Subscribe(HandleFileChange);
watcher.Changed += (sender, args) =>
{
@ -953,17 +996,77 @@ namespace GameCult.Caching
// Stub creator (minimal; only for rare fallback—extend if Deletion events need more data)
private DatabaseEntry CreateStubEntry(Guid id, Type type)
{
var entry = (DatabaseEntry)Activator.CreateInstance(type);
var entry = type == typeof(DatabaseEntry)
? new DeletedDatabaseEntry()
: (DatabaseEntry)Activator.CreateInstance(type)!;
typeof(DatabaseEntry).GetField("ID")?.SetValue(entry, id); // Set ID via reflection
return entry;
}
/// <inheritdoc/>
public new void Dispose()
public override void Dispose()
{
StopObservingChanges();
base.Dispose();
watcher?.Dispose();
}
private bool TryGetExistingPath(Guid id, out string path)
{
foreach (var pair in _filePathToIdMap)
{
if (pair.Value == id)
{
path = pair.Key;
return true;
}
}
path = string.Empty;
return false;
}
private static void WriteAllBytesAtomic(string filePath, byte[] data)
{
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
var tempFilePath = Path.Combine(directory ?? string.Empty, $"{Path.GetFileName(filePath)}.{Guid.NewGuid():N}.tmp");
File.WriteAllBytes(tempFilePath, data);
if (File.Exists(filePath))
{
File.Replace(tempFilePath, filePath, null);
}
else
{
File.Move(tempFilePath, filePath);
}
}
private static void TryDeleteFile(string filePath)
{
var file = new FileInfo(filePath);
if (file.Exists)
{
file.Delete();
}
}
private void StopObservingChanges()
{
_changedSubscription?.Dispose();
_changedSubscription = null;
_changedSubject?.Dispose();
_changedSubject = null;
watcher?.Dispose();
watcher = null;
}
private sealed class DeletedDatabaseEntry : DatabaseEntry
{
}
}
@ -1063,7 +1166,22 @@ namespace GameCult.Caching
public override void PushAll(bool soft = false)
{
var entriesArray = Entries.Values.ToArray();
File.WriteAllBytes(FileInfo.FullName, Serialize(entriesArray));
var directory = FileInfo.DirectoryName;
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
var tempFilePath = Path.Combine(directory ?? string.Empty, $"{Path.GetFileName(FileInfo.FullName)}.{Guid.NewGuid():N}.tmp");
File.WriteAllBytes(tempFilePath, Serialize(entriesArray));
if (FileInfo.Exists)
{
File.Replace(tempFilePath, FileInfo.FullName, null);
}
else
{
File.Move(tempFilePath, FileInfo.FullName);
}
}
}
}
}

View file

@ -0,0 +1,241 @@
# GameCult.Caching
`GameCult.Caching` provides a typed in-memory cache for `DatabaseEntry` models and a persistence abstraction for backing stores.
## Scope
The library is designed for applications that need:
- fast in-memory lookup by `Guid`
- optional lookup by human-readable name
- optional lookup by indexed fields or properties
- persistence through one or more pluggable stores
- a common model base type for runtime and storage code
It is a domain cache with persistence adapters.
## Main Concepts
### `DatabaseEntry`
All cacheable models inherit from `DatabaseEntry`.
```csharp
using GameCult.Caching;
public class ItemData : DatabaseEntry, INamedEntry
{
public string Name = string.Empty;
public string Category = string.Empty;
public int Value;
public string EntryName
{
get => Name;
set => Name = value;
}
}
```
### `INamedEntry`
If a type implements `INamedEntry`, `CultCache` maintains a name-to-id lookup for it. That enables:
- `GetIdByName<T>(...)`
- `GetByName<T>(...)`
### Registered Indexes
Indexes are opt-in. Register an index once for a field or property, and the cache maintains a value-to-id lookup map for that member.
```csharp
var cache = new CultCache();
cache.RegisterIndex<ItemData>("Category");
```
After registration:
```csharp
var consumable = cache.GetByIndex<ItemData>("Category", "Consumable");
```
### Global Entries
If a `DatabaseEntry` type is marked with `GlobalSettingsAttribute`, the cache treats it as a singleton-style global entry and exposes it through `GetGlobal<T>()`.
## Core Features
- ID-based lookup
- optional name-based lookup
- optional field/property indexes
- global singleton-style entries
- pluggable backing stores
- event propagation from backing stores into the cache
- support for both single-file and multi-file persistence strategies
## Basic Usage
```csharp
using GameCult.Caching;
var cache = new CultCache();
cache.RegisterIndex<ItemData>("Category");
await cache.AddAsync(new ItemData
{
ID = Guid.NewGuid(),
Name = "Potion",
Category = "Consumable",
Value = 50
});
var potion = cache.GetByName<ItemData>("Potion");
var samePotion = cache.GetByIndex<ItemData>("Category", "Consumable");
```
## Important Concept: Backing Store Routing
`CultCache` can have:
- generic backing stores
- type/domain-specific backing stores
### Domain-Specific Stores
If you register a store with one or more domain types:
```csharp
cache.AddBackingStore(playerStore, typeof(PlayerData));
cache.AddBackingStore(settingsStore, typeof(AppSettings));
```
then writes for those types are routed directly to those stores.
### Generic Stores
If you register stores without domains:
```csharp
cache.AddBackingStore(primaryStore);
cache.AddBackingStore(mirrorStore);
```
the first generic store becomes the primary write target for entries that do not have a type-specific store.
The later generic stores subscribe to earlier ones and mirror their changes.
That means:
- generic store registration order matters
- the first generic store is effectively the source of truth for generic writes
- later generic stores behave like downstream replicas
### How Multiple Backing Stores Behave
With this setup:
```csharp
cache.AddBackingStore(primaryStore);
cache.AddBackingStore(reportingMirror);
cache.AddBackingStore(playerStore, typeof(PlayerData));
```
the behavior is:
- `PlayerData` writes go to `playerStore`
- other entry types go to `primaryStore`
- `reportingMirror` mirrors change events from `primaryStore`
- `PullAllBackingStoresAsync()` pulls from all registered stores
This is not a multi-master design. If you need symmetric writes to multiple stores, implement that outside `CultCache`.
## Persistence Semantics
`AddAsync` persists to the appropriate backing store before the cache treats the write as committed. If persistence fails, the call throws instead of updating only the in-memory state.
Effects:
- memory and disk are less likely to diverge silently
- callers can react to persistence failures explicitly
## Backing Store Base Types
### `CacheBackingStore`
Abstract base for persistence implementations. It exposes:
- `PullAll()`
- `Push(DatabaseEntry entry)`
- `Delete(DatabaseEntry entry)`
- `PushAll(bool soft = false)`
### `SingleFileBackingStore`
Stores the entire set of entries in one file.
Use cases:
- small datasets
- settings-style data
- atomic snapshot persistence
### `MultiFileBackingStore`
Stores one file per entry in type-specific directories.
Use cases:
- large sets of independent entries
- change observation with `FileSystemWatcher`
- workflows where per-entry files are useful
## Rename Behavior In `MultiFileBackingStore`
For `INamedEntry` types, the filename is derived from the entry name. The backing store removes the stale old file before committing the new one.
## Example: Custom Backing Store
```csharp
using System.IO;
using System.Text;
using GameCult.Caching;
public sealed class TextItemStore : MultiFileBackingStore
{
public TextItemStore(string path) : base(path)
{
}
public override byte[] Serialize(DatabaseEntry entry)
{
var item = (ItemData)entry;
return Encoding.UTF8.GetBytes($"{item.ID}|{item.Name}|{item.Category}|{item.Value}");
}
public override DatabaseEntry Deserialize(byte[] data)
{
var parts = Encoding.UTF8.GetString(data).Split('|');
return new ItemData
{
ID = Guid.Parse(parts[0]),
Name = parts[1],
Category = parts[2],
Value = int.Parse(parts[3])
};
}
public override string Extension => "item";
}
```
## Typical Startup Sequence
```csharp
var cache = new CultCache();
cache.RegisterIndex<PlayerData>("Email");
var store = new TextItemStore("Data");
cache.AddBackingStore(store, typeof(ItemData));
await cache.PullAllBackingStoresAsync();
```

View file

@ -2,16 +2,21 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace GameCult.Caching
{
/// <summary>
/// Provides cached reflection helpers used by the cache infrastructure.
/// </summary>
public static class ReflectionExtensions
{
private static Dictionary<Type, Type[]> ParentTypes = new Dictionary<Type, Type[]>();
/// <summary>
/// Gets all base types for the supplied type, caching the result.
/// </summary>
/// <param name="type">The type whose parent types should be returned.</param>
/// <returns>An array of base types ordered from immediate parent upward.</returns>
public static Type[] GetParentTypes(this Type type)
{
if (ParentTypes.ContainsKey(type))
@ -37,6 +42,12 @@ namespace GameCult.Caching
}
private static Dictionary<Type, Type[]> ChildClasses = new Dictionary<Type, Type[]>();
/// <summary>
/// Gets all loaded types assignable to the supplied type, caching the result.
/// </summary>
/// <param name="type">The base type or interface to search from.</param>
/// <returns>An array of matching loaded types.</returns>
public static Type[] GetAllChildClasses(this Type type)
{
if (ChildClasses.ContainsKey(type))
@ -44,6 +55,5 @@ namespace GameCult.Caching
return ChildClasses[type] = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(ass => ass.GetTypes()).Where(type.IsAssignableFrom).ToArray();
}
}
}
}

View file

@ -3,29 +3,36 @@ using UnityEngine;
namespace GameCult.Logging.Unity
{
/// <summary>
/// Adapts <see cref="ILogger"/> calls to Unity's logging facilities.
/// </summary>
public class UnityLogger : ILogger
{
private readonly bool _isDebugBuild = Debug.isDebugBuild; // Cache Unity-specific
/// <inheritdoc />
public void LogInfo(string message)
{
Observable.NextFrame().Subscribe(_ => Debug.Log($"[INFO] {message}"));
}
/// <inheritdoc />
public void LogWarning(string message)
{
Observable.NextFrame().Subscribe(_ => Debug.LogWarning($"[WARN] {message}"));
}
/// <inheritdoc />
public void LogError(string message)
{
Observable.NextFrame().Subscribe(_ => Debug.LogError($"[ERROR] {message}"));
}
/// <inheritdoc />
public void LogDebug(string message)
{
if (_isDebugBuild)
Observable.NextFrame().Subscribe(_ => Debug.Log($"[DEBUG] {message}"));
}
}
}
}

View file

@ -2,25 +2,32 @@ using System;
namespace GameCult.Logging
{
/// <summary>
/// Writes log messages to the process console.
/// </summary>
public class ConsoleLogger : ILogger
{
/// <inheritdoc />
public void LogInfo(string message) => Console.WriteLine($"[INFO] {message}");
/// <inheritdoc />
public void LogWarning(string message)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[WARN] {message}"); Console.ResetColor();
}
/// <inheritdoc />
public void LogError(string message)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[ERROR] {message}"); Console.ResetColor();
}
/// <inheritdoc />
public void LogDebug(string message)
{
if(Environment.GetEnvironmentVariable("NODEV") is not null) Console.WriteLine($"[DEBUG] {message}");
}
}
}
}

View file

@ -3,10 +3,17 @@ using System.IO;
namespace GameCult.Logging
{
/// <summary>
/// Appends timestamped log entries to a file on disk.
/// </summary>
public class FileLogger : ILogger
{
private readonly string _logPath;
/// <summary>
/// Initializes a new file-backed logger.
/// </summary>
/// <param name="logPath">The path to the log file to append to.</param>
public FileLogger(string logPath = "server.log")
{
_logPath = logPath;
@ -17,9 +24,16 @@ namespace GameCult.Logging
File.AppendAllText(_logPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{level}] {message}\n");
}
/// <inheritdoc />
public void LogInfo(string message) => Append("INFO", message);
/// <inheritdoc />
public void LogWarning(string message) => Append("WARN", message);
/// <inheritdoc />
public void LogError(string message) => Append("ERROR", message);
/// <inheritdoc />
public void LogDebug(string message) => Append("DEBUG", message);
}
}
}

View file

@ -1,19 +1,50 @@
namespace GameCult.Logging
{
/// <summary>
/// Defines the logging contract used across CultLib components.
/// </summary>
public interface ILogger
{
/// <summary>
/// Writes an informational message.
/// </summary>
/// <param name="message">The message to record.</param>
void LogInfo(string message);
/// <summary>
/// Writes a warning message.
/// </summary>
/// <param name="message">The message to record.</param>
void LogWarning(string message);
/// <summary>
/// Writes an error message.
/// </summary>
/// <param name="message">The message to record.</param>
void LogError(string message);
/// <summary>
/// Writes a debug message.
/// </summary>
/// <param name="message">The message to record.</param>
void LogDebug(string message); // Optional; skip in prod
}
// Null impl for no-op logging (e.g., perf tests)
/// <summary>
/// Provides a no-op logger implementation.
/// </summary>
public class NullLogger : ILogger
{
/// <inheritdoc />
public void LogInfo(string message) { }
/// <inheritdoc />
public void LogWarning(string message) { }
/// <inheritdoc />
public void LogError(string message) { }
/// <inheritdoc />
public void LogDebug(string message) { }
}
}
}

View file

@ -0,0 +1,69 @@
# GameCult.Logging
`GameCult.Logging` provides a common logging contract for cache, networking, and Unity-facing code.
## Scope
This package provides a minimal logging abstraction. It does not provide structured logging, external sinks, scopes, or framework integration.
It is designed for:
- simple runtime diagnostics
- lightweight library dependencies
- swapping logging behavior by environment
- avoiding a hard dependency on a specific logging ecosystem
## Included Types
- `ILogger`: interface with `LogInfo`, `LogWarning`, `LogError`, and `LogDebug`
- `NullLogger`: no-op implementation
- `ConsoleLogger`: writes to `System.Console`
- `FileLogger`: appends timestamped log lines to a file
- `UnityLogger`: Unity-specific implementation in `src/GameCult.Logging.Unity`
## Behavior Notes
- `NullLogger` is the safe default used by other libraries when no logger is supplied.
- `ConsoleLogger` colors warnings and errors.
- `FileLogger` writes plain text entries with timestamps.
- `UnityLogger` schedules logging to the next Unity frame instead of writing immediately.
## Typical Usage
```csharp
using GameCult.Logging;
ILogger logger = new ConsoleLogger();
logger.LogInfo("Server starting");
logger.LogWarning("High latency detected");
logger.LogError("Connection failed");
```
## Swapping Implementations
Consumers can choose the implementation that fits the runtime.
```csharp
using GameCult.Logging;
using GameCult.Networking;
var client = new Client
{
Logger = new FileLogger("client.log")
};
client.Connect("127.0.0.1", 3075);
```
## When To Use Something Else
If you need:
- structured events
- sinks to external observability platforms
- log scopes
- sampling and filtering pipelines
- JSON log output
use a logging framework at the application boundary.

View file

@ -1,52 +1,89 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using GameCult.Logging;
using LiteNetLib;
using MessagePack;
using MessagePack.Resolvers;
using R3;
namespace GameCult.Networking
{
public class Client
/// <summary>
/// Provides client-side connection, authentication, and message dispatch logic.
/// </summary>
public class Client : IDisposable
{
public event Action<string> OnError; // Hook for important user-facing feedback (e.g. via modal UI element)
private NetManager _client;
private NetPeer _peer;
/// <summary>
/// Raised when the client encounters a user-visible error.
/// </summary>
public event Action<string>? OnError; // Hook for important user-facing feedback (e.g. via modal UI element)
private NetManager? _client;
private NetPeer? _peer;
private IDisposable? _pollSubscription;
private IDisposable? _reconnectSubscription;
private bool _disposed;
private bool _manualDisconnect;
private bool _isReconnecting;
private readonly ConcurrentDictionary<Type, Delegate> _messageDelegates = new();
private Guid _sessionToken;
private string _lastHost;
private string? _sessionToken;
private string _lastHost = "localhost";
private int _lastPort;
private readonly ClientSecurityOptions _security;
private ILogger _logger = new NullLogger();
private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(1);
/// <summary>
/// Gets or sets the logger used by the client.
/// </summary>
public ILogger Logger
{
get => _logger;
set => _logger = value ?? new NullLogger();
}
/// <summary>
/// Gets the last reported round-trip latency in milliseconds.
/// </summary>
public int Ping { get; private set; }
public bool Verified => _sessionToken != Guid.Empty;
public bool Connected => _client != null;
public Client()
/// <summary>
/// Gets a value indicating whether the client currently has a verified session.
/// </summary>
public bool Verified => !string.IsNullOrWhiteSpace(_sessionToken);
/// <summary>
/// Gets a value indicating whether the network manager has been created.
/// </summary>
public bool Connected => _client is { IsRunning: true } && _peer != null;
/// <summary>
/// Initializes a new client instance.
/// </summary>
/// <param name="security">Validated client security configuration. This must be provided explicitly by the caller.</param>
public Client(ClientSecurityOptions security)
{
_security = security ?? throw new ArgumentNullException(nameof(security));
OnError += s => Logger.LogError(s);
}
/// <summary>
/// Sends a message when the client is connected and authorized to do so.
/// </summary>
/// <typeparam name="T">The message type.</typeparam>
/// <param name="m">The message to send.</param>
public void Send<T>(T m) where T : Message
{
if (Connected)
if (_disposed)
{
throw new ObjectDisposedException(nameof(Client));
}
if (Connected && _peer != null)
{
if (Verified || m is LoginMessage || m is RegisterMessage || m is VerifyMessage)
{
Logger.LogDebug($"Sending message {MessagePackSerializer.SerializeToJson(m as Message)}");
Logger.LogDebug($"Sending message {MessagePackSerializer.SerializeToJson(m)}");
_peer.Send(m);
}
else Logger.LogError("Cannot send, client is not verified!");
@ -54,11 +91,19 @@ namespace GameCult.Networking
else Logger.LogError("Cannot send, client is not connected!");
}
/// <summary>
/// Removes all registered message listeners.
/// </summary>
public void ClearMessageListeners()
{
_messageDelegates.Clear();
}
/// <summary>
/// Adds a listener for a specific message type.
/// </summary>
/// <typeparam name="T">The message type to subscribe to.</typeparam>
/// <param name="callback">The callback to invoke when the message is received.</param>
public void AddMessageListener<T>(Action<T> callback) where T : Message
{
var type = typeof(T);
@ -73,15 +118,25 @@ namespace GameCult.Networking
});
}
/// <summary>
/// Removes a previously registered listener for a specific message type.
/// </summary>
/// <typeparam name="T">The message type to unsubscribe from.</typeparam>
/// <param name="callback">The callback to remove.</param>
public void RemoveMessageListener<T>(Action<T> callback) where T : Message
{
if (_messageDelegates.TryGetValue(typeof(T), out var currentDelegate))
{
var newDelegate = Delegate.Remove(currentDelegate, callback) as Action<T>;
_messageDelegates[typeof(T)] = newDelegate; // Null if empty
_messageDelegates[typeof(T)] = newDelegate ?? (_ => { }); // Null if empty
}
}
/// <summary>
/// Sends a login request.
/// </summary>
/// <param name="auth">The email address or username.</param>
/// <param name="password">The plaintext password.</param>
public void Login(string auth, string password)
{
if (auth.Length > 0)
@ -92,8 +147,8 @@ namespace GameCult.Networking
Send(new LoginMessage
{
Nonce = nonce,
Auth = Secret.EncryptString(auth, nonce),
Password = Secret.EncryptString(password, nonce)
Auth = Secret.EncryptString(auth, nonce, _security) ?? Array.Empty<byte>(),
Password = Secret.EncryptString(password, nonce, _security) ?? Array.Empty<byte>()
});
}
else OnError?.Invoke("Password Invalid: needs to be at least 8 characters");
@ -101,6 +156,12 @@ namespace GameCult.Networking
else OnError?.Invoke("Email/Username Empty");
}
/// <summary>
/// Sends a registration request.
/// </summary>
/// <param name="email">The email address for the new account.</param>
/// <param name="username">The requested username.</param>
/// <param name="password">The plaintext password.</param>
public void Register(string email, string username, string password)
{
if (email.Length > 0)
@ -113,9 +174,9 @@ namespace GameCult.Networking
Send(new RegisterMessage
{
Nonce = nonce,
Email = Secret.EncryptString(email, nonce),
Name = Secret.EncryptString(username, nonce),
Password = Secret.EncryptString(password, nonce)
Email = Secret.EncryptString(email, nonce, _security) ?? Array.Empty<byte>(),
Name = Secret.EncryptString(username, nonce, _security) ?? Array.Empty<byte>(),
Password = Secret.EncryptString(password, nonce, _security) ?? Array.Empty<byte>()
});
}
else OnError?.Invoke("Password Invalid: needs to be at least 8 characters");
@ -125,20 +186,35 @@ namespace GameCult.Networking
else OnError?.Invoke("Email Empty");
}
/// <summary>
/// Connects the client to a server and starts polling LiteNetLib events.
/// </summary>
/// <param name="host">The remote host name or IP address.</param>
/// <param name="port">The remote server port.</param>
public void Connect(string host = "localhost", int port = 3075)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(Client));
}
_manualDisconnect = false;
_isReconnecting = false;
_reconnectSubscription?.Dispose();
_reconnectSubscription = null;
_lastHost = host;
_lastPort = port;
EventBasedNetListener listener = new EventBasedNetListener();
DisposeTransport();
var listener = new EventBasedNetListener();
_client = new NetManager(listener)
{
// UnsyncedEvents = true,
NatPunchEnabled = true
};
_client.Start(3074);
_peer = _client.Connect(host, port, Secret.ConnectionKey);
Observable.EveryUpdate().Subscribe(_ => _client.PollEvents());
_peer = _client.Connect(host, port, _security.ConnectionKey);
_pollSubscription = Observable.EveryUpdate().Subscribe(_ => _client?.PollEvents());
listener.NetworkErrorEvent += (point, code) => Logger.LogInfo($"{point.Address}: Error {code}");
listener.NetworkReceiveEvent += (peer, reader, channel, method) =>
{
@ -146,27 +222,24 @@ namespace GameCult.Networking
{
var bytes = reader.GetRemainingBytes();
Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory<byte>(bytes))}");
var message = MessagePackSerializer.Deserialize<Message>(bytes);
var message = MessageSerialization.Deserialize<Message>(bytes);
var type = message.GetType();
if (!Verified)
switch (message)
{
switch (message)
{
case LoginSuccessMessage loginSuccess:
_sessionToken = new Guid(Secret.DecryptBytes(loginSuccess.Session, loginSuccess.Nonce));
break;
case ErrorMessage error:
OnError?.Invoke(error.Error);
break;
}
case LoginSuccessMessage loginSuccess:
_sessionToken = Secret.DecryptString(loginSuccess.Session, loginSuccess.Nonce, _security);
break;
case ErrorMessage error:
OnError?.Invoke(error.Error);
break;
}
if (_messageDelegates.TryGetValue(message.GetType(), out var del) && del != null)
if (_messageDelegates.TryGetValue(type, out var del) && del != null)
del.DynamicInvoke(message);
else
Logger.LogWarning($"No listener for {message.GetType().Name}");
Logger.LogWarning($"No listener for {type.Name}");
}
catch (Exception ex)
{
@ -182,19 +255,106 @@ namespace GameCult.Networking
if (Verified)
{
var nonce = Secret.NewNonce;
peer.Send(new VerifyMessage { Nonce = nonce, Session = Secret.EncryptBytes(_sessionToken.ToByteArray(), nonce) });
peer.Send(new VerifyMessage
{
Nonce = nonce,
Session = Secret.EncryptString(_sessionToken, nonce, _security) ?? Array.Empty<byte>()
});
}
};
listener.PeerDisconnectedEvent += (peer, info) =>
{
Logger.LogInfo($"Peer {peer.Address}:{peer.Port} disconnected: {info.Reason}.");
_peer = null;
Connect(_lastHost, _lastPort);
DisposeTransport();
ScheduleReconnect();
};
listener.NetworkLatencyUpdateEvent +=
(peer, latency) => Ping = latency; //Logger($"Ping received: {latency} ms");
}
/// <summary>
/// Disconnects the client and suppresses automatic reconnect.
/// </summary>
public void Disconnect()
{
_manualDisconnect = true;
_reconnectSubscription?.Dispose();
_reconnectSubscription = null;
DisposeTransport();
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_manualDisconnect = true;
_reconnectSubscription?.Dispose();
_reconnectSubscription = null;
DisposeTransport();
}
private void ScheduleReconnect()
{
if (_disposed || _manualDisconnect || string.IsNullOrWhiteSpace(_lastHost) || _isReconnecting)
{
return;
}
_isReconnecting = true;
_reconnectSubscription = Observable.Timer(ReconnectDelay).Subscribe(_ =>
{
_isReconnecting = false;
_reconnectSubscription?.Dispose();
_reconnectSubscription = null;
try
{
Connect(_lastHost, _lastPort);
}
catch (Exception ex)
{
Logger.LogError($"Reconnect failed: {ex.Message}");
ScheduleReconnect();
}
});
}
private void DisposeTransport()
{
_pollSubscription?.Dispose();
_pollSubscription = null;
if (_peer != null)
{
try
{
_peer.Disconnect();
}
catch
{
// Best-effort cleanup.
}
}
if (_client != null)
{
try
{
_client.Stop();
}
finally
{
_client = null;
_peer = null;
}
}
}
}
}
}

View file

@ -0,0 +1,51 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace GameCult.Networking
{
/// <summary>
/// Carries the client-side networking configuration required to talk to a server.
/// </summary>
public sealed class ClientSecurityOptions
{
private readonly byte[] _encryptionKey;
/// <summary>
/// Shared LiteNetLib connection key and encryption seed.
/// </summary>
public string ConnectionKey { get; }
/// <summary>
/// Initializes a new client security configuration.
/// </summary>
/// <param name="connectionKey">The shared connection key used by both client and server.</param>
public ClientSecurityOptions(string connectionKey)
{
if (string.IsNullOrWhiteSpace(connectionKey))
throw new ArgumentException("Connection key must be provided.", nameof(connectionKey));
ConnectionKey = connectionKey;
_encryptionKey = ComputeSha256(Encoding.UTF8.GetBytes(connectionKey));
}
/// <summary>
/// Creates a deterministic development-only configuration.
/// </summary>
public static ClientSecurityOptions Development()
{
return new ClientSecurityOptions("gamecult-dev-connection-key");
}
internal byte[] GetEncryptionKey()
{
return _encryptionKey;
}
private static byte[] ComputeSha256(byte[] data)
{
using var sha256 = SHA256.Create();
return sha256.ComputeHash(data);
}
}
}

View file

@ -0,0 +1,20 @@
using MessagePack;
namespace GameCult.Networking
{
internal static class MessageSerialization
{
public static readonly MessagePackSerializerOptions Options =
MessagePackSerializer.DefaultOptions.WithSecurity(MessagePackSecurity.UntrustedData);
public static byte[] Serialize<T>(T message) where T : Message
{
return MessagePackSerializer.Serialize(message, Options);
}
public static T Deserialize<T>(byte[] payload)
{
return MessagePackSerializer.Deserialize<T>(payload, Options);
}
}
}

View file

@ -4,66 +4,132 @@ using MessagePack;
namespace GameCult.Networking
{
/// <summary>
/// Base type for all network messages exchanged by the client and server.
/// </summary>
[Union(0, typeof(LoginMessage)),
Union(1, typeof(RegisterMessage)),
Union(2, typeof(VerifyMessage)),
Union(3, typeof(LoginSuccessMessage)),
Union(4, typeof(ErrorMessage)),
Union(5, typeof(ChangeNameMessage)),
Union(6, typeof(ChatMessage)),
MessagePackObject]
public abstract class Message
{
[IgnoreMember] public NetPeer Peer { get; set; }
/// <summary>
/// Gets or sets the peer associated with the received message.
/// </summary>
[IgnoreMember] public NetPeer? Peer { get; set; }
}
/// <summary>
/// Base type for encrypted messages that carry a nonce.
/// </summary>
public class EncryptedMessage : Message
{
[Key(0)] public byte[] Nonce;
/// <summary>
/// Nonce used for AES-GCM encryption of the message payload.
/// </summary>
[Key(0)] public byte[] Nonce = Array.Empty<byte>();
}
/// <summary>
/// Requests authentication with an email or username and password.
/// </summary>
[MessagePackObject]
public class LoginMessage : EncryptedMessage
{
[Key(1)] public byte[] Auth;
[Key(2)] public byte[] Password;
/// <summary>
/// Encrypted email address or username.
/// </summary>
[Key(1)] public byte[] Auth = Array.Empty<byte>();
/// <summary>
/// Encrypted password payload.
/// </summary>
[Key(2)] public byte[] Password = Array.Empty<byte>();
}
/// <summary>
/// Requests registration of a new player account.
/// </summary>
[MessagePackObject]
public class RegisterMessage : EncryptedMessage
{
[Key(1)] public byte[] Email;
[Key(2)] public byte[] Password;
[Key(3)] public byte[] Name;
/// <summary>
/// Encrypted email address.
/// </summary>
[Key(1)] public byte[] Email = Array.Empty<byte>();
/// <summary>
/// Encrypted password payload.
/// </summary>
[Key(2)] public byte[] Password = Array.Empty<byte>();
/// <summary>
/// Encrypted display name payload.
/// </summary>
[Key(3)] public byte[] Name = Array.Empty<byte>();
}
/// <summary>
/// Attempts to restore a previously issued session.
/// </summary>
[MessagePackObject]
public class VerifyMessage : EncryptedMessage
{
[Key(1)] public byte[] Session;
/// <summary>
/// Encrypted session identifier.
/// </summary>
[Key(1)] public byte[] Session = Array.Empty<byte>();
}
/// <summary>
/// Indicates successful authentication or verification.
/// </summary>
[MessagePackObject]
public class LoginSuccessMessage : EncryptedMessage
{
[Key(1)] public byte[] Session;
/// <summary>
/// Encrypted session identifier.
/// </summary>
[Key(1)] public byte[] Session = Array.Empty<byte>();
}
/// <summary>
/// Sends a non-success error back to the client.
/// </summary>
[MessagePackObject]
public class ErrorMessage : Message
{
[Key(0)] public string Error;
/// <summary>
/// Human-readable error text.
/// </summary>
[Key(0)] public string Error = string.Empty;
}
/// <summary>
/// Requests a username change for the authenticated user.
/// </summary>
[MessagePackObject]
public class ChangeNameMessage : Message
{
[Key(0)] public string Name;
/// <summary>
/// Requested new username.
/// </summary>
[Key(0)] public string Name = string.Empty;
}
/// <summary>
/// Carries plain chat text.
/// </summary>
[MessagePackObject]
public class ChatMessage : Message
{
[Key(0)] public string Text;
/// <summary>
/// Chat text content.
/// </summary>
[Key(0)] public string Text = string.Empty;
}
}
}

View file

@ -3,11 +3,20 @@ using MessagePack;
namespace GameCult.Networking
{
/// <summary>
/// Adds MessagePack-based send helpers for <see cref="NetPeer"/>.
/// </summary>
public static class NetPeerExtensions
{
/// <summary>
/// Serializes and sends a message using reliable ordered delivery.
/// </summary>
/// <typeparam name="T">The concrete message type.</typeparam>
/// <param name="peer">The peer to send through.</param>
/// <param name="message">The message to serialize and send.</param>
public static void Send<T>(this NetPeer peer, T message) where T : Message
{
peer.Send(MessagePackSerializer.Serialize(message), DeliveryMethod.ReliableOrdered);
peer.Send(MessageSerialization.Serialize(message), DeliveryMethod.ReliableOrdered);
}
}
}
}

View file

@ -14,19 +14,19 @@ namespace GameCult.Networking
/// Email address of the player.
/// </summary>
[Key(1)]
public string Email;
public string Email = string.Empty;
/// <summary>
/// Password hash of the player.
/// </summary>
[Key(2)]
public string PasswordHash;
public string PasswordHash = string.Empty;
/// <summary>
/// Username of the player.
/// </summary>
[Key(3)]
public string Username;
public string Username = string.Empty;
/// <inheritdoc/>
[IgnoreMember]
@ -36,4 +36,4 @@ namespace GameCult.Networking
set => Username = value;
}
}
}
}

View file

@ -0,0 +1,183 @@
# GameCult.Networking
`GameCult.Networking` provides client/server messaging over LiteNetLib with encrypted credential exchange and signed session tokens.
It is intended for game or game-service scenarios where you want a compact transport layer with a small set of built-in authentication flows rather than a full HTTP stack.
## Scope
The library currently includes:
- a shared message contract model
- a client wrapper around LiteNetLib
- a server wrapper around LiteNetLib
- encrypted login, register, and verify flows
- signed session-token generation and validation
- `PlayerData` integration with `CultCache`
The library is focused on the built-in authentication and session flows in this repository.
## Main Types
- `Message`: base type for all wire messages
- `Client`: client connection, dispatch, reconnect, and login/register helpers
- `Server`: server dispatch, auth flow handling, rate limiting, and session refresh
- `Secret`: encryption and signed-token helper methods
- `PlayerData`: cache-backed player record type
## Authentication Model
The built-in flow is:
1. client connects with the shared LiteNetLib connection key
2. credentials are encrypted with AES-GCM using a per-message nonce
3. server validates credentials and issues a signed session token
4. client stores the encrypted session token and can send `VerifyMessage` on reconnect
5. server validates the token and re-establishes the session
The session token is signed and validated by the server.
## Runtime Secrets
`GameCult.Networking` uses:
- `GAMECULT_CONNECTION_KEY`
- `GAMECULT_SESSION_SIGNING_SECRET`
Production requirements:
- set both before constructing `Server` without explicit `ServerSecurityOptions`
- use high-entropy random values, not human-memorable strings
- keep `GAMECULT_SESSION_SIGNING_SECRET` in your deployment platform's secret store rather than in source control or appsettings checked into the repo
- distribute `GAMECULT_CONNECTION_KEY` to clients through your build or asset pipeline, not through runtime environment variables on shipped clients
- partial configuration is rejected by `ServerSecurityOptions.FromEnvironment()`
Recommended format:
- 32 or more random bytes per value
- store as Base64/Base64Url text if needed
Example PowerShell generation:
```powershell
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))
```
Example environment assignment in PowerShell:
```powershell
$env:GAMECULT_CONNECTION_KEY = "<random-base64-value>"
$env:GAMECULT_SESSION_SIGNING_SECRET = "<different-random-base64-value>"
```
Deployment model:
- the client and server must share the same `GAMECULT_CONNECTION_KEY`
- the server alone needs `GAMECULT_SESSION_SIGNING_SECRET`
- `GAMECULT_CONNECTION_KEY` is shared protocol configuration, not a server-only secret
- rotating `GAMECULT_CONNECTION_KEY` requires a coordinated client/server rollout or multi-key server support during migration
- `GAMECULT_SESSION_SIGNING_SECRET` can be rotated server-side with the usual session invalidation tradeoffs
Strict production-style configuration:
```csharp
using GameCult.Caching;
using GameCult.Networking;
var cache = new CultCache();
var security = ServerSecurityOptions.FromEnvironment();
var server = new Server(cache, security);
```
Explicit local-development server configuration:
```csharp
using GameCult.Caching;
using GameCult.Networking;
var cache = new CultCache();
var security = ServerSecurityOptions.Development();
var server = new Server(cache, security);
```
Explicit client configuration:
```csharp
using GameCult.Networking;
var security = new ClientSecurityOptions("<matching-connection-key>");
var client = new Client(security);
```
Use `ServerSecurityOptions.Development()` only for local development and tests. `Client` does not read environment variables.
## Basic Server Usage
```csharp
using GameCult.Caching;
using GameCult.Logging;
using GameCult.Networking;
var cache = new CultCache();
var server = new Server(cache)
{
Logger = new ConsoleLogger()
};
server.AddMessageListener<ChatMessage>(message =>
{
server.Logger.LogInfo($"Chat: {message.Text}");
});
server.Start();
```
## Basic Client Usage
```csharp
using GameCult.Logging;
using GameCult.Networking;
var client = new Client(new ClientSecurityOptions("<matching-connection-key>"))
{
Logger = new ConsoleLogger()
};
client.OnError += error => client.Logger.LogError($"Client error: {error}");
client.AddMessageListener<ChatMessage>(message => client.Logger.LogInfo($"Chat: {message.Text}"));
client.Connect("localhost", 3075);
client.Login("user@example.com", "correct horse battery staple");
```
## Reconnect Behavior
The client:
- disposes its prior polling subscription before reconnecting
- stops the prior `NetManager`
- schedules reconnect with a short delay
- automatically re-verifies using the stored signed session token
## Important Constraints
- `Server` currently centers on `PlayerData` as the built-in account model.
- Message authorization is based on the built-in verify/login/register flow.
- Transport is LiteNetLib, so this is not a drop-in substitute for HTTP or WebSocket stacks.
## Message Example
Messages derive from `Message` and are MessagePack-serializable:
```csharp
using GameCult.Networking;
using MessagePack;
[MessagePackObject]
public class CustomPingMessage : Message
{
[Key(0)] public string Value = string.Empty;
}
```
If you extend the built-in message set, update the union and serialization model accordingly.

View file

@ -1,15 +1,39 @@
using System;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace GameCult.Networking
{
/// <summary>
/// Provides shared encryption helpers and connection secrets for the networking layer.
/// </summary>
public static class Secret
{
public const string ConnectionKey = "cultpong-843ctnsw";
public static readonly byte[] EncryptionKey = SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(ConnectionKey)); // PSK
private static readonly Lazy<ServerSecurityOptions> DefaultOptions =
new(() => ServerSecurityOptions.FromEnvironment());
private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create();
/// <summary>
/// Configures the default server security options used by overloads that do not take an explicit options instance.
/// </summary>
public static void ConfigureDefault(ServerSecurityOptions options)
{
if (options == null) throw new ArgumentNullException(nameof(options));
_configuredOptions = options;
}
private static ServerSecurityOptions? _configuredOptions;
/// <summary>
/// Shared LiteNetLib connection key from the configured default options.
/// </summary>
public static string ConnectionKey => GetDefaultOptions().ConnectionKey;
/// <summary>
/// Gets a newly generated nonce suitable for AES-GCM operations.
/// </summary>
public static byte[] NewNonce
{
get
@ -20,18 +44,51 @@ namespace GameCult.Networking
}
}
public static byte[] EncryptString(string input, byte[] nonce)
/// <summary>
/// Encrypts a string using AES-GCM.
/// </summary>
/// <param name="input">The plaintext string to encrypt.</param>
/// <param name="nonce">The nonce to use for the operation.</param>
/// <returns>The encrypted payload, or <c>null</c> when <paramref name="input"/> is null or empty.</returns>
public static byte[]? EncryptString(string? input, byte[] nonce) =>
EncryptString(input, nonce, GetDefaultOptions());
/// <summary>
/// Encrypts a string using AES-GCM.
/// </summary>
public static byte[]? EncryptString(string? input, byte[] nonce, ClientSecurityOptions options)
{
if (string.IsNullOrEmpty(input)) return null;
return EncryptBytes(Encoding.UTF8.GetBytes(input), nonce);
return EncryptBytes(Encoding.UTF8.GetBytes(input), nonce, options);
}
public static byte[] EncryptBytes(byte[] input, byte[] nonce)
/// <summary>
/// Encrypts a string using AES-GCM.
/// </summary>
public static byte[]? EncryptString(string? input, byte[] nonce, ServerSecurityOptions options)
{
if (string.IsNullOrEmpty(input)) return null;
return EncryptBytes(Encoding.UTF8.GetBytes(input), nonce, options);
}
/// <summary>
/// Encrypts a byte array using AES-GCM.
/// </summary>
/// <param name="input">The plaintext bytes to encrypt.</param>
/// <param name="nonce">The nonce to use for the operation.</param>
/// <returns>The authentication tag followed by the ciphertext.</returns>
public static byte[] EncryptBytes(byte[] input, byte[] nonce) =>
EncryptBytes(input, nonce, GetDefaultOptions());
/// <summary>
/// Encrypts a byte array using AES-GCM.
/// </summary>
public static byte[] EncryptBytes(byte[] input, byte[] nonce, ClientSecurityOptions options)
{
if (input == null || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize)
throw new CryptographicException("Invalid input or nonce");
using var aes = new AesGcm(EncryptionKey);
using var aes = new AesGcm(options.GetEncryptionKey());
var ciphertext = new byte[input.Length];
var tag = new byte[AesGcm.TagByteSizes.MaxSize];
aes.Encrypt(nonce, input, ciphertext, tag);
@ -41,24 +98,214 @@ namespace GameCult.Networking
return result;
}
public static string DecryptString(byte[] encrypted, byte[] nonce)
/// <summary>
/// Encrypts a byte array using AES-GCM.
/// </summary>
public static byte[] EncryptBytes(byte[] input, byte[] nonce, ServerSecurityOptions options)
{
if (input == null || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize)
throw new CryptographicException("Invalid input or nonce");
using var aes = new AesGcm(options.GetEncryptionKey());
var ciphertext = new byte[input.Length];
var tag = new byte[AesGcm.TagByteSizes.MaxSize];
aes.Encrypt(nonce, input, ciphertext, tag);
var result = new byte[tag.Length + ciphertext.Length];
Buffer.BlockCopy(tag, 0, result, 0, tag.Length);
Buffer.BlockCopy(ciphertext, 0, result, tag.Length, ciphertext.Length);
return result;
}
/// <summary>
/// Decrypts an encrypted UTF-8 string payload.
/// </summary>
/// <param name="encrypted">The encrypted bytes produced by the corresponding string or byte encryption overloads.</param>
/// <param name="nonce">The nonce used during encryption.</param>
/// <returns>The decrypted string, or <c>null</c> when the inputs are invalid.</returns>
public static string? DecryptString(byte[]? encrypted, byte[]? nonce) =>
DecryptString(encrypted, nonce, GetDefaultOptions());
/// <summary>
/// Decrypts an encrypted UTF-8 string payload.
/// </summary>
public static string? DecryptString(byte[]? encrypted, byte[]? nonce, ClientSecurityOptions options)
{
if (encrypted == null || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize) return null;
var decrypted = DecryptBytes(encrypted, nonce);
var decrypted = DecryptBytes(encrypted, nonce, options);
return Encoding.UTF8.GetString(decrypted);
}
public static byte[] DecryptBytes(byte[] encrypted, byte[] nonce)
/// <summary>
/// Decrypts an encrypted UTF-8 string payload.
/// </summary>
public static string? DecryptString(byte[]? encrypted, byte[]? nonce, ServerSecurityOptions options)
{
if (encrypted == null || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize) return null;
var decrypted = DecryptBytes(encrypted, nonce, options);
return Encoding.UTF8.GetString(decrypted);
}
/// <summary>
/// Decrypts an AES-GCM payload into raw bytes.
/// </summary>
/// <param name="encrypted">The encrypted bytes containing the authentication tag prefix.</param>
/// <param name="nonce">The nonce used during encryption.</param>
/// <returns>The decrypted plaintext bytes.</returns>
public static byte[] DecryptBytes(byte[] encrypted, byte[] nonce) =>
DecryptBytes(encrypted, nonce, GetDefaultOptions());
/// <summary>
/// Decrypts an AES-GCM payload into raw bytes.
/// </summary>
public static byte[] DecryptBytes(byte[] encrypted, byte[] nonce, ClientSecurityOptions options)
{
if (encrypted == null || encrypted.Length < AesGcm.TagByteSizes.MaxSize || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize)
throw new CryptographicException("Invalid encrypted data or nonce");
using var aes = new AesGcm(EncryptionKey);
using var aes = new AesGcm(options.GetEncryptionKey());
var decrypted = new byte[encrypted.Length - AesGcm.TagByteSizes.MaxSize];
var tag = encrypted[..AesGcm.TagByteSizes.MaxSize];
var ciphertext = encrypted[AesGcm.TagByteSizes.MaxSize..];
aes.Decrypt(nonce, ciphertext, tag, decrypted);
return decrypted;
}
/// <summary>
/// Decrypts an AES-GCM payload into raw bytes.
/// </summary>
public static byte[] DecryptBytes(byte[] encrypted, byte[] nonce, ServerSecurityOptions options)
{
if (encrypted == null || encrypted.Length < AesGcm.TagByteSizes.MaxSize || nonce == null || nonce.Length != AesGcm.NonceByteSizes.MaxSize)
throw new CryptographicException("Invalid encrypted data or nonce");
using var aes = new AesGcm(options.GetEncryptionKey());
var decrypted = new byte[encrypted.Length - AesGcm.TagByteSizes.MaxSize];
var tag = encrypted[..AesGcm.TagByteSizes.MaxSize];
var ciphertext = encrypted[AesGcm.TagByteSizes.MaxSize..];
aes.Decrypt(nonce, ciphertext, tag, decrypted);
return decrypted;
}
/// <summary>
/// Creates a signed session token for the supplied user.
/// </summary>
/// <param name="userId">The authenticated user identifier.</param>
/// <param name="expiresAtUtc">The UTC expiration time for the token.</param>
/// <returns>A tamper-evident session token.</returns>
public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc) =>
CreateSessionToken(userId, expiresAtUtc, GetDefaultOptions());
/// <summary>
/// Creates a signed session token for the supplied user.
/// </summary>
public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc, ServerSecurityOptions options)
{
var payload = $"{userId:N}|{expiresAtUtc.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)}";
var payloadBytes = Encoding.UTF8.GetBytes(payload);
var signatureBytes = ComputeHmacSha256(options.GetSessionSigningKey(), payloadBytes);
return $"{ToBase64Url(payloadBytes)}.{ToBase64Url(signatureBytes)}";
}
/// <summary>
/// Validates and parses a signed session token.
/// </summary>
/// <param name="token">The token to validate.</param>
/// <param name="userId">The extracted user identifier.</param>
/// <param name="expiresAtUtc">The extracted UTC expiration time.</param>
/// <returns><c>true</c> when the token is valid and unexpired.</returns>
public static bool TryValidateSessionToken(string? token, out Guid userId, out DateTimeOffset expiresAtUtc) =>
TryValidateSessionToken(token, GetDefaultOptions(), out userId, out expiresAtUtc);
/// <summary>
/// Validates and parses a signed session token.
/// </summary>
public static bool TryValidateSessionToken(
string? token,
ServerSecurityOptions options,
out Guid userId,
out DateTimeOffset expiresAtUtc)
{
userId = Guid.Empty;
expiresAtUtc = DateTimeOffset.MinValue;
if (string.IsNullOrWhiteSpace(token))
return false;
var parts = token.Split('.');
if (parts.Length != 2)
return false;
try
{
var payloadBytes = FromBase64Url(parts[0]);
var signatureBytes = FromBase64Url(parts[1]);
var expectedSignature = ComputeHmacSha256(options.GetSessionSigningKey(), payloadBytes);
if (!CryptographicOperations.FixedTimeEquals(signatureBytes, expectedSignature))
return false;
var payload = Encoding.UTF8.GetString(payloadBytes);
var payloadParts = payload.Split('|');
if (payloadParts.Length != 2 ||
!Guid.TryParseExact(payloadParts[0], "N", out userId) ||
!long.TryParse(payloadParts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var expiryUnixSeconds))
{
userId = Guid.Empty;
return false;
}
expiresAtUtc = DateTimeOffset.FromUnixTimeSeconds(expiryUnixSeconds);
return expiresAtUtc > DateTimeOffset.UtcNow;
}
catch (FormatException)
{
userId = Guid.Empty;
expiresAtUtc = DateTimeOffset.MinValue;
return false;
}
}
private static string ToBase64Url(byte[] bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] FromBase64Url(string input)
{
var padded = input
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2:
padded += "==";
break;
case 3:
padded += "=";
break;
}
return Convert.FromBase64String(padded);
}
private static byte[] ComputeSha256(byte[] data)
{
using var sha256 = SHA256.Create();
return sha256.ComputeHash(data);
}
private static byte[] ComputeHmacSha256(byte[] key, byte[] data)
{
using var hmac = new HMACSHA256(key);
return hmac.ComputeHash(data);
}
private static ServerSecurityOptions GetDefaultOptions()
{
return _configuredOptions ?? DefaultOptions.Value;
}
}
}
}

View file

@ -3,8 +3,6 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using GameCult.Caching;
@ -16,79 +14,129 @@ using R3;
namespace GameCult.Networking
{
public class Server
/// <summary>
/// Hosts the server-side authentication, session, and message dispatch pipeline.
/// </summary>
public class Server : IDisposable
{
private const string EmailPattern =
@"^([0-9a-zA-Z]([\+\-_\.][0-9a-zA-Z]+)*)+@(([0-9a-zA-Z][-\w]*[0-9a-zA-Z]*\.)+[a-zA-Z0-9]{2,17})$";
private const string UsernamePattern = @"^[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*$";
private const int ServerPort = 3075;
private const float SessionTimeoutSeconds = 1800; // 30 minutes
private const float SessionRefreshThresholdSeconds = 300; // 5 minutes
private const int MaxLoginAttemptsPerMinute = 5;
private readonly ConcurrentDictionary<Type, Delegate> _messageDelegates = new(); // Aggregated multicast Action<T> per type
private readonly ConcurrentDictionary<Type, Delegate> _messageDelegates = new();
private readonly ConcurrentDictionary<long, User> _users = new();
private readonly ConcurrentDictionary<Guid, Session> _sessions = new();
private readonly ConcurrentDictionary<Guid, long> _sessionToUserId = new();
private readonly ConcurrentDictionary<string, Queue<DateTime>> _loginAttempts = new(); // IP to attempt timestamps
private NetManager _netManager;
private Stopwatch _timer;
private readonly ConcurrentDictionary<string, Queue<DateTimeOffset>> _loginAttempts = new();
private readonly ConcurrentDictionary<string, object> _loginAttemptLocks = new();
private readonly IDisposable _cleanupSubscription;
private readonly CultCache _database;
private readonly ServerSecurityOptions _security;
private NetManager? _netManager;
private Stopwatch? _timer;
private ILogger _logger = new NullLogger();
private bool _disposed;
/// <summary>
/// Gets or sets the logger used by the server.
/// </summary>
public ILogger Logger
{
get => _logger;
set => _logger = value ?? new NullLogger();
}
private float Time => (float)_timer.Elapsed.TotalSeconds;
private float Time => (float)(_timer?.Elapsed.TotalSeconds ?? 0d);
public Server(CultCache cache)
/// <summary>
/// Initializes a new server instance over the supplied cache.
/// </summary>
/// <param name="cache">The backing cache used for player persistence.</param>
/// <param name="security">Optional validated server security options. When omitted, strict environment-based configuration is used.</param>
public Server(CultCache cache, ServerSecurityOptions? security = null)
{
_database = cache;
_security = security ?? ServerSecurityOptions.FromEnvironment();
_database.RegisterIndex<PlayerData>("Email");
Observable.Timer(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60))
_cleanupSubscription = Observable.Timer(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60))
.Subscribe(_ => CleanupExpiredSessions());
}
/// <summary>
/// Validates an email address against the server's accepted pattern.
/// </summary>
/// <param name="email">The email address to validate.</param>
/// <returns><c>true</c> when the address matches the expected format.</returns>
public bool IsValidEmail(string email) => Regex.IsMatch(email, EmailPattern);
/// <summary>
/// Validates a username against the server's accepted pattern.
/// </summary>
/// <param name="name">The username to validate.</param>
/// <returns><c>true</c> when the username matches the expected format.</returns>
public bool IsValidUsername(string name) => Regex.IsMatch(name, UsernamePattern);
/// <summary>
/// Removes all registered message listeners.
/// </summary>
public void ClearMessageListeners()
{
_messageDelegates.Clear();
}
/// <summary>
/// Adds a listener for a specific authenticated message type.
/// </summary>
/// <typeparam name="T">The message type to subscribe to.</typeparam>
/// <param name="callback">The callback to invoke when the message is received.</param>
public void AddMessageListener<T>(Action<T> callback) where T : Message
{
var type = typeof(T);
// Atomically aggregate into multicast delegate (thread-safe via CAS)
_messageDelegates.AddOrUpdate(type,
_ => callback, // First callback
_ => callback,
(t, current) =>
{
// Combine with existing (supports multiple callbacks)
var combined = Delegate.Combine(current, callback) as Action<T>;
return combined ?? throw new InvalidOperationException($"Failed to combine delegates for {t.Name}");
});
}
/// <summary>
/// Removes a previously registered listener for a specific message type.
/// </summary>
/// <typeparam name="T">The message type to unsubscribe from.</typeparam>
/// <param name="callback">The callback to remove.</param>
public void RemoveMessageListener<T>(Action<T> callback) where T : Message
{
if (_messageDelegates.TryGetValue(typeof(T), out var currentDelegate))
{
var newDelegate = Delegate.Remove(currentDelegate, callback) as Action<T>;
_messageDelegates[typeof(T)] = newDelegate; // Null if empty
_messageDelegates[typeof(T)] = newDelegate!;
}
}
/// <summary>
/// Stops the underlying LiteNetLib server.
/// </summary>
public void Stop()
{
_netManager.Stop();
_netManager?.Stop();
_netManager = null;
}
/// <summary>
/// Starts listening for connections and configures message handlers.
/// </summary>
public void Start()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(Server));
}
Stop();
_timer = Stopwatch.StartNew();
var listener = new EventBasedNetListener();
@ -104,9 +152,13 @@ namespace GameCult.Networking
listener.ConnectionRequestEvent += request =>
{
if (CheckRateLimit(request.RemoteEndPoint.Address.ToString()))
request.AcceptIfKey(Secret.ConnectionKey);
{
request.AcceptIfKey(_security.ConnectionKey);
}
else
{
request.Reject();
}
};
listener.PeerConnectedEvent += peer =>
@ -118,17 +170,15 @@ namespace GameCult.Networking
listener.PeerDisconnectedEvent += (peer, info) =>
{
Logger.LogInfo($"User Disconnected: {peer.Address}");
if (_users.TryRemove(peer.Id, out var user) && user.SessionGuid != Guid.Empty)
{
_sessions.TryRemove(user.SessionGuid, out _);
_sessionToUserId.TryRemove(user.SessionGuid, out _);
}
_users.TryRemove(peer.Id, out _);
};
listener.NetworkLatencyUpdateEvent += (peer, latency) =>
{
if (_users.TryGetValue(peer.Id, out var user))
{
user.Latency = latency;
}
};
listener.NetworkReceiveEvent += async (peer, reader, channel, method) =>
@ -136,10 +186,13 @@ namespace GameCult.Networking
try
{
var bytes = reader.GetRemainingBytes();
var message = MessagePackSerializer.Deserialize<Message>(bytes);
var message = MessageSerialization.Deserialize<Message>(bytes);
Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory<byte>(bytes))}");
if (message == null)
{
return;
}
message.Peer = peer;
var user = _users.GetOrAdd(peer.Id, _ => new User { Peer = peer });
@ -147,120 +200,42 @@ namespace GameCult.Networking
{
if (IsVerified(user))
{
var nonce = Secret.NewNonce;
peer.Send(new LoginSuccessMessage { Nonce = nonce, Session = Secret.EncryptBytes(user.SessionGuid.ToByteArray(), nonce) });
SendSessionToken(peer, user.PlayerId);
return;
}
Guid sessionGuid;
if (message is LoginMessage or RegisterMessage && !CheckRateLimit(peer.Address.ToString()))
{
peer.Send(new ErrorMessage { Error = "Too Many Attempts" });
return;
}
switch (message)
{
case RegisterMessage register:
{
var name = Secret.DecryptString(register.Name, register.Nonce);
var email = Secret.DecryptString(register.Email, register.Nonce);
if (!IsValidUsername(name))
{
peer.Send(new ErrorMessage { Error = "Username Invalid" });
return;
}
if (!IsValidEmail(email))
{
peer.Send(new ErrorMessage { Error = "Email Invalid" });
return;
}
if (_database.GetIdByIndex<PlayerData>("Email", email) != null)
{
peer.Send(new ErrorMessage { Error = "Email Taken" });
return;
}
if (_database.GetIdByName<PlayerData>(name) != null)
{
peer.Send(new ErrorMessage { Error = "Username Taken" });
return;
}
sessionGuid = GenerateSessionGuid();
var password = Secret.DecryptString(register.Password, register.Nonce);
var newUserData = new PlayerData
{
ID = Guid.NewGuid(),
Email = email,
PasswordHash = Argon2.Hash(password, memoryCost: 16384),
Username = name
};
await _database.AddAsync(newUserData); // Immediate cache + async file write
_sessions.TryAdd(sessionGuid, new Session { Data = newUserData, LastUpdate = DateTime.Now });
_sessionToUserId[sessionGuid] = peer.Id;
var nonce = Secret.NewNonce;
peer.Send(new LoginSuccessMessage { Nonce = nonce, Session = Secret.EncryptBytes(sessionGuid.ToByteArray(), nonce) });
if (_users.TryGetValue(peer.Id, out var u1))
u1.SessionGuid = sessionGuid;
await HandleRegisterAsync(peer, user, register);
break;
}
case VerifyMessage verify:
var session = new Guid(Secret.DecryptBytes(verify.Session, verify.Nonce));
if (!_sessions.ContainsKey(session))
{
peer.Send(new ErrorMessage { Error = "Session Not Found" });
return;
}
if (_users.TryGetValue(peer.Id, out var u2))
{
u2.SessionGuid = session;
var nonce = Secret.NewNonce;
peer.Send(new LoginSuccessMessage { Nonce = nonce, Session = Secret.EncryptBytes(session.ToByteArray(), nonce) });
}
HandleVerify(peer, user, verify);
break;
case LoginMessage login:
{
var auth = Secret.DecryptString(login.Auth, login.Nonce);
var isEmail = IsValidEmail(auth);
var userData = isEmail ?
_database.GetByIndex<PlayerData>("Email", auth) :
_database.GetByName<PlayerData>(auth);
if (userData == null)
{
peer.Send(new ErrorMessage { Error = isEmail ? "Email Not Found" : "Username Not Found" });
return;
}
var password = Secret.DecryptString(login.Password, login.Nonce);
if (!Argon2.Verify(userData.PasswordHash, password))
{
peer.Send(new ErrorMessage { Error = "Password Incorrect" });
return;
}
sessionGuid = GenerateSessionGuid();
_sessions.TryAdd(sessionGuid, new Session { Data = userData, LastUpdate = DateTime.Now });
_sessionToUserId[sessionGuid] = peer.Id;
var nonce = Secret.NewNonce;
peer.Send(new LoginSuccessMessage { Nonce = nonce, Session = Secret.EncryptBytes(sessionGuid.ToByteArray(), nonce) });
if (_users.TryGetValue(peer.Id, out var u3))
u3.SessionGuid = sessionGuid;
HandleLogin(peer, user, login);
break;
}
}
}
else if (IsVerified(user))
{
// Zero-reflection dispatch via cached multicast delegate
if (_messageDelegates.TryGetValue(message.GetType(), out var del) && del != null)
{
del.DynamicInvoke(message);
}
else
{
Logger.LogWarning($"No listener for {message.GetType().Name}");
if (_sessions.TryGetValue(user.SessionGuid, out var session))
session.LastUpdate = DateTime.Now;
}
user.SessionExpiresAt = DateTimeOffset.UtcNow.AddSeconds(SessionTimeoutSeconds);
RefreshSessionIfNeeded(peer, user);
}
else
{
@ -273,21 +248,29 @@ namespace GameCult.Networking
}
};
AddMessageListener<ChangeNameMessage>(async (message) =>
AddMessageListener<ChangeNameMessage>(async message =>
{
if (!IsValidUsername(message.Name))
{
message.Peer.Send(new ErrorMessage { Error = "Username Invalid" });
message.Peer?.Send(new ErrorMessage { Error = "Username Invalid" });
return;
}
if (_database.GetIdByName<PlayerData>(message.Name) != null)
{
message.Peer.Send(new ErrorMessage { Error = "Username Taken" });
message.Peer?.Send(new ErrorMessage { Error = "Username Taken" });
return;
}
if (_users.TryGetValue(message.Peer.Id, out var user))
if (message.Peer != null && _users.TryGetValue(message.Peer.Id, out var user))
{
var data = SessionData(user);
if (data == null)
{
message.Peer.Send(new ErrorMessage { Error = "User Not Verified" });
return;
}
data.Username = message.Name;
await _database.AddAsync(data);
}
@ -296,64 +279,241 @@ namespace GameCult.Networking
Logger.LogInfo($"Server started on port {ServerPort}.");
}
private bool IsVerified(User u) => u != null && _sessions.ContainsKey(u.SessionGuid);
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
{
return;
}
private PlayerData SessionData(User user) =>
IsVerified(user) ? _sessions[user.SessionGuid].Data : null;
_disposed = true;
_cleanupSubscription.Dispose();
Stop();
}
private async Task HandleRegisterAsync(NetPeer peer, User user, RegisterMessage register)
{
var name = Secret.DecryptString(register.Name, register.Nonce, _security);
var email = Secret.DecryptString(register.Email, register.Nonce, _security);
var password = Secret.DecryptString(register.Password, register.Nonce, _security);
if (string.IsNullOrWhiteSpace(name) || !IsValidUsername(name))
{
peer.Send(new ErrorMessage { Error = "Username Invalid" });
return;
}
if (string.IsNullOrWhiteSpace(email) || !IsValidEmail(email))
{
peer.Send(new ErrorMessage { Error = "Email Invalid" });
return;
}
if (_database.GetIdByIndex<PlayerData>("Email", email) != null)
{
peer.Send(new ErrorMessage { Error = "Email Taken" });
return;
}
if (_database.GetIdByName<PlayerData>(name) != null)
{
peer.Send(new ErrorMessage { Error = "Username Taken" });
return;
}
if (string.IsNullOrWhiteSpace(password))
{
peer.Send(new ErrorMessage { Error = "Password Invalid" });
return;
}
var newUserData = new PlayerData
{
ID = Guid.NewGuid(),
Email = email,
PasswordHash = Argon2.Hash(password, memoryCost: 16384),
Username = name
};
await _database.AddAsync(newUserData);
AttachUser(user, newUserData.ID);
SendSessionToken(peer, newUserData.ID);
}
private void HandleVerify(NetPeer peer, User user, VerifyMessage verify)
{
var token = Secret.DecryptString(verify.Session, verify.Nonce, _security);
if (!Secret.TryValidateSessionToken(token, _security, out var playerId, out _))
{
peer.Send(new ErrorMessage { Error = "Session Invalid" });
return;
}
if (_database.Get<PlayerData>(playerId) == null)
{
peer.Send(new ErrorMessage { Error = "Session Not Found" });
return;
}
AttachUser(user, playerId);
SendSessionToken(peer, playerId);
}
private void HandleLogin(NetPeer peer, User user, LoginMessage login)
{
var auth = Secret.DecryptString(login.Auth, login.Nonce, _security);
var password = Secret.DecryptString(login.Password, login.Nonce, _security);
if (string.IsNullOrWhiteSpace(auth) || string.IsNullOrWhiteSpace(password))
{
peer.Send(new ErrorMessage { Error = "Credentials Invalid" });
return;
}
var isEmail = IsValidEmail(auth);
var userData = isEmail
? _database.GetByIndex<PlayerData>("Email", auth)
: _database.GetByName<PlayerData>(auth);
if (userData == null)
{
peer.Send(new ErrorMessage { Error = isEmail ? "Email Not Found" : "Username Not Found" });
return;
}
if (!Argon2.Verify(userData.PasswordHash, password))
{
peer.Send(new ErrorMessage { Error = "Password Incorrect" });
return;
}
AttachUser(user, userData.ID);
SendSessionToken(peer, userData.ID);
}
private bool IsVerified(User? user) =>
user != null &&
user.PlayerId != Guid.Empty &&
user.SessionExpiresAt > DateTimeOffset.UtcNow &&
_database.Get<PlayerData>(user.PlayerId) != null;
private PlayerData? SessionData(User user) =>
IsVerified(user) ? _database.Get<PlayerData>(user.PlayerId) : null;
private bool CheckRateLimit(string ip)
{
var now = DateTime.Now;
var now = DateTimeOffset.UtcNow;
var windowStart = now.AddMinutes(-1);
_loginAttempts.AddOrUpdate(
ip,
_ => new Queue<DateTime>(new[] { now }),
(_, queue) =>
var queue = _loginAttempts.GetOrAdd(ip, _ => new Queue<DateTimeOffset>());
var gate = _loginAttemptLocks.GetOrAdd(ip, _ => new object());
lock (gate)
{
while (queue.Count > 0 && queue.Peek() < windowStart)
{
// Remove attempts older than 1 minute
while (queue.Count > 0 && queue.Peek() < windowStart)
queue.Dequeue();
queue.Enqueue(now);
return queue;
});
queue.Dequeue();
}
return _loginAttempts[ip].Count <= MaxLoginAttemptsPerMinute;
}
private Guid GenerateSessionGuid()
{
var guid = Guid.NewGuid();
// TODO: Implement HMAC signing for session validation
return guid;
queue.Enqueue(now);
return queue.Count <= MaxLoginAttemptsPerMinute;
}
}
private void CleanupExpiredSessions()
{
foreach (var session in _sessions.ToArray())
foreach (var entry in _users.ToArray())
{
if ((DateTime.Now - session.Value.LastUpdate).TotalSeconds > SessionTimeoutSeconds)
if (entry.Value.SessionExpiresAt != default && entry.Value.SessionExpiresAt <= DateTimeOffset.UtcNow)
{
_sessions.TryRemove(session.Key, out _);
if (_sessionToUserId.TryRemove(session.Key, out var userId) && _users.TryGetValue(userId, out var user))
if (_users.TryRemove(entry.Key, out var user))
{
user.Peer.Disconnect();
_users.TryRemove(userId, out _);
}
}
}
}
private void AttachUser(User user, Guid playerId)
{
user.PlayerId = playerId;
user.SessionExpiresAt = DateTimeOffset.UtcNow.AddSeconds(SessionTimeoutSeconds);
}
private void SendSessionToken(NetPeer peer, Guid playerId)
{
var expiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(SessionTimeoutSeconds);
var token = Secret.CreateSessionToken(playerId, expiresAtUtc, _security);
if (_users.TryGetValue(peer.Id, out var user))
{
user.PlayerId = playerId;
user.SessionExpiresAt = expiresAtUtc;
user.SessionToken = token;
}
var nonce = Secret.NewNonce;
peer.Send(new LoginSuccessMessage
{
Nonce = nonce,
Session = Secret.EncryptString(token, nonce, _security) ?? Array.Empty<byte>()
});
}
private void RefreshSessionIfNeeded(NetPeer peer, User user)
{
if ((user.SessionExpiresAt - DateTimeOffset.UtcNow).TotalSeconds > SessionRefreshThresholdSeconds)
{
return;
}
SendSessionToken(peer, user.PlayerId);
}
}
/// <summary>
/// Represents an active authenticated session.
/// </summary>
public class Session
{
/// <summary>
/// The last time the session was observed as active.
/// </summary>
public DateTime LastUpdate;
public PlayerData Data;
/// <summary>
/// Player data associated with the session.
/// </summary>
public PlayerData Data = null!;
}
/// <summary>
/// Tracks connection state for a connected peer.
/// </summary>
public class User
{
public NetPeer Peer;
/// <summary>
/// The connected network peer.
/// </summary>
public NetPeer Peer = null!;
/// <summary>
/// The last reported latency for the peer.
/// </summary>
public int Latency;
public Guid SessionGuid;
/// <summary>
/// The authenticated player identifier.
/// </summary>
public Guid PlayerId;
/// <summary>
/// The current session expiration timestamp in UTC.
/// </summary>
public DateTimeOffset SessionExpiresAt;
/// <summary>
/// The latest signed session token issued to the peer.
/// </summary>
public string SessionToken = string.Empty;
}
}
}

View file

@ -0,0 +1,127 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace GameCult.Networking
{
/// <summary>
/// Carries the server-side networking configuration and server-only signing secret.
/// </summary>
public sealed class ServerSecurityOptions
{
/// <summary>
/// Environment variable name for the LiteNetLib connection key.
/// </summary>
public const string ConnectionKeyEnvironmentVariable = "GAMECULT_CONNECTION_KEY";
/// <summary>
/// Environment variable name for the session-token signing secret.
/// </summary>
public const string SessionSigningSecretEnvironmentVariable = "GAMECULT_SESSION_SIGNING_SECRET";
private readonly byte[] _encryptionKey;
private readonly byte[] _sessionSigningKey;
/// <summary>
/// Shared LiteNetLib connection key and encryption seed.
/// </summary>
public string ConnectionKey { get; }
/// <summary>
/// Gets a value indicating whether the options were created for explicit development use.
/// </summary>
public bool IsDevelopment { get; }
/// <summary>
/// Initializes a new server security configuration.
/// </summary>
public ServerSecurityOptions(string connectionKey, string sessionSigningSecret, bool isDevelopment = false)
{
if (string.IsNullOrWhiteSpace(connectionKey))
throw new ArgumentException("Connection key must be provided.", nameof(connectionKey));
if (string.IsNullOrWhiteSpace(sessionSigningSecret))
throw new ArgumentException("Session signing secret must be provided.", nameof(sessionSigningSecret));
ConnectionKey = connectionKey;
IsDevelopment = isDevelopment;
_encryptionKey = ComputeSha256(Encoding.UTF8.GetBytes(connectionKey));
_sessionSigningKey = ComputeSha256(Encoding.UTF8.GetBytes(sessionSigningSecret));
}
/// <summary>
/// Creates a strict production-style configuration from environment variables.
/// </summary>
/// <param name="allowDevelopmentDefaults">
/// When <c>true</c>, returns <see cref="Development"/> if both environment variables are absent.
/// </param>
public static ServerSecurityOptions FromEnvironment(bool allowDevelopmentDefaults = false)
{
var connectionKey = Environment.GetEnvironmentVariable(ConnectionKeyEnvironmentVariable);
var sessionSigningSecret = Environment.GetEnvironmentVariable(SessionSigningSecretEnvironmentVariable);
var missingConnectionKey = string.IsNullOrWhiteSpace(connectionKey);
var missingSessionSigningSecret = string.IsNullOrWhiteSpace(sessionSigningSecret);
if (missingConnectionKey && missingSessionSigningSecret)
{
if (allowDevelopmentDefaults)
return Development();
throw new InvalidOperationException(
"Server security configuration is not configured. Set " +
$"{ConnectionKeyEnvironmentVariable} and {SessionSigningSecretEnvironmentVariable}, " +
"or explicitly use ServerSecurityOptions.Development() for local development.");
}
if (missingConnectionKey || missingSessionSigningSecret)
{
throw new InvalidOperationException(
"Server security configuration is partially configured. Missing: " +
string.Join(", ", GetMissingVariables(missingConnectionKey, missingSessionSigningSecret)) + ".");
}
return new ServerSecurityOptions(connectionKey!, sessionSigningSecret!);
}
/// <summary>
/// Creates a deterministic development-only configuration.
/// </summary>
public static ServerSecurityOptions Development()
{
return new ServerSecurityOptions(
"gamecult-dev-connection-key",
"gamecult-dev-session-signing-secret",
true);
}
internal byte[] GetEncryptionKey()
{
return _encryptionKey;
}
internal byte[] GetSessionSigningKey()
{
return _sessionSigningKey;
}
internal ClientSecurityOptions ToClientOptions()
{
return new ClientSecurityOptions(ConnectionKey);
}
private static byte[] ComputeSha256(byte[] data)
{
using var sha256 = SHA256.Create();
return sha256.ComputeHash(data);
}
private static string[] GetMissingVariables(bool missingConnectionKey, bool missingSessionSigningSecret)
{
if (missingConnectionKey && missingSessionSigningSecret)
return [ConnectionKeyEnvironmentVariable, SessionSigningSecretEnvironmentVariable];
if (missingConnectionKey)
return [ConnectionKeyEnvironmentVariable];
return [SessionSigningSecretEnvironmentVariable];
}
}
}

View file

@ -144,14 +144,14 @@ Light:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 705507993}
m_Enabled: 1
serializedVersion: 11
serializedVersion: 13
m_Type: 1
m_Color: {r: 1, g: 0.95686275, b: 0.8392157, a: 1}
m_Intensity: 1
m_Range: 10
m_SpotAngle: 30
m_InnerSpotAngle: 21.80208
m_CookieSize: 10
m_CookieSize2D: {x: 10, y: 10}
m_Shadows:
m_Type: 2
m_Resolution: -1
@ -196,7 +196,7 @@ Light:
m_UseBoundingSphereOverride: 0
m_UseViewFrustumForShadowCasterCull: 1
m_ForceVisible: 0
m_ShadowRadius: 0
m_ShapeRadius: 0
m_ShadowAngle: 0
m_LightUnit: 1
m_LuxAtDistance: 1
@ -1087,6 +1087,7 @@ Canvas:
m_OverridePixelPerfect: 0
m_SortingBucketNormalizedSize: 0
m_VertexColorAlwaysGammaSpace: 1
m_UseReflectionProbes: 0
m_AdditionalShaderChannelsFlag: 25
m_UpdateRectTransformForStandalone: 0
m_SortingLayerID: 0
@ -1169,6 +1170,7 @@ MonoBehaviour:
m_VerticalAlignment: 256
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: -10
m_lineSpacingMax: 0

View file

@ -6,16 +6,6 @@ public class InspectableAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Field)]
public abstract class PreferredInspectorAttribute : InspectableAttribute { }
public class InspectableEnumValuesAttribute : PreferredInspectorAttribute
{
public Type EnumType;
public InspectableEnumValuesAttribute(Type enumType)
{
EnumType = enumType;
}
}
public class InspectableRangedFloatAttribute : PreferredInspectorAttribute
{
public readonly float Min, Max;
@ -36,4 +26,4 @@ public class InspectableRangedIntAttribute : PreferredInspectorAttribute
Min = min;
Max = max;
}
}
}

View file

@ -6,40 +6,61 @@ namespace GameCult.Unity.UI
public class InspectableAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public abstract class PreferredInspectorAttribute : InspectableAttribute { }
public abstract class PreferredInspectorAttribute : InspectableAttribute
{
public bool ReadOnly { get; }
protected PreferredInspectorAttribute(bool readOnly = false)
{
ReadOnly = readOnly;
}
public abstract PreferredInspectorAttribute AsReadOnly();
}
public class InspectableReadOnlyLabelAttribute : PreferredInspectorAttribute { }
public class InspectableReadOnlyLabelAttribute : PreferredInspectorAttribute
{
public InspectableReadOnlyLabelAttribute() : base(true) { }
public override PreferredInspectorAttribute AsReadOnly() => this;
}
public class InspectableRangedFloatAttribute : PreferredInspectorAttribute
{
public readonly float Min, Max;
public InspectableRangedFloatAttribute(float min, float max)
public InspectableRangedFloatAttribute(float min, float max, bool readOnly = false) : base(readOnly)
{
Min = min;
Max = max;
}
public override PreferredInspectorAttribute AsReadOnly() => new InspectableRangedFloatAttribute(Min, Max, true);
}
public class InspectableRangedIntAttribute : PreferredInspectorAttribute
{
public readonly int Min, Max;
public InspectableRangedIntAttribute(int min, int max)
public InspectableRangedIntAttribute(int min, int max, bool readOnly = false) : base(readOnly)
{
Min = min;
Max = max;
}
public override PreferredInspectorAttribute AsReadOnly() => new InspectableRangedIntAttribute(Min, Max, true);
}
public class InspectableIncrementIntAttribute : PreferredInspectorAttribute
{
public readonly int Min, Max;
public InspectableIncrementIntAttribute(int min, int max)
public InspectableIncrementIntAttribute(int min, int max, bool readOnly = false) : base(readOnly)
{
Min = min;
Max = max;
}
public override PreferredInspectorAttribute AsReadOnly() => new InspectableIncrementIntAttribute(Min, Max, true);
}
}
}

View file

@ -154,7 +154,7 @@ namespace GameCult.Unity.UI.Components
context.Refresh += () =>
{
var val = read();
if (float.TryParse(inputField.text, out var existing) && Mathf.Abs(existing-val) < .001)
if (!float.TryParse(inputField.text, out var existing) || Mathf.Abs(existing - val) > .001f)
inputField.text = val.ToString("0.###");
};
@ -215,4 +215,4 @@ namespace GameCult.Unity.UI.Components
string? prefabName = null) =>
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write);
}
}
}

View file

@ -0,0 +1,150 @@
using System;
using UnityEngine;
using UnityEngine.UI;
namespace GameCult.Unity.UI.Components
{
public class ProgressField : LayoutComponent, IFieldHandler
{
[SerializeField] private Slider? slider;
public int Priority => 100;
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
(type == typeof(float) && attr is InspectableRangedFloatAttribute { ReadOnly: true }) ||
(type == typeof(int) && attr is InspectableRangedIntAttribute { ReadOnly: true });
public void ConfigureForMember(IUIContext context, object target, System.Reflection.MemberInfo member, DisplayOptions? displayOptions = null)
{
if (member is System.Reflection.FieldInfo field)
{
if (field.FieldType == typeof(float))
{
var range = field.GetCustomAttributes(typeof(InspectableRangedFloatAttribute), false);
if (range.Length > 0)
{
var attr = (InspectableRangedFloatAttribute)range[0];
Configure(context, () => (float)field.GetValue(target), attr.Min, attr.Max, displayOptions);
}
}
else if (field.FieldType == typeof(int))
{
var range = field.GetCustomAttributes(typeof(InspectableRangedIntAttribute), false);
if (range.Length > 0)
{
var attr = (InspectableRangedIntAttribute)range[0];
Configure(context, () => (int)field.GetValue(target), attr.Min, attr.Max, displayOptions);
}
}
}
else if (member is System.Reflection.PropertyInfo property)
{
if (property.PropertyType == typeof(float))
{
var range = property.GetCustomAttributes(typeof(InspectableRangedFloatAttribute), false);
if (range.Length > 0)
{
var attr = (InspectableRangedFloatAttribute)range[0];
Configure(context, () => (float)property.GetValue(target), attr.Min, attr.Max, displayOptions);
}
}
else if (property.PropertyType == typeof(int))
{
var range = property.GetCustomAttributes(typeof(InspectableRangedIntAttribute), false);
if (range.Length > 0)
{
var attr = (InspectableRangedIntAttribute)range[0];
Configure(context, () => (int)property.GetValue(target), attr.Min, attr.Max, displayOptions);
}
}
}
}
public ProgressField Configure(IUIContext context,
Func<float> read,
float min = 0,
float max = 1,
DisplayOptions? displayOptions = null)
{
if (displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (slider is null) return this;
slider.wholeNumbers = false;
slider.minValue = min;
slider.maxValue = max;
slider.interactable = false;
slider.value = read();
context.Refresh += () => slider.value = read();
this.ApplyLayoutOptions(displayOptions);
return this;
}
public ProgressField Configure(IUIContext context,
Func<int> read,
int min = 0,
int max = 1,
DisplayOptions? displayOptions = null)
{
if (displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (slider is null) return this;
slider.wholeNumbers = true;
slider.minValue = min;
slider.maxValue = max;
slider.interactable = false;
slider.value = read();
context.Refresh += () => slider.value = read();
this.ApplyLayoutOptions(displayOptions);
return this;
}
}
public static class ProgressFieldGeneratorExtensions
{
public static void AddProgressInspector(this IUIContext context,
string label,
Func<float> read,
float min = 0,
float max = 1,
DisplayOptions? displayOptions = null,
string? prefabName = "Progress") =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<ProgressField>(prefabName)?.Configure(context, read, min, max, displayOptions));
public static void AddProgressInspector(this IUIContext context,
string label,
Func<int> read,
int min = 0,
int max = 1,
DisplayOptions? displayOptions = null,
string? prefabName = "Progress") =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<ProgressField>(prefabName)?.Configure(context, read, min, max, displayOptions));
public static void AddProgressField(this IUIContext context,
Func<float> read,
float min = 0,
float max = 1,
DisplayOptions? displayOptions = null,
string? prefabName = "Progress") =>
context.Resolver.Resolve<ProgressField>(prefabName)?.Configure(context, read, min, max, displayOptions);
public static void AddProgressField(this IUIContext context,
Func<int> read,
int min = 0,
int max = 1,
DisplayOptions? displayOptions = null,
string? prefabName = "Progress") =>
context.Resolver.Resolve<ProgressField>(prefabName)?.Configure(context, read, min, max, displayOptions);
}
}

View file

@ -17,8 +17,8 @@ namespace GameCult.Unity.UI.Components
public int Priority => 100;
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
(type == typeof(float) && attr is InspectableRangedFloatAttribute) ||
(type == typeof(int) && attr is InspectableRangedIntAttribute);
(type == typeof(float) && attr is InspectableRangedFloatAttribute { ReadOnly: false }) ||
(type == typeof(int) && attr is InspectableRangedIntAttribute { ReadOnly: false });
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
{
@ -93,7 +93,6 @@ namespace GameCult.Unity.UI.Components
return this;
}
public SliderField Configure(IUIContext context, Func<int> read, Action<int> write, int min, int max, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
@ -155,5 +154,6 @@ namespace GameCult.Unity.UI.Components
DisplayOptions? displayOptions = null,
string? prefabName = null) =>
context.Resolver.Resolve<SliderField>(prefabName)?.Configure(context, read, write, min, max, displayOptions);
}
}
}

View file

@ -30,3 +30,5 @@ MonoBehaviour:
- {fileID: 250733312254258763, guid: 60d88f7843b61904fb9a4fff0186d674, type: 3}
- {fileID: 6714342674142997196, guid: 7d1e00d21aea76841b3315773e2d3628, type: 3}
- {fileID: 8778816924239424740, guid: 9ac2b6d69983d2045baa2ed4e35582f4, type: 3}
- {fileID: 3811655469822244809, guid: f2fc90ef4c27407ba2445cc1f07def90, type: 3}

View file

@ -30,7 +30,7 @@ namespace GameCult.Unity.UI
public Canvas Canvas => GetComponentInParent<Canvas>();
public RectTransform CanvasRoot => (RectTransform)Canvas.transform;
public void Inspect(object obj, bool inspectableOnly = false, bool recursive = true)
public void Inspect(object obj, bool inspectableOnly = false, bool recursive = true, bool readOnly = false)
{
var members = obj.GetType().GetMembers(BindingFlags.Public | BindingFlags.Instance);
foreach (var member in members)
@ -43,13 +43,14 @@ namespace GameCult.Unity.UI
case FieldInfo field:
{
var type = field.FieldType;
var handler = Resolver.ResolveField(type, preferred);
var usedAttr = GetEffectivePreferredAttribute(preferred, type, readOnly);
var handler = Resolver.ResolveField(type, usedAttr);
// If there's a handler for this field and attribute type, use that
if (handler != null)
{
var group = Resolver.Resolve<HorizontalGroup>()?.Configure(this);
if (group == null) continue;
Resolver.Resolve<Label>()?.Configure(group, member.Name);
Resolver.Resolve<Label>()?.Configure(group, member.Name.SplitCamelCase());
handler.ConfigureForMember(group, obj, field);
}
else
@ -63,12 +64,12 @@ namespace GameCult.Unity.UI
var foldout = Resolver.Resolve<GeneratorFoldout>();
if (foldout != null)
{
foldout.Title = member.Name;
foldout.Inspect(field.GetValue(obj), inspectableOnly, recursive);
foldout.Title = member.Name.SplitCamelCase();
foldout.Inspect(field.GetValue(obj), inspectableOnly, recursive, readOnly);
}
}
// If all else fails, just display the ToString
else this.AddLabelInspector(member.Name, () => field.GetValue(obj)?.ToString() ?? "Null");
else this.AddLabelInspector(member.Name.SplitCamelCase(), () => FormatValue(field.GetValue(obj), type));
}
continue;
}
@ -76,18 +77,13 @@ namespace GameCult.Unity.UI
{
var type = property.PropertyType;
var isReadOnly = !property.CanWrite;
PreferredInspectorAttribute? usedAttr = preferred;
if (isReadOnly && preferred == null && IsDisplayablePrimitive(type))
{
usedAttr = new InspectableReadOnlyLabelAttribute();
}
var usedAttr = GetEffectivePreferredAttribute(preferred, type, readOnly || isReadOnly);
var handler = Resolver.ResolveField(type, usedAttr);
if (handler != null)
{
var group = Resolver.Resolve<HorizontalGroup>()?.Configure(this);
if (group == null) continue;
Resolver.Resolve<Label>()?.Configure(group, member.Name);
Resolver.Resolve<Label>()?.Configure(group, member.Name.SplitCamelCase());
handler.ConfigureForMember(group, obj, property);
}
else
@ -98,11 +94,11 @@ namespace GameCult.Unity.UI
var foldout = Resolver.Resolve<GeneratorFoldout>();
if (foldout != null)
{
foldout.Title = member.Name;
foldout.Inspect(property.GetValue(obj), inspectableOnly, recursive);
foldout.Title = member.Name.SplitCamelCase();
foldout.Inspect(property.GetValue(obj), inspectableOnly, recursive, readOnly || isReadOnly);
}
}
else this.AddLabelInspector(member.Name, () => property.GetValue(obj)?.ToString() ?? "Null");
else this.AddLabelInspector(member.Name.SplitCamelCase(), () => FormatValue(property.GetValue(obj), type));
}
continue;
}
@ -110,6 +106,28 @@ namespace GameCult.Unity.UI
}
}
private static PreferredInspectorAttribute? GetEffectivePreferredAttribute(PreferredInspectorAttribute? preferred, Type type, bool readOnly)
{
if (readOnly)
{
if (preferred != null) return preferred.AsReadOnly();
if (IsDisplayablePrimitive(type)) return new InspectableReadOnlyLabelAttribute();
return null;
}
return preferred;
}
private static string FormatValue(object? value, Type type)
{
if (value == null) return "Null";
if (type == typeof(float)) return ((float)value).ToString("0.###");
if (type == typeof(double)) return ((double)value).ToString("0.###");
if (type == typeof(decimal)) return ((decimal)value).ToString("0.###");
if (type.IsEnum) return value.ToString()?.SplitCamelCase() ?? "Null";
return value.ToString() ?? "Null";
}
private static bool IsDisplayablePrimitive(Type type)
{
return type.IsPrimitive || type == typeof(string) || type == typeof(decimal);
@ -138,4 +156,4 @@ namespace GameCult.Unity.UI
Elements.Clear();
}
}
}
}

View file

@ -138,6 +138,7 @@ namespace GameCult.Unity.UI
_nextMenu.panel.AddEnumInspector("Enum", () => testEnum, e => testEnum = e);
var testFlags = AnimalTraits.HasFur;
_nextMenu.panel.AddFlagsEnumInspector("Flags", () => testFlags, f => testFlags = f);
_nextMenu.panel.AddProgressInspector("Progress", () => Mathf.PingPong(Time.time * 0.2f, 1f));
_nextMenu.panel.AddTextButton("Back",
() =>
{
@ -248,4 +249,4 @@ namespace GameCult.Unity.UI
});
}
}
}
}

View file

@ -0,0 +1,512 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &702164251974396902
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 4445717086663197202}
m_Layer: 5
m_Name: Fill Area
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &4445717086663197202
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 702164251974396902}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 8051022211909145788}
m_Father: {fileID: 56337254898317991}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &2106398138603144528
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9206263513462592621}
- component: {fileID: 4179748305306203749}
- component: {fileID: 6506401992227284411}
m_Layer: 5
m_Name: Background
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9206263513462592621
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2106398138603144528}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 56337254898317991}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4179748305306203749
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2106398138603144528}
m_CullTransparentMesh: 0
--- !u!114 &6506401992227284411
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2106398138603144528}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.2, g: 0.2, b: 0.2, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 7ed12bf887e951949bd02894a21fb3ff, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &2988999502710108009
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 545409677249738502}
m_Layer: 5
m_Name: Handle Slide Area
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &545409677249738502
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2988999502710108009}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 142043936000400587}
m_Father: {fileID: 56337254898317991}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 1, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: -10, y: 16}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &4001191192590482813
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 8051022211909145788}
- component: {fileID: 2640905336499490197}
- component: {fileID: 7723267998676520126}
m_Layer: 5
m_Name: Fill
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &8051022211909145788
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4001191192590482813}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4445717086663197202}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &2640905336499490197
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4001191192590482813}
m_CullTransparentMesh: 0
--- !u!114 &7723267998676520126
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4001191192590482813}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 0.44583336, b: 0.050000012, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 7ed12bf887e951949bd02894a21fb3ff, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &4968680124705292911
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 142043936000400587}
- component: {fileID: 7907894582991311579}
- component: {fileID: 8102839323297657006}
- component: {fileID: 1114879522420959802}
m_Layer: 5
m_Name: Handle
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &142043936000400587
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4968680124705292911}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0.75, y: 0.75, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 545409677249738502}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 16, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &7907894582991311579
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4968680124705292911}
m_CullTransparentMesh: 0
--- !u!114 &8102839323297657006
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4968680124705292911}
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 0.44583336, b: 0.050000012, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 84d126f984de5fd42ac66361c00f830c, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &1114879522420959802
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4968680124705292911}
m_Enabled: 0
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 86710e43de46f6f4bac7c8e50813a599, type: 3}
m_Name:
m_EditorClassIdentifier:
m_AspectMode: 2
m_AspectRatio: 1
--- !u!1001 &2689390688280664784
PrefabInstance:
m_ObjectHideFlags: 0
serializedVersion: 2
m_Modification:
serializedVersion: 3
m_TransformParent: {fileID: 0}
m_Modifications:
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_Pivot.x
value: 1
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_Pivot.y
value: 0.5
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_AnchorMax.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_AnchorMax.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_AnchorMin.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_AnchorMin.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_SizeDelta.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_SizeDelta.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_LocalPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_LocalPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_LocalPosition.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_LocalRotation.w
value: 1
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_LocalRotation.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_LocalRotation.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_LocalRotation.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_AnchoredPosition.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_LocalEulerAnglesHint.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_LocalEulerAnglesHint.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_LocalEulerAnglesHint.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 3431245070033892138, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
propertyPath: m_Name
value: Progress
objectReference: {fileID: 0}
m_RemovedComponents: []
m_RemovedGameObjects: []
m_AddedGameObjects:
- targetCorrespondingSourceObject: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
insertIndex: -1
addedObject: {fileID: 9206263513462592621}
- targetCorrespondingSourceObject: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
insertIndex: -1
addedObject: {fileID: 4445717086663197202}
- targetCorrespondingSourceObject: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
insertIndex: -1
addedObject: {fileID: 545409677249738502}
m_AddedComponents:
- targetCorrespondingSourceObject: {fileID: 3431245070033892138, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
insertIndex: 1
addedObject: {fileID: 5356524847211812639}
- targetCorrespondingSourceObject: {fileID: 3431245070033892138, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
insertIndex: -1
addedObject: {fileID: 3811655469822244809}
m_SourcePrefab: {fileID: 100100000, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
--- !u!224 &56337254898317991 stripped
RectTransform:
m_CorrespondingSourceObject: {fileID: 2709624342289047671, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
m_PrefabInstance: {fileID: 2689390688280664784}
m_PrefabAsset: {fileID: 0}
--- !u!1 &778165760141322746 stripped
GameObject:
m_CorrespondingSourceObject: {fileID: 3431245070033892138, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
m_PrefabInstance: {fileID: 2689390688280664784}
m_PrefabAsset: {fileID: 0}
--- !u!114 &5356524847211812639
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 778165760141322746}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 67db9e8f0e2ae9c40bc1e2b64352a6b4, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Highlighted
m_DisabledTrigger: Disabled
m_Interactable: 0
m_TargetGraphic: {fileID: 0}
m_FillRect: {fileID: 8051022211909145788}
m_HandleRect: {fileID: 0}
m_Direction: 0
m_MinValue: 0
m_MaxValue: 1
m_WholeNumbers: 0
m_Value: 0.76
m_OnValueChanged:
m_PersistentCalls:
m_Calls: []
--- !u!114 &3811655469822244809
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 778165760141322746}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5b0da3e29aa64d6eb2c728e17e7036a7, type: 3}
m_Name:
m_EditorClassIdentifier: GameCult.Unity.UI::GameCult.Unity.UI.Components.ProgressField
layoutElement: {fileID: 7427040527870426657}
slider: {fileID: 5356524847211812639}
--- !u!114 &7427040527870426657 stripped
MonoBehaviour:
m_CorrespondingSourceObject: {fileID: 4773961386689766641, guid: 63bce854f3934aa49b497baebb161e50, type: 3}
m_PrefabInstance: {fileID: 2689390688280664784}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 778165760141322746}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:

View file

@ -0,0 +1,137 @@
# CultUI
`CultUI` is a Unity runtime UI composition and inspector framework, packaged for UPM and developed alongside a demo project that showcases its components, layout system, and reflective inspector workflow.
This repository serves two purposes:
- the source of the `org.gamecult.ui` package
- a Unity project for developing and demonstrating the package in a real scene
## Scope
CultUI is aimed at runtime, data-driven interface generation rather than hand-authoring every screen from scratch.
It is built for cases like:
- in-game settings menus
- debug and developer panels
- runtime inspectors for game objects and data models
- tools for exposing configurable values with minimal UI boilerplate
- reusable menu flows composed from prefabs and field handlers
## Features
- reflective runtime inspector generation for fields and properties
- prefab-driven resolver system for mapping value types to UI controls
- built-in field components for common input and display patterns
- foldouts and nested inspectors for structured data
- modal and color-picker style components
- reusable layout components for composing inspector rows and panels
- runtime menu composition for demos and application flows
- UPM-style package metadata and assembly definition for integration into Unity projects
## Package Layout
The package source lives under `Assets/UI`.
Key parts of the package include:
- `package.json`: package metadata for `org.gamecult.ui`
- `GameCult.Unity.UI.asmdef`: assembly definition for the runtime package
- `Generator.cs`: reflective inspector generation entry point
- `ReflectiveResolver.cs`: runtime resolver for prefab-backed field handlers and components
- `Components/`: built-in controls such as toggles, sliders, enum selectors, text input, buttons, labels, foldouts, and modal UI
- `Prefabs/`, `Resources/`, `Shaders/`: packaged assets used by the framework
## Demo Project
This repository also contains a Unity project used to develop and preview the package.
The demo project provides:
- a sample scene under `Assets/Scenes`
- package assets wired into a working Unity editor project
- demo menu flow showing how CultUI panels can be assembled at runtime
- a place to iterate on prefabs, resources, and inspector behavior without needing a separate host project
## Installation
### Local Package Development
If you are working in this repository directly:
1. Open the project in Unity `6000.4.2f1`.
2. Let Unity import packages and regenerate project files.
3. Open the demo scene in `Assets/Scenes`.
### Installing As A UPM Package
To consume CultUI from another Unity project, reference the package by local path or Git URL in your project's `manifest.json`.
Example local reference:
```json
{
"dependencies": {
"org.gamecult.ui": "file:../path-to-CultUI/Assets/UI"
}
}
```
## Usage
At a high level, CultUI works by combining:
- a `ReflectiveResolver` asset that knows which prefabs handle which value types
- reusable UI component prefabs
- a `Generator` or `GeneratorPanel` that inspects an object and emits UI rows at runtime
Typical flow:
1. Create or configure a `ReflectiveResolver`.
2. Assign field and component prefabs to the resolver.
3. Place a `Generator` or `GeneratorPanel` in your canvas hierarchy.
4. Call `Inspect(...)` on a target object or use the helper methods for explicit UI composition.
5. Refresh displayed values as your runtime state changes.
CultUI can be used both reflectively and imperatively:
- reflective mode generates inspector UI from object members
- imperative mode builds menus and panels directly through helper methods and component composition
## Included Controls
The package currently includes components for patterns such as:
- labels
- text buttons
- boolean toggles
- text input
- numeric sliders
- increment/decrement fields
- enum selection
- foldouts
- modal overlays
- color selection workflows
## Requirements
- Unity `6000.4.2f1` for this repository's demo project
- `uGUI`
- TextMeshPro
The development project also includes additional Unity packages and NuGet-managed dependencies used by the demo environment.
## Repository Structure
- `Assets/UI`: package source
- `Assets/Scenes`: demo scene content
- `Assets/Prefabs`, `Assets/Resources`, `Assets/Scripts`: demo-project support assets and scripts
- `Packages`, `ProjectSettings`: Unity project configuration for package development and demonstration
## Development Notes
- the Unity project is the development host for the package
- generated `.csproj` and solution files are editor artifacts
- package-facing code should remain centered in `Assets/UI`
- demo-only code should stay separate from reusable package code

View file

@ -0,0 +1,262 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Caching.NewtonsoftJson;
using Newtonsoft.Json;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
public class BackingStoreTests
{
[Test]
public async Task MultiFileBackingStore_Rename_RemovesStaleFile()
{
var rootPath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(rootPath);
try
{
var store = new MultiFileNewtonsoftJsonBackingStore(rootPath);
store.RegisterType<NamedTestEntry>();
var cache = new CultCache();
cache.AddBackingStore(store);
var entry = new NamedTestEntry
{
ID = Guid.NewGuid(),
Name = "OriginalName",
Value = "value"
};
await cache.AddAsync(entry);
entry.Name = "RenamedUser";
await cache.AddAsync(entry);
var files = Directory.GetFiles(rootPath, "*.json", SearchOption.AllDirectories)
.Select(Path.GetFileName)
.ToArray();
Assert.That(files, Does.Contain("RenamedUser.json"));
Assert.That(files, Does.Not.Contain("OriginalName.json"));
}
finally
{
if (Directory.Exists(rootPath))
{
Directory.Delete(rootPath, true);
}
}
}
[Test]
public async Task MultiFileNewtonsoftJsonBackingStore_RoundTrips_DiscoveredEntryType()
{
var rootPath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(rootPath);
try
{
var writeStore = new MultiFileNewtonsoftJsonBackingStore(rootPath);
writeStore.RegisterType<NamedTestEntry>();
var writeCache = new CultCache();
writeCache.AddBackingStore(writeStore);
var entry = new NamedTestEntry
{
ID = Guid.NewGuid(),
Name = "RoundTrip",
Value = "payload"
};
await writeCache.AddAsync(entry);
var readStore = new MultiFileNewtonsoftJsonBackingStore(rootPath);
readStore.RegisterType<NamedTestEntry>();
var readCache = new CultCache();
readCache.AddBackingStore(readStore);
await readCache.PullAllBackingStoresAsync();
var loaded = readCache.Get(entry.ID);
Assert.That(loaded, Is.Not.Null);
Assert.That(GetEntryValue(loaded!), Is.EqualTo("payload"));
}
finally
{
if (Directory.Exists(rootPath))
{
Directory.Delete(rootPath, true);
}
}
}
[Test]
public async Task SingleFileNewtonsoftJsonBackingStore_RoundTrips_DiscoveredEntryType()
{
var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.json");
try
{
var writeStore = new SingleFileNewtonsoftJsonBackingStore(filePath);
writeStore.RegisterType<NamedTestEntry>();
var writeCache = new CultCache();
writeCache.AddBackingStore(writeStore);
var entry = new NamedTestEntry
{
ID = Guid.NewGuid(),
Name = "SingleFile",
Value = "payload"
};
await writeCache.AddAsync(entry);
writeStore.PushAll();
var readStore = new SingleFileNewtonsoftJsonBackingStore(filePath);
readStore.RegisterType<NamedTestEntry>();
var readCache = new CultCache();
readCache.AddBackingStore(readStore);
await readCache.PullAllBackingStoresAsync();
var loaded = readCache.Get(entry.ID);
Assert.That(loaded, Is.Not.Null);
Assert.That(GetEntryValue(loaded!), Is.EqualTo("payload"));
}
finally
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
}
[Test]
public void MultiFileNewtonsoftJsonBackingStore_Rejects_UnknownDiscriminator()
{
var rootPath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(rootPath);
try
{
var store = new MultiFileNewtonsoftJsonBackingStore(rootPath);
store.RegisterType<NamedTestEntry>();
var payload = """
{
"Type": "Unknown.Assembly:Unknown.Type",
"Data": {
"ID": "00000000-0000-0000-0000-000000000001",
"Name": "Bad",
"Value": "Bad"
}
}
""";
Assert.That(
() => store.Deserialize(Encoding.UTF8.GetBytes(payload)),
Throws.TypeOf<JsonSerializationException>().With.Message.Contains("Unknown DatabaseEntry discriminator"));
}
finally
{
if (Directory.Exists(rootPath))
{
Directory.Delete(rootPath, true);
}
}
}
[Test]
public async Task MultiFileNewtonsoftJsonBackingStore_Delete_Removes_File()
{
var rootPath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(rootPath);
try
{
var store = new MultiFileNewtonsoftJsonBackingStore(rootPath);
store.RegisterType<NamedTestEntry>();
var cache = new CultCache();
cache.AddBackingStore(store);
var entry = new NamedTestEntry
{
ID = Guid.NewGuid(),
Name = "DeleteMe",
Value = "payload"
};
await cache.AddAsync(entry);
cache.Remove(entry);
var files = Directory.GetFiles(rootPath, "*.json", SearchOption.AllDirectories)
.Select(Path.GetFileName)
.ToArray();
Assert.That(files, Does.Not.Contain("DeleteMe.json"));
}
finally
{
if (Directory.Exists(rootPath))
{
Directory.Delete(rootPath, true);
}
}
}
[Test]
public void MessagePackDatabaseEntrySerialization_RoundTrips_DatabaseLink()
{
var serializationType = typeof(MultiFileMessagePackBackingStore).Assembly
.GetType("GameCult.Caching.MessagePack.DatabaseEntrySerialization", throwOnError: true)!;
var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(DatabaseLink<DatabaseEntry>));
var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(DatabaseLink<DatabaseEntry>));
var link = new DatabaseLink<DatabaseEntry> { LinkID = Guid.NewGuid() };
var payload = (byte[])serialize.Invoke(null, [link])!;
var roundTrip = (DatabaseLink<DatabaseEntry>)deserialize.Invoke(null, [payload])!;
Assert.That(roundTrip.LinkID, Is.EqualTo(link.LinkID));
}
[Test]
public void MessagePackDatabaseEntrySerialization_Rejects_InvalidPayload()
{
var serializationType = typeof(MultiFileMessagePackBackingStore).Assembly
.GetType("GameCult.Caching.MessagePack.DatabaseEntrySerialization", throwOnError: true)!;
var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(DatabaseEntry));
Assert.That(
() => deserialize.Invoke(null, [new byte[] { 0xC1 }]),
Throws.TypeOf<TargetInvocationException>()
.With.InnerException.InstanceOf<global::MessagePack.MessagePackSerializationException>());
}
private sealed class NamedTestEntry : DatabaseEntry, INamedEntry
{
public string Name = string.Empty;
public string Value = string.Empty;
public string EntryName
{
get => Name;
set => Name = value;
}
}
private static string GetEntryValue(DatabaseEntry entry)
{
return entry.GetType().GetField("Value")?.GetValue(entry) as string ?? string.Empty;
}
}
}

View file

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<RootNamespace>GameCult.Caching.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\..\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="..\..\src\GameCult.Caching.NewtonsoftJson\GameCult.Caching.NewtonsoftJson.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.msbuild" Version="10.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NUnit" Version="4.*" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,15 @@
# GameCult.Caching.Tests
`GameCult.Caching.Tests` contains NUnit tests for the caching library and its persistence abstractions.
## Scope
The tests currently cover:
- rename behavior in `MultiFileNewtonsoftJsonBackingStore`
## Run
```powershell
dotnet test tests\GameCult.Caching.Tests\GameCult.Caching.Tests.csproj
```

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<RootNamespace>GameCult.Networking.Tests</RootNamespace>
</PropertyGroup>

View file

@ -1,3 +1,6 @@
#nullable enable
using System;
using System.Reflection;
using System.Text;
using NUnit.Framework;
@ -5,14 +8,141 @@ namespace GameCult.Networking.Tests
{
public class NetworkingTests
{
private static readonly ServerSecurityOptions DevelopmentServerSecurity = ServerSecurityOptions.Development();
private static readonly ClientSecurityOptions DevelopmentClientSecurity = ClientSecurityOptions.Development();
[Test]
public void EncryptDecrypt_Roundtrip()
{
var plaintext = Encoding.UTF8.GetBytes("test");
var nonce = Secret.NewNonce;
var encrypted = Secret.EncryptBytes(plaintext, nonce);
var decrypted = Secret.DecryptBytes(encrypted, nonce);
var encrypted = Secret.EncryptBytes(plaintext, nonce, DevelopmentClientSecurity);
var decrypted = Secret.DecryptBytes(encrypted, nonce, DevelopmentClientSecurity);
Assert.That(plaintext, Is.EqualTo(decrypted));
}
[Test]
public void SessionToken_Validates_AndRejectsTampering()
{
var userId = Guid.NewGuid();
var token = Secret.CreateSessionToken(userId, DateTimeOffset.UtcNow.AddMinutes(5), DevelopmentServerSecurity);
Assert.That(Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out var parsedUserId, out var expiresAt), Is.True);
Assert.That(parsedUserId, Is.EqualTo(userId));
Assert.That(expiresAt, Is.GreaterThan(DateTimeOffset.UtcNow));
var tamperedToken = $"{token}tampered";
Assert.That(Secret.TryValidateSessionToken(tamperedToken, DevelopmentServerSecurity, out _, out _), Is.False);
}
[Test]
public void SessionToken_RejectsExpiredTokens()
{
var token = Secret.CreateSessionToken(Guid.NewGuid(), DateTimeOffset.UtcNow.AddMinutes(-1), DevelopmentServerSecurity);
Assert.That(Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out _, out _), Is.False);
}
[Test]
public void SecurityOptions_FromEnvironment_Rejects_MissingSecrets()
{
using var _ = new EnvironmentVariableScope(
(ServerSecurityOptions.ConnectionKeyEnvironmentVariable, null),
(ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null));
Assert.That(
() => ServerSecurityOptions.FromEnvironment(),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("Server security configuration is not configured"));
}
[Test]
public void SecurityOptions_FromEnvironment_Rejects_PartialConfiguration()
{
using var _ = new EnvironmentVariableScope(
(ServerSecurityOptions.ConnectionKeyEnvironmentVariable, "connection-key"),
(ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null));
Assert.That(
() => ServerSecurityOptions.FromEnvironment(),
Throws.TypeOf<InvalidOperationException>()
.With.Message.Contains(ServerSecurityOptions.SessionSigningSecretEnvironmentVariable));
}
[Test]
public void ServerSecurityOptions_FromEnvironment_Can_OptInto_DevelopmentDefaults()
{
using var _ = new EnvironmentVariableScope(
(ServerSecurityOptions.ConnectionKeyEnvironmentVariable, null),
(ServerSecurityOptions.SessionSigningSecretEnvironmentVariable, null));
var options = ServerSecurityOptions.FromEnvironment(allowDevelopmentDefaults: true);
Assert.That(options.IsDevelopment, Is.True);
Assert.That(options.ConnectionKey, Is.Not.Empty);
}
[Test]
public void MessageSerialization_RoundTrips_KnownMessageUnion()
{
var client = new Client(DevelopmentClientSecurity);
var message = new LoginMessage
{
Nonce = Secret.NewNonce,
Auth = Encoding.UTF8.GetBytes("auth"),
Password = Encoding.UTF8.GetBytes("password")
};
var serializationType = client.GetType().Assembly
.GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!;
var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(Message));
var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(Message));
var payload = (byte[])serialize.Invoke(null, [message])!;
var roundTrip = (LoginMessage)deserialize.Invoke(null, [payload])!;
Assert.That(roundTrip.Auth, Is.EqualTo(message.Auth));
Assert.That(roundTrip.Password, Is.EqualTo(message.Password));
Assert.That(roundTrip.Nonce, Is.EqualTo(message.Nonce));
}
[Test]
public void MessageSerialization_Rejects_InvalidPayload()
{
var client = new Client(DevelopmentClientSecurity);
var serializationType = client.GetType().Assembly
.GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!;
var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)!
.MakeGenericMethod(typeof(Message));
Assert.That(
() => deserialize.Invoke(null, [new byte[] { 0xC1 }]),
Throws.TypeOf<TargetInvocationException>()
.With.InnerException.InstanceOf<MessagePack.MessagePackSerializationException>());
}
private sealed class EnvironmentVariableScope : IDisposable
{
private readonly (string Name, string? Value)[] _originalValues;
public EnvironmentVariableScope(params (string Name, string? Value)[] values)
{
_originalValues = new (string Name, string? Value)[values.Length];
for (var i = 0; i < values.Length; i++)
{
_originalValues[i] = (values[i].Name, Environment.GetEnvironmentVariable(values[i].Name));
Environment.SetEnvironmentVariable(values[i].Name, values[i].Value);
}
}
public void Dispose()
{
foreach (var original in _originalValues)
{
Environment.SetEnvironmentVariable(original.Name, original.Value);
}
}
}
}
}
}

View file

@ -0,0 +1,27 @@
# GameCult.Networking.Tests
`GameCult.Networking.Tests` contains NUnit tests for the networking library.
## Scope
The tests currently cover:
- AES-GCM encrypt/decrypt round-trips in `Secret`
- signed session-token validation
- signed session-token tamper detection
- signed session-token expiry handling
## Run
```powershell
dotnet test tests\GameCult.Networking.Tests\GameCult.Networking.Tests.csproj
```
## Adding New Tests
Typical future areas to expand include:
- client reconnect and verify flows
- login and registration integration tests
- malformed message handling
- rate-limiting behavior under concurrency