From 0bfc85a4f28d9bcde78a03f438ed5999843041b1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 18 Apr 2026 13:01:24 +0100 Subject: [PATCH] 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. --- AGENTS.md | 11 + CultLib.sln | 14 + README.md | 226 +++++++- .../Class1.cs | 5 +- .../README.md | 25 + .../DatabaseEntryMessagePackGenerator.cs | 7 + .../README.md | 63 +++ .../DatabaseEntryResolver.cs | 8 + .../DatabaseEntrySerialization.cs | 24 + .../DatabaseLinkFormatter.cs | 8 +- .../MessagePackBackingStore.cs | 29 +- src/GameCult.Caching.MessagePack/README.md | 115 ++++ .../GameCult.Caching.NewtonsoftJson.csproj | 10 + .../NewtonsoftJsonBackingStore.cs | 238 ++++++++ src/GameCult.Caching.NewtonsoftJson/README.md | 49 ++ src/GameCult.Caching/CultCache.cs | 186 +++++-- src/GameCult.Caching/README.md | 241 +++++++++ src/GameCult.Caching/ReflectionExtensions.cs | 20 +- src/GameCult.Logging.Unity/UnityLogger.cs | 9 +- src/GameCult.Logging/ConsoleLogger.cs | 11 +- src/GameCult.Logging/FileLogger.cs | 16 +- src/GameCult.Logging/ILogger.cs | 35 +- src/GameCult.Logging/README.md | 69 +++ src/GameCult.Networking/Client.cs | 246 +++++++-- .../ClientSecurityOptions.cs | 51 ++ .../MessageSerialization.cs | 20 + src/GameCult.Networking/Messages.cs | 94 +++- src/GameCult.Networking/NetPeerExtensions.cs | 13 +- src/GameCult.Networking/PlayerData.cs | 8 +- src/GameCult.Networking/README.md | 183 +++++++ src/GameCult.Networking/Secret.cs | 269 ++++++++- src/GameCult.Networking/Server.cs | 460 +++++++++++----- .../ServerSecurityOptions.cs | 127 +++++ .../Assets/Scenes/SampleScene.unity | 8 +- .../Assets/Scripts/Attributes.cs | 12 +- src/GameCult.Unity/Assets/UI/Attributes.cs | 33 +- .../Assets/UI/Components/InputField.cs | 4 +- .../Assets/UI/Components/ProgressField.cs | 150 +++++ .../Assets/UI/Components/SliderField.cs | 8 +- .../Assets/UI/Default Resolver.asset | 2 + src/GameCult.Unity/Assets/UI/Generator.cs | 52 +- src/GameCult.Unity/Assets/UI/MainMenu.cs | 3 +- .../Assets/UI/Prefabs/Progress.prefab | 512 ++++++++++++++++++ src/GameCult.Unity/README.md | 137 +++++ .../BackingStoreTests.cs | 262 +++++++++ .../GameCult.Caching.Tests.csproj | 29 + tests/GameCult.Caching.Tests/README.md | 15 + .../GameCult.Networking.Tests.csproj | 2 +- .../NetworkingTests.cs | 136 ++++- tests/GameCult.Networking.Tests/README.md | 27 + 50 files changed, 3957 insertions(+), 325 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/GameCult.Caching.MessagePack.Analyzers/README.md create mode 100644 src/GameCult.Caching.MessagePack.Generator/README.md create mode 100644 src/GameCult.Caching.MessagePack/DatabaseEntrySerialization.cs create mode 100644 src/GameCult.Caching.MessagePack/README.md create mode 100644 src/GameCult.Caching.NewtonsoftJson/GameCult.Caching.NewtonsoftJson.csproj create mode 100644 src/GameCult.Caching.NewtonsoftJson/NewtonsoftJsonBackingStore.cs create mode 100644 src/GameCult.Caching.NewtonsoftJson/README.md create mode 100644 src/GameCult.Caching/README.md create mode 100644 src/GameCult.Logging/README.md create mode 100644 src/GameCult.Networking/ClientSecurityOptions.cs create mode 100644 src/GameCult.Networking/MessageSerialization.cs create mode 100644 src/GameCult.Networking/README.md create mode 100644 src/GameCult.Networking/ServerSecurityOptions.cs create mode 100644 src/GameCult.Unity/Assets/UI/Components/ProgressField.cs create mode 100644 src/GameCult.Unity/Assets/UI/Prefabs/Progress.prefab create mode 100644 src/GameCult.Unity/README.md create mode 100644 tests/GameCult.Caching.Tests/BackingStoreTests.cs create mode 100644 tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj create mode 100644 tests/GameCult.Caching.Tests/README.md create mode 100644 tests/GameCult.Networking.Tests/README.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..fccae541 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CultLib.sln b/CultLib.sln index 37f75cc1..c56c38e8 100644 --- a/CultLib.sln +++ b/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 diff --git a/README.md b/README.md index 48e167f9..de704fd9 100644 --- a/README.md +++ b/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(""); +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) diff --git a/src/GameCult.Caching.MessagePack.Analyzers/Class1.cs b/src/GameCult.Caching.MessagePack.Analyzers/Class1.cs index 37aa7186..345e38e6 100644 --- a/src/GameCult.Caching.MessagePack.Analyzers/Class1.cs +++ b/src/GameCult.Caching.MessagePack.Analyzers/Class1.cs @@ -2,7 +2,10 @@ namespace GameCult.Caching.MessagePack.Analyzer { + /// + /// Placeholder type for the analyzer packaging project. + /// public class Class1 { } -} \ No newline at end of file +} diff --git a/src/GameCult.Caching.MessagePack.Analyzers/README.md b/src/GameCult.Caching.MessagePack.Analyzers/README.md new file mode 100644 index 00000000..e4b25bd4 --- /dev/null +++ b/src/GameCult.Caching.MessagePack.Analyzers/README.md @@ -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. diff --git a/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs b/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs index 687b934a..c5467d9c 100644 --- a/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs +++ b/src/GameCult.Caching.MessagePack.Generator/DatabaseEntryMessagePackGenerator.cs @@ -8,9 +8,16 @@ using Microsoft.CodeAnalysis.Text; namespace GameCult.Caching.MessagePack.Generator { + /// + /// Generates MessagePack formatters and resolver mappings for concrete DatabaseEntry types. + /// [Generator] public class DatabaseEntryMessagePackGenerator : IIncrementalGenerator { + /// + /// Configures the incremental generator pipeline. + /// + /// The generator initialization context. public void Initialize(IncrementalGeneratorInitializationContext ctx) { // Select DatabaseEntry subclass declarations diff --git a/src/GameCult.Caching.MessagePack.Generator/README.md b/src/GameCult.Caching.MessagePack.Generator/README.md new file mode 100644 index 00000000..57fb314f --- /dev/null +++ b/src/GameCult.Caching.MessagePack.Generator/README.md @@ -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` 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`. + +## 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). diff --git a/src/GameCult.Caching.MessagePack/DatabaseEntryResolver.cs b/src/GameCult.Caching.MessagePack/DatabaseEntryResolver.cs index 88fb5584..a1236b1b 100644 --- a/src/GameCult.Caching.MessagePack/DatabaseEntryResolver.cs +++ b/src/GameCult.Caching.MessagePack/DatabaseEntryResolver.cs @@ -12,12 +12,20 @@ namespace GameCult.Caching.MessagePack /// public sealed class DatabaseEntryResolver : IFormatterResolver { + /// + /// Shared singleton instance of the resolver. + /// public static readonly DatabaseEntryResolver Instance = new DatabaseEntryResolver(); private readonly ConcurrentDictionary _formatterCache = new(); private DatabaseEntryResolver() { } + /// + /// Resolves a formatter for a cache entry type or a . + /// + /// The requested serialized type. + /// The formatter if one is available; otherwise, null. public IMessagePackFormatter? GetFormatter() { // 1. Check generated resolver mapping diff --git a/src/GameCult.Caching.MessagePack/DatabaseEntrySerialization.cs b/src/GameCult.Caching.MessagePack/DatabaseEntrySerialization.cs new file mode 100644 index 00000000..0e87e930 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/DatabaseEntrySerialization.cs @@ -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 value) + { + return MessagePackSerializer.Serialize(value, Options); + } + + public static T Deserialize(byte[] payload) + { + return MessagePackSerializer.Deserialize(payload, Options); + } +} diff --git a/src/GameCult.Caching.MessagePack/DatabaseLinkFormatter.cs b/src/GameCult.Caching.MessagePack/DatabaseLinkFormatter.cs index 74791cde..14c9f661 100644 --- a/src/GameCult.Caching.MessagePack/DatabaseLinkFormatter.cs +++ b/src/GameCult.Caching.MessagePack/DatabaseLinkFormatter.cs @@ -4,8 +4,13 @@ using MessagePack.Formatters; namespace GameCult.Caching.MessagePack { - public sealed class DatabaseLinkFormatter : IMessagePackFormatter> where T : DatabaseEntry + /// + /// Serializes values as their linked identifier only. + /// + /// The target entry type. + public sealed class DatabaseLinkFormatter : IMessagePackFormatter?> where T : DatabaseEntry { + /// public void Serialize(ref MessagePackWriter writer, DatabaseLink? value, MessagePackSerializerOptions options) { if (value == null) { writer.WriteNil(); return; } @@ -14,6 +19,7 @@ namespace GameCult.Caching.MessagePack fmt.Serialize(ref writer, value.LinkID, options); } + /// public DatabaseLink? Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { if (reader.TryReadNil()) return null; diff --git a/src/GameCult.Caching.MessagePack/MessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/MessagePackBackingStore.cs index f42d073f..5fcc1a53 100644 --- a/src/GameCult.Caching.MessagePack/MessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/MessagePackBackingStore.cs @@ -2,34 +2,53 @@ using MessagePack; namespace GameCult.Caching.MessagePack; +/// +/// Persists all cache entries to a single MessagePack file. +/// public class SingleFileMessagePackBackingStore : SingleFileBackingStore { + /// + /// Initializes a single-file MessagePack backing store. + /// + /// The path to the backing file. public SingleFileMessagePackBackingStore(string filePath) : base(filePath) { } + /// public override byte[] Serialize(DatabaseEntry[] entries) { - return MessagePackSerializer.Serialize(entries); + return DatabaseEntrySerialization.Serialize(entries); } + /// public override DatabaseEntry[] Deserialize(byte[] data) { - return MessagePackSerializer.Deserialize(data); + return DatabaseEntrySerialization.Deserialize(data); } } +/// +/// Persists each cache entry to its own MessagePack file. +/// public class MultiFileMessagePackBackingStore : MultiFileBackingStore { + /// + /// Initializes a multi-file MessagePack backing store rooted at the supplied directory. + /// + /// The root directory for stored entry files. public MultiFileMessagePackBackingStore(string path) : base(path) { } + /// public override byte[] Serialize(DatabaseEntry entry) { - return MessagePackSerializer.Serialize(entry); + return DatabaseEntrySerialization.Serialize(entry); } + /// public override DatabaseEntry Deserialize(byte[] data) { - return MessagePackSerializer.Deserialize(data); + return DatabaseEntrySerialization.Deserialize(data); } + /// public override string Extension => "msgpack"; -} \ No newline at end of file +} diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md new file mode 100644 index 00000000..d53c1d14 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/README.md @@ -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` + +## Included Types + +- `SingleFileMessagePackBackingStore` +- `MultiFileMessagePackBackingStore` +- `DatabaseEntryResolver` +- `DatabaseLinkFormatter` + +## 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` + +`DatabaseLink` 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. diff --git a/src/GameCult.Caching.NewtonsoftJson/GameCult.Caching.NewtonsoftJson.csproj b/src/GameCult.Caching.NewtonsoftJson/GameCult.Caching.NewtonsoftJson.csproj new file mode 100644 index 00000000..239287cc --- /dev/null +++ b/src/GameCult.Caching.NewtonsoftJson/GameCult.Caching.NewtonsoftJson.csproj @@ -0,0 +1,10 @@ + + + Newtonsoft.Json backing stores for CultCache + + + + + + + diff --git a/src/GameCult.Caching.NewtonsoftJson/NewtonsoftJsonBackingStore.cs b/src/GameCult.Caching.NewtonsoftJson/NewtonsoftJsonBackingStore.cs new file mode 100644 index 00000000..438f8ee5 --- /dev/null +++ b/src/GameCult.Caching.NewtonsoftJson/NewtonsoftJsonBackingStore.cs @@ -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; + +/// +/// Persists all cache entries to a single JSON file using Newtonsoft.Json. +/// +public class SingleFileNewtonsoftJsonBackingStore : SingleFileBackingStore +{ + private readonly KnownDatabaseEntryTypes _knownTypes = new(); + + /// + /// Initializes a single-file Newtonsoft.Json backing store. + /// + /// The path to the backing file. + public SingleFileNewtonsoftJsonBackingStore(string filePath) : base(filePath) + { + } + + /// + /// Registers a concrete type for serialization. + /// + /// The entry type to register. + public void RegisterType() where T : DatabaseEntry + { + _knownTypes.RegisterType(typeof(T)); + } + + /// + /// Registers all concrete types from the supplied assembly. + /// + /// The assembly to scan. + public void RegisterAssembly(Assembly assembly) + { + _knownTypes.RegisterAssembly(assembly); + } + + /// + /// Rebuilds the known-type map from the currently loaded assemblies. + /// + public void RefreshKnownTypes() + { + _knownTypes.RegisterLoadedAssemblies(); + } + + /// + 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); + } + + /// + public override DatabaseEntry[] Deserialize(byte[] data) + { + _knownTypes.RegisterLoadedAssemblies(); + + var json = Encoding.UTF8.GetString(data); + var serializer = JsonSerializer.Create(KnownDatabaseEntryTypes.SerializerSettings); + var payload = JsonConvert.DeserializeObject(json, KnownDatabaseEntryTypes.SerializerSettings) ?? []; + return payload.Select(envelope => _knownTypes.FromEnvelope(envelope, serializer)).ToArray(); + } +} + +/// +/// Persists each cache entry to its own JSON file using Newtonsoft.Json. +/// +public class MultiFileNewtonsoftJsonBackingStore : MultiFileBackingStore +{ + private readonly KnownDatabaseEntryTypes _knownTypes = new(); + + /// + /// Initializes a multi-file Newtonsoft.Json backing store rooted at the supplied directory. + /// + /// The root directory for stored entry files. + public MultiFileNewtonsoftJsonBackingStore(string path) : base(path) + { + } + + /// + /// Registers a concrete type for serialization. + /// + /// The entry type to register. + public void RegisterType() where T : DatabaseEntry + { + _knownTypes.RegisterType(typeof(T)); + } + + /// + /// Registers all concrete types from the supplied assembly. + /// + /// The assembly to scan. + public void RegisterAssembly(Assembly assembly) + { + _knownTypes.RegisterAssembly(assembly); + } + + /// + /// Rebuilds the known-type map from the currently loaded assemblies. + /// + public void RefreshKnownTypes() + { + _knownTypes.RegisterLoadedAssemblies(); + } + + /// + 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); + } + + /// + public override DatabaseEntry Deserialize(byte[] data) + { + _knownTypes.RegisterLoadedAssemblies(); + + var json = Encoding.UTF8.GetString(data); + var serializer = JsonSerializer.Create(KnownDatabaseEntryTypes.SerializerSettings); + var payload = JsonConvert.DeserializeObject(json, KnownDatabaseEntryTypes.SerializerSettings) + ?? throw new JsonSerializationException("Failed to deserialize DatabaseEntry envelope."); + return _knownTypes.FromEnvelope(payload, serializer); + } + + /// + public override string Extension => "json"; +} + +internal sealed class KnownDatabaseEntryTypes +{ + internal static readonly JsonSerializerSettings SerializerSettings = new() + { + Formatting = Formatting.Indented + }; + + private readonly ConcurrentDictionary _discriminatorToType = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _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 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(); +} diff --git a/src/GameCult.Caching.NewtonsoftJson/README.md b/src/GameCult.Caching.NewtonsoftJson/README.md new file mode 100644 index 00000000..f3ddf4ce --- /dev/null +++ b/src/GameCult.Caching.NewtonsoftJson/README.md @@ -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()` +- `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. diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index da8f9da2..78e885ea 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -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 _globals = new(); private readonly ConcurrentDictionary> _types = new(); private readonly ConcurrentDictionary> _nameToIdMap = new(); + private readonly ConcurrentDictionary _pendingStoreOperations = new(); // Generic indexes: Map (Type, FieldName) -> (Value -> ID). Allows registering indexes for any field private readonly ConcurrentDictionary<(Type Type, string FieldName), ConcurrentDictionary> _indexes = new(); // Cache getters per type (list of (field, getter) for that type only). Avoids scanning all indexes. private readonly ConcurrentDictionary Getter)>> _typeToGetters = new(); private ILogger _logger = new NullLogger(); + private long _storeOperationId; /// /// Gets or sets the 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(); } /// @@ -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}"); - } } /// @@ -555,6 +566,32 @@ namespace GameCult.Caching foreach (var store in _storeTypes.Keys) if (store is IDisposable d) d.Dispose(); } + + private void TrackStoreOperation(Func 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); + } + } } /// @@ -654,7 +691,7 @@ namespace GameCult.Caching } /// - public void Dispose() + public virtual void Dispose() { EntryAdded?.Dispose(); EntryDeleted?.Dispose(); @@ -697,6 +734,7 @@ namespace GameCult.Caching protected readonly ConcurrentDictionary _filePathToIdMap = new(); private FileSystemWatcher? watcher; private Subject? _changedSubject; + private IDisposable? _changedSubscription; /// /// Serializes a 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(); // 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; } /// - 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); + } } } -} \ No newline at end of file +} diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md new file mode 100644 index 00000000..8e03d8a5 --- /dev/null +++ b/src/GameCult.Caching/README.md @@ -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(...)` +- `GetByName(...)` + +### 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("Category"); +``` + +After registration: + +```csharp +var consumable = cache.GetByIndex("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()`. + +## 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("Category"); + +await cache.AddAsync(new ItemData +{ + ID = Guid.NewGuid(), + Name = "Potion", + Category = "Consumable", + Value = 50 +}); + +var potion = cache.GetByName("Potion"); +var samePotion = cache.GetByIndex("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("Email"); + +var store = new TextItemStore("Data"); +cache.AddBackingStore(store, typeof(ItemData)); + +await cache.PullAllBackingStoresAsync(); +``` diff --git a/src/GameCult.Caching/ReflectionExtensions.cs b/src/GameCult.Caching/ReflectionExtensions.cs index 883ba0af..1002878c 100644 --- a/src/GameCult.Caching/ReflectionExtensions.cs +++ b/src/GameCult.Caching/ReflectionExtensions.cs @@ -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 { + /// + /// Provides cached reflection helpers used by the cache infrastructure. + /// public static class ReflectionExtensions { private static Dictionary ParentTypes = new Dictionary(); + /// + /// Gets all base types for the supplied type, caching the result. + /// + /// The type whose parent types should be returned. + /// An array of base types ordered from immediate parent upward. public static Type[] GetParentTypes(this Type type) { if (ParentTypes.ContainsKey(type)) @@ -37,6 +42,12 @@ namespace GameCult.Caching } private static Dictionary ChildClasses = new Dictionary(); + + /// + /// Gets all loaded types assignable to the supplied type, caching the result. + /// + /// The base type or interface to search from. + /// An array of matching loaded types. 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(); } - } -} \ No newline at end of file +} diff --git a/src/GameCult.Logging.Unity/UnityLogger.cs b/src/GameCult.Logging.Unity/UnityLogger.cs index 2a5e2de2..6690360b 100644 --- a/src/GameCult.Logging.Unity/UnityLogger.cs +++ b/src/GameCult.Logging.Unity/UnityLogger.cs @@ -3,29 +3,36 @@ using UnityEngine; namespace GameCult.Logging.Unity { + /// + /// Adapts calls to Unity's logging facilities. + /// public class UnityLogger : ILogger { private readonly bool _isDebugBuild = Debug.isDebugBuild; // Cache Unity-specific + /// public void LogInfo(string message) { Observable.NextFrame().Subscribe(_ => Debug.Log($"[INFO] {message}")); } + /// public void LogWarning(string message) { Observable.NextFrame().Subscribe(_ => Debug.LogWarning($"[WARN] {message}")); } + /// public void LogError(string message) { Observable.NextFrame().Subscribe(_ => Debug.LogError($"[ERROR] {message}")); } + /// public void LogDebug(string message) { if (_isDebugBuild) Observable.NextFrame().Subscribe(_ => Debug.Log($"[DEBUG] {message}")); } } -} \ No newline at end of file +} diff --git a/src/GameCult.Logging/ConsoleLogger.cs b/src/GameCult.Logging/ConsoleLogger.cs index 0540aa44..94357ad1 100644 --- a/src/GameCult.Logging/ConsoleLogger.cs +++ b/src/GameCult.Logging/ConsoleLogger.cs @@ -2,25 +2,32 @@ using System; namespace GameCult.Logging { + /// + /// Writes log messages to the process console. + /// public class ConsoleLogger : ILogger { + /// public void LogInfo(string message) => Console.WriteLine($"[INFO] {message}"); + + /// public void LogWarning(string message) { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine($"[WARN] {message}"); Console.ResetColor(); } + /// public void LogError(string message) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine($"[ERROR] {message}"); Console.ResetColor(); } + /// public void LogDebug(string message) { if(Environment.GetEnvironmentVariable("NODEV") is not null) Console.WriteLine($"[DEBUG] {message}"); } - } -} \ No newline at end of file +} diff --git a/src/GameCult.Logging/FileLogger.cs b/src/GameCult.Logging/FileLogger.cs index d504336e..165afcfa 100644 --- a/src/GameCult.Logging/FileLogger.cs +++ b/src/GameCult.Logging/FileLogger.cs @@ -3,10 +3,17 @@ using System.IO; namespace GameCult.Logging { + /// + /// Appends timestamped log entries to a file on disk. + /// public class FileLogger : ILogger { private readonly string _logPath; + /// + /// Initializes a new file-backed logger. + /// + /// The path to the log file to append to. 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"); } + /// public void LogInfo(string message) => Append("INFO", message); + + /// public void LogWarning(string message) => Append("WARN", message); + + /// public void LogError(string message) => Append("ERROR", message); + + /// public void LogDebug(string message) => Append("DEBUG", message); } -} \ No newline at end of file +} diff --git a/src/GameCult.Logging/ILogger.cs b/src/GameCult.Logging/ILogger.cs index 65a072d6..d6ea37d3 100644 --- a/src/GameCult.Logging/ILogger.cs +++ b/src/GameCult.Logging/ILogger.cs @@ -1,19 +1,50 @@ namespace GameCult.Logging { + /// + /// Defines the logging contract used across CultLib components. + /// public interface ILogger { + /// + /// Writes an informational message. + /// + /// The message to record. void LogInfo(string message); + + /// + /// Writes a warning message. + /// + /// The message to record. void LogWarning(string message); + + /// + /// Writes an error message. + /// + /// The message to record. void LogError(string message); + + /// + /// Writes a debug message. + /// + /// The message to record. void LogDebug(string message); // Optional; skip in prod } - // Null impl for no-op logging (e.g., perf tests) + /// + /// Provides a no-op logger implementation. + /// public class NullLogger : ILogger { + /// public void LogInfo(string message) { } + + /// public void LogWarning(string message) { } + + /// public void LogError(string message) { } + + /// public void LogDebug(string message) { } } -} \ No newline at end of file +} diff --git a/src/GameCult.Logging/README.md b/src/GameCult.Logging/README.md new file mode 100644 index 00000000..e3a98b59 --- /dev/null +++ b/src/GameCult.Logging/README.md @@ -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. diff --git a/src/GameCult.Networking/Client.cs b/src/GameCult.Networking/Client.cs index 65c83aad..e3ba7c3e 100644 --- a/src/GameCult.Networking/Client.cs +++ b/src/GameCult.Networking/Client.cs @@ -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 + /// + /// Provides client-side connection, authentication, and message dispatch logic. + /// + public class Client : IDisposable { - public event Action OnError; // Hook for important user-facing feedback (e.g. via modal UI element) - private NetManager _client; - private NetPeer _peer; + /// + /// Raised when the client encounters a user-visible error. + /// + public event Action? 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 _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); + /// + /// Gets or sets the logger used by the client. + /// public ILogger Logger { get => _logger; set => _logger = value ?? new NullLogger(); } + /// + /// Gets the last reported round-trip latency in milliseconds. + /// public int Ping { get; private set; } - public bool Verified => _sessionToken != Guid.Empty; - public bool Connected => _client != null; - public Client() + /// + /// Gets a value indicating whether the client currently has a verified session. + /// + public bool Verified => !string.IsNullOrWhiteSpace(_sessionToken); + + /// + /// Gets a value indicating whether the network manager has been created. + /// + public bool Connected => _client is { IsRunning: true } && _peer != null; + + /// + /// Initializes a new client instance. + /// + /// Validated client security configuration. This must be provided explicitly by the caller. + public Client(ClientSecurityOptions security) { + _security = security ?? throw new ArgumentNullException(nameof(security)); OnError += s => Logger.LogError(s); } + /// + /// Sends a message when the client is connected and authorized to do so. + /// + /// The message type. + /// The message to send. public void Send(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!"); } + /// + /// Removes all registered message listeners. + /// public void ClearMessageListeners() { _messageDelegates.Clear(); } + /// + /// Adds a listener for a specific message type. + /// + /// The message type to subscribe to. + /// The callback to invoke when the message is received. public void AddMessageListener(Action callback) where T : Message { var type = typeof(T); @@ -73,15 +118,25 @@ namespace GameCult.Networking }); } + /// + /// Removes a previously registered listener for a specific message type. + /// + /// The message type to unsubscribe from. + /// The callback to remove. public void RemoveMessageListener(Action callback) where T : Message { if (_messageDelegates.TryGetValue(typeof(T), out var currentDelegate)) { var newDelegate = Delegate.Remove(currentDelegate, callback) as Action; - _messageDelegates[typeof(T)] = newDelegate; // Null if empty + _messageDelegates[typeof(T)] = newDelegate ?? (_ => { }); // Null if empty } } + /// + /// Sends a login request. + /// + /// The email address or username. + /// The plaintext password. 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(), + Password = Secret.EncryptString(password, nonce, _security) ?? Array.Empty() }); } 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"); } + /// + /// Sends a registration request. + /// + /// The email address for the new account. + /// The requested username. + /// The plaintext password. 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(), + Name = Secret.EncryptString(username, nonce, _security) ?? Array.Empty(), + Password = Secret.EncryptString(password, nonce, _security) ?? Array.Empty() }); } else OnError?.Invoke("Password Invalid: needs to be at least 8 characters"); @@ -125,20 +186,35 @@ namespace GameCult.Networking else OnError?.Invoke("Email Empty"); } + /// + /// Connects the client to a server and starts polling LiteNetLib events. + /// + /// The remote host name or IP address. + /// The remote server port. 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(bytes))}"); - var message = MessagePackSerializer.Deserialize(bytes); + var message = MessageSerialization.Deserialize(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() + }); } }; 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"); } + + /// + /// Disconnects the client and suppresses automatic reconnect. + /// + public void Disconnect() + { + _manualDisconnect = true; + _reconnectSubscription?.Dispose(); + _reconnectSubscription = null; + DisposeTransport(); + } + + /// + 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; + } + } + } } -} \ No newline at end of file +} diff --git a/src/GameCult.Networking/ClientSecurityOptions.cs b/src/GameCult.Networking/ClientSecurityOptions.cs new file mode 100644 index 00000000..234bd3b1 --- /dev/null +++ b/src/GameCult.Networking/ClientSecurityOptions.cs @@ -0,0 +1,51 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace GameCult.Networking +{ + /// + /// Carries the client-side networking configuration required to talk to a server. + /// + public sealed class ClientSecurityOptions + { + private readonly byte[] _encryptionKey; + + /// + /// Shared LiteNetLib connection key and encryption seed. + /// + public string ConnectionKey { get; } + + /// + /// Initializes a new client security configuration. + /// + /// The shared connection key used by both client and server. + 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)); + } + + /// + /// Creates a deterministic development-only configuration. + /// + 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); + } + } +} diff --git a/src/GameCult.Networking/MessageSerialization.cs b/src/GameCult.Networking/MessageSerialization.cs new file mode 100644 index 00000000..234395ca --- /dev/null +++ b/src/GameCult.Networking/MessageSerialization.cs @@ -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 message) where T : Message + { + return MessagePackSerializer.Serialize(message, Options); + } + + public static T Deserialize(byte[] payload) + { + return MessagePackSerializer.Deserialize(payload, Options); + } + } +} diff --git a/src/GameCult.Networking/Messages.cs b/src/GameCult.Networking/Messages.cs index 6ca8150e..b775e298 100644 --- a/src/GameCult.Networking/Messages.cs +++ b/src/GameCult.Networking/Messages.cs @@ -4,66 +4,132 @@ using MessagePack; namespace GameCult.Networking { - + /// + /// Base type for all network messages exchanged by the client and server. + /// [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; } + /// + /// Gets or sets the peer associated with the received message. + /// + [IgnoreMember] public NetPeer? Peer { get; set; } } + /// + /// Base type for encrypted messages that carry a nonce. + /// public class EncryptedMessage : Message { - [Key(0)] public byte[] Nonce; + /// + /// Nonce used for AES-GCM encryption of the message payload. + /// + [Key(0)] public byte[] Nonce = Array.Empty(); } + /// + /// Requests authentication with an email or username and password. + /// [MessagePackObject] public class LoginMessage : EncryptedMessage { - [Key(1)] public byte[] Auth; - [Key(2)] public byte[] Password; + /// + /// Encrypted email address or username. + /// + [Key(1)] public byte[] Auth = Array.Empty(); + + /// + /// Encrypted password payload. + /// + [Key(2)] public byte[] Password = Array.Empty(); } + /// + /// Requests registration of a new player account. + /// [MessagePackObject] public class RegisterMessage : EncryptedMessage { - [Key(1)] public byte[] Email; - [Key(2)] public byte[] Password; - [Key(3)] public byte[] Name; + /// + /// Encrypted email address. + /// + [Key(1)] public byte[] Email = Array.Empty(); + + /// + /// Encrypted password payload. + /// + [Key(2)] public byte[] Password = Array.Empty(); + + /// + /// Encrypted display name payload. + /// + [Key(3)] public byte[] Name = Array.Empty(); } + /// + /// Attempts to restore a previously issued session. + /// [MessagePackObject] public class VerifyMessage : EncryptedMessage { - [Key(1)] public byte[] Session; + /// + /// Encrypted session identifier. + /// + [Key(1)] public byte[] Session = Array.Empty(); } + /// + /// Indicates successful authentication or verification. + /// [MessagePackObject] public class LoginSuccessMessage : EncryptedMessage { - [Key(1)] public byte[] Session; + /// + /// Encrypted session identifier. + /// + [Key(1)] public byte[] Session = Array.Empty(); } + /// + /// Sends a non-success error back to the client. + /// [MessagePackObject] public class ErrorMessage : Message { - [Key(0)] public string Error; + /// + /// Human-readable error text. + /// + [Key(0)] public string Error = string.Empty; } + /// + /// Requests a username change for the authenticated user. + /// [MessagePackObject] public class ChangeNameMessage : Message { - [Key(0)] public string Name; + /// + /// Requested new username. + /// + [Key(0)] public string Name = string.Empty; } + /// + /// Carries plain chat text. + /// [MessagePackObject] public class ChatMessage : Message { - [Key(0)] public string Text; + /// + /// Chat text content. + /// + [Key(0)] public string Text = string.Empty; } -} \ No newline at end of file +} diff --git a/src/GameCult.Networking/NetPeerExtensions.cs b/src/GameCult.Networking/NetPeerExtensions.cs index 5d4de3a4..0d2d1130 100644 --- a/src/GameCult.Networking/NetPeerExtensions.cs +++ b/src/GameCult.Networking/NetPeerExtensions.cs @@ -3,11 +3,20 @@ using MessagePack; namespace GameCult.Networking { + /// + /// Adds MessagePack-based send helpers for . + /// public static class NetPeerExtensions { + /// + /// Serializes and sends a message using reliable ordered delivery. + /// + /// The concrete message type. + /// The peer to send through. + /// The message to serialize and send. public static void Send(this NetPeer peer, T message) where T : Message { - peer.Send(MessagePackSerializer.Serialize(message), DeliveryMethod.ReliableOrdered); + peer.Send(MessageSerialization.Serialize(message), DeliveryMethod.ReliableOrdered); } } -} \ No newline at end of file +} diff --git a/src/GameCult.Networking/PlayerData.cs b/src/GameCult.Networking/PlayerData.cs index 9d2d6d51..4205eea9 100644 --- a/src/GameCult.Networking/PlayerData.cs +++ b/src/GameCult.Networking/PlayerData.cs @@ -14,19 +14,19 @@ namespace GameCult.Networking /// Email address of the player. /// [Key(1)] - public string Email; + public string Email = string.Empty; /// /// Password hash of the player. /// [Key(2)] - public string PasswordHash; + public string PasswordHash = string.Empty; /// /// Username of the player. /// [Key(3)] - public string Username; + public string Username = string.Empty; /// [IgnoreMember] @@ -36,4 +36,4 @@ namespace GameCult.Networking set => Username = value; } } -} \ No newline at end of file +} diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md new file mode 100644 index 00000000..226304c1 --- /dev/null +++ b/src/GameCult.Networking/README.md @@ -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 = "" +$env:GAMECULT_SESSION_SIGNING_SECRET = "" +``` + +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(""); +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(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("")) +{ + Logger = new ConsoleLogger() +}; + +client.OnError += error => client.Logger.LogError($"Client error: {error}"); +client.AddMessageListener(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. diff --git a/src/GameCult.Networking/Secret.cs b/src/GameCult.Networking/Secret.cs index 47c66ad1..86ec99ad 100644 --- a/src/GameCult.Networking/Secret.cs +++ b/src/GameCult.Networking/Secret.cs @@ -1,15 +1,39 @@ using System; +using System.Globalization; using System.Security.Cryptography; using System.Text; namespace GameCult.Networking { + /// + /// Provides shared encryption helpers and connection secrets for the networking layer. + /// 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 DefaultOptions = + new(() => ServerSecurityOptions.FromEnvironment()); + private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create(); + /// + /// Configures the default server security options used by overloads that do not take an explicit options instance. + /// + public static void ConfigureDefault(ServerSecurityOptions options) + { + if (options == null) throw new ArgumentNullException(nameof(options)); + _configuredOptions = options; + } + + private static ServerSecurityOptions? _configuredOptions; + + /// + /// Shared LiteNetLib connection key from the configured default options. + /// + public static string ConnectionKey => GetDefaultOptions().ConnectionKey; + + /// + /// Gets a newly generated nonce suitable for AES-GCM operations. + /// public static byte[] NewNonce { get @@ -20,18 +44,51 @@ namespace GameCult.Networking } } - public static byte[] EncryptString(string input, byte[] nonce) + /// + /// Encrypts a string using AES-GCM. + /// + /// The plaintext string to encrypt. + /// The nonce to use for the operation. + /// The encrypted payload, or null when is null or empty. + public static byte[]? EncryptString(string? input, byte[] nonce) => + EncryptString(input, nonce, GetDefaultOptions()); + + /// + /// Encrypts a string using AES-GCM. + /// + 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) + /// + /// Encrypts a string using AES-GCM. + /// + public static byte[]? EncryptString(string? input, byte[] nonce, ServerSecurityOptions options) + { + if (string.IsNullOrEmpty(input)) return null; + return EncryptBytes(Encoding.UTF8.GetBytes(input), nonce, options); + } + + /// + /// Encrypts a byte array using AES-GCM. + /// + /// The plaintext bytes to encrypt. + /// The nonce to use for the operation. + /// The authentication tag followed by the ciphertext. + public static byte[] EncryptBytes(byte[] input, byte[] nonce) => + EncryptBytes(input, nonce, GetDefaultOptions()); + + /// + /// Encrypts a byte array using AES-GCM. + /// + 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) + /// + /// Encrypts a byte array using AES-GCM. + /// + 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; + } + + /// + /// Decrypts an encrypted UTF-8 string payload. + /// + /// The encrypted bytes produced by the corresponding string or byte encryption overloads. + /// The nonce used during encryption. + /// The decrypted string, or null when the inputs are invalid. + public static string? DecryptString(byte[]? encrypted, byte[]? nonce) => + DecryptString(encrypted, nonce, GetDefaultOptions()); + + /// + /// Decrypts an encrypted UTF-8 string payload. + /// + 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) + /// + /// Decrypts an encrypted UTF-8 string payload. + /// + 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); + } + + /// + /// Decrypts an AES-GCM payload into raw bytes. + /// + /// The encrypted bytes containing the authentication tag prefix. + /// The nonce used during encryption. + /// The decrypted plaintext bytes. + public static byte[] DecryptBytes(byte[] encrypted, byte[] nonce) => + DecryptBytes(encrypted, nonce, GetDefaultOptions()); + + /// + /// Decrypts an AES-GCM payload into raw bytes. + /// + 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; } + + /// + /// Decrypts an AES-GCM payload into raw bytes. + /// + 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; + } + + /// + /// Creates a signed session token for the supplied user. + /// + /// The authenticated user identifier. + /// The UTC expiration time for the token. + /// A tamper-evident session token. + public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc) => + CreateSessionToken(userId, expiresAtUtc, GetDefaultOptions()); + + /// + /// Creates a signed session token for the supplied user. + /// + 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)}"; + } + + /// + /// Validates and parses a signed session token. + /// + /// The token to validate. + /// The extracted user identifier. + /// The extracted UTC expiration time. + /// true when the token is valid and unexpired. + public static bool TryValidateSessionToken(string? token, out Guid userId, out DateTimeOffset expiresAtUtc) => + TryValidateSessionToken(token, GetDefaultOptions(), out userId, out expiresAtUtc); + + /// + /// Validates and parses a signed session token. + /// + 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; + } } -} \ No newline at end of file +} diff --git a/src/GameCult.Networking/Server.cs b/src/GameCult.Networking/Server.cs index c368f0ee..2f197edb 100644 --- a/src/GameCult.Networking/Server.cs +++ b/src/GameCult.Networking/Server.cs @@ -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 + /// + /// Hosts the server-side authentication, session, and message dispatch pipeline. + /// + 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 _messageDelegates = new(); // Aggregated multicast Action per type + private readonly ConcurrentDictionary _messageDelegates = new(); private readonly ConcurrentDictionary _users = new(); - private readonly ConcurrentDictionary _sessions = new(); - private readonly ConcurrentDictionary _sessionToUserId = new(); - private readonly ConcurrentDictionary> _loginAttempts = new(); // IP to attempt timestamps - private NetManager _netManager; - private Stopwatch _timer; + private readonly ConcurrentDictionary> _loginAttempts = new(); + private readonly ConcurrentDictionary _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; + + /// + /// Gets or sets the logger used by the server. + /// 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) + /// + /// Initializes a new server instance over the supplied cache. + /// + /// The backing cache used for player persistence. + /// Optional validated server security options. When omitted, strict environment-based configuration is used. + public Server(CultCache cache, ServerSecurityOptions? security = null) { _database = cache; + _security = security ?? ServerSecurityOptions.FromEnvironment(); _database.RegisterIndex("Email"); - Observable.Timer(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)) + _cleanupSubscription = Observable.Timer(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)) .Subscribe(_ => CleanupExpiredSessions()); } + /// + /// Validates an email address against the server's accepted pattern. + /// + /// The email address to validate. + /// true when the address matches the expected format. public bool IsValidEmail(string email) => Regex.IsMatch(email, EmailPattern); + + /// + /// Validates a username against the server's accepted pattern. + /// + /// The username to validate. + /// true when the username matches the expected format. public bool IsValidUsername(string name) => Regex.IsMatch(name, UsernamePattern); + /// + /// Removes all registered message listeners. + /// public void ClearMessageListeners() { _messageDelegates.Clear(); } + /// + /// Adds a listener for a specific authenticated message type. + /// + /// The message type to subscribe to. + /// The callback to invoke when the message is received. public void AddMessageListener(Action 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; return combined ?? throw new InvalidOperationException($"Failed to combine delegates for {t.Name}"); }); } + /// + /// Removes a previously registered listener for a specific message type. + /// + /// The message type to unsubscribe from. + /// The callback to remove. public void RemoveMessageListener(Action callback) where T : Message { if (_messageDelegates.TryGetValue(typeof(T), out var currentDelegate)) { var newDelegate = Delegate.Remove(currentDelegate, callback) as Action; - _messageDelegates[typeof(T)] = newDelegate; // Null if empty + _messageDelegates[typeof(T)] = newDelegate!; } } + /// + /// Stops the underlying LiteNetLib server. + /// public void Stop() { - _netManager.Stop(); + _netManager?.Stop(); + _netManager = null; } + /// + /// Starts listening for connections and configures message handlers. + /// 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(bytes); + var message = MessageSerialization.Deserialize(bytes); Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(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("Email", email) != null) - { - peer.Send(new ErrorMessage { Error = "Email Taken" }); - return; - } - - if (_database.GetIdByName(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("Email", auth) : - _database.GetByName(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(async (message) => + AddMessageListener(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(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); + /// + 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("Email", email) != null) + { + peer.Send(new ErrorMessage { Error = "Email Taken" }); + return; + } + + if (_database.GetIdByName(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(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("Email", auth) + : _database.GetByName(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(user.PlayerId) != null; + + private PlayerData? SessionData(User user) => + IsVerified(user) ? _database.Get(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(new[] { now }), - (_, queue) => + var queue = _loginAttempts.GetOrAdd(ip, _ => new Queue()); + 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() + }); + } + + private void RefreshSessionIfNeeded(NetPeer peer, User user) + { + if ((user.SessionExpiresAt - DateTimeOffset.UtcNow).TotalSeconds > SessionRefreshThresholdSeconds) + { + return; + } + + SendSessionToken(peer, user.PlayerId); + } } + /// + /// Represents an active authenticated session. + /// public class Session { + /// + /// The last time the session was observed as active. + /// public DateTime LastUpdate; - public PlayerData Data; + + /// + /// Player data associated with the session. + /// + public PlayerData Data = null!; } + /// + /// Tracks connection state for a connected peer. + /// public class User { - public NetPeer Peer; + /// + /// The connected network peer. + /// + public NetPeer Peer = null!; + + /// + /// The last reported latency for the peer. + /// public int Latency; - public Guid SessionGuid; + + /// + /// The authenticated player identifier. + /// + public Guid PlayerId; + + /// + /// The current session expiration timestamp in UTC. + /// + public DateTimeOffset SessionExpiresAt; + + /// + /// The latest signed session token issued to the peer. + /// + public string SessionToken = string.Empty; } -} \ No newline at end of file +} diff --git a/src/GameCult.Networking/ServerSecurityOptions.cs b/src/GameCult.Networking/ServerSecurityOptions.cs new file mode 100644 index 00000000..b08201bd --- /dev/null +++ b/src/GameCult.Networking/ServerSecurityOptions.cs @@ -0,0 +1,127 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace GameCult.Networking +{ + /// + /// Carries the server-side networking configuration and server-only signing secret. + /// + public sealed class ServerSecurityOptions + { + /// + /// Environment variable name for the LiteNetLib connection key. + /// + public const string ConnectionKeyEnvironmentVariable = "GAMECULT_CONNECTION_KEY"; + + /// + /// Environment variable name for the session-token signing secret. + /// + public const string SessionSigningSecretEnvironmentVariable = "GAMECULT_SESSION_SIGNING_SECRET"; + + private readonly byte[] _encryptionKey; + private readonly byte[] _sessionSigningKey; + + /// + /// Shared LiteNetLib connection key and encryption seed. + /// + public string ConnectionKey { get; } + + /// + /// Gets a value indicating whether the options were created for explicit development use. + /// + public bool IsDevelopment { get; } + + /// + /// Initializes a new server security configuration. + /// + 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)); + } + + /// + /// Creates a strict production-style configuration from environment variables. + /// + /// + /// When true, returns if both environment variables are absent. + /// + 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!); + } + + /// + /// Creates a deterministic development-only configuration. + /// + 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]; + } + } +} diff --git a/src/GameCult.Unity/Assets/Scenes/SampleScene.unity b/src/GameCult.Unity/Assets/Scenes/SampleScene.unity index 21256ca9..71e8b967 100644 --- a/src/GameCult.Unity/Assets/Scenes/SampleScene.unity +++ b/src/GameCult.Unity/Assets/Scenes/SampleScene.unity @@ -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 diff --git a/src/GameCult.Unity/Assets/Scripts/Attributes.cs b/src/GameCult.Unity/Assets/Scripts/Attributes.cs index 398df158..0c972c3d 100644 --- a/src/GameCult.Unity/Assets/Scripts/Attributes.cs +++ b/src/GameCult.Unity/Assets/Scripts/Attributes.cs @@ -6,16 +6,6 @@ public class InspectableAttribute : Attribute { } [AttributeUsage(AttributeTargets.Field)] public abstract class PreferredInspectorAttribute : InspectableAttribute { } -public class InspectableEnumValuesAttribute : PreferredInspectorAttribute -{ - public Type EnumType; - - public InspectableEnumValuesAttribute(Type enumType) - { - EnumType = enumType; - } -} - public class InspectableRangedFloatAttribute : PreferredInspectorAttribute { public readonly float Min, Max; @@ -36,4 +26,4 @@ public class InspectableRangedIntAttribute : PreferredInspectorAttribute Min = min; Max = max; } -} \ No newline at end of file +} diff --git a/src/GameCult.Unity/Assets/UI/Attributes.cs b/src/GameCult.Unity/Assets/UI/Attributes.cs index 16554e3e..ffc3f30f 100644 --- a/src/GameCult.Unity/Assets/UI/Attributes.cs +++ b/src/GameCult.Unity/Assets/UI/Attributes.cs @@ -6,40 +6,61 @@ namespace GameCult.Unity.UI public class InspectableAttribute : Attribute { } [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] - public abstract class PreferredInspectorAttribute : InspectableAttribute { } + public abstract class PreferredInspectorAttribute : InspectableAttribute + { + public bool ReadOnly { get; } + + protected PreferredInspectorAttribute(bool readOnly = false) + { + ReadOnly = readOnly; + } + + public abstract PreferredInspectorAttribute AsReadOnly(); + } - public class InspectableReadOnlyLabelAttribute : PreferredInspectorAttribute { } + public class InspectableReadOnlyLabelAttribute : PreferredInspectorAttribute + { + public InspectableReadOnlyLabelAttribute() : base(true) { } + + public override PreferredInspectorAttribute AsReadOnly() => this; + } public class InspectableRangedFloatAttribute : PreferredInspectorAttribute { public readonly float Min, Max; - public InspectableRangedFloatAttribute(float min, float max) + public InspectableRangedFloatAttribute(float min, float max, bool readOnly = false) : base(readOnly) { Min = min; Max = max; } + + public override PreferredInspectorAttribute AsReadOnly() => new InspectableRangedFloatAttribute(Min, Max, true); } public class InspectableRangedIntAttribute : PreferredInspectorAttribute { public readonly int Min, Max; - public InspectableRangedIntAttribute(int min, int max) + public InspectableRangedIntAttribute(int min, int max, bool readOnly = false) : base(readOnly) { Min = min; Max = max; } + + public override PreferredInspectorAttribute AsReadOnly() => new InspectableRangedIntAttribute(Min, Max, true); } public class InspectableIncrementIntAttribute : PreferredInspectorAttribute { public readonly int Min, Max; - public InspectableIncrementIntAttribute(int min, int max) + public InspectableIncrementIntAttribute(int min, int max, bool readOnly = false) : base(readOnly) { Min = min; Max = max; } + + public override PreferredInspectorAttribute AsReadOnly() => new InspectableIncrementIntAttribute(Min, Max, true); } -} \ No newline at end of file +} diff --git a/src/GameCult.Unity/Assets/UI/Components/InputField.cs b/src/GameCult.Unity/Assets/UI/Components/InputField.cs index d8479f0f..5ff8e438 100644 --- a/src/GameCult.Unity/Assets/UI/Components/InputField.cs +++ b/src/GameCult.Unity/Assets/UI/Components/InputField.cs @@ -154,7 +154,7 @@ namespace GameCult.Unity.UI.Components context.Refresh += () => { var val = read(); - if (float.TryParse(inputField.text, out var existing) && Mathf.Abs(existing-val) < .001) + if (!float.TryParse(inputField.text, out var existing) || Mathf.Abs(existing - val) > .001f) inputField.text = val.ToString("0.###"); }; @@ -215,4 +215,4 @@ namespace GameCult.Unity.UI.Components string? prefabName = null) => context.Resolver.Resolve(prefabName)?.Configure(context, read, write); } -} \ No newline at end of file +} diff --git a/src/GameCult.Unity/Assets/UI/Components/ProgressField.cs b/src/GameCult.Unity/Assets/UI/Components/ProgressField.cs new file mode 100644 index 00000000..84c19e3d --- /dev/null +++ b/src/GameCult.Unity/Assets/UI/Components/ProgressField.cs @@ -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 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 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 read, + float min = 0, + float max = 1, + DisplayOptions? displayOptions = null, + string? prefabName = "Progress") => + context.Resolver.Resolve()?.Configure(context, + context.Resolver.Resolve