diff --git a/docs/Bots.md b/docs/Bots.md index 53e4d4e..cd88521 100644 --- a/docs/Bots.md +++ b/docs/Bots.md @@ -238,8 +238,10 @@ hunt the leader's maps. The elf heals, the buffs are shared, the party experience bonus applies. Parties re-form every hour. A player may invite a bot into their own party: it accepts after a human-like -pause of a few seconds, provided the level gap is sane and it is not in the -middle of an errand. A living player takes precedence over the bot's own company +pause of a few seconds, as long as it is not in the middle of an errand. There is +no level gate — just like OpenMU's own party action, a bot accepts an inviter of +any level, since it is the player who invites and the bot leaves once it gets +bored. A living player takes precedence over the bot's own company — a bot hunting with other bots leaves them for the inviter, and breaks that bot party up if it was leading it, so a player never has to guess which bot happens to be free. In a party the bot follows its leader, defers a due reset, and diff --git a/src/Dapr/GameServer.Host/Layout/MainLayout.razor b/src/Dapr/GameServer.Host/Layout/MainLayout.razor index 919fd3e..6d98d8a 100644 --- a/src/Dapr/GameServer.Host/Layout/MainLayout.razor +++ b/src/Dapr/GameServer.Host/Layout/MainLayout.razor @@ -1,12 +1,6 @@ @inherits LayoutComponentBase
-
diff --git a/src/Dapr/GameServer.Host/_Imports.razor b/src/Dapr/GameServer.Host/_Imports.razor index 4a18a36..61d3ed5 100644 --- a/src/Dapr/GameServer.Host/_Imports.razor +++ b/src/Dapr/GameServer.Host/_Imports.razor @@ -9,9 +9,6 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop -@using Blazored.Toast -@using Blazored.Toast.Services - @using BlazorInputFile @using MUnique.OpenMU.Web.Shared diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 91b3051..5c2708a 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,7 +6,6 @@ - diff --git a/src/GameLogic/Bots/BotPartyHandler.cs b/src/GameLogic/Bots/BotPartyHandler.cs index c93e17b..8ce440b 100644 --- a/src/GameLogic/Bots/BotPartyHandler.cs +++ b/src/GameLogic/Bots/BotPartyHandler.cs @@ -11,22 +11,13 @@ using MUnique.OpenMU.GameLogic.Offline; /// ): the invitation is accepted after a short /// human-like delay, and the bot then follows the leader like any party member (see the follow logic /// in ) until it gets bored and politely leaves. Safeguards keep it -/// believable and abuse-free: no grouping across an absurd level gap, no acceptance while the bot is -/// on an errand (shopping trip) or has unfinished business (revenge), and the invitation is -/// re-validated when the delay has passed - the inviter may have joined another party or left. +/// believable and abuse-free: no acceptance while the bot is on an errand (shopping trip) or has +/// unfinished business (revenge), and the invitation is re-validated when the delay has passed - the +/// inviter may have joined another party or left. There is no level gate, matching OpenMU's own party +/// action: it is the player who invites, and the bot leaves again once it gets bored. /// internal static class BotPartyHandler { - /// - /// The maximum difference of the reset-aware effective level (see - /// ) between the bot and the inviter. Within one - /// reset worth of levels plus some slack, hunting together still makes sense for both; grouping a - /// fresh character with a 15-resets veteran would only be a power-leveling service. On servers - /// without the reset feature the plain levels always lie within this bound, matching OpenMU's own - /// party action, which has no level gate at all. - /// - private const int MaxEffectiveLevelGap = 500; - /// Lower bound of the human-like delay before the bot answers an invitation. private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2); @@ -67,7 +58,7 @@ internal static class BotPartyHandler return false; } - if (!IsRequesterEligible(bot, requester)) + if (!IsRequesterEligible(requester)) { return false; } @@ -141,7 +132,7 @@ internal static class BotPartyHandler { // Re-validate: between the invitation and this answer, the bot may have joined a human's party // and the inviter may have died, left the game or joined another party. - if (HasHumanCompanion(bot) || !IsRequesterEligible(bot, requester)) + if (HasHumanCompanion(bot) || !IsRequesterEligible(requester)) { bot.Logger.LogInformation("Bot '{Name}' dropped the party invitation of '{Requester}' - the situation changed.", bot.Name, requester.Name); return; @@ -209,14 +200,8 @@ internal static class BotPartyHandler await party.KickMySelfAsync(bot).ConfigureAwait(false); } - private static bool IsRequesterEligible(OfflinePlayer bot, Player requester) + private static bool IsRequesterEligible(Player requester) { - if (!requester.IsAlive || requester.PlayerState.CurrentState != PlayerState.EnteredWorld) - { - return false; - } - - var levelGap = Math.Abs(BotResetHandler.GetEffectiveLevel(bot) - BotResetHandler.GetEffectiveLevel(requester)); - return levelGap <= MaxEffectiveLevelGap; + return requester.IsAlive && requester.PlayerState.CurrentState == PlayerState.EnteredWorld; } } diff --git a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs index 73bc9a1..c36bb58 100644 --- a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs +++ b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs @@ -141,7 +141,11 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable { try { - await this.TickAsync(cancellationToken).ConfigureAwait(false); + // Run the whole tick under the player's persistence lock so its structural mutations + // (loot pickup, combat ammo/pet destruction, queued equip/jewel actions) never overlap + // this bot's periodic progress save, which runs on a separate timer. The tick has no + // internal delays, so the lock is held only for its brief duration. + await this._player.RunPersistenceExclusiveAsync(() => this.TickAsync(cancellationToken)).ConfigureAwait(false); this._player.OnAiTickSucceeded(); } catch (OperationCanceledException) diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index 5b9928b..6e55ec8 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -54,6 +54,21 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke private readonly AsyncLock _moveLock = new(); private readonly AsyncLock _experienceLock = new(); + /// + /// Serializes context mutations done by this player's action handlers against the periodic and + /// disconnect progress saves, which run on an independent timer flow. See + /// . + /// + private readonly AsyncLock _persistenceLock = new(); + + /// + /// Tracks, per asynchronous flow, whether is already held, so the + /// lock can be re-entered (Nito's is not reentrant). It is an instance + /// field on purpose: reentrancy must be tracked per player, so a flow holding player A's lock + /// still acquires player B's lock (e.g. during a trade) instead of wrongly skipping it. + /// + private readonly AsyncLocal _persistenceLockHeld = new(); + private readonly Walker _walker; private readonly AppearanceDataAdapter _appearanceData; @@ -1879,12 +1894,86 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke /// Success of the save operation. public async ValueTask SaveProgressAsync(CancellationToken cancellationToken = default) { - if (!this.IsTemplatePlayer) + if (this.IsTemplatePlayer) { - return await this.PersistenceContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return true; } - return true; + return await this.RunPersistenceExclusiveAsync( + () => this.PersistenceContext.SaveChangesAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Runs the given operation while holding this player's persistence lock, so that context + /// mutations and progress saves for the player never run concurrently. + /// + /// + /// The periodic progress save () runs on an + /// independent timer flow. Action handlers mutate tracked entities with plain field/collection + /// writes (e.g. crafting toggling item.ItemOptions) which bypass the persistence context's + /// own lock; if such a mutation runs while enumerates the + /// change tracker, the save throws (collection-modified / DbUpdateConcurrency) and every following + /// save fails too, so the whole session is lost on relog. Serializing the packet handler funnel + /// and the save against each other closes that window. The lock is re-entrant per asynchronous + /// flow, so an inline save inside an already-serialized handler does not deadlock. + /// + /// Invariant: never acquire another player's persistence lock (via their + /// or ) from inside a + /// packet handler, which already holds this player's lock, unless a global lock order is enforced. + /// Today only the trade accept does a cross-player save, and it cannot form a cycle because a trade + /// has a single accepting side (so the A-then-B acquisition order has no concurrent B-then-A + /// counterpart). A second cross-player caller with the opposite order could deadlock. + /// + /// + /// The result type of the operation. + /// The operation to run exclusively. + /// The cancellation token. + /// The result of the operation. + public async ValueTask RunPersistenceExclusiveAsync(Func> operation, CancellationToken cancellationToken = default) + { + if (this._persistenceLockHeld.Value) + { + return await operation().ConfigureAwait(false); + } + + using var l = await this._persistenceLock.LockAsync(cancellationToken).ConfigureAwait(false); + this._persistenceLockHeld.Value = true; + try + { + return await operation().ConfigureAwait(false); + } + finally + { + this._persistenceLockHeld.Value = false; + } + } + + /// + /// Runs the given operation while holding this player's persistence lock. + /// See for the rationale. + /// + /// The operation to run exclusively. + /// The cancellation token. + /// A value task which completes when the operation completed. + public async ValueTask RunPersistenceExclusiveAsync(Func operation, CancellationToken cancellationToken = default) + { + if (this._persistenceLockHeld.Value) + { + await operation().ConfigureAwait(false); + return; + } + + using var l = await this._persistenceLock.LockAsync(cancellationToken).ConfigureAwait(false); + this._persistenceLockHeld.Value = true; + try + { + await operation().ConfigureAwait(false); + } + finally + { + this._persistenceLockHeld.Value = false; + } } /// diff --git a/src/GameServer/RemoteView/RemotePlayer.cs b/src/GameServer/RemoteView/RemotePlayer.cs index 2c0b584..d7687f7 100644 --- a/src/GameServer/RemoteView/RemotePlayer.cs +++ b/src/GameServer/RemoteView/RemotePlayer.cs @@ -151,7 +151,7 @@ public class RemotePlayer : Player, IClientVersionProvider, IHasIpAddress this.Logger.LogDebug("[C->S] {0}", buffer.ToArray().AsString()); } - await this.MainPacketHandler.HandlePacketAsync(this, buffer).ConfigureAwait(false); + await this.RunPersistenceExclusiveAsync(() => this.MainPacketHandler.HandlePacketAsync(this, buffer)).ConfigureAwait(false); } finally { diff --git a/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs b/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs index ff16a74..459443d 100644 --- a/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs +++ b/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs @@ -92,69 +92,6 @@ internal class EntityFrameworkContextBase : IContext } } - /// - /// Determines whether the exception is a transient conflict caused by a concurrent entity mutation - /// racing this save, and is therefore worth retrying. - /// - /// The exception thrown by the save. - /// true if the save should be retried. - private static bool IsTransientConcurrencyConflict(Exception exception) - { - // A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration. - // Depending on exactly where change detection was, it surfaces as one of several types - a - // modified collection (InvalidOperationException), a transiently-null internal key - // (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient: - // the racing mutation is a single quick operation, so a bounded retry lands on a stable moment. - // A genuinely persistent error of the same type is not masked - it rethrows once the retries - // are exhausted. The deterministic serialization (per-player persistence lock) is the primary - // guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for. - return exception is DbUpdateConcurrencyException - or InvalidOperationException - or ArgumentNullException - or NullReferenceException - or IndexOutOfRangeException - or KeyNotFoundException; - } - - private async ValueTask SaveChangesCoreAsync(CancellationToken cancellationToken) - { - using var l = await this._lock.LockAsync(); - - // when we have a change publisher attached, we want to get the changed entries before accepting them. - // Otherwise, we can accept them. - var acceptChanges = true; - - object? sender = null; - SavedChangesEventArgs? args = null; - if (this._changeListener is { }) - { - this.Context.SavedChanges += OnSavedChanges; - acceptChanges = false; - } - - try - { - await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false); - - if (args is not null) - { - await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false); - } - } - finally - { - this.Context.SavedChanges -= OnSavedChanges; - } - - return true; - - void OnSavedChanges(object? s, SavedChangesEventArgs e) - { - sender = s; - args = e; - } - } - /// public IDisposable SuspendChangeNotifications() { @@ -323,6 +260,69 @@ internal class EntityFrameworkContextBase : IContext this.Context.Dispose(); } + /// + /// Determines whether the exception is a transient conflict caused by a concurrent entity mutation + /// racing this save, and is therefore worth retrying. + /// + /// The exception thrown by the save. + /// true if the save should be retried. + private static bool IsTransientConcurrencyConflict(Exception exception) + { + // A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration. + // Depending on exactly where change detection was, it surfaces as one of several types - a + // modified collection (InvalidOperationException), a transiently-null internal key + // (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient: + // the racing mutation is a single quick operation, so a bounded retry lands on a stable moment. + // A genuinely persistent error of the same type is not masked - it rethrows once the retries + // are exhausted. The deterministic serialization (per-player persistence lock) is the primary + // guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for. + return exception is DbUpdateConcurrencyException + or InvalidOperationException + or ArgumentNullException + or NullReferenceException + or IndexOutOfRangeException + or KeyNotFoundException; + } + + private async ValueTask SaveChangesCoreAsync(CancellationToken cancellationToken) + { + using var l = await this._lock.LockAsync(); + + // when we have a change publisher attached, we want to get the changed entries before accepting them. + // Otherwise, we can accept them. + var acceptChanges = true; + + object? sender = null; + SavedChangesEventArgs? args = null; + if (this._changeListener is { }) + { + this.Context.SavedChanges += OnSavedChanges; + acceptChanges = false; + } + + try + { + await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false); + + if (args is not null) + { + await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false); + } + } + finally + { + this.Context.SavedChanges -= OnSavedChanges; + } + + return true; + + void OnSavedChanges(object? s, SavedChangesEventArgs e) + { + sender = s; + args = e; + } + } + private bool DetachInternal(object item) { var entry = this.Context.Entry(item); diff --git a/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs b/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs index d1bceb0..d40faa7 100644 --- a/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs +++ b/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs @@ -34,7 +34,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations TaxStore = table.Column(type: "smallint", nullable: false), TaxHunt = table.Column(type: "integer", nullable: false), IsHuntZoneEnabled = table.Column(type: "boolean", nullable: false), - TributeMoney = table.Column(type: "bigint", nullable: false) + TributeMoney = table.Column(type: "bigint", nullable: false), }, constraints: table => { @@ -53,7 +53,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations DefenseLevel = table.Column(type: "smallint", nullable: false), RegenLevel = table.Column(type: "smallint", nullable: false), LifeLevel = table.Column(type: "smallint", nullable: false), - CurrentHp = table.Column(type: "integer", nullable: false) + CurrentHp = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -87,7 +87,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations GuildScoreCastleSiege = table.Column(type: "integer", nullable: false), GuildScoreCastleSiegeMembers = table.Column(type: "integer", nullable: false), GateBuyPrice = table.Column(type: "integer", nullable: false), - StatueBuyPrice = table.Column(type: "integer", nullable: false) + StatueBuyPrice = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -125,7 +125,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations DefaultSide = table.Column(type: "smallint", nullable: false), SpawnX = table.Column(type: "smallint", nullable: false), SpawnY = table.Column(type: "smallint", nullable: false), - Direction = table.Column(type: "integer", nullable: false) + Direction = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -155,7 +155,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations State = table.Column(type: "smallint", nullable: false), DayOfWeek = table.Column(type: "integer", nullable: false), Hour = table.Column(type: "smallint", nullable: false), - Minute = table.Column(type: "smallint", nullable: false) + Minute = table.Column(type: "smallint", nullable: false), }, constraints: table => { @@ -183,7 +183,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations Level = table.Column(type: "smallint", nullable: false), RequiredJewelOfGuardianCount = table.Column(type: "integer", nullable: false), RequiredZen = table.Column(type: "integer", nullable: false), - Value = table.Column(type: "integer", nullable: false) + Value = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -236,7 +236,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations X1 = table.Column(type: "smallint", nullable: false), Y1 = table.Column(type: "smallint", nullable: false), X2 = table.Column(type: "smallint", nullable: false), - Y2 = table.Column(type: "smallint", nullable: false) + Y2 = table.Column(type: "smallint", nullable: false), }, constraints: table => { diff --git a/src/Web/AdminPanel/Components/Layout/CreationPanel.razor b/src/Web/AdminPanel/Components/Layout/CreationPanel.razor index 855e43d..e6dad5d 100644 --- a/src/Web/AdminPanel/Components/Layout/CreationPanel.razor +++ b/src/Web/AdminPanel/Components/Layout/CreationPanel.razor @@ -8,7 +8,7 @@ @implements IDisposable @inject CreationPanelService Panel -@inject Blazored.Toast.Services.IToastService ToastService +@inject IToastService ToastService @if (this.Panel.Current is { } session) { diff --git a/src/Web/AdminPanel/Components/Layout/MainLayout.razor b/src/Web/AdminPanel/Components/Layout/MainLayout.razor index 429c724..ff9f4f4 100644 --- a/src/Web/AdminPanel/Components/Layout/MainLayout.razor +++ b/src/Web/AdminPanel/Components/Layout/MainLayout.razor @@ -39,7 +39,7 @@
- +
@Body diff --git a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj index 276cb71..a974a32 100644 --- a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj +++ b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj @@ -25,7 +25,6 @@ - diff --git a/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs b/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs index 48c4497..0489c52 100644 --- a/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs +++ b/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs @@ -6,12 +6,12 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.ComponentModel.DataAnnotations; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs b/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs index a5597d6..b7a45f9 100644 --- a/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs +++ b/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs @@ -6,13 +6,13 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.ComponentModel.DataAnnotations; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; using MUnique.OpenMU.Web.Shared.Components.Modal; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/EditBase.cs b/src/Web/AdminPanel/Pages/EditBase.cs index d180a72..f5cc8bd 100644 --- a/src/Web/AdminPanel/Pages/EditBase.cs +++ b/src/Web/AdminPanel/Pages/EditBase.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.Reflection; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Routing; @@ -19,6 +18,7 @@ using MUnique.OpenMU.Web.AdminPanel.Properties; using MUnique.OpenMU.Web.Shared; using MUnique.OpenMU.Web.Shared.Components; using MUnique.OpenMU.Web.Shared.Components.Modal; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs b/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs index 5479006..e95056e 100644 --- a/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs +++ b/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs @@ -8,7 +8,6 @@ using System.Collections; using System.ComponentModel; using System.Reflection; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.QuickGrid; using Microsoft.Extensions.Logging; @@ -18,6 +17,7 @@ using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; using MUnique.OpenMU.Web.Shared; using MUnique.OpenMU.Web.Shared.Components.Modal; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/EditMap.cs b/src/Web/AdminPanel/Pages/EditMap.cs index 477a1d9..9700e16 100644 --- a/src/Web/AdminPanel/Pages/EditMap.cs +++ b/src/Web/AdminPanel/Pages/EditMap.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.Reflection; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Routing; @@ -19,6 +18,7 @@ using MUnique.OpenMU.Web.Shared; using MUnique.OpenMU.Web.Shared.Components; using MUnique.OpenMU.Web.Shared.Components.MapEditor; using MUnique.OpenMU.Web.Shared.Components.Modal; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/LogFiles.razor b/src/Web/AdminPanel/Pages/LogFiles.razor index 5904925..49ecc4a 100644 --- a/src/Web/AdminPanel/Pages/LogFiles.razor +++ b/src/Web/AdminPanel/Pages/LogFiles.razor @@ -1,57 +1,434 @@ @page "/logfiles" @using System.IO +@using Microsoft.Extensions.Logging @using MUnique.OpenMU.Web.AdminPanel.Properties +@implements IAsyncDisposable +@inject IJSRuntime JSRuntime +@inject ILogger Logger OpenMU: @Resources.LogFiles -
- - - - - - - - - - @foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime)) - { - - - - - - } - -
@Resources.FileName@Resources.LastUpdate@Resources.Size
- @entry.Name - @entry.LastWriteTime@FormatFileSize(entry.Length)
+ +
+ +
+
+
+
+ @Resources.LogFiles + +
+
+
+ + + + + @if (this._selectedFile == null) + { + + + } + + + + + @foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime)) + { + var isSelected = this._selectedFile?.FullName == entry.FullName; + + + @if (this._selectedFile == null) + { + + + } + + + } + +
@Resources.FileName@Resources.LastUpdate@Resources.Size@Resources.Actions
+ + @if (this._selectedFile != null) + { +
@FormatFileSize(entry.Length)
+ } +
@entry.LastWriteTime@FormatFileSize(entry.Length) + + + +
+
+
+
+ + + @if (this._selectedFile != null) + { +
+
+
+
+ + @Resources.LogViewer: + @this._selectedFile.Name +
+
+
+ + +
+ + +
+
+
+
+
+
+ + + @if (!string.IsNullOrEmpty(this._searchText)) + { + + } +
+
+
+ +
+ @if (this._logLines.Count == 0) + { +
@Resources.NoLogEntriesFound
+ } + else if (this._filteredLines.Count == 0) + { +
@Resources.NoLogEntriesMatchFilter
+ } + else + { + @foreach (var line in this._filteredLines) + { +
@line
+ } + } +
+ +
+
+ @string.Format(Resources.ShowingXOfYLines, this._filteredLines.Count, this._logLines.Count, MaxLogLinesToRead) +
+ +
+
+
+
+ }
@code { - private readonly List _files = new (); + private const int MaxLogLinesToRead = 300; + private const long LogReadBufferSizeBytes = 102400; // 100 KB + private const int LiveUpdateIntervalMs = 2000; + private const string TerminalElementId = "log-terminal"; - /// - /// Initializes a new instance of class . - /// - public LogFiles() + private readonly List _files = new(); + private FileInfo? _selectedFile; + private long _lastFileLength; + private DateTime _lastFileWriteTime; + private List _logLines = new(); + private List _filteredLines = new(); + private string _searchText = string.Empty; + private bool _liveUpdate; + private System.Threading.Timer? _timer; + private bool _shouldScrollToBottom; + private bool _disposed; + private IJSObjectReference? _jsModule; + + /// + public async ValueTask DisposeAsync() { - var files = Directory.GetFiles(Path.Combine(Directory.GetCurrentDirectory(), "logs")); - foreach (var filePath in files) + this._disposed = true; + this._timer?.Dispose(); + if (this._jsModule != null) { - this._files.Add(new FileInfo(filePath)); + try + { + await this._jsModule.DisposeAsync(); + } + catch (JSDisconnectedException) + { + // The circuit is already gone, so the module is disposed anyway. + } } } - private string FormatFileSize(long size) + /// + protected override void OnInitialized() + { + this.RefreshFileList(); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + this._jsModule = await this.JSRuntime.InvokeAsync("import", "./Pages/LogFiles.razor.js"); + } + catch (JSException ex) + { + // Without the module, the viewer still works - only the automatic scrolling is unavailable. + this.Logger.LogWarning(ex, "Could not load the log viewer javascript module."); + } + catch (JSDisconnectedException) + { + // The circuit is gone; nothing to do. + } + } + + if (this._shouldScrollToBottom) + { + this._shouldScrollToBottom = false; + await this.ScrollToBottomAsync(); + } + } + + private static string FormatFileSize(long size) { return size switch { - (< 1024 << 10) => $"{Math.Round(size / 1024D, 2)} KiB", - (< 1024 << 20) => $"{Math.Round(size * 1D / (1024 << 10), 2)} MiB", - (< 1024L << 30) => $"{Math.Round(size * 1D / (1024L << 20), 2)} GiB", - _ => $"{size} bytes" - }; + < 1024 => $"{size} bytes", + < 1024 * 1024 => $"{Math.Round(size / 1024D, 2)} KiB", + < 1024L * 1024 * 1024 => $"{Math.Round(size / (1024D * 1024D), 2)} MiB", + _ => $"{Math.Round(size / (1024D * 1024D * 1024D), 2)} GiB", + }; + } + + private static List ReadLastLines(string path, int maxLines) + { + var lines = new List(); + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + long offset = Math.Max(0, fs.Length - LogReadBufferSizeBytes); + fs.Seek(offset, SeekOrigin.Begin); + using var reader = new StreamReader(fs, System.Text.Encoding.UTF8); + + if (offset > 0) + { + // Discard partial line + reader.ReadLine(); + } + + string? line; + while ((line = reader.ReadLine()) != null) + { + lines.Add(line); + } + + if (lines.Count > maxLines) + { + lines = lines.Skip(lines.Count - maxLines).ToList(); + } + } + catch (Exception ex) + { + lines.Add($"Error reading log file: {ex.Message}"); + } + + return lines; + } + + private static string GetLineColorStyle(string line) + { + if (line.Contains("[Error]", StringComparison.OrdinalIgnoreCase) || line.Contains("[Critical]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #ff6b6b; font-weight: bold;"; + } + + if (line.Contains("[Warning]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #feca57;"; + } + + if (line.Contains("[Debug]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #8a8d93; font-style: italic;"; + } + + if (line.Contains("[Information]", StringComparison.OrdinalIgnoreCase)) + { + return "color: #1dd1a1;"; + } + + return "color: #d1d2d6;"; + } + + private void RefreshFileList() + { + this._files.Clear(); + var logsPath = Path.Combine(Directory.GetCurrentDirectory(), "logs"); + if (Directory.Exists(logsPath)) + { + var files = Directory.GetFiles(logsPath); + foreach (var filePath in files) + { + this._files.Add(new FileInfo(filePath)); + } + } + } + + private void SelectFile(FileInfo file) + { + this._selectedFile = file; + this._searchText = string.Empty; + this._lastFileLength = -1; + this.RefreshLogLines(); + this._shouldScrollToBottom = true; + this.SetupTimer(); + } + + private void CloseViewer() + { + this._selectedFile = null; + this._searchText = string.Empty; + this._logLines.Clear(); + this._filteredLines.Clear(); + this._liveUpdate = false; + this.SetupTimer(); + } + + private void ClearSearch() + { + this._searchText = string.Empty; + this.UpdateFilteredLines(); + } + + private void OnSearchInput(ChangeEventArgs e) + { + this._searchText = e.Value?.ToString() ?? string.Empty; + this.UpdateFilteredLines(); + } + + private void ToggleLiveUpdate(ChangeEventArgs e) + { + this._liveUpdate = (bool)(e.Value ?? false); + this.SetupTimer(); + } + + private void SetupTimer() + { + if (this._liveUpdate && this._selectedFile != null) + { + this._timer ??= new System.Threading.Timer(_ => + { + if (this._disposed) + { + return; + } + + this.InvokeAsync(async () => + { + if (this._disposed || this._selectedFile == null) + { + return; + } + + var updatedInfo = new FileInfo(this._selectedFile.FullName); + if (updatedInfo.Length == this._lastFileLength && updatedInfo.LastWriteTimeUtc == this._lastFileWriteTime) + { + return; + } + + // Only follow the new entries when the user didn't scroll up to read the history. + var isFollowing = await this.IsScrolledToBottomAsync(); + this.RefreshLogLines(); + this._shouldScrollToBottom = isFollowing; + this.StateHasChanged(); + }); + }, null, 0, LiveUpdateIntervalMs); + } + else + { + this._timer?.Dispose(); + this._timer = null; + } + } + + private void RefreshLogLines() + { + if (this._selectedFile == null) + { + return; + } + + var fileInfo = new FileInfo(this._selectedFile.FullName); + this._selectedFile = fileInfo; + this._lastFileLength = fileInfo.Length; + this._lastFileWriteTime = fileInfo.LastWriteTimeUtc; + this._logLines = ReadLastLines(fileInfo.FullName, MaxLogLinesToRead); + this.UpdateFilteredLines(); + } + + private void UpdateFilteredLines() + { + if (string.IsNullOrWhiteSpace(this._searchText)) + { + this._filteredLines = this._logLines; + } + else + { + this._filteredLines = this._logLines + .Where(line => line.Contains(this._searchText, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + } + + private async Task ScrollToBottomAsync() + { + if (this._jsModule is null) + { + return; + } + + try + { + await this._jsModule.InvokeVoidAsync("scrollToBottom", TerminalElementId); + } + catch (JSDisconnectedException) + { + // The circuit is gone; nothing to do. + } + } + + private async ValueTask IsScrolledToBottomAsync() + { + if (this._jsModule is null) + { + return true; + } + + try + { + return await this._jsModule.InvokeAsync("isScrolledToBottom", TerminalElementId); + } + catch (JSDisconnectedException) + { + return false; + } } } diff --git a/src/Web/AdminPanel/Pages/LogFiles.razor.js b/src/Web/AdminPanel/Pages/LogFiles.razor.js new file mode 100644 index 0000000..4b2efcd --- /dev/null +++ b/src/Web/AdminPanel/Pages/LogFiles.razor.js @@ -0,0 +1,14 @@ +export function scrollToBottom(elementId) { + const el = document.getElementById(elementId); + if (el) { + el.scrollTop = el.scrollHeight; + } +} + +export function isScrolledToBottom(elementId) { + const el = document.getElementById(elementId); + if (el) { + return Math.abs(el.scrollHeight - el.clientHeight - el.scrollTop) < 50; + } + return true; +} diff --git a/src/Web/AdminPanel/Pages/Merchants.razor.cs b/src/Web/AdminPanel/Pages/Merchants.razor.cs index 76bd696..62728f6 100644 --- a/src/Web/AdminPanel/Pages/Merchants.razor.cs +++ b/src/Web/AdminPanel/Pages/Merchants.razor.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.ComponentModel; using System.Threading; -using Blazored.Toast.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.QuickGrid; using Microsoft.AspNetCore.Components.Routing; @@ -16,6 +15,7 @@ using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/Servers.razor b/src/Web/AdminPanel/Pages/Servers.razor index 0c1c70b..595ca2d 100644 --- a/src/Web/AdminPanel/Pages/Servers.razor +++ b/src/Web/AdminPanel/Pages/Servers.razor @@ -2,7 +2,6 @@ @using System.ComponentModel @using Microsoft.Extensions.DependencyInjection -@using Blazored.Toast.Services @using MUnique.OpenMU.Interfaces @using MUnique.OpenMU.Web.AdminPanel.Properties diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 0b344f8..1b469d4 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -249,6 +249,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Close. + /// + public static string Close { + get { + return ResourceManager.GetString("Close", resourceCulture); + } + } + /// /// Looks up a localized string similar to Command. /// @@ -468,6 +477,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Download File. + /// + public static string DownloadFile { + get { + return ResourceManager.GetString("DownloadFile", resourceCulture); + } + } + /// /// Looks up a localized string similar to Drop item groups. /// @@ -558,6 +576,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Filter log entries.... + /// + public static string FilterLogEntries { + get { + return ResourceManager.GetString("FilterLogEntries", resourceCulture); + } + } + /// /// Looks up a localized string similar to Finished! Have fun :). /// @@ -774,6 +801,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Live. + /// + public static string Live { + get { + return ResourceManager.GetString("Live", resourceCulture); + } + } + /// /// Looks up a localized string similar to Live Map. /// @@ -819,6 +855,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Log Viewer. + /// + public static string LogViewer { + get { + return ResourceManager.GetString("LogViewer", resourceCulture); + } + } + /// /// Looks up a localized string similar to Major. /// @@ -972,6 +1017,24 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to No log entries found.. + /// + public static string NoLogEntriesFound { + get { + return ResourceManager.GetString("NoLogEntriesFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No log entries match your filter.. + /// + public static string NoLogEntriesMatchFilter { + get { + return ResourceManager.GetString("NoLogEntriesMatchFilter", resourceCulture); + } + } + /// /// Looks up a localized string similar to This command has no parameters.. /// @@ -1197,6 +1260,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Reload File List. + /// + public static string ReloadFileList { + get { + return ResourceManager.GetString("ReloadFileList", resourceCulture); + } + } + /// /// Looks up a localized string similar to Remove. /// @@ -1260,6 +1332,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Scroll to Bottom. + /// + public static string ScrollToBottom { + get { + return ResourceManager.GetString("ScrollToBottom", resourceCulture); + } + } + /// /// Looks up a localized string similar to Search. /// @@ -1377,6 +1458,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties { } } + /// + /// Looks up a localized string similar to Showing {0} of {1} lines (Last {2} lines loaded).. + /// + public static string ShowingXOfYLines { + get { + return ResourceManager.GetString("ShowingXOfYLines", resourceCulture); + } + } + /// /// Looks up a localized string similar to Size. /// diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index 000bb99..29673ee 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -1,4 +1,4 @@ - +