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(); - public RectTransform CanvasRoot => (RectTransform)Canvas.transform; - - public void Inspect(object obj, bool inspectableOnly = false, bool recursive = true) - { - var members = obj.GetType().GetMembers(BindingFlags.Public | BindingFlags.Instance); - foreach (var member in members) - { - var inspectable = member.GetCustomAttribute(); - if (inspectable == null && inspectableOnly) continue; - var preferred = inspectable as PreferredInspectorAttribute; - switch (member) - { - case FieldInfo field: - { - var type = field.FieldType; - var handler = Resolver.ResolveField(type, preferred); - // If there's a handler for this field and attribute type, use that - if (handler != null) - { - var group = Resolver.Resolve()?.Configure(this); - if (group == null) continue; - Resolver.Resolve