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( + // 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 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!));