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.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.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/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