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:
parent
d88eecb4ba
commit
0bfc85a4f2
50 changed files with 3957 additions and 325 deletions
11
AGENTS.md
Normal file
11
AGENTS.md
Normal 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.
|
||||
14
CultLib.sln
14
CultLib.sln
|
|
@ -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
226
README.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
namespace GameCult.Caching.MessagePack.Analyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Placeholder type for the analyzer packaging project.
|
||||
/// </summary>
|
||||
public class Class1
|
||||
{
|
||||
}
|
||||
|
|
|
|||
25
src/GameCult.Caching.MessagePack.Analyzers/README.md
Normal file
25
src/GameCult.Caching.MessagePack.Analyzers/README.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
63
src/GameCult.Caching.MessagePack.Generator/README.md
Normal file
63
src/GameCult.Caching.MessagePack.Generator/README.md
Normal 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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
115
src/GameCult.Caching.MessagePack/README.md
Normal file
115
src/GameCult.Caching.MessagePack/README.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
49
src/GameCult.Caching.NewtonsoftJson/README.md
Normal file
49
src/GameCult.Caching.NewtonsoftJson/README.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
241
src/GameCult.Caching/README.md
Normal file
241
src/GameCult.Caching/README.md
Normal 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();
|
||||
```
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -3,25 +3,32 @@ 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)
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) { }
|
||||
}
|
||||
}
|
||||
69
src/GameCult.Logging/README.md
Normal file
69
src/GameCult.Logging/README.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/GameCult.Networking/ClientSecurityOptions.cs
Normal file
51
src/GameCult.Networking/ClientSecurityOptions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/GameCult.Networking/MessageSerialization.cs
Normal file
20
src/GameCult.Networking/MessageSerialization.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
183
src/GameCult.Networking/README.md
Normal file
183
src/GameCult.Networking/README.md
Normal 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
127
src/GameCult.Networking/ServerSecurityOptions.cs
Normal file
127
src/GameCult.Networking/ServerSecurityOptions.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
public class InspectableReadOnlyLabelAttribute : PreferredInspectorAttribute { }
|
||||
protected PreferredInspectorAttribute(bool readOnly = false)
|
||||
{
|
||||
ReadOnly = readOnly;
|
||||
}
|
||||
|
||||
public abstract PreferredInspectorAttribute AsReadOnly();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.###");
|
||||
};
|
||||
|
||||
|
|
|
|||
150
src/GameCult.Unity/Assets/UI/Components/ProgressField.cs
Normal file
150
src/GameCult.Unity/Assets/UI/Components/ProgressField.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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",
|
||||
() =>
|
||||
{
|
||||
|
|
|
|||
512
src/GameCult.Unity/Assets/UI/Prefabs/Progress.prefab
Normal file
512
src/GameCult.Unity/Assets/UI/Prefabs/Progress.prefab
Normal 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:
|
||||
137
src/GameCult.Unity/README.md
Normal file
137
src/GameCult.Unity/README.md
Normal 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
|
||||
262
tests/GameCult.Caching.Tests/BackingStoreTests.cs
Normal file
262
tests/GameCult.Caching.Tests/BackingStoreTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj
Normal file
29
tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj
Normal 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>
|
||||
15
tests/GameCult.Caching.Tests/README.md
Normal file
15
tests/GameCult.Caching.Tests/README.md
Normal 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
|
||||
```
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
tests/GameCult.Networking.Tests/README.md
Normal file
27
tests/GameCult.Networking.Tests/README.md
Normal 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
|
||||
Loading…
Reference in a new issue