Merge branch 'integrate/openmu-20260813a'

OpenMU upstream Dalga A: d4ca915c4..a7412572c aralığındaki düşük riskli
sekiz PR. #858, #854, #845 ve #864 uygulandı; #754 ve #860'ın gövdesi zaten
ağaçtaydı; #859 boş çıktığı için atlandı.

Ayrıca #860'ın eksik kalan testi ve #845'in düşürdüğü chat command kaynak
dizeleri tamamlandı.

1.295 test başarılı, 6 atlandı, 0 hata.
This commit is contained in:
Acentech Dev
2026-08-13 09:03:28 +03:00
40 changed files with 1381 additions and 176 deletions

View File

@@ -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

View File

@@ -1,12 +1,6 @@
@inherits LayoutComponentBase
<div class="page">
<!--
<div class="sidebar">
<NavMenu />
</div>
<BlazoredToasts />
-->
<main>
<div class="top-row px-4">
<BreadcrumbNavigation />

View File

@@ -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

View File

@@ -6,7 +6,6 @@
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="BlazorInputFile" Version="0.2.0" />
<PackageVersion Include="Blazored.Toast" Version="4.2.1" />
<PackageVersion Include="BuildWebCompiler2022" Version="1.14.15" />
<PackageVersion Include="DG.AdvancedDataGridView" Version="1.2.30115.18" />
<PackageVersion Include="Dapr.AspNetCore" Version="1.16.1" />

View File

@@ -11,22 +11,13 @@ using MUnique.OpenMU.GameLogic.Offline;
/// <see cref="BotMuHelperSettings.AutoAcceptAnyone"/>): 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 <see cref="BotNavigator"/>) 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.
/// </summary>
internal static class BotPartyHandler
{
/// <summary>
/// The maximum difference of the reset-aware effective level (see
/// <see cref="BotResetHandler.GetEffectiveLevel"/>) 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.
/// </summary>
private const int MaxEffectiveLevelGap = 500;
/// <summary>Lower bound of the human-like delay before the bot answers an invitation.</summary>
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;
}
}

View File

@@ -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)

View File

@@ -54,6 +54,21 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
private readonly AsyncLock _moveLock = new();
private readonly AsyncLock _experienceLock = new();
/// <summary>
/// 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
/// <see cref="RunPersistenceExclusiveAsync{T}"/>.
/// </summary>
private readonly AsyncLock _persistenceLock = new();
/// <summary>
/// Tracks, per asynchronous flow, whether <see cref="_persistenceLock"/> is already held, so the
/// lock can be re-entered (Nito's <see cref="AsyncLock"/> 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.
/// </summary>
private readonly AsyncLocal<bool> _persistenceLockHeld = new();
private readonly Walker _walker;
private readonly AppearanceDataAdapter _appearanceData;
@@ -1879,12 +1894,86 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
/// <returns>Success of the save operation.</returns>
public async ValueTask<bool> 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);
}
/// <summary>
/// Runs the given operation while holding this player's persistence lock, so that context
/// mutations and progress saves for the player never run concurrently.
/// </summary>
/// <remarks>
/// The periodic progress save (<see cref="PlugIns.PeriodicSaveProgressPlugIn"/>) runs on an
/// independent timer flow. Action handlers mutate tracked entities with plain field/collection
/// writes (e.g. crafting toggling <c>item.ItemOptions</c>) which bypass the persistence context's
/// own lock; if such a mutation runs while <see cref="IContext.SaveChangesAsync"/> 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.
/// <para>
/// Invariant: never acquire another player's persistence lock (via their
/// <see cref="SaveProgressAsync"/> or <see cref="RunPersistenceExclusiveAsync{T}"/>) 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.
/// </para>
/// </remarks>
/// <typeparam name="T">The result type of the operation.</typeparam>
/// <param name="operation">The operation to run exclusively.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The result of the operation.</returns>
public async ValueTask<T> RunPersistenceExclusiveAsync<T>(Func<ValueTask<T>> 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;
}
}
/// <summary>
/// Runs the given operation while holding this player's persistence lock.
/// See <see cref="RunPersistenceExclusiveAsync{T}"/> for the rationale.
/// </summary>
/// <param name="operation">The operation to run exclusively.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A value task which completes when the operation completed.</returns>
public async ValueTask RunPersistenceExclusiveAsync(Func<ValueTask> 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;
}
}
/// <summary>

View File

@@ -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
{

View File

@@ -92,69 +92,6 @@ internal class EntityFrameworkContextBase : IContext
}
}
/// <summary>
/// Determines whether the exception is a transient conflict caused by a concurrent entity mutation
/// racing this save, and is therefore worth retrying.
/// </summary>
/// <param name="exception">The exception thrown by the save.</param>
/// <returns><c>true</c> if the save should be retried.</returns>
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<bool> 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;
}
}
/// <inheritdoc />
public IDisposable SuspendChangeNotifications()
{
@@ -323,6 +260,69 @@ internal class EntityFrameworkContextBase : IContext
this.Context.Dispose();
}
/// <summary>
/// Determines whether the exception is a transient conflict caused by a concurrent entity mutation
/// racing this save, and is therefore worth retrying.
/// </summary>
/// <param name="exception">The exception thrown by the save.</param>
/// <returns><c>true</c> if the save should be retried.</returns>
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<bool> 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);

View File

@@ -34,7 +34,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
TaxStore = table.Column<byte>(type: "smallint", nullable: false),
TaxHunt = table.Column<int>(type: "integer", nullable: false),
IsHuntZoneEnabled = table.Column<bool>(type: "boolean", nullable: false),
TributeMoney = table.Column<long>(type: "bigint", nullable: false)
TributeMoney = table.Column<long>(type: "bigint", nullable: false),
},
constraints: table =>
{
@@ -53,7 +53,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
DefenseLevel = table.Column<byte>(type: "smallint", nullable: false),
RegenLevel = table.Column<byte>(type: "smallint", nullable: false),
LifeLevel = table.Column<byte>(type: "smallint", nullable: false),
CurrentHp = table.Column<int>(type: "integer", nullable: false)
CurrentHp = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
@@ -87,7 +87,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
GuildScoreCastleSiege = table.Column<int>(type: "integer", nullable: false),
GuildScoreCastleSiegeMembers = table.Column<int>(type: "integer", nullable: false),
GateBuyPrice = table.Column<int>(type: "integer", nullable: false),
StatueBuyPrice = table.Column<int>(type: "integer", nullable: false)
StatueBuyPrice = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
@@ -125,7 +125,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
DefaultSide = table.Column<byte>(type: "smallint", nullable: false),
SpawnX = table.Column<byte>(type: "smallint", nullable: false),
SpawnY = table.Column<byte>(type: "smallint", nullable: false),
Direction = table.Column<int>(type: "integer", nullable: false)
Direction = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
@@ -155,7 +155,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
State = table.Column<byte>(type: "smallint", nullable: false),
DayOfWeek = table.Column<int>(type: "integer", nullable: false),
Hour = table.Column<byte>(type: "smallint", nullable: false),
Minute = table.Column<byte>(type: "smallint", nullable: false)
Minute = table.Column<byte>(type: "smallint", nullable: false),
},
constraints: table =>
{
@@ -183,7 +183,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
Level = table.Column<byte>(type: "smallint", nullable: false),
RequiredJewelOfGuardianCount = table.Column<int>(type: "integer", nullable: false),
RequiredZen = table.Column<int>(type: "integer", nullable: false),
Value = table.Column<int>(type: "integer", nullable: false)
Value = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
@@ -236,7 +236,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
X1 = table.Column<byte>(type: "smallint", nullable: false),
Y1 = table.Column<byte>(type: "smallint", nullable: false),
X2 = table.Column<byte>(type: "smallint", nullable: false),
Y2 = table.Column<byte>(type: "smallint", nullable: false)
Y2 = table.Column<byte>(type: "smallint", nullable: false),
},
constraints: table =>
{

View File

@@ -8,7 +8,7 @@
@implements IDisposable
@inject CreationPanelService Panel
@inject Blazored.Toast.Services.IToastService ToastService
@inject IToastService ToastService
@if (this.Panel.Current is { } session)
{

View File

@@ -39,7 +39,7 @@
</div>
</div>
<BlazoredToasts />
<ToastContainer />
<article class="content px-4 py-3">
@Body

View File

@@ -25,7 +25,6 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="Blazored.Toast" />
<PackageReference Include="BlazorInputFile" />
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" />
<PackageReference Include="Nito.AsyncEx" />

View File

@@ -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;
/// <summary>

View File

@@ -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;
/// <summary>

View File

@@ -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;
/// <summary>

View File

@@ -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;
/// <summary>

View File

@@ -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;
/// <summary>

View File

@@ -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<LogFiles> Logger
<PageTitle>OpenMU: @Resources.LogFiles</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.LogFiles"/>
<div>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>@Resources.FileName</th>
<th>@Resources.LastUpdate</th>
<th>@Resources.Size</th>
</tr>
</thead>
<tbody>
@foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime))
{
<tr>
<td>
<a href="logs/@entry.Name">@entry.Name</a>
</td>
<td>@entry.LastWriteTime</td>
<td>@FormatFileSize(entry.Length)</td>
</tr>
}
</tbody>
</table>
<div class="row">
<!-- Left Column: File List -->
<div class="@(this._selectedFile != null ? "col-lg-3 col-md-4" : "col-12") transition-all">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-light py-2 px-3">
<div class="d-flex justify-content-between align-items-center">
<strong class="m-0">@Resources.LogFiles</strong>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" @onclick="this.RefreshFileList" title="@Resources.ReloadFileList">
<span class="oi oi-reload" style="font-size: 11px;"></span>
</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 620px; overflow-y: auto;">
<table class="table table-striped table-hover mb-0">
<thead class="table-light">
<tr>
<th>@Resources.FileName</th>
@if (this._selectedFile == null)
{
<th>@Resources.LastUpdate</th>
<th>@Resources.Size</th>
}
<th class="text-end px-3">@Resources.Actions</th>
</tr>
</thead>
<tbody>
@foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime))
{
var isSelected = this._selectedFile?.FullName == entry.FullName;
<tr class="@(isSelected ? "table-info" : "")">
<td>
<button type="button" class="btn btn-link p-0 text-start font-monospace text-decoration-none fw-bold text-truncate" style="max-width: @(this._selectedFile != null ? "140px" : "100%");" @onclick="() => this.SelectFile(entry)" title="@entry.Name">
<span class="oi oi-terminal me-1 @(isSelected ? "text-primary" : "text-muted")"></span>@entry.Name
</button>
@if (this._selectedFile != null)
{
<div class="text-muted" style="font-size: 11px;">@FormatFileSize(entry.Length)</div>
}
</td>
@if (this._selectedFile == null)
{
<td>@entry.LastWriteTime</td>
<td>@FormatFileSize(entry.Length)</td>
}
<td class="text-end px-3">
<a href="logs/@entry.Name" download class="btn btn-sm btn-outline-secondary py-0 px-2" title="@Resources.DownloadFile">
<span class="oi oi-data-transfer-download" aria-hidden="true"></span>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<!-- Right Column: Log Viewer Terminal -->
@if (this._selectedFile != null)
{
<div class="col-lg-9 col-md-8">
<div class="card border-secondary shadow-lg mb-4">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center py-2 px-3">
<div class="d-flex align-items-center text-truncate me-2">
<span class="oi oi-terminal text-info me-2" aria-hidden="true"></span>
<span class="me-2 d-none d-sm-inline">@Resources.LogViewer:</span>
<span class="badge bg-secondary font-monospace text-truncate" style="max-width: 250px;">@this._selectedFile.Name</span>
</div>
<div class="d-flex align-items-center gap-2 gap-sm-3 flex-shrink-0">
<div class="form-check form-switch m-0 d-flex align-items-center gap-2">
<input class="form-check-input cursor-pointer" type="checkbox" id="liveUpdateSwitch" @onchange="this.ToggleLiveUpdate" checked="@this._liveUpdate">
<label class="form-check-label text-light select-none cursor-pointer" for="liveUpdateSwitch" style="font-size: 13px;">@Resources.Live</label>
</div>
<button class="btn btn-sm btn-outline-info d-flex align-items-center gap-1 py-1" @onclick="this.RefreshLogLines">
<span class="oi oi-reload" style="font-size: 11px;"></span> @Resources.Refresh
</button>
<button class="btn btn-sm btn-outline-danger d-flex align-items-center gap-1 py-1" @onclick="this.CloseViewer">
<span class="oi oi-x" style="font-size: 11px;"></span> @Resources.Close
</button>
</div>
</div>
<div class="card-body bg-dark p-3" style="background-color: #121214 !important;">
<div class="row g-2 mb-3">
<div class="col">
<div class="input-group">
<span class="input-group-text bg-secondary text-white border-0"><span class="oi oi-magnifying-glass" aria-hidden="true"></span></span>
<input type="text" class="form-control bg-secondary text-white border-0" placeholder="@Resources.FilterLogEntries" value="@this._searchText" @oninput="this.OnSearchInput" style="background-color: #2b2b30 !important; color: #fff !important;" />
@if (!string.IsNullOrEmpty(this._searchText))
{
<button class="btn btn-secondary border-0" @onclick="this.ClearSearch"><span class="oi oi-x" aria-hidden="true"></span></button>
}
</div>
</div>
</div>
<div id="@TerminalElementId" class="p-3 rounded" style="height: 480px; overflow-y: auto; font-family: 'Consolas', 'Liberation Mono', Menlo, Courier, monospace; font-size: 13px; line-height: 1.5; white-space: pre-wrap; background-color: #0c0c0d !important; border: 1px solid #2d2d30;">
@if (this._logLines.Count == 0)
{
<div class="text-muted text-center py-5">@Resources.NoLogEntriesFound</div>
}
else if (this._filteredLines.Count == 0)
{
<div class="text-muted text-center py-5">@Resources.NoLogEntriesMatchFilter</div>
}
else
{
@foreach (var line in this._filteredLines)
{
<div style="@GetLineColorStyle(line)">@line</div>
}
}
</div>
<div class="d-flex justify-content-between align-items-center mt-2 text-muted" style="font-size: 12px;">
<div>
@string.Format(Resources.ShowingXOfYLines, this._filteredLines.Count, this._logLines.Count, MaxLogLinesToRead)
</div>
<button class="btn btn-sm btn-outline-secondary py-1 px-2" style="font-size: 12px; color: #a0a0a8;" @onclick="this.ScrollToBottomAsync">
<span class="oi oi-arrow-bottom" aria-hidden="true"></span> @Resources.ScrollToBottom
</button>
</div>
</div>
</div>
</div>
}
</div>
@code {
private readonly List<FileInfo> _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";
/// <summary>
/// Initializes a new instance of class <see cref="LogFiles"/>.
/// </summary>
public LogFiles()
private readonly List<FileInfo> _files = new();
private FileInfo? _selectedFile;
private long _lastFileLength;
private DateTime _lastFileWriteTime;
private List<string> _logLines = new();
private List<string> _filteredLines = new();
private string _searchText = string.Empty;
private bool _liveUpdate;
private System.Threading.Timer? _timer;
private bool _shouldScrollToBottom;
private bool _disposed;
private IJSObjectReference? _jsModule;
/// <inheritdoc />
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)
/// <inheritdoc />
protected override void OnInitialized()
{
this.RefreshFileList();
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
this._jsModule = await this.JSRuntime.InvokeAsync<IJSObjectReference>("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<string> ReadLastLines(string path, int maxLines)
{
var lines = new List<string>();
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<bool> IsScrolledToBottomAsync()
{
if (this._jsModule is null)
{
return true;
}
try
{
return await this._jsModule.InvokeAsync<bool>("isScrolledToBottom", TerminalElementId);
}
catch (JSDisconnectedException)
{
return false;
}
}
}

View File

@@ -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;
}

View File

@@ -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;
/// <summary>

View File

@@ -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

View File

@@ -249,6 +249,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Close.
/// </summary>
public static string Close {
get {
return ResourceManager.GetString("Close", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Command.
/// </summary>
@@ -468,6 +477,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Download File.
/// </summary>
public static string DownloadFile {
get {
return ResourceManager.GetString("DownloadFile", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Drop item groups.
/// </summary>
@@ -558,6 +576,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Filter log entries....
/// </summary>
public static string FilterLogEntries {
get {
return ResourceManager.GetString("FilterLogEntries", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Finished! Have fun :).
/// </summary>
@@ -774,6 +801,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Live.
/// </summary>
public static string Live {
get {
return ResourceManager.GetString("Live", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Live Map.
/// </summary>
@@ -819,6 +855,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Log Viewer.
/// </summary>
public static string LogViewer {
get {
return ResourceManager.GetString("LogViewer", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Major.
/// </summary>
@@ -972,6 +1017,24 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to No log entries found..
/// </summary>
public static string NoLogEntriesFound {
get {
return ResourceManager.GetString("NoLogEntriesFound", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No log entries match your filter..
/// </summary>
public static string NoLogEntriesMatchFilter {
get {
return ResourceManager.GetString("NoLogEntriesMatchFilter", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to This command has no parameters..
/// </summary>
@@ -1197,6 +1260,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Reload File List.
/// </summary>
public static string ReloadFileList {
get {
return ResourceManager.GetString("ReloadFileList", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Remove.
/// </summary>
@@ -1260,6 +1332,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Scroll to Bottom.
/// </summary>
public static string ScrollToBottom {
get {
return ResourceManager.GetString("ScrollToBottom", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Search.
/// </summary>
@@ -1377,6 +1458,15 @@ namespace MUnique.OpenMU.Web.AdminPanel.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Showing {0} of {1} lines (Last {2} lines loaded)..
/// </summary>
public static string ShowingXOfYLines {
get {
return ResourceManager.GetString("ShowingXOfYLines", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Size.
/// </summary>

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
@@ -612,4 +612,70 @@
<data name="Target" xml:space="preserve">
<value>Target</value>
</data>
<data name="LogViewer" xml:space="preserve">
<value>Log Viewer</value>
</data>
<data name="Live" xml:space="preserve">
<value>Live</value>
</data>
<data name="Close" xml:space="preserve">
<value>Close</value>
</data>
<data name="FilterLogEntries" xml:space="preserve">
<value>Filter log entries...</value>
</data>
<data name="NoLogEntriesFound" xml:space="preserve">
<value>No log entries found.</value>
</data>
<data name="NoLogEntriesMatchFilter" xml:space="preserve">
<value>No log entries match your filter.</value>
</data>
<data name="ShowingXOfYLines" xml:space="preserve">
<value>Showing {0} of {1} lines (Last {2} lines loaded).</value>
</data>
<data name="ScrollToBottom" xml:space="preserve">
<value>Scroll to Bottom</value>
</data>
<data name="ReloadFileList" xml:space="preserve">
<value>Reload File List</value>
</data>
<data name="DownloadFile" xml:space="preserve">
<value>Download File</value>
</data>
<data name="ChatCommands" xml:space="preserve">
<value>Chat commands</value>
</data>
<data name="CommandColumn" xml:space="preserve">
<value>Command</value>
</data>
<data name="CommandDescription" xml:space="preserve">
<value>Description</value>
</data>
<data name="CommandUsage" xml:space="preserve">
<value>Usage</value>
</data>
<data name="MinimumCharacterStatus" xml:space="preserve">
<value>Required status</value>
</data>
<data name="NoParameters" xml:space="preserve">
<value>This command has no parameters.</value>
</data>
<data name="ParameterName" xml:space="preserve">
<value>Name</value>
</data>
<data name="ParameterShortName" xml:space="preserve">
<value>Short name</value>
</data>
<data name="ParameterType" xml:space="preserve">
<value>Type</value>
</data>
<data name="ParameterValidValues" xml:space="preserve">
<value>Valid values</value>
</data>
<data name="ParametersOf" xml:space="preserve">
<value>Parameters of {0}</value>
</data>
<data name="Required" xml:space="preserve">
<value>Required</value>
</data>
</root>

View File

@@ -5,7 +5,6 @@
namespace MUnique.OpenMU.Web.AdminPanel;
using System.IO;
using Blazored.Toast;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
@@ -61,7 +60,7 @@ public class Startup
.ConfigureApplicationPartManager(setup =>
setup.FeatureProviders.Add(new GenericControllerFeatureProvider()));
services.AddBlazoredToast();
services.AddToasts();
services.AddScoped<ModalService>();
services.AddScoped<IModalService>(sp => sp.GetRequiredService<ModalService>());

View File

@@ -5,7 +5,6 @@
namespace MUnique.OpenMU.Web.AdminPanel;
using System.IO;
using Blazored.Toast;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
using Microsoft.Extensions.DependencyInjection;
@@ -66,7 +65,7 @@ public static class WebApplicationExtensions
.ConfigureApplicationPartManager(setup =>
setup.FeatureProviders.Add(new GenericControllerFeatureProvider()));
services.AddBlazoredToast();
services.AddToasts();
services.AddScoped<ModalService>();
services.AddScoped<IModalService>(sp => sp.GetRequiredService<ModalService>());

View File

@@ -10,9 +10,6 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Blazored.Toast
@using Blazored.Toast.Services
@using BlazorInputFile
@using MUnique.OpenMU.Web.AdminPanel
@@ -20,6 +17,7 @@
@using MUnique.OpenMU.Web.Shared
@using MUnique.OpenMU.Web.Shared.Components
@using MUnique.OpenMU.Web.Shared.Components.Toast
@using MUnique.OpenMU.Web.Shared.Components.Modal
@using MUnique.OpenMU.Web.Shared.Components.Form
@using MUnique.OpenMU.Web.Shared.Components.Form.Modal

View File

@@ -0,0 +1,63 @@
// <copyright file="IToastService.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.Shared.Components.Toast;
using System;
using System.Collections.Generic;
/// <summary>
/// Service for showing toast notifications.
/// </summary>
public interface IToastService
{
/// <summary>
/// Occurs when the list of toasts has changed (added, closed, cleared).
/// </summary>
event Action? StateChanged;
/// <summary>
/// Gets the currently shown toasts.
/// </summary>
IReadOnlyList<ToastInstance> Toasts { get; }
/// <summary>
/// Shows a success toast.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="heading">The optional heading.</param>
void ShowSuccess(string message, string? heading = null);
/// <summary>
/// Shows an info toast.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="heading">The optional heading.</param>
void ShowInfo(string message, string? heading = null);
/// <summary>
/// Shows a warning toast.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="heading">The optional heading.</param>
void ShowWarning(string message, string? heading = null);
/// <summary>
/// Shows an error toast.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="heading">The optional heading.</param>
void ShowError(string message, string? heading = null);
/// <summary>
/// Closes the specified toast (triggers its closing animation).
/// </summary>
/// <param name="toast">The toast to close.</param>
void Close(ToastInstance toast);
/// <summary>
/// Closes all currently shown toasts.
/// </summary>
void Clear();
}

View File

@@ -0,0 +1,66 @@
@using MUnique.OpenMU.Web.Shared.Components.Toast
@using MUnique.OpenMU.Web.Shared.Services
@inject ToastService ToastService
@implements IDisposable
@if (this.ToastService.Toasts.Count > 0)
{
<div class="toast-container position-fixed top-0 end-0 p-3" aria-live="polite" aria-atomic="true">
@foreach (var toast in this.ToastService.Toasts)
{
var (iconClass, accentClass) = this.GetStyling(toast.Level);
<div @key="toast.Key" class="toast @accentClass @(toast.IsClosing ? "closing" : "show")" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body d-flex align-items-start">
<span class="oi @iconClass toast__icon" aria-hidden="true"></span>
<div class="ms-2 w-100">
@if (toast.Heading is { } heading)
{
<strong class="d-block">@heading</strong>
}
<div>@toast.Message</div>
</div>
</div>
<button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast" aria-label="Close" @onclick="() => this.CloseAsync(toast)"></button>
</div>
</div>
}
</div>
}
@code {
/// <inheritdoc />
protected override void OnInitialized()
{
this.ToastService.StateChanged += this.OnStateChanged;
}
/// <inheritdoc />
public void Dispose()
{
this.ToastService.StateChanged -= this.OnStateChanged;
}
private void OnStateChanged()
{
_ = this.InvokeAsync(this.StateHasChanged);
}
private Task CloseAsync(ToastInstance toast)
{
this.ToastService.Close(toast);
return Task.CompletedTask;
}
private (string IconClass, string AccentClass) GetStyling(ToastLevel level)
{
return level switch
{
ToastLevel.Success => ("oi-circle-check", "text-success"),
ToastLevel.Info => ("oi-info", "text-primary"),
ToastLevel.Warning => ("oi-warning", "text-warning"),
ToastLevel.Error => ("oi-bolt", "text-danger"),
_ => ("oi-info", "text-primary"),
};
}
}

View File

@@ -0,0 +1,34 @@
.toast-container {
z-index: 1080;
}
.toast {
min-width: 18rem;
border-left: 4px solid currentColor;
background-color: var(--bs-body-bg);
box-shadow: var(--bs-box-shadow-lg);
animation: toast-slide-in 0.2s ease-out;
opacity: 1;
transition: opacity 0.3s ease;
}
.toast.closing {
opacity: 0;
}
.toast__icon {
font-size: 1.1rem;
line-height: 1;
align-self: center;
}
@keyframes toast-slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

View File

@@ -0,0 +1,52 @@
// <copyright file="ToastInstance.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.Shared.Components.Toast;
using System;
/// <summary>
/// Represents a single toast message shown in the <see cref="ToastContainer"/>.
/// </summary>
public sealed class ToastInstance
{
/// <summary>
/// Initializes a new instance of the <see cref="ToastInstance"/> class.
/// </summary>
/// <param name="level">The level.</param>
/// <param name="message">The message.</param>
/// <param name="heading">The optional heading.</param>
internal ToastInstance(ToastLevel level, string message, string? heading)
{
this.Key = Guid.NewGuid();
this.Level = level;
this.Message = message;
this.Heading = heading;
}
/// <summary>
/// Gets a stable key identifying this toast, used as a render key.
/// </summary>
public Guid Key { get; }
/// <summary>
/// Gets the level.
/// </summary>
public ToastLevel Level { get; }
/// <summary>
/// Gets the message.
/// </summary>
public string Message { get; }
/// <summary>
/// Gets the optional heading.
/// </summary>
public string? Heading { get; }
/// <summary>
/// Gets or sets a value indicating whether the toast is performing its closing animation.
/// </summary>
internal bool IsClosing { get; set; }
}

View File

@@ -0,0 +1,31 @@
// <copyright file="ToastLevel.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.Shared.Components.Toast;
/// <summary>
/// The level of a toast message.
/// </summary>
public enum ToastLevel
{
/// <summary>
/// Informational message.
/// </summary>
Info,
/// <summary>
/// Success message.
/// </summary>
Success,
/// <summary>
/// Warning message.
/// </summary>
Warning,
/// <summary>
/// Error message.
/// </summary>
Error,
}

View File

@@ -52,7 +52,6 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="Blazored.Toast" />
<PackageReference Include="BlazorInputFile" />
<PackageReference Include="Mapster" />
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" />

View File

@@ -0,0 +1,172 @@
// <copyright file="ToastService.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.Shared.Services;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MUnique.OpenMU.Web.Shared.Components.Toast;
/// <summary>
/// Default implementation of <see cref="IToastService"/>.
/// </summary>
public sealed class ToastService : IToastService, IDisposable
{
private static readonly TimeSpan DefaultDuration = TimeSpan.FromSeconds(5);
private static readonly TimeSpan ClosingDuration = TimeSpan.FromMilliseconds(300);
private readonly object _lock = new();
private readonly List<ToastInstance> _toasts = new();
private readonly List<CancellationTokenSource> _cancellations = new();
/// <inheritdoc />
public event Action? StateChanged;
/// <inheritdoc />
public IReadOnlyList<ToastInstance> Toasts
{
get
{
lock (this._lock)
{
return this._toasts.ToArray();
}
}
}
/// <inheritdoc />
public void ShowSuccess(string message, string? heading = null)
{
this.Show(ToastLevel.Success, message, heading);
}
/// <inheritdoc />
public void ShowInfo(string message, string? heading = null)
{
this.Show(ToastLevel.Info, message, heading);
}
/// <inheritdoc />
public void ShowWarning(string message, string? heading = null)
{
this.Show(ToastLevel.Warning, message, heading);
}
/// <inheritdoc />
public void ShowError(string message, string? heading = null)
{
this.Show(ToastLevel.Error, message, heading);
}
/// <inheritdoc />
public void Close(ToastInstance toast)
{
this.StartClosing(toast);
}
/// <inheritdoc />
public void Clear()
{
lock (this._lock)
{
foreach (var cts in this._cancellations)
{
cts.Cancel();
}
this._cancellations.Clear();
this._toasts.Clear();
}
this.StateChanged?.Invoke();
}
/// <inheritdoc />
public void Dispose()
{
lock (this._lock)
{
foreach (var cts in this._cancellations)
{
cts.Dispose();
}
this._cancellations.Clear();
this._toasts.Clear();
}
}
private void Show(ToastLevel level, string message, string? heading)
{
var toast = new ToastInstance(level, message, heading);
var cts = new CancellationTokenSource();
lock (this._lock)
{
this._toasts.Add(toast);
this._cancellations.Add(cts);
}
this.StateChanged?.Invoke();
_ = this.AutoCloseAsync(toast, cts);
}
private async Task AutoCloseAsync(ToastInstance toast, CancellationTokenSource cts)
{
try
{
await Task.Delay(DefaultDuration, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
this.StartClosing(toast);
}
private void StartClosing(ToastInstance toast)
{
lock (this._lock)
{
var index = this._toasts.IndexOf(toast);
if (index < 0 || toast.IsClosing)
{
return;
}
toast.IsClosing = true;
this._cancellations[index].Cancel();
}
this.StateChanged?.Invoke();
_ = this.FinishClosingAsync(toast);
}
private Task FinishClosingAsync(ToastInstance toast)
{
return Task.Run(async () =>
{
await Task.Delay(ClosingDuration).ConfigureAwait(false);
lock (this._lock)
{
var index = this._toasts.IndexOf(toast);
if (index < 0)
{
return;
}
this._cancellations[index].Dispose();
this._cancellations.RemoveAt(index);
this._toasts.RemoveAt(index);
}
this.StateChanged?.Invoke();
});
}
}

View File

@@ -0,0 +1,26 @@
// <copyright file="ToastServiceCollectionExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.Shared.Services;
using Microsoft.Extensions.DependencyInjection;
using MUnique.OpenMU.Web.Shared.Components.Toast;
/// <summary>
/// Extension methods for registering the toast service.
/// </summary>
public static class ToastServiceCollectionExtensions
{
/// <summary>
/// Adds the toast service to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection, for chaining.</returns>
public static IServiceCollection AddToasts(this IServiceCollection services)
{
services.AddScoped<ToastService>();
services.AddScoped<IToastService>(sp => sp.GetRequiredService<ToastService>());
return services;
}
}

View File

@@ -6,9 +6,6 @@
@using Microsoft.Extensions.Localization
@using Microsoft.JSInterop
@using Blazored.Toast
@using Blazored.Toast.Services
@using BlazorInputFile
@using MUnique.OpenMU.Web.Shared

View File

@@ -0,0 +1,34 @@
// <copyright file="ConfigurationChangePublishingTests.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.Tests;
using MUnique.OpenMU.Persistence.EntityFramework;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
/// <summary>
/// Tests filtering of Entity Framework configuration change notifications.
/// </summary>
[TestFixture]
internal class ConfigurationChangePublishingTests
{
/// <summary>
/// Verifies that only configuration entities are published to the configuration change listener.
/// </summary>
/// <param name="entityType">The entity type.</param>
/// <param name="shouldPublish">Whether changes of this entity type should be published.</param>
[TestCase(typeof(CastleSiegeConfiguration), true)]
[TestCase(typeof(CastleSiegeNpcState), false)]
[TestCase(typeof(Account), false)]
[TestCase(typeof(Guild), false)]
[TestCase(typeof(Friend), false)]
public void OnlyConfigurationEntitiesArePublished(Type entityType, bool shouldPublish)
{
using var context = new EntityDataContext();
var modelType = context.Model.FindEntityType(entityType);
Assert.That(modelType, Is.Not.Null);
Assert.That(EntityFrameworkContextBase.PublishesConfigurationChanges(modelType!), Is.EqualTo(shouldPublish));
}
}

View File

@@ -24,7 +24,10 @@ internal class JsonQueryBuilderTests
[OneTimeSetUp]
public void Setup()
{
ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider());
if (!ConnectionConfigurator.IsInitialized)
{
ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider());
}
}
/// <summary>

View File

@@ -44,22 +44,25 @@ public class BotPartyHandlerTest
}
/// <summary>
/// An inviter whose effective level is too far from the bot's is declined - the group would only
/// be a power-leveling service.
/// There is no level gate (matching OpenMU's own party action): a bot accepts an inviter of any
/// level, since it is the player who invites and the bot leaves again once it gets bored. The
/// inviter here is a maxed veteran (character level 400 plus the master level cap of 200, i.e. the
/// ceiling of the Season 6 seed) inviting a low-level bot - the widest gap a stock server produces.
/// </summary>
[Test]
public async ValueTask RejectsTooLargeLevelGapAsync()
public async ValueTask AcceptsInviteRegardlessOfLevelGapAsync()
{
var gameContext = GameContextTestHelper.CreateGameContext();
var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false);
var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false);
requester.Attributes![Stats.Level] = 700;
requester.Attributes![Stats.Level] = 400;
requester.Attributes![Stats.MasterLevel] = 200;
var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false);
Assert.That(scheduled, Is.False);
Assert.That(bot.PendingPartyInvite, Is.Null);
Assert.That(bot.LastPartyRequester, Is.Null);
Assert.That(scheduled, Is.True);
Assert.That(bot.PendingPartyInvite, Is.Not.Null);
Assert.That(bot.LastPartyRequester, Is.SameAs(requester));
}
/// <summary>

View File

@@ -0,0 +1,114 @@
// <copyright file="PersistenceLockTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests;
using System.Threading;
/// <summary>
/// Tests for the per-player persistence lock (<see cref="MUnique.OpenMU.GameLogic.Player.RunPersistenceExclusiveAsync(System.Func{System.Threading.Tasks.ValueTask},System.Threading.CancellationToken)"/>),
/// which serializes a player's context mutations against its periodic/disconnect progress saves so they
/// can never run concurrently. Without it, a mutation running during <c>SaveChangesAsync</c> corrupts the
/// change tracker and rolls the whole session back.
/// </summary>
[TestFixture]
public class PersistenceLockTest
{
/// <summary>
/// Verifies that concurrent exclusive operations for the same player never overlap.
/// </summary>
[Test]
public async Task ConcurrentAccessIsSerializedAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var concurrent = 0;
var overlapDetected = false;
async ValueTask BodyAsync()
{
if (Interlocked.Increment(ref concurrent) > 1)
{
overlapDetected = true;
}
await Task.Delay(1).ConfigureAwait(false);
Interlocked.Decrement(ref concurrent);
}
var tasks = Enumerable.Range(0, 50)
.Select(_ => player.RunPersistenceExclusiveAsync(BodyAsync).AsTask())
.ToArray();
await Task.WhenAll(tasks).ConfigureAwait(false);
Assert.That(overlapDetected, Is.False, "Two exclusive operations for the same player ran at the same time.");
}
/// <summary>
/// Verifies that re-entering the lock from within an already-held exclusive scope does not deadlock
/// (an inline save inside a packet handler is exactly this case).
/// </summary>
[Test]
public async Task ReentrantAccessDoesNotDeadlockAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var executed = 0;
var run = player.RunPersistenceExclusiveAsync(async () =>
{
Interlocked.Increment(ref executed);
await player.RunPersistenceExclusiveAsync(async () =>
{
Interlocked.Increment(ref executed);
await Task.Yield();
}).ConfigureAwait(false);
}).AsTask();
// If reentrancy deadlocked, this would hang; fail fast instead of blocking the suite.
await run.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
Assert.That(executed, Is.EqualTo(2));
}
/// <summary>
/// Verifies that a re-entrant exclusive operation still runs while another flow holds the lock:
/// the outer flow keeps the lock, an independent flow must wait, and the re-entrant call inside the
/// outer flow proceeds without waiting for itself.
/// </summary>
[Test]
public async Task IndependentFlowWaitsWhileLockIsHeldAsync()
{
var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false);
var otherEntered = false;
var holderHasLock = new TaskCompletionSource();
var mayRelease = new TaskCompletionSource();
// Holder runs on its own flow and keeps the lock until signalled.
var holder = Task.Run(() => player.RunPersistenceExclusiveAsync(async () =>
{
holderHasLock.SetResult();
// A re-entrant call from the holding flow must NOT block on the lock we already hold.
await player.RunPersistenceExclusiveAsync(() => ValueTask.CompletedTask).ConfigureAwait(false);
await mayRelease.Task.ConfigureAwait(false);
}).AsTask());
await holderHasLock.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
// Competing flow started from an INDEPENDENT context (does not inherit the reentrancy flag).
var other = Task.Run(() => player.RunPersistenceExclusiveAsync(() =>
{
otherEntered = true;
return ValueTask.CompletedTask;
}).AsTask());
await Task.Delay(50).ConfigureAwait(false);
Assert.That(otherEntered, Is.False, "An independent flow entered while the lock was held.");
mayRelease.SetResult();
await holder.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
await other.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
Assert.That(otherEntered, Is.True, "The competing flow never ran after the lock was released.");
}
}