From 834c6b1b1380206cb75e14402d0b933a4e34ee02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 00:08:55 +0000 Subject: [PATCH 1/2] Replace string-message throws with typed exception classes Domain and plugin-infrastructure failures were signalled with BCL exceptions carrying interpolated messages, so callers could only react by matching on message text. Each failure now has a type that carries its data as properties and composes its own message. Plugin loading (Turbo.Plugins/Exceptions/): - PluginException, an abstract base carrying PluginKey, so a caller can catch any plugin problem uniformly. - PluginManifestException (NotFound / Unreadable / MissingField) with ManifestPath and FieldName. - PluginAssemblyException (NotFound / EntryPointNotFound / KeyMismatch) with AssemblyLocation and EntryPointKey. - PluginDependencyException (Missing / Cycle / DependentsActive / DependencyInactive) with the related keys. - PluginExportNotBoundException with the export type. Domain failures: - WalletDebitFailedException carries CurrencyKind plus the requested and applied amounts, replacing a bare Exception in PlayerWalletGrain. - CatalogTypeNotSupportedException carries the CatalogType. - RoomModelNotFoundException carries ModelId; RoomModelDataInvalidException carries the reason. - WiredParamTypeMismatchException carries the index and both types. - InvalidActionContextException carries the rejected context type. It lives in Turbo.Primitives rather than using TurboException, because Turbo.Primitives has no project references and TurboException is defined in Turbo.Logging, which depends on it. Two sites had no data worth carrying and use the existing coded pattern instead: the wired comparison switch now throws TurboException(TurboErrorCodeEnum.InvalidWired). The manifest parse handler gained a `when (ex is not PluginManifestException)` filter so the specific validation failures thrown inside the try are no longer re-wrapped as a generic parse error. BCL argument guards are deliberately untouched: ArgumentException and ArgumentOutOfRangeException remain correct for parameter validation, and Turbo.Runtime.AssemblyProcessing stays on BCL types since it cannot reference Turbo.Plugins. Clean quality gate on SDK 10.0.400: 0 errors, warning count unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01H1WWL4hLiAvJr2jzxoGyaP --- Turbo.Catalog/CatalogService.cs | 7 +-- .../CatalogTypeNotSupportedException.cs | 11 ++++ .../Exceptions/WalletDebitFailedException.cs | 24 +++++++ Turbo.Players/Grains/PlayerWalletGrain.cs | 7 ++- .../Exceptions/PluginAssemblyException.cs | 62 +++++++++++++++++++ .../Exceptions/PluginDependencyException.cs | 58 +++++++++++++++++ Turbo.Plugins/Exceptions/PluginException.cs | 17 +++++ .../PluginExportNotBoundException.cs | 10 +++ .../Exceptions/PluginManifestException.cs | 55 ++++++++++++++++ Turbo.Plugins/Exports/ReloadableExport.cs | 4 +- Turbo.Plugins/PluginHelpers.cs | 55 +++++++++------- Turbo.Plugins/PluginManager.cs | 36 +++++++---- Turbo.Primitives/Action/ActionContext.cs | 3 +- .../Action/InvalidActionContextException.cs | 17 +++++ .../RoomModelDataInvalidException.cs | 10 +++ .../Exceptions/RoomModelNotFoundException.cs | 10 +++ .../WiredParamTypeMismatchException.cs | 20 ++++++ .../WiredSelectorItemsWithVariable.cs | 2 +- Turbo.Rooms/Providers/RoomModelProvider.cs | 5 +- Turbo.Rooms/Wired/WiredData.cs | 9 +-- 20 files changed, 370 insertions(+), 52 deletions(-) create mode 100644 Turbo.Catalog/Exceptions/CatalogTypeNotSupportedException.cs create mode 100644 Turbo.Players/Exceptions/WalletDebitFailedException.cs create mode 100644 Turbo.Plugins/Exceptions/PluginAssemblyException.cs create mode 100644 Turbo.Plugins/Exceptions/PluginDependencyException.cs create mode 100644 Turbo.Plugins/Exceptions/PluginException.cs create mode 100644 Turbo.Plugins/Exceptions/PluginExportNotBoundException.cs create mode 100644 Turbo.Plugins/Exceptions/PluginManifestException.cs create mode 100644 Turbo.Primitives/Action/InvalidActionContextException.cs create mode 100644 Turbo.Rooms/Exceptions/RoomModelDataInvalidException.cs create mode 100644 Turbo.Rooms/Exceptions/RoomModelNotFoundException.cs create mode 100644 Turbo.Rooms/Exceptions/WiredParamTypeMismatchException.cs diff --git a/Turbo.Catalog/CatalogService.cs b/Turbo.Catalog/CatalogService.cs index 8bcdabdb..e4620308 100644 --- a/Turbo.Catalog/CatalogService.cs +++ b/Turbo.Catalog/CatalogService.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Turbo.Catalog.Exceptions; using Turbo.Database.Context; using Turbo.Primitives.Catalog; using Turbo.Primitives.Catalog.Enums; @@ -29,10 +30,8 @@ public CatalogSnapshot GetCatalogSnapshot(CatalogType catalogType) return catalogType switch { CatalogType.Normal => _normalCatalogProvider.Current, - CatalogType.BuildersClub => throw new NotSupportedException( - $"Catalog type {catalogType} is not supported." - ), - _ => throw new NotSupportedException($"Catalog type {catalogType} is not supported."), + CatalogType.BuildersClub => throw new CatalogTypeNotSupportedException(catalogType), + _ => throw new CatalogTypeNotSupportedException(catalogType), }; } diff --git a/Turbo.Catalog/Exceptions/CatalogTypeNotSupportedException.cs b/Turbo.Catalog/Exceptions/CatalogTypeNotSupportedException.cs new file mode 100644 index 00000000..2906a62f --- /dev/null +++ b/Turbo.Catalog/Exceptions/CatalogTypeNotSupportedException.cs @@ -0,0 +1,11 @@ +using System; +using Turbo.Primitives.Catalog.Enums; + +namespace Turbo.Catalog.Exceptions; + +/// Raised when a catalog snapshot is requested for a catalog type that has no provider. +public sealed class CatalogTypeNotSupportedException(CatalogType catalogType) + : Exception($"Catalog type '{catalogType}' is not supported.") +{ + public CatalogType CatalogType { get; } = catalogType; +} diff --git a/Turbo.Players/Exceptions/WalletDebitFailedException.cs b/Turbo.Players/Exceptions/WalletDebitFailedException.cs new file mode 100644 index 00000000..ad0c9d69 --- /dev/null +++ b/Turbo.Players/Exceptions/WalletDebitFailedException.cs @@ -0,0 +1,24 @@ +using System; +using Turbo.Primitives.Players.Wallet; + +namespace Turbo.Players.Exceptions; + +/// +/// Raised when a wallet debit did not move the balance by the requested amount, which means the +/// currency could not cover it. Carries the request detail so the caller can report which currency +/// fell short without re-deriving it. +/// +public sealed class WalletDebitFailedException( + CurrencyKind currencyKind, + int requestedAmount, + int appliedAmount +) : Exception($"Wallet debit of {requestedAmount} failed; {appliedAmount} was applied instead.") +{ + public CurrencyKind CurrencyKind { get; } = currencyKind; + + /// The amount the debit asked for. + public int RequestedAmount { get; } = requestedAmount; + + /// The amount the balance actually moved by. + public int AppliedAmount { get; } = appliedAmount; +} diff --git a/Turbo.Players/Grains/PlayerWalletGrain.cs b/Turbo.Players/Grains/PlayerWalletGrain.cs index ad870477..50dfe6ba 100644 --- a/Turbo.Players/Grains/PlayerWalletGrain.cs +++ b/Turbo.Players/Grains/PlayerWalletGrain.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Orleans; using Turbo.Database.Context; +using Turbo.Players.Exceptions; using Turbo.Primitives.Orleans; using Turbo.Primitives.Players.Enums.Wallet; using Turbo.Primitives.Players.Grains; @@ -54,7 +55,11 @@ CancellationToken ct var update = await ProcessDebitRequestAsync(dbCtx, request, ct); if (update.ChangedBy != request.Amount) - throw new Exception("Failed to process debit request"); + throw new WalletDebitFailedException( + request.CurrencyKind, + request.Amount, + update.ChangedBy + ); updates.Add(update); } diff --git a/Turbo.Plugins/Exceptions/PluginAssemblyException.cs b/Turbo.Plugins/Exceptions/PluginAssemblyException.cs new file mode 100644 index 00000000..a2fd674a --- /dev/null +++ b/Turbo.Plugins/Exceptions/PluginAssemblyException.cs @@ -0,0 +1,62 @@ +using System; + +namespace Turbo.Plugins.Exceptions; + +public enum PluginAssemblyErrorType +{ + /// No assembly matching the manifest could be located in the plugin directory. + NotFound, + + /// The assembly loaded, but contains no ITurboPlugin entry point. + EntryPointNotFound, + + /// The entry point's key does not match the key declared in the manifest. + KeyMismatch, +} + +/// Raised when a plugin's assembly cannot be located, or does not expose a usable entry point. +public sealed class PluginAssemblyException : PluginException +{ + public PluginAssemblyErrorType ErrorType { get; } + + /// Plugin directory or assembly name involved in the failure, when known. + public string? AssemblyLocation { get; } + + /// Key reported by the loaded entry point, set only for . + public string? EntryPointKey { get; } + + public PluginAssemblyException( + PluginAssemblyErrorType errorType, + string? pluginKey = null, + string? assemblyLocation = null, + string? entryPointKey = null, + Exception? innerException = null + ) + : base( + BuildMessage(errorType, pluginKey, assemblyLocation, entryPointKey), + pluginKey, + innerException + ) + { + ErrorType = errorType; + AssemblyLocation = assemblyLocation; + EntryPointKey = entryPointKey; + } + + private static string BuildMessage( + PluginAssemblyErrorType errorType, + string? pluginKey, + string? assemblyLocation, + string? entryPointKey + ) => + errorType switch + { + PluginAssemblyErrorType.NotFound => + $"No assembly found for plugin '{pluginKey}' in '{assemblyLocation}'.", + PluginAssemblyErrorType.EntryPointNotFound => + $"No ITurboPlugin entry point found in assembly '{assemblyLocation}'.", + PluginAssemblyErrorType.KeyMismatch => + $"Plugin key mismatch: manifest declares '{pluginKey}' but the entry point reports '{entryPointKey}'.", + _ => $"Plugin assembly for '{pluginKey}' is not valid.", + }; +} diff --git a/Turbo.Plugins/Exceptions/PluginDependencyException.cs b/Turbo.Plugins/Exceptions/PluginDependencyException.cs new file mode 100644 index 00000000..b49b459f --- /dev/null +++ b/Turbo.Plugins/Exceptions/PluginDependencyException.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Turbo.Plugins.Exceptions; + +public enum PluginDependencyErrorType +{ + /// A declared dependency is not present in the discovered plugin set. + Missing, + + /// The dependency graph contains a cycle and cannot be ordered. + Cycle, + + /// The plugin cannot be reloaded or unloaded because live plugins depend on it. + DependentsActive, + + /// The plugin cannot be reloaded because one of its dependencies is not live. + DependencyInactive, +} + +/// Raised when the plugin dependency graph prevents a load, reload or unload. +public sealed class PluginDependencyException : PluginException +{ + public PluginDependencyErrorType ErrorType { get; } + + /// The dependencies or dependents that caused the failure. + public ImmutableArray RelatedKeys { get; } + + public PluginDependencyException( + PluginDependencyErrorType errorType, + string? pluginKey = null, + IEnumerable? relatedKeys = null + ) + : base(BuildMessage(errorType, pluginKey, relatedKeys?.ToImmutableArray() ?? []), pluginKey) + { + ErrorType = errorType; + RelatedKeys = relatedKeys?.ToImmutableArray() ?? []; + } + + private static string BuildMessage( + PluginDependencyErrorType errorType, + string? pluginKey, + ImmutableArray relatedKeys + ) => + errorType switch + { + PluginDependencyErrorType.Missing => + $"Plugin '{pluginKey}' is missing dependency '{string.Join(", ", relatedKeys)}'.", + PluginDependencyErrorType.Cycle => "Plugin dependencies contain a cycle.", + PluginDependencyErrorType.DependentsActive => + $"Plugin '{pluginKey}' cannot be reloaded or unloaded while dependents are active: {string.Join(", ", relatedKeys)}.", + PluginDependencyErrorType.DependencyInactive => + $"Plugin '{pluginKey}' cannot be reloaded because dependency '{string.Join(", ", relatedKeys)}' is not active.", + _ => $"Plugin '{pluginKey}' has an unsatisfied dependency graph.", + }; +} diff --git a/Turbo.Plugins/Exceptions/PluginException.cs b/Turbo.Plugins/Exceptions/PluginException.cs new file mode 100644 index 00000000..d2b6b035 --- /dev/null +++ b/Turbo.Plugins/Exceptions/PluginException.cs @@ -0,0 +1,17 @@ +using System; + +namespace Turbo.Plugins.Exceptions; + +/// +/// Base type for plugin discovery, loading and lifecycle failures. Callers that want to treat any +/// plugin problem uniformly (for example, skipping a bad plugin folder) can catch this type. +/// +public abstract class PluginException( + string message, + string? pluginKey = null, + Exception? innerException = null +) : Exception(message, innerException) +{ + /// The plugin key this failure relates to, when it is known. + public string? PluginKey { get; } = pluginKey; +} diff --git a/Turbo.Plugins/Exceptions/PluginExportNotBoundException.cs b/Turbo.Plugins/Exceptions/PluginExportNotBoundException.cs new file mode 100644 index 00000000..06e1d367 --- /dev/null +++ b/Turbo.Plugins/Exceptions/PluginExportNotBoundException.cs @@ -0,0 +1,10 @@ +using System; + +namespace Turbo.Plugins.Exceptions; + +/// Raised when a reloadable export is read before any implementation has been bound to it. +public sealed class PluginExportNotBoundException(Type exportType) + : PluginException($"Export '{exportType.Name}' has not been bound yet.") +{ + public Type ExportType { get; } = exportType; +} diff --git a/Turbo.Plugins/Exceptions/PluginManifestException.cs b/Turbo.Plugins/Exceptions/PluginManifestException.cs new file mode 100644 index 00000000..cb3ce30d --- /dev/null +++ b/Turbo.Plugins/Exceptions/PluginManifestException.cs @@ -0,0 +1,55 @@ +using System; + +namespace Turbo.Plugins.Exceptions; + +public enum PluginManifestErrorType +{ + /// No manifest.json exists at the expected path. + NotFound, + + /// The manifest exists but could not be read or deserialized. + Unreadable, + + /// The manifest parsed, but a required field was absent or blank. + MissingField, +} + +/// Raised when a plugin's manifest.json is absent, unreadable or incomplete. +public sealed class PluginManifestException : PluginException +{ + public PluginManifestErrorType ErrorType { get; } + + /// Path to the manifest, or to the plugin directory when the manifest is missing. + public string ManifestPath { get; } + + /// The offending manifest field, set only for . + public string? FieldName { get; } + + public PluginManifestException( + PluginManifestErrorType errorType, + string manifestPath, + string? fieldName = null, + Exception? innerException = null + ) + : base(BuildMessage(errorType, manifestPath, fieldName), null, innerException) + { + ErrorType = errorType; + ManifestPath = manifestPath; + FieldName = fieldName; + } + + private static string BuildMessage( + PluginManifestErrorType errorType, + string manifestPath, + string? fieldName + ) => + errorType switch + { + PluginManifestErrorType.NotFound => $"Plugin manifest not found at '{manifestPath}'.", + PluginManifestErrorType.Unreadable => + $"Plugin manifest at '{manifestPath}' could not be read.", + PluginManifestErrorType.MissingField => + $"Plugin manifest at '{manifestPath}' is missing required field '{fieldName}'.", + _ => $"Plugin manifest at '{manifestPath}' is not valid.", + }; +} diff --git a/Turbo.Plugins/Exports/ReloadableExport.cs b/Turbo.Plugins/Exports/ReloadableExport.cs index e61291bd..3a814cf1 100644 --- a/Turbo.Plugins/Exports/ReloadableExport.cs +++ b/Turbo.Plugins/Exports/ReloadableExport.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Turbo.Contracts.Plugins.Exports; +using Turbo.Plugins.Exceptions; namespace Turbo.Plugins.Exports; @@ -12,8 +13,7 @@ public sealed class ReloadableExport : IExport private volatile T? _current; private ImmutableArray> _subs = []; - public T Current => - _current ?? throw new InvalidOperationException($"Export {typeof(T).Name} not bound yet."); + public T Current => _current ?? throw new PluginExportNotBoundException(typeof(T)); public async Task SwapAsync(T value) { diff --git a/Turbo.Plugins/PluginHelpers.cs b/Turbo.Plugins/PluginHelpers.cs index 9444e491..fb7b3929 100644 --- a/Turbo.Plugins/PluginHelpers.cs +++ b/Turbo.Plugins/PluginHelpers.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.Json; using Turbo.Contracts.Plugins; +using Turbo.Plugins.Exceptions; namespace Turbo.Plugins; @@ -21,41 +22,45 @@ public static PluginManifest ReadManifest(string dir) var path = Path.Combine(dir, "manifest.json"); if (!File.Exists(path)) - throw new FileNotFoundException($"Plugin manifest not found: {path}"); + throw new PluginManifestException(PluginManifestErrorType.NotFound, path); try { var manifest = - ( - JsonSerializer.Deserialize( - File.ReadAllText(path), - _jsonSerializerOptions - ) ?? throw new InvalidOperationException("Invalid manifest.json") - ) - ?? throw new InvalidDataException($"manifest.json at {path} deserialized to null"); + JsonSerializer.Deserialize( + File.ReadAllText(path), + _jsonSerializerOptions + ) ?? throw new PluginManifestException(PluginManifestErrorType.Unreadable, path); if (string.IsNullOrWhiteSpace(manifest.Name)) - throw new InvalidDataException( - $"Plugin manifest missing required 'Name' in {path}" + throw new PluginManifestException( + PluginManifestErrorType.MissingField, + path, + nameof(manifest.Name) ); if (string.IsNullOrWhiteSpace(manifest.Version)) - throw new InvalidDataException( - $"Plugin manifest missing required 'Version' in {path}" + throw new PluginManifestException( + PluginManifestErrorType.MissingField, + path, + nameof(manifest.Version) ); if (string.IsNullOrWhiteSpace(manifest.AssemblyFile)) - throw new InvalidDataException( - $"Plugin manifest missing required 'AssemblyFile' in {path}" + throw new PluginManifestException( + PluginManifestErrorType.MissingField, + path, + nameof(manifest.AssemblyFile) ); return manifest; } - catch (Exception ex) + catch (Exception ex) when (ex is not PluginManifestException) { - throw new InvalidDataException( - $"Failed to parse manifest.json for plugin at {dir}: {ex.Message}", - ex + throw new PluginManifestException( + PluginManifestErrorType.Unreadable, + path, + innerException: ex ); } } @@ -80,7 +85,11 @@ IReadOnlyList manifests { if (!byKey.ContainsKey(d.Key)) { - throw new InvalidOperationException($"{m.Key} is missing dependency {d.Key}"); + throw new PluginDependencyException( + PluginDependencyErrorType.Missing, + m.Key, + [d.Key] + ); } graph[d.Key].Add(m.Key); @@ -102,7 +111,7 @@ IReadOnlyList manifests } if (order.Count != manifests.Count) - throw new InvalidOperationException("Cyclic plugin dependencies."); + throw new PluginDependencyException(PluginDependencyErrorType.Cycle); return [.. order.Select(k => byKey[k])]; } @@ -123,8 +132,10 @@ public static string GetAssemblyPath(string pluginDir, PluginManifest manifest) Path.GetFileNameWithoutExtension(f) .Contains(manifest.Key, StringComparison.OrdinalIgnoreCase) ) - ?? throw new FileNotFoundException( - $"No assembly for plugin {manifest.Key} in {pluginDir}" + ?? throw new PluginAssemblyException( + PluginAssemblyErrorType.NotFound, + manifest.Key, + pluginDir ); asmPath = alt; } diff --git a/Turbo.Plugins/PluginManager.cs b/Turbo.Plugins/PluginManager.cs index c8d256a5..8ecf6a41 100644 --- a/Turbo.Plugins/PluginManager.cs +++ b/Turbo.Plugins/PluginManager.cs @@ -13,6 +13,7 @@ using Turbo.Contracts.Plugins; using Turbo.Logging.Extensions; using Turbo.Plugins.Configuration; +using Turbo.Plugins.Exceptions; using Turbo.Plugins.Exports; using Turbo.Runtime; using Turbo.Runtime.AssemblyProcessing; @@ -152,8 +153,10 @@ public async Task LoadAllAsync(bool unloadRemoved = true, CancellationToken ct = _dependents.TryGetValue(m.Key, out var deps) && deps.Any(_live.ContainsKey) ) - throw new InvalidOperationException( - $"Cannot reload {m.Key} while dependents are active: {string.Join(",", deps.Where(_live.ContainsKey))}" + throw new PluginDependencyException( + PluginDependencyErrorType.DependentsActive, + m.Key, + deps.Where(_live.ContainsKey) ); await StopAndTearDownAsync(current, ct).ConfigureAwait(false); @@ -238,14 +241,18 @@ public async Task ReloadAsync(string key, CancellationToken ct = default) { foreach (var dep in manifest.Dependencies.Where(dep => !_live.ContainsKey(dep.Key))) { - throw new InvalidOperationException( - $"Cannot reload {key}; dependency {dep.Key} is not active." + throw new PluginDependencyException( + PluginDependencyErrorType.DependencyInactive, + key, + [dep.Key] ); } if (_dependents.TryGetValue(key, out var deps) && deps.Any(_live.ContainsKey)) - throw new InvalidOperationException( - $"Cannot reload {key} while dependents are active: {string.Join(",", deps.Where(_live.ContainsKey))}" + throw new PluginDependencyException( + PluginDependencyErrorType.DependentsActive, + key, + deps.Where(_live.ContainsKey) ); var asm = GetLoadedPluginAssembly(manifest, folder); @@ -285,8 +292,10 @@ private async Task UnloadAsync(string key, CancellationToken ct = default) try { if (_dependents.TryGetValue(key, out var deps) && deps.Any(_live.ContainsKey)) - throw new InvalidOperationException( - $"Cannot unload {key}; dependents active: {string.Join(",", deps.Where(_live.ContainsKey))}" + throw new PluginDependencyException( + PluginDependencyErrorType.DependentsActive, + key, + deps.Where(_live.ContainsKey) ); if (_live.TryRemove(key, out var env)) @@ -356,8 +365,10 @@ CancellationToken ct var inst = CreatePluginInstance(asm.Assembly); if (!string.Equals(inst.Key, m.Key, StringComparison.Ordinal)) - throw new InvalidOperationException( - $"Plugin key mismatch: manifest={m.Key} entry={inst.Key}" + throw new PluginAssemblyException( + PluginAssemblyErrorType.KeyMismatch, + m.Key, + entryPointKey: inst.Key ); var sp = CreatePluginServiceProvider(inst, m); @@ -400,8 +411,9 @@ private static ITurboPlugin CreatePluginInstance(Assembly asm) { var pluginType = AssemblyExplorer.FindType(asm, typeof(ITurboPlugin)) - ?? throw new InvalidOperationException( - $"Failed to find ITurboPlugin in assembly '{asm.GetName().Name}'." + ?? throw new PluginAssemblyException( + PluginAssemblyErrorType.EntryPointNotFound, + assemblyLocation: asm.GetName().Name ); return (ITurboPlugin)Activator.CreateInstance(pluginType)!; diff --git a/Turbo.Primitives/Action/ActionContext.cs b/Turbo.Primitives/Action/ActionContext.cs index 7dafec78..7cc0b0fc 100644 --- a/Turbo.Primitives/Action/ActionContext.cs +++ b/Turbo.Primitives/Action/ActionContext.cs @@ -1,4 +1,3 @@ -using System; using Orleans; using Turbo.Primitives.Networking; using Turbo.Primitives.Players; @@ -38,7 +37,7 @@ public static ActionContext CreateForObjectContext(IRoomObjectContext ctx) => playerCtx.RoomObject.PlayerId, ctx.RoomId ), - _ => throw new Exception("Cannot create ActionContext for object context"), + _ => throw new InvalidActionContextException(ctx.GetType()), }; public static ActionContext Invalid => diff --git a/Turbo.Primitives/Action/InvalidActionContextException.cs b/Turbo.Primitives/Action/InvalidActionContextException.cs new file mode 100644 index 00000000..7ed8a4c4 --- /dev/null +++ b/Turbo.Primitives/Action/InvalidActionContextException.cs @@ -0,0 +1,17 @@ +using System; +using Turbo.Primitives.Rooms.Object; + +namespace Turbo.Primitives.Action; + +/// +/// Raised when an is requested for a room object context that has no +/// action origin, such as a non-player object. +/// +public sealed class InvalidActionContextException(Type roomObjectContextType) + : Exception( + $"An action context cannot be created for room object context '{roomObjectContextType.Name}'." + ) +{ + /// The implementation that was rejected. + public Type RoomObjectContextType { get; } = roomObjectContextType; +} diff --git a/Turbo.Rooms/Exceptions/RoomModelDataInvalidException.cs b/Turbo.Rooms/Exceptions/RoomModelDataInvalidException.cs new file mode 100644 index 00000000..6218b2ee --- /dev/null +++ b/Turbo.Rooms/Exceptions/RoomModelDataInvalidException.cs @@ -0,0 +1,10 @@ +using System; + +namespace Turbo.Rooms.Exceptions; + +/// Raised when stored room model data cannot be compiled into a usable map. +public sealed class RoomModelDataInvalidException(string reason) + : Exception($"Room model data is not valid: {reason}") +{ + public string Reason { get; } = reason; +} diff --git a/Turbo.Rooms/Exceptions/RoomModelNotFoundException.cs b/Turbo.Rooms/Exceptions/RoomModelNotFoundException.cs new file mode 100644 index 00000000..e65197f1 --- /dev/null +++ b/Turbo.Rooms/Exceptions/RoomModelNotFoundException.cs @@ -0,0 +1,10 @@ +using System; + +namespace Turbo.Rooms.Exceptions; + +/// Raised when a room model is requested by id and no such model is loaded. +public sealed class RoomModelNotFoundException(int modelId) + : Exception($"Room model '{modelId}' could not be found.") +{ + public int ModelId { get; } = modelId; +} diff --git a/Turbo.Rooms/Exceptions/WiredParamTypeMismatchException.cs b/Turbo.Rooms/Exceptions/WiredParamTypeMismatchException.cs new file mode 100644 index 00000000..c6e05f03 --- /dev/null +++ b/Turbo.Rooms/Exceptions/WiredParamTypeMismatchException.cs @@ -0,0 +1,20 @@ +using System; + +namespace Turbo.Rooms.Exceptions; + +/// Raised when a wired parameter is read or written as a type the rule does not declare. +public sealed class WiredParamTypeMismatchException( + int parameterIndex, + Type? declaredType, + Type requestedType +) + : Exception( + $"Wired parameter {parameterIndex} is '{declaredType?.Name ?? "unset"}', not '{requestedType.Name}'." + ) +{ + public int ParameterIndex { get; } = parameterIndex; + + public Type? DeclaredType { get; } = declaredType; + + public Type RequestedType { get; } = requestedType; +} diff --git a/Turbo.Rooms/Object/Logic/Furniture/Floor/Wired/Selectors/WiredSelectorItemsWithVariable.cs b/Turbo.Rooms/Object/Logic/Furniture/Floor/Wired/Selectors/WiredSelectorItemsWithVariable.cs index 3af0b1cc..99cd7f5b 100644 --- a/Turbo.Rooms/Object/Logic/Furniture/Floor/Wired/Selectors/WiredSelectorItemsWithVariable.cs +++ b/Turbo.Rooms/Object/Logic/Furniture/Floor/Wired/Selectors/WiredSelectorItemsWithVariable.cs @@ -117,7 +117,7 @@ CancellationToken ct WiredComparisonType.LessThan => comparisonResult < 0, WiredComparisonType.Equals => comparisonResult == 0, WiredComparisonType.GreaterThan => comparisonResult > 0, - _ => throw new InvalidOperationException("Invalid comparison type."), + _ => throw new TurboException(TurboErrorCodeEnum.InvalidWired), }; if (!isMatch) diff --git a/Turbo.Rooms/Providers/RoomModelProvider.cs b/Turbo.Rooms/Providers/RoomModelProvider.cs index 04adc417..7bb07185 100644 --- a/Turbo.Rooms/Providers/RoomModelProvider.cs +++ b/Turbo.Rooms/Providers/RoomModelProvider.cs @@ -11,6 +11,7 @@ using Turbo.Primitives.Rooms.Object; using Turbo.Primitives.Rooms.Providers; using Turbo.Primitives.Rooms.Snapshots.Mapping; +using Turbo.Rooms.Exceptions; namespace Turbo.Rooms.Providers; @@ -29,7 +30,7 @@ ILogger logger public RoomModelSnapshot GetModelById(int modelId) => _modelsById.TryGetValue(modelId, out var model) ? model - : throw new KeyNotFoundException($"Room model not found: ModelId={modelId}"); + : throw new RoomModelNotFoundException(modelId); public async Task ReloadAsync(CancellationToken ct = default) { @@ -84,7 +85,7 @@ private static CompiledRoomModelSnapshot CompileModelFromString(string model) var rows = SplitLines(model); if (rows.Count == 0) - throw new InvalidDataException("Room model data is empty."); + throw new RoomModelDataInvalidException("the model contains no rows"); var height = rows.Count; var width = rows.Max(x => x.Length); diff --git a/Turbo.Rooms/Wired/WiredData.cs b/Turbo.Rooms/Wired/WiredData.cs index bc2307df..1433374d 100644 --- a/Turbo.Rooms/Wired/WiredData.cs +++ b/Turbo.Rooms/Wired/WiredData.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Turbo.Primitives.Rooms.Enums.Wired; using Turbo.Primitives.Rooms.Wired; +using Turbo.Rooms.Exceptions; namespace Turbo.Rooms.Wired; @@ -32,9 +33,7 @@ public T GetIntParam(int index) var rule = _intRules[index]; if (rule.ValueType != typeof(T)) - throw new InvalidOperationException( - $"Param {index} is {rule.ValueType?.Name}, not {typeof(T).Name}" - ); + throw new WiredParamTypeMismatchException(index, rule.ValueType, typeof(T)); return (T)rule.FromInt(IntParams[index]); } @@ -44,9 +43,7 @@ public void SetIntParam(int index, T value) var rule = _intRules[index]; if (rule.ValueType != typeof(T)) - throw new InvalidOperationException( - $"Param {index} is {rule.ValueType?.Name}, not {typeof(T).Name}" - ); + throw new WiredParamTypeMismatchException(index, rule.ValueType, typeof(T)); IntParams[index] = rule.Sanitize(rule.ToInt(value!)); From d768a8d7dad97f0ff1a3f8db050da9cc42bfaf9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:24:56 +0000 Subject: [PATCH 2/2] Stabilize room tick cadence for smooth avatar walk packets The 500ms avatar walk boundary was being hit late by a varying amount each cycle, which shows up in game as choppy walking: consecutive UserUpdateMessageComposer packets arrived anywhere from ~400ms to ~600ms apart. Two independent sources of jitter: 1. The room stream is a pull-based memory stream, and the pulling agent polls its queue every 100ms by default (GetQueueMsgsTimerPeriod). Since walk updates arrive 500ms apart, the agent is always idle when one is published, so every room packet paid a random 0-100ms queue wait. The room stream provider now polls every 10ms. 2. The room tick used a periodic grain timer, and Orleans measures a grain timer's period from the completion of the previous callback, so the effective cadence was RoomTickMs plus the tick body's execution time and the phase drifted continuously relative to the epoch-aligned boundary grid. The timer is now one-shot and re-arms itself to the next epoch-aligned RoomTickMs boundary after each tick (IGrainTimer.Change), so ticks land on the avatar/wired/roller boundaries within scheduler noise. The re-arm sits in a finally block so a failed tick cannot kill the room's tick loop, and a re-arm that lands exactly on a boundary is pushed a full period to avoid double-ticking it. Verified with the full quality gate on SDK 10.0.400: 0 errors, warning count unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01H1WWL4hLiAvJr2jzxoGyaP --- .../HostApplicationBuilderExtensions.cs | 16 ++++++- Turbo.Rooms/Grains/RoomGrain.cs | 45 +++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/Turbo.Main/Extensions/HostApplicationBuilderExtensions.cs b/Turbo.Main/Extensions/HostApplicationBuilderExtensions.cs index 0369e3bf..2fe460a5 100644 --- a/Turbo.Main/Extensions/HostApplicationBuilderExtensions.cs +++ b/Turbo.Main/Extensions/HostApplicationBuilderExtensions.cs @@ -30,7 +30,21 @@ public static HostApplicationBuilder AddOrleans(this HostApplicationBuilder buil .AddMemoryGrainStorage(OrleansStorageNames.PLAYER_STORE) .AddMemoryGrainStorage(OrleansStorageNames.ROOM_STORE) .AddMemoryStreams(OrleansStreamProviders.DEFAULT_STREAM_PROVIDER) - .AddMemoryStreams(OrleansStreamProviders.ROOM_STREAM_PROVIDER); + .AddMemoryStreams( + OrleansStreamProviders.ROOM_STREAM_PROVIDER, + streams => + streams.ConfigurePullingAgent(ob => + ob.Configure(options => + { + // Memory streams are pull-based; the default 100ms poll + // adds up to 100ms of jitter to every room packet, which + // is visible in the avatar walk cadence. + options.GetQueueMsgsTimerPeriod = TimeSpan.FromMilliseconds( + 10 + ); + }) + ) + ); } ) ); diff --git a/Turbo.Rooms/Grains/RoomGrain.cs b/Turbo.Rooms/Grains/RoomGrain.cs index 8e456b2a..1d4ee67e 100644 --- a/Turbo.Rooms/Grains/RoomGrain.cs +++ b/Turbo.Rooms/Grains/RoomGrain.cs @@ -43,6 +43,8 @@ public sealed partial class RoomGrain : Grain, IRoomGrain internal IAsyncStream _roomOutbound = default!; + private IGrainTimer? _roomTimer; + internal readonly RoomLiveState _state; public readonly RoomEventModule EventModule; @@ -124,23 +126,48 @@ public override async Task OnActivateAsync(CancellationToken ct) _roomOutbound = provider.GetStream(streamId); - this.RegisterGrainTimer( + // One-shot timer re-armed to the next epoch-aligned boundary after each tick. + // A periodic grain timer measures its period from the end of the previous callback, + // so tick phase would drift by the callback's execution time and the avatar/wired/roller + // boundaries (all multiples of RoomTickMs from EpochMs) would be crossed late by a + // varying amount each cycle. + _roomTimer = this.RegisterGrainTimer( async (state, ct) => { - var now = NowMs(); - - await AvatarTickSystem.ProcessAvatarsAsync(now, ct); - await WiredSystem.ProcessWiredAsync(now, ct); - await RollerSystem.ProcessRollersAsync(now, ct); - await FlushDirtyTilesAsync(ct); - await FlushDirtyItemsAsync(ct); + try + { + var now = NowMs(); + + await AvatarTickSystem.ProcessAvatarsAsync(now, ct); + await WiredSystem.ProcessWiredAsync(now, ct); + await RollerSystem.ProcessRollersAsync(now, ct); + await FlushDirtyTilesAsync(ct); + await FlushDirtyItemsAsync(ct); + } + finally + { + RearmRoomTimer(); + } }, null, TimeSpan.FromMilliseconds(_roomConfig.RoomTickMs), - TimeSpan.FromMilliseconds(_roomConfig.RoomTickMs) + Timeout.InfiniteTimeSpan ); } + private void RearmRoomTimer() + { + var now = NowMs(); + var next = AlignToNextBoundary(now, _roomConfig.RoomTickMs); + + // AlignToNextBoundary returns `now` when it lands exactly on a boundary; firing again + // with a zero due time would double-tick the same boundary. + if (next <= now) + next = now + _roomConfig.RoomTickMs; + + _roomTimer?.Change(TimeSpan.FromMilliseconds(next - now), Timeout.InfiniteTimeSpan); + } + public override async Task OnDeactivateAsync(DeactivationReason reason, CancellationToken ct) { try