diff --git a/.voidbot/status/void-memory-maintenance.json b/.voidbot/status/void-memory-maintenance.json
deleted file mode 100644
index 0d0b876a..00000000
--- a/.voidbot/status/void-memory-maintenance.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "status": "starting",
- "startedAt": "2026-06-14T19:46:12.715Z",
- "stateFile": "E:\\Projects\\CultLib\\.voidbot\\state\\libby.cc",
- "launcher": "repo_face_heartbeat",
- "reason": "repo_face_stale_short_term_memory"
-}
diff --git a/CultLib.txt b/CultLib.txt
deleted file mode 100644
index 4755ac07..00000000
--- a/CultLib.txt
+++ /dev/null
@@ -1,132 +0,0 @@
-
-
-// E:\Projects\CultLib\src\GameCult.Caching\GameCult.Caching.csproj
-
-
-
- CultCache: Concurrent polymorphic cache with modular backing stores for .NET
-
-
-
-
-
-
-
-
-
-
-// E:\Projects\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj
-
-
-
- File-based MessagePack backing store for CultCache
-
-
-
-
-
-
-
-
-
-// E:\Projects\CultLib\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj
-
-
-
-
-
- true
- enable
- true
- false
-
-
- Source generator that emits MessagePack formatters for DatabaseEntry subclasses.
- MessagePack;SourceGenerator;DatabaseEntry
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-// E:\Projects\CultLib\src\GameCult.Logging\GameCult.Logging.csproj
-
-
-
- Simple pluggable logging interface for CultLib
-
-
-
-// E:\Projects\CultLib\src\GameCult.Logging.Unity\GameCult.Logging.Unity.csproj
-
-
-
- Unity integration for GameCult.Logging
-
-
-
-
-
-
-
-
-// E:\Projects\CultLib\src\GameCult.Networking\GameCult.Networking.csproj
-
-
-
- CultNet: Secure, high-performance messaging for games/servers
-
-
-
-
-
-
-
-
-
-
-
-
-
-// E:\Projects\CultLib\tests\GameCult.Networking.Tests\GameCult.Networking.Tests.csproj
-
-
-
-
- net8.0
- false
- GameCult.Networking.Tests
-
-
-
-
-
-
-
-
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
-
-
-
-
-
- all
-
-
-
-
diff --git a/src/GameCult.Caching/GameCult.Caching.txt b/src/GameCult.Caching/GameCult.Caching.txt
deleted file mode 100644
index ab959478..00000000
--- a/src/GameCult.Caching/GameCult.Caching.txt
+++ /dev/null
@@ -1,1213 +0,0 @@
-
-
-// E:\Projects\CultLib\src\GameCult.Caching\CultCache.cs
-
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Reflection;
-using System.Threading.Tasks;
-using ConcurrentCollections;
-using GameCult.Logging;
-using MessagePack;
-using R3;
-
-namespace GameCult.Caching
-{
- ///
- /// Specifies that the decorated class contains global settings.
- /// Use this attribute to mark classes intended to hold application-wide configuration or settings.
- ///
- [AttributeUsage(AttributeTargets.Class)]
- public class GlobalSettingsAttribute : Attribute { }
-
- ///
- /// Represents an in-memory and persistent cache for objects, supporting global entries,
- /// named entries, generic field indexes, and multiple backing stores. Provides thread-safe operations for adding,
- /// retrieving, indexing, and removing entries, with support for event notifications and logging.
- ///
- ///
- ///
- /// Features:
- ///
- /// Supports multiple s for persistence and synchronization.
- /// Handles global settings entries via .
- /// Provides fast lookup by ID, name (for ), and indexed fields.
- /// Allows registering generic indexes for any public field of a type.
- /// Thread-safe via and .
- /// Notifies on entry updates via event.
- /// Supports both synchronous and asynchronous add operations.
- /// Integrates with for error and event logging.
- ///
- ///
- ///
- /// Usage:
- ///
- /// var cache = new CultCache();
- /// cache.RegisterIndex<PlayerData>("Email");
- /// cache.AddAsync(new PlayerData { ... }).GetAwaiter().GetResult();
- /// var player = cache.GetByIndex<PlayerData>("Email", "user@example.com");
- ///
- ///
- ///
- public class CultCache : IDisposable
- {
- private readonly object addLock = new object(); // Retained for synchronous Add compatibility
-
- ///
- /// Occurs when a is updated.
- /// The event provides the old and new instances.
- ///
- ///
- /// The first parameter is the previous entry, and the second parameter is the updated entry.
- /// The first parameter will be null if the entry is newly added,
- /// and the second parameter will be null if the entry is deleted.
- ///
- public event Action? OnUpdate;
-
- private readonly List _backingStores = new();
- private readonly ConcurrentDictionary _typeStores = new();
- private readonly ConcurrentDictionary _storeTypes = new();
- private readonly ConcurrentDictionary _entries = new();
- private readonly ConcurrentDictionary _globals = new();
- private readonly ConcurrentDictionary> _types = new();
- private readonly ConcurrentDictionary> _nameToIdMap = 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();
-
- ///
- /// Gets or sets the instance used for logging within the cache.
- /// If set to null, a will be used as a fallback.
- ///
- public ILogger Logger
- {
- get => _logger;
- set => _logger = value ?? new NullLogger();
- }
-
- ///
- /// Gets all objects currently stored in the cache.
- ///
- public IEnumerable AllEntries => _entries.Values;
-
- ///
- /// Initializes a new instance of the class.
- /// Sets up the cache by registering all child classes of .
- /// For each child type:
- ///
- /// Adds a concurrent hash set for caching entries of that type.
- /// If the type is marked with , adds it to the global settings and creates an instance.
- /// If the type implements , initializes a name-to-ID mapping dictionary.
- ///
- /// Also sets the static cache reference in .
- ///
- public CultCache()
- {
- DatabaseLinkBase.Cache = this;
-
- foreach (var type in typeof(DatabaseEntry).GetAllChildClasses())
- {
- _types.TryAdd(type, new ConcurrentHashSet());
- if (type.GetCustomAttribute() != null)
- {
- _globals.TryAdd(type, null);
- try
- {
- var global = (DatabaseEntry)Activator.CreateInstance(type)!;
- AddAsync(global).GetAwaiter().GetResult();
- _globals[type] = global; // Update after successful add
- }
- catch (Exception ex)
- {
- Logger.LogError($"Failed to initialize global {type.Name}: {ex.Message}");
- _globals[type] = null;
- }
- }
- // Initialize name-to-ID map for INamedEntry types
- if (typeof(INamedEntry).IsAssignableFrom(type))
- {
- _nameToIdMap.TryAdd(type, new ConcurrentDictionary());
- }
- }
- }
-
- ///
- /// Adds a backing store to the cache system and configures domain type associations and event subscriptions.
- ///
- /// The instance to add.
- ///
- /// Optional array of objects representing domain types associated with the backing store.
- /// If provided, the store is mapped to these types; otherwise, the store is added to the general backing store list and subscribes to existing stores.
- ///
- ///
- ///
- /// If no domain types are specified, the store subscribes to all existing backing stores to receive their updates,
- /// ensuring synchronization across stores, with the first store added acting as the primary source of truth.
- ///
- ///
- /// Specifying domain types allows for targeted storage and retrieval of specific types,
- /// for example, using a database store for user data and a file-based store for configuration data or game assets.
- ///
- ///
- public void AddBackingStore(CacheBackingStore store, params Type[] domain)
- {
- if (domain.Length > 0)
- {
- _storeTypes.TryAdd(store, domain);
- foreach (var t in domain) _typeStores.TryAdd(t, store);
- }
- else
- {
- foreach (var existingStore in _backingStores) store.SubscribeTo(existingStore);
- _backingStores.Add(store);
- }
- store.EntryAdded.Subscribe(async entry =>
- {
- try // Try-catch logs without swallowing. Added to EntryDeleted for consistency (even though sync)
- {
- await AddAsync(entry, store);
- OnUpdate?.Invoke(null, entry);
- }
- catch (Exception ex)
- {
- Logger.LogError($"EntryAdded processing failed for {entry?.ID}: {ex.Message}");
- }
- });
- store.EntryUpdated.Subscribe(async entry =>
- {
- try
- {
- await AddAsync(entry, store);
- OnUpdate?.Invoke(entry, entry);
- }
- catch (Exception ex)
- {
- Logger.LogError($"EntryUpdated processing failed for {entry?.ID}: {ex.Message}");
- }
- });
- store.EntryDeleted.Subscribe(entry =>
- {
- try
- {
- Remove(entry, store);
- OnUpdate?.Invoke(entry, null);
- }
- catch (Exception ex)
- {
- Logger.LogError($"EntryDeleted processing failed for {entry?.ID}: {ex.Message}");
- }
- });
- store.Logger = Logger;
- }
-
- ///
- /// Asynchronously pulls all data from the backing stores.
- /// Initiates parallel tasks to execute the PullAll method on each backing store and store type,
- /// and waits for all tasks to complete.
- ///
- /// A task that represents the asynchronous operation.
- public async Task PullAllBackingStoresAsync()
- {
- var tasks = _backingStores.Select(store => Task.Run(store.PullAll))
- .Concat(_storeTypes.Keys.Select(store => Task.Run(store.PullAll)))
- .ToArray();
- await Task.WhenAll(tasks);
- }
-
- ///
- /// Adds a to the cache asynchronously, updating all relevant maps and indexes.
- /// If the entry already exists, cleans up old mappings before updating.
- /// Also persists the entry to the backing store if available.
- ///
- /// The to add or update in the cache.
- ///
- /// The source from which the entry originated, used internally to avoid redundant writes.
- /// Optional; defaults to null.
- ///
- /// A representing the asynchronous operation.
- public async Task AddAsync(DatabaseEntry entry, CacheBackingStore? source = null)
- {
- if (entry == null) return;
-
- var type = entry.GetType();
-
- // Pre-fetch existing to detect updates (for map/index cleanup)
- if (!_entries.TryGetValue(entry.ID, out var existing)) existing = null;
-
- // Cleanup old maps/indexes BEFORE updating _entries
- if (existing != null)
- {
- // For name map (if updating existing)
- if (existing is INamedEntry existingNamed && _nameToIdMap.TryGetValue(type, out var nameMap))
- {
- nameMap.TryRemove(existingNamed.EntryName, out _);
- }
-
- // For generic indexes (if registered)
- if (_typeToGetters.TryGetValue(type, out var getters))
- {
- foreach (var (fieldName, getter) in getters)
- {
- var oldValue = getter(existing);
- if (!string.IsNullOrEmpty(oldValue))
- {
- var indexKey = (type, fieldName);
- _indexes[indexKey].TryRemove(oldValue, out _);
- }
- }
- }
-
- // For type sets
- _types[existing.GetType()].TryRemove(existing);
- foreach (var parentType in existing.GetType().GetParentTypes())
- {
- if (_types.TryGetValue(parentType, out var typeSet))
- typeSet.TryRemove(existing);
- }
- }
-
- _entries[entry.ID] = entry;
- _types[type].Add(entry); // Assumes exists; throws if not (edge case)
- foreach (var parentType in type.GetParentTypes())
- {
- if (_types.TryGetValue(parentType, out var typeSet))
- typeSet.Add(entry);
- }
-
- if (_globals.ContainsKey(type))
- {
- _globals.AddOrUpdate(type, entry, (_, old) => entry);
- }
-
- // Add new values (always, for both new and updates)
- // Update name-to-ID map if INamedEntry
- if (entry is INamedEntry namedEntry && _nameToIdMap.TryGetValue(type, out var nameMap2))
- {
- nameMap2[namedEntry.EntryName] = entry.ID;
- }
-
- // Update generic indexes if registered for this type
- if (_typeToGetters.TryGetValue(type, out var getters2))
- {
- foreach (var (fieldName, getter) in getters2)
- {
- var value = getter(entry);
- if (!string.IsNullOrEmpty(value))
- {
- var indexKey = (type, fieldName);
- _indexes[indexKey][value] = entry.ID;
- }
- }
- }
-
- // 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}");
- }
- }
-
- ///
- /// Determines whether the specified is a global settings entry.
- ///
- /// The to check.
- /// true if the entry is global; otherwise, false.
- public bool IsGlobal(DatabaseEntry entry) => _globals.ContainsKey(entry.GetType());
-
- ///
- /// Adds all objects from the specified collection to the cache asynchronously.
- ///
- /// The collection of objects to add.
- ///
- /// The source from which the entries originated, used to avoid redundant writes.
- /// Optional; defaults to null.
- ///
- /// A representing the asynchronous operation.
- public Task AddAllAsync(IEnumerable entries, CacheBackingStore? source = null)
- {
- var tasks = entries.Select(entry => AddAsync(entry, source)).ToArray();
- return Task.WhenAll(tasks);
- }
-
- ///
- /// Retrieves a from the cache by its unique identifier.
- ///
- /// The unique identifier of the entry to retrieve.
- /// The associated with the specified , or null if not found.
- public DatabaseEntry Get(Guid guid)
- {
- _entries.TryGetValue(guid, out var entry);
- return entry;
- }
-
- ///
- /// Retrieves a of type from the cache by its unique identifier.
- ///
- /// The type of to retrieve.
- /// The unique identifier of the entry to retrieve.
- /// The entry of type associated with the specified , or null if not found or not of type .
- public T? Get(Guid guid) where T : DatabaseEntry
- {
- return Get(guid) as T;
- }
-
- ///
- /// Retrieves the global instance for the specified type, if it exists.
- ///
- /// The type of the global entry to retrieve.
- /// The global instance associated with the specified type, or null if not found.
- public DatabaseEntry? GetGlobal(Type type)
- {
- _globals.TryGetValue(type, out var entry);
- return entry;
- }
-
- ///
- /// Retrieves the global instance of type , if it exists.
- ///
- /// The type of the global entry to retrieve.
- /// The global entry of type , or null if not found.
- public T? GetGlobal() where T : DatabaseEntry
- {
- return GetGlobal(typeof(T)) as T;
- }
-
- ///
- /// Retrieves all objects of the specified type from the cache.
- ///
- /// The type of to retrieve.
- /// An enumerable collection of objects of the specified type.
- public IEnumerable GetAll(Type type)
- {
- return _types.TryGetValue(type, out var entries) ? entries : Enumerable.Empty();
- }
-
- ///
- /// Retrieves all objects of type from the cache.
- ///
- /// The type of to retrieve.
- /// An enumerable collection of objects from the cache.
- public IEnumerable GetAll() where T : DatabaseEntry
- {
- return _types.TryGetValue(typeof(T), out var entries) ? entries.Cast() : Enumerable.Empty();
- }
-
- ///
- /// Retrieves the unique identifier () of an entry of type by its name.
- ///
- /// The type of that implements .
- /// The name of the entry to look up.
- /// The of the entry if found; otherwise, null.
- public Guid? GetIdByName(string name) where T : DatabaseEntry, INamedEntry
- {
- var type = typeof(T);
- if (_nameToIdMap.TryGetValue(type, out var nameMap) && nameMap.TryGetValue(name, out var id))
- return id;
- return null;
- }
-
- ///
- /// Retrieves an entry of type by its name from the cache.
- ///
- /// The type of that implements .
- /// The name of the entry to retrieve.
- /// The entry of type if found; otherwise, null.
- public T? GetByName(string name) where T : DatabaseEntry, INamedEntry
- {
- return GetIdByName(name) is { } id ? Get(id) : null;
- }
-
- ///
- /// Registers an index for a public member (field or property) of the specified type.
- /// This enables fast lookup of entries by the value of the indexed member.
- /// Call this method once per member to be indexed.
- ///
- /// The type of for which to register the index.
- /// The name of the public member to index.
- /// Thrown if the specified member does not exist on the type.
- public void RegisterIndex(string memberName) where T : DatabaseEntry
- {
- var type = typeof(T);
- var key = (type, memberName);
- if (_indexes.ContainsKey(key))
- return; // Already registered; ignore
-
- _indexes[key] = new ConcurrentDictionary();
-
- // Cache a getter func for perf.
- if (type.GetField(memberName) is { } field)
- {
- var getter = (Func)(entry => field.GetValue(entry)?.ToString() ?? string.Empty);
- _typeToGetters.AddOrUpdate(type,
- _ => new List<(string, Func)> { (memberName, getter) },
- (_, list) => { list.Add((memberName, getter)); return list; });
- }
- else if (type.GetProperty(memberName) is { } property)
- {
- var getter = (Func)(entry => property.GetValue(entry)?.ToString() ?? string.Empty);
- _typeToGetters.AddOrUpdate(type,
- _ => new List<(string, Func)> { (memberName, getter) },
- (_, list) => { list.Add((memberName, getter)); return list; });
- }
- else
- {
- throw new ArgumentException($"No public field or property '{memberName}' on {type.Name}");
- }
- }
-
- ///
- /// Retrieves the unique identifier () of an entry of type by the value of an indexed field.
- ///
- /// The type of for which the index is registered.
- /// The name of the indexed field.
- /// The value of the indexed field to look up.
- /// The of the entry if found; otherwise, null.
- public Guid? GetIdByIndex(string fieldName, string value) where T : DatabaseEntry
- {
- var key = (typeof(T), fieldName);
- if (_indexes.TryGetValue(key, out var indexMap) && indexMap.TryGetValue(value, out var id))
- return id;
- return null;
- }
-
- ///
- /// Retrieves an entry of type by the value of an indexed field from the cache.
- ///
- /// The type of for which the index is registered.
- /// The name of the indexed field.
- /// The value of the indexed field to look up.
- /// The entry of type if found; otherwise, null.
- public T? GetByIndex(string fieldName, string value) where T : DatabaseEntry
- {
- return GetIdByIndex(fieldName, value) is { } id ? Get(id) : null;
- }
-
- ///
- /// Removes the specified from the cache, including all associated indexes, type stores,
- /// global references, parent type sets, and name-to-ID mappings.
- ///
- /// The to remove from the cache.
- ///
- /// The to exclude from deletion. Used internally to avoid redundant deletions when the entry originates from a specific store.
- ///
- public void Remove(DatabaseEntry entry, CacheBackingStore? source = null)
- {
- var type = entry.GetType();
- if (!_entries.TryRemove(entry.ID, out _)) return;
-
- // Clean up type sets
- _types[type].TryRemove(entry);
- foreach (var parentType in type.GetParentTypes())
- {
- if (_types.TryGetValue(parentType, out var typeSet))
- typeSet.TryRemove(entry);
- }
-
- // Clean up global if applicable
- if (_globals.TryGetValue(type, out var global) && global != null && global.Equals(entry)) _globals[type] = null!;
-
- // Propagate delete to backing stores (except source)
- if (_typeStores.TryGetValue(type, out var typeStore))
- typeStore.Delete(entry);
- else
- {
- foreach (var store in _backingStores)
- {
- if (store != source)
- store.Delete(entry);
- }
- }
-
- // Clean up name-to-ID map if INamedEntry
- if (entry is INamedEntry namedEntry && _nameToIdMap.TryGetValue(type, out var nameMap))
- {
- nameMap.TryRemove(namedEntry.EntryName, out _);
- }
-
- // Clean up generic indexes if registered for this type
- if (_typeToGetters.TryGetValue(type, out var getters))
- {
- foreach (var (fieldName, getter) in getters)
- {
- var value = getter(entry);
- if (!string.IsNullOrEmpty(value))
- {
- var indexKey = (type, fieldName);
- _indexes[indexKey].TryRemove(value, out _);
- }
- }
- }
- }
-
- ///
- public void Dispose()
- {
- foreach (var store in _backingStores)
- if (store is IDisposable d) d.Dispose();
- foreach (var store in _storeTypes.Keys)
- if (store is IDisposable d) d.Dispose();
- }
- }
-
- ///
- /// Represents a backing store that supports observing real-time changes.
- ///
- public interface IRealtimeBackingStore
- {
- ///
- /// Begins observing real-time changes in the backing store and triggers appropriate events when changes occur.
- ///
- public void ObserveChanges();
- }
-
- ///
- /// Represents an abstract backing store for caching objects.
- /// Provides mechanisms for pulling, pushing, deleting, and synchronizing entries, as well as event notifications for entry changes.
- ///
- public abstract class CacheBackingStore : IDisposable
- {
- private ILogger _logger = new NullLogger();
-
- ///
- /// Gets or sets the instance used for logging within the cache.
- /// If set to null, a will be used as a fallback.
- ///
- public ILogger Logger
- {
- get => _logger;
- set => _logger = value ?? new NullLogger();
- }
-
- ///
- /// Initializes a new instance of the class.
- /// Sets up subjects for entry added, deleted, and updated events.
- ///
- protected CacheBackingStore()
- {
- EntryAdded = new Subject();
- EntryDeleted = new Subject();
- EntryUpdated = new Subject();
- }
-
- ///
- /// Pulls all entries from the backing store into the cache.
- /// Implementations should load all persisted objects and notify via .
- ///
- public abstract void PullAll();
- ///
- /// Persists the specified to the backing store.
- /// Implementations should save or update the entry in the underlying storage.
- ///
- /// The to persist.
- public abstract void Push(DatabaseEntry entry);
- ///
- /// Deletes the specified from the backing store.
- /// Implementations should remove the entry from the underlying storage.
- ///
- /// The to delete.
- public abstract void Delete(DatabaseEntry entry);
- ///
- /// Persists all objects to the backing store.
- /// Implementations should save or update all entries in the underlying storage.
- ///
- ///
- /// If true, performs a soft push (implementation-specific); otherwise, performs a full push.
- ///
- public abstract void PushAll(bool soft = false);
-
- ///
- /// Gets the subject that notifies when a is added to the backing store.
- ///
- public Subject EntryAdded { get; }
- ///
- /// Gets the subject that notifies when a is deleted from the backing store.
- ///
- public Subject EntryDeleted { get; }
- ///
- /// Gets the subject that notifies when a is updated in the backing store.
- ///
- public Subject EntryUpdated { get; }
-
- ///
- /// Stores all objects managed by this backing store, keyed by their unique identifier.
- ///
- protected ConcurrentDictionary Entries = new();
-
- ///
- /// Subscribes this backing store to the entry events of another ,
- /// so that added, deleted, and updated entries in the target store are automatically pushed or deleted in this store.
- ///
- /// The target to subscribe to.
- public void SubscribeTo(CacheBackingStore targetStore)
- {
- targetStore.EntryAdded.Subscribe(Push);
- targetStore.EntryDeleted.Subscribe(Delete);
- targetStore.EntryUpdated.Subscribe(Push);
- }
-
- ///
- public void Dispose()
- {
- EntryAdded?.Dispose();
- EntryDeleted?.Dispose();
- EntryUpdated?.Dispose();
- }
- }
-
- ///
- /// An abstract cache backing store that persists objects as individual files,
- /// organizing them by entry type in subdirectories under a root directory. Supports real-time change observation
- /// via and provides serialization/deserialization hooks for custom formats.
- ///
- ///
- ///
- /// Each entry type is mapped to its own directory, and entries are stored as files with a configurable extension.
- /// The class maintains a mapping between file paths and entry IDs for efficient lookup and change tracking.
- ///
- ///
- /// Real-time changes (creation, modification, deletion) to files are observed and propagated to upstream subscribers
- /// using Rx subjects and debounced event handling.
- ///
- ///
- /// Subclasses must implement , , and
- /// to define the file format.
- ///
- ///
- public abstract class MultiFileBackingStore : CacheBackingStore, IRealtimeBackingStore, IDisposable
- {
- ///
- /// Gets the root directory where all entry files are stored.
- ///
- public DirectoryInfo RootDirectory { get; }
- ///
- /// Maps each type to its corresponding subdirectory.
- ///
- protected readonly Dictionary EntryTypeDirectories = new();
- ///
- /// Maps file paths to their corresponding IDs for quick lookup during change detection.
- ///
- protected readonly ConcurrentDictionary _filePathToIdMap = new();
- private FileSystemWatcher? watcher;
- private Subject? _changedSubject;
-
- ///
- /// Serializes a into a byte array for file storage.
- ///
- /// The to serialize.
- /// A byte array representing the serialized entry.
- public abstract byte[] Serialize(DatabaseEntry entry);
- ///
- /// Deserializes a byte array into a object.
- ///
- /// The byte array containing the serialized entry.
- /// The deserialized object.
- public abstract DatabaseEntry Deserialize(byte[] data);
- ///
- /// Gets the file extension used for storing entries (e.g., ".json", ".msgpack").
- ///
- public abstract string Extension { get; }
-
- ///
- /// Initializes a new instance of the class.
- /// Creates the root directory if it does not exist and sets up subdirectories for each child type.
- ///
- /// The path to the root directory for storing entry files.
- public MultiFileBackingStore(string path)
- {
- RootDirectory = new DirectoryInfo(path);
- foreach (var type in typeof(DatabaseEntry).GetAllChildClasses())
- {
- EntryTypeDirectories[type] = GetDirectoryForType(type);
- }
- }
-
- private DirectoryInfo GetDirectoryForType(Type type)
- {
- var stack = new Stack();
- var t = type;
- while (t != null && t != typeof(DatabaseEntry))
- {
- stack.Push(t.Name);
- t = t.BaseType;
- }
- stack.Push(RootDirectory.FullName);
- // ToArray() copies in LIFO order (top/first: root, then parents, deepest child last)
- // Ensures Path.Combine yields: Root\Parent\...\Child
- return Directory.CreateDirectory(Path.Combine(stack.ToArray()));
- }
-
- ///
- /// Loads all objects from individual files in the root directory and its subdirectories,
- /// organized by entry type. Each file is deserialized using ,
- /// added to the local dictionary, and notifies upstream via .
- /// Skips files that cannot be deserialized, logging errors without failing the entire operation.
- /// Only processes files matching the .
- ///
- public override void PullAll()
- {
- if (!RootDirectory.Exists) return;
-
- foreach (var directory in EntryTypeDirectories.Values)
- {
- foreach (var file in directory.EnumerateFiles($"*.{Extension}"))
- {
- try
- {
- var entry = Deserialize(File.ReadAllBytes(file.FullName));
- Entries[entry.ID] = entry;
- EntryAdded.OnNext(entry);
- _filePathToIdMap[file.FullName] = entry.ID;
- }
- catch (Exception ex)
- {
- Logger.LogError($"Failed to pull entry from {file.FullName}: {ex.Message}");
- }
- }
- }
- }
-
- private string GetFileName(DatabaseEntry entry)
- {
- string name = entry is INamedEntry namedEntry ? namedEntry.EntryName : entry.ID.ToString();
- // Sanitize to prevent invalid chars/path traversal
- foreach (char invalid in Path.GetInvalidFileNameChars())
- name = name.Replace(invalid, '_');
- return $"{name}.{Extension}";
- }
-
- ///
- /// Persists the specified to a file in the appropriate type subdirectory.
- /// The file name is generated using , which prefers the entry name if available.
- /// The entry is serialized via and written to disk.
- /// Updates the local dictionary and the for change tracking.
- /// Logs errors if the write fails but re-throws to allow caller handling.
- ///
- /// The to persist to a file.
- public override void Push(DatabaseEntry entry)
- {
- var type = entry.GetType();
- Entries[entry.ID] = entry;
- var directory = EntryTypeDirectories[type];
- var filePath = Path.Combine(directory.FullName, GetFileName(entry));
- File.WriteAllBytes(filePath, Serialize(entry));
- _filePathToIdMap[filePath] = entry.ID;
- }
-
- ///
- /// Removes the specified from the local dictionary
- /// and deletes its corresponding file in the type subdirectory.
- /// Updates the and notifies upstream via .
- /// File deletion failures are logged but do not prevent local removal or notification.
- ///
- /// The to delete from storage.
- public override void Delete(DatabaseEntry entry)
- {
- 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));
- _filePathToIdMap.TryRemove(filePath, out _);
- var file = new FileInfo(filePath);
- if (file.Exists)
- file.Delete();
- }
- }
-
- ///
- /// Persists all entries in to their respective files by invoking for each.
- /// The parameter is ignored in this implementation, as all pushes are full writes.
- /// Errors during individual pushes are logged but do not halt the process.
- /// Suitable for batch synchronization after bulk changes.
- ///
- /// If true, performs a soft push (not supported; treated as full push).
- public override void PushAll(bool soft = false)
- {
- foreach (var entry in Entries.Values.ToArray()) Push(entry);
- }
-
- ///
- /// Starts monitoring the root directory and its subdirectories for file system changes related to entry files.
- /// Initializes a with filters for creation, modification, and deletion events on files matching .
- /// Uses debouncing for change events to handle bursty writes (100ms delay via and R3).
- /// Disposes any existing watcher before creating a new one to prevent duplicates.
- /// Logs a warning if the root directory does not exist and skips observation.
- /// Logs info when observation starts successfully.
- /// Call this method to enable real-time synchronization with external file modifications.
- ///
- public void ObserveChanges()
- {
- if (watcher != null) // Guard against re-creation
- Dispose();
-
- watcher = new FileSystemWatcher(RootDirectory.FullName, $"*.{Extension}")
- {
- NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName,
- EnableRaisingEvents = true,
- IncludeSubdirectories = true
- };
-
- // Use R3 to throttle events if file writes are bursty
- _changedSubject = new Subject(); // For paths
- _changedSubject.Debounce(TimeSpan.FromMilliseconds(100)).Subscribe(HandleFileChange);
-
- watcher.Changed += (sender, args) =>
- {
- _changedSubject.OnNext(args.FullPath); // Debounced handling
- };
- watcher.Created += (sender, args) =>
- {
- HandleFileCreation(args.FullPath);
- };
- watcher.Deleted += (sender, args) =>
- {
- HandleFileRemoval(args.FullPath);
- };
- }
-
- private void HandleFileCreation(string fullPath)
- {
- try
- {
- var entry = Deserialize(File.ReadAllBytes(fullPath));
- Entries[entry.ID] = entry;
- _filePathToIdMap[fullPath] = entry.ID;
- EntryAdded.OnNext(entry); // Upstream notify
- }
- catch (Exception ex)
- {
- Logger.LogError($"Failed to handle creation of {fullPath}: {ex.Message}");
- }
- }
-
- private void HandleFileChange(string fullPath)
- {
- try
- {
- var entry = Deserialize(File.ReadAllBytes(fullPath));
- Entries[entry.ID] = entry;
- _filePathToIdMap[fullPath] = entry.ID;
- EntryUpdated.OnNext(entry); // Upstream notify
- }
- catch (Exception ex)
- {
- Logger.LogError($"Failed to handle change in {fullPath}: {ex.Message}");
- }
- }
-
- private void HandleFileRemoval(string fullPath)
- {
- try
- {
- // External deletion detected—handle without deserializing
- if (_filePathToIdMap.TryGetValue(fullPath, out var id))
- {
- _filePathToIdMap.TryRemove(fullPath, out _); // Clean map immediately
-
- // Emit delete if we have the entry locally
- if (Entries.TryGetValue(id, out var entry))
- {
- Entries.TryRemove(id, out _); // Sync local dict
- EntryDeleted.OnNext(entry);
- }
- else
- {
- // Fallback: Stub if race/removed already (rare)
- var type = GetTypeFromPath(fullPath); // See helper below
- var stub = CreateStubEntry(id, type);
- EntryDeleted.OnNext(stub);
- Logger.LogWarning($"External deletion of {fullPath} (ID: {id}) with no local entry; emitted stub for cleanup.");
- }
- }
- }
- catch (Exception ex)
- {
- Logger.LogError($"Failed to handle deletion of {fullPath}: {ex.Message}");
- }
- }
-
- // Helper: Infer type from path (e.g., based on directory name matching EntryTypeDirectories keys)
- private Type GetTypeFromPath(string filePath)
- {
- var currentDir = new DirectoryInfo(Path.GetDirectoryName(filePath) ?? string.Empty);
- // Walk up the directory tree to find matching EntryTypeDirectories (handles sub-subdirs)
- while (currentDir is { Exists: true })
- {
- var matchingType = EntryTypeDirectories.FirstOrDefault(kvp => kvp.Value.FullName == currentDir.FullName).Key;
- if (matchingType != null)
- return matchingType;
- currentDir = currentDir.Parent; // Walk up (e.g., from "PlayerData/backup" to "PlayerData")
- }
- return typeof(DatabaseEntry); // Fallback if no match (e.g., manual file)
- }
-
- // 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);
- typeof(DatabaseEntry).GetField("ID")?.SetValue(entry, id); // Set ID via reflection
- return entry;
- }
-
- ///
- public new void Dispose()
- {
- base.Dispose();
- watcher?.Dispose();
- _changedSubject?.Dispose();
- }
- }
-
- ///
- /// An abstract cache backing store that persists all objects in a single file.
- /// Supports serialization of the entire collection and provides hooks for custom binary formats.
- /// Suitable for smaller datasets or when atomic updates are preferred over individual file management.
- ///
- ///
- ///
- /// All entries are loaded and saved as a single array in the specified file.
- /// Changes do not trigger a write, must manually call PushAll to persist.
- ///
- ///
- /// Subclasses must implement and
- /// to define the storage format (e.g., JSON array, MessagePack array).
- ///
- ///
- public abstract class SingleFileBackingStore : CacheBackingStore
- {
- ///
- /// Gets the file where all entries are stored.
- ///
- public FileInfo FileInfo { get; }
-
- ///
- /// Serializes an array of objects into a byte array for single-file storage.
- ///
- /// The array of objects to serialize.
- /// A byte array representing the serialized entries.
- public abstract byte[] Serialize(DatabaseEntry[] entries);
- ///
- /// Deserializes a byte array into an array of objects.
- ///
- /// The byte array containing the serialized entries.
- /// An array of deserialized objects.
- public abstract DatabaseEntry[] Deserialize(byte[] data);
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The path to the single file for storing all entries.
- public SingleFileBackingStore(string filePath)
- {
- FileInfo = new FileInfo(filePath);
- }
-
- ///
- /// Loads all objects from the single storage file by reading its contents,
- /// deserializing via into an array, and adding each to the local dictionary.
- /// Notifies upstream via for each entry.
- /// If the file does not exist or deserialization fails, logs an error and returns without adding entries.
- /// Logs the number of loaded entries for debugging.
- ///
- public override void PullAll()
- {
- if (!FileInfo.Exists) return;
-
- foreach (var entry in Deserialize(File.ReadAllBytes(FileInfo.FullName)))
- {
- Entries[entry.ID] = entry;
- EntryAdded.OnNext(entry);
- }
- }
-
- ///
- /// Adds or updates the specified in the local dictionary.
- /// Does not immediately persist to the single file; call to save changes.
- ///
- /// The to persist.
- public override void Push(DatabaseEntry entry)
- {
- var type = entry.GetType();
- Entries[entry.ID] = entry;
- }
-
- ///
- /// Removes the specified from the local dictionary.
- /// Does not immediately persist to the single file; call to save changes.
- ///
- /// The to delete.
- public override void Delete(DatabaseEntry entry)
- {
- if (Entries.TryGetValue(entry.ID, out _))
- {
- Entries.TryRemove(entry.ID, out _);
- }
- }
-
- ///
- /// Serializes all entries in to a byte array using
- /// and writes it to the single storage file, overwriting any existing content.
- /// The parameter is ignored in this implementation, as saves are always full rewrites.
- /// Logs the number of saved entries and errors if the write fails, re-throwing for caller handling.
- ///
- /// If true, performs a soft push (not supported; treated as full push).
- public override void PushAll(bool soft = false)
- {
- var entriesArray = Entries.Values.ToArray();
- File.WriteAllBytes(FileInfo.FullName, Serialize(entriesArray));
- }
- }
-}
-
-// E:\Projects\CultLib\src\GameCult.Caching\DatabaseEntry.cs
-
-using System;
-using MessagePack;
-
-namespace GameCult.Caching
-{
- public interface INamedEntry
- {
- string EntryName { get; set; }
- }
-
- ///
- /// Base abstract class for all database entries in the cache.
- /// For optional MessagePack integration, define concrete subclasses with [MessagePackObject] and [Key(n)] on fields.
- /// The ID field can be serialized via a custom formatter in MessagePack stores.
- ///
- public abstract class DatabaseEntry
- {
- ///
- /// Unique identifier for the entry. Handled by custom formatters in MessagePack integration.
- ///
- public Guid ID = Guid.NewGuid();
-
- ///
- public override int GetHashCode()
- {
- return ID.GetHashCode();
- }
-
- ///
- public override bool Equals(object obj)
- {
- if (obj is DatabaseEntry entry) return entry.ID == ID;
- return false;
- }
- }
-
- ///
- /// Generic link to a in the cache.
- /// Value is computed from the cache; serializes only LinkID.
- ///
- public class DatabaseLink : DatabaseLinkBase where T : DatabaseEntry
- {
- ///
- /// Computed value from the cache (not serialized; regenerated post-deserialize).
- ///
- public T? Value => Cache.Get(LinkID);
- }
-
- ///
- /// Base for database links. LinkID is serialized directly; Value is computed in subclasses.
- ///
- public class DatabaseLinkBase
- {
- ///
- /// ID of the linked entry.
- ///
- public Guid LinkID;
-
- ///
- /// Static reference to the cache (not serialized).
- ///
- public static CultCache? Cache;
- }
-
- // Example of a DatabaseEntry implementation in user code
- public class PlayerData : DatabaseEntry, INamedEntry
- {
- [Key(1)]
- public string Email;
-
- [Key(2)]
- public string PasswordHash;
-
- [Key(3)]
- public string Username;
-
- [IgnoreMember]
- public string EntryName
- {
- get => Username;
- set => Username = value;
- }
- }
-}
-
-// E:\Projects\CultLib\src\GameCult.Caching\ReflectionExtensions.cs
-
-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
-{
- public static class ReflectionExtensions
- {
- private static Dictionary ParentTypes = new Dictionary();
-
- public static Type[] GetParentTypes(this Type type)
- {
- if (ParentTypes.ContainsKey(type))
- return ParentTypes[type];
- return ParentTypes[type] = type.GetParents().ToArray();
- }
-
- private static IEnumerable GetParents(this Type type)
- {
- // is there any base type?
- if (type == null)
- {
- yield break;
- }
-
- // return all inherited types
- var currentBaseType = type.BaseType;
- while (currentBaseType != null)
- {
- yield return currentBaseType;
- currentBaseType = currentBaseType.BaseType;
- }
- }
-
- private static Dictionary ChildClasses = new Dictionary();
- public static Type[] GetAllChildClasses(this Type type)
- {
- if (ChildClasses.ContainsKey(type))
- return ChildClasses[type];
- return ChildClasses[type] = AppDomain.CurrentDomain.GetAssemblies()
- .SelectMany(ass => ass.GetTypes()).Where(type.IsAssignableFrom).ToArray();
- }
-
- }
-}
diff --git a/src/GameCult.Unity/Assets/UI/UI.txt b/src/GameCult.Unity/Assets/UI/UI.txt
deleted file mode 100644
index 8789f73a..00000000
--- a/src/GameCult.Unity/Assets/UI/UI.txt
+++ /dev/null
@@ -1,2917 +0,0 @@
-
-
-// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Attributes.cs
-
-using System;
-
-namespace GameCult.Unity.UI
-{
- [AttributeUsage(AttributeTargets.All, Inherited = false)]
- public class InspectableAttribute : Attribute { }
-
- [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
- public abstract class PreferredInspectorAttribute : InspectableAttribute { }
-
- public class InspectableReadOnlyLabelAttribute : PreferredInspectorAttribute { }
-
- public class InspectableRangedFloatAttribute : PreferredInspectorAttribute
- {
- public readonly float Min, Max;
-
- public InspectableRangedFloatAttribute(float min, float max)
- {
- Min = min;
- Max = max;
- }
- }
-
- public class InspectableRangedIntAttribute : PreferredInspectorAttribute
- {
- public readonly int Min, Max;
-
- public InspectableRangedIntAttribute(int min, int max)
- {
- Min = min;
- Max = max;
- }
- }
-
- public class InspectableIncrementIntAttribute : PreferredInspectorAttribute
- {
- public readonly int Min, Max;
-
- public InspectableIncrementIntAttribute(int min, int max)
- {
- Min = min;
- Max = max;
- }
- }
-}
-
-// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\ColorConversion.cs
-
-using Unity.Mathematics;
-using UnityEngine;
-using static Unity.Mathematics.math;
-
-namespace GameCult.Unity.UI
-{
- // Color conversion functions from https://www.chilliant.com/rgb2hsv.html
- public static class ColorConversion
- {
- private static float Epsilon = 1e-10f;
-
- public static float3 HUEtoRGB(in float h)
- {
- float R = abs(h * 6 - 3) - 1;
- float G = 2 - abs(h * 6 - 2);
- float B = 2 - abs(h * 6 - 4);
- return saturate(float3(R,G,B));
- }
-
- public static float3 RGBtoHCV(in float3 rgb)
- {
- // Based on work by Sam Hocevar and Emil Persson
- float4 P = (rgb.y < rgb.z) ? float4(rgb.zy, -1.0f, 2.0f/3.0f) : float4(rgb.yz, 0.0f, -1.0f/3.0f);
- float4 Q = (rgb.x < P.x) ? float4(P.xyw, rgb.x) : float4(rgb.x, P.yzx);
- float C = Q.x - min(Q.w, Q.y);
- float H = abs((Q.w - Q.y) / (6 * C + Epsilon) + Q.z);
- return float3(H, C, Q.x);
- }
-
- public static float3 HSVtoRGB(in float3 hsv)
- {
- float3 rgb = HUEtoRGB(hsv.x);
- return ((rgb - 1) * hsv.y + 1) * hsv.z;
- }
-
- public static float3 HSLtoRGB(in float3 hsl)
- {
- float3 RGB = HUEtoRGB(hsl.x);
- float C = (1 - abs(2 * hsl.z - 1)) * hsl.y;
- return (RGB - 0.5f) * C + hsl.z;
- }
-
- // The weights of RGB contributions to luminance.
- // Should sum to unity.
- private static float3 HCYwts = float3(0.299f, 0.587f, 0.114f);
-
- public static float3 HCYtoRGB(float3 hcy)
- {
- float3 RGB = HUEtoRGB(hcy.x);
- float Z = dot(RGB, HCYwts);
- if (hcy.z < Z)
- {
- hcy.y *= hcy.z / Z;
- }
- else if (Z < 1)
- {
- hcy.y *= (1 - hcy.z) / (1 - Z);
- }
- return (RGB - Z) * hcy.y + hcy.z;
- }
-
- public static float3 RGBtoHSV(in float3 rgb)
- {
- float3 HCV = RGBtoHCV(rgb);
- float S = HCV.y / (HCV.z + Epsilon);
- return float3(HCV.x, S, HCV.z);
- }
-
- public static float3 RGBtoHSL(in float3 rgb)
- {
- float3 HCV = RGBtoHCV(rgb);
- float L = HCV.z - HCV.y * 0.5f;
- float S = HCV.y / (1 - abs(L * 2 - 1) + Epsilon);
- return float3(HCV.x, S, L);
- }
-
- public static float3 RGBtoHCY(in float3 rgb)
- {
- // Corrected by David Schaeffer
- float3 HCV = RGBtoHCV(rgb);
- float Y = dot(rgb, HCYwts);
- float Z = dot(HUEtoRGB(HCV.x), HCYwts);
- if (Y < Z)
- {
- HCV.y *= Z / (Epsilon + Y);
- }
- else
- {
- HCV.y *= (1 - Z) / (Epsilon + 1 - Y);
- }
- return float3(HCV.x, HCV.y, Y);
- }
-
- public static Color ToColor(this float3 color) => new Color(color.x, color.y, color.z);
- public static float3 ToFloat3(this Color color) => new float3(color.r, color.g, color.b);
- }
-}
-
-// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\DisplayOptions.cs
-
-using TMPro;
-
-namespace GameCult.Unity.UI
-{
- public class DisplayOptions
- {
- public TextAlignmentOptions? TextAlignment { get; }
- public VerticalAlignmentOptions? VerticalAlignment { get; }
- public float? LayoutPriority { get; }
- public float? LayoutHeight { get; }
- public float? TextSize { get; }
- public bool Interactable { get; }
- public bool PlaceInContext { get; }
-
- public DisplayOptions(bool placeInContext = true,
- bool interactable = true,
- float? layoutHeight = null,
- float? textSize = null,
- float? layoutPriority = null,
- VerticalAlignmentOptions? verticalAlignment = null,
- TextAlignmentOptions? textAlignment = null)
- {
- PlaceInContext = placeInContext;
- LayoutHeight = layoutHeight;
- Interactable = interactable;
- TextSize = textSize;
- LayoutPriority = layoutPriority;
- VerticalAlignment = verticalAlignment;
- TextAlignment = textAlignment;
- }
- }
-}
-
-// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Generator.cs
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Linq.Expressions;
-using System.Reflection;
-using GameCult.Unity.UI.Components;
-using UnityEngine;
-using ZLinq;
-
-namespace GameCult.Unity.UI
-{
- public class Generator : ResolverComponent, IUIContext
- {
- [SerializeField] private float inspectorLabelPriority = 3;
- [SerializeField] private float inspectorFieldPriority = 2;
- [SerializeField] private ReflectiveResolver? resolver;
- [SerializeField] private RectTransform? contentRoot;
- protected event Action? OnPropertyAdded;
- protected event Action? RefreshPropertyValues;
-
- protected List Elements { get; } = new();
- public IUIResolver Resolver => resolver ?? throw new NullReferenceException("Generator doesn't have an assigned resolver.");
- public Action? Refresh
- {
- get => RefreshPropertyValues;
- set => RefreshPropertyValues = value;
- }
-
- public RectTransform ContentRoot => contentRoot ?? (RectTransform)transform;
- public Canvas Canvas => GetComponentInParent