16 Commits

Author SHA1 Message Date
Acentech Dev
df338578d1 Pin the production image to the OpenMU upstream Dalga A release (d60aad7d2f)
Some checks failed
.NET Core / build (push) Has been cancelled
2026-08-13 09:27:10 +03:00
Acentech Dev
d60aad7d2f Load the log viewer script from the path it is actually served on
The admin panel sets StaticWebAssetBasePath to _content/<assembly>, so the
colocated module is served as
/_content/MUnique.OpenMU.Web.AdminPanel/Pages/LogFiles.razor.js. The import
asked for "./Pages/LogFiles.razor.js", which resolves against the page url
/logfiles and answers 404. The failure is caught and logged, so the viewer
still renders - it just never scrolls itself.

Build the path from the assembly name the way ThemeSelector and MapEditor
already do.
2026-08-13 09:13:56 +03:00
Acentech Dev
233ad41f6b 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.
2026-08-13 09:03:28 +03:00
Eduardo
72874567c6 replace Bazored.Toast with custom Toast component 2026-08-13 08:53:09 +03:00
Acentech Dev
23bc36fd65 Restore the chat command resource strings the log viewer merge dropped
The admin panel log viewer (#845) reordered Resources.resx around the
chat command entries. AdaMu never took the admin panel page which
displays them (upstream PR #851), so the conflict resolution left the
entries out - but Resources.Designer.cs still declares the twelve
properties, and each would have returned null at runtime.

Add the entries back, unchanged from upstream, so resx and the generated
designer agree again.
2026-08-13 08:53:09 +03:00
Claude
79922204c5 Fix build and behavior issues in the admin panel log viewer
- Resources.resx: close the unterminated DownloadFile data element, which
  made the file invalid XML, and remove the duplicated Actions and Refresh
  entries which already exist.
- Resources.Designer.cs: restore the UTF-8 BOM and put the new properties
  into the alphabetical order the strongly typed resource builder produces,
  so the file matches its generated form again.
- LogFiles.razor: import the collocated script from ./Pages/LogFiles.razor.js.
  The _content/{PackageId} prefix only applies to razor class libraries, so
  the import failed for this web application and the module was never loaded.
- LogFiles.razor: follow the new entries in live mode again by using the
  isScrolledToBottom helper, so the terminal scrolls along unless the user
  scrolled up to read the history.
- LogFiles.razor: only catch the expected javascript interop exceptions and
  log a failing module import instead of swallowing it silently.
- LogFiles.razor: restore the BOM and the trailing newline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vjs6n29WzQx8pGg3KJPGXk
(cherry picked from commit 7be969ea7b2a9b7441a8c5a65cc8a0928009d595)
2026-08-13 08:52:04 +03:00
Rhefew
f5b4292af1 fix(admin): address code review feedback on log viewer localization, CSP JS module, timer disposal, and scroll UX 2026-08-13 08:51:08 +03:00
Rhefew
074f91bd47 style(admin): apply code formatting, static helper functions and ConfigureWait to LogFiles.razor 2026-08-13 08:51:08 +03:00
Rhefew
f050e56e91 feat(admin): auto-scroll terminal to bottom on log refreshes 2026-08-13 08:51:08 +03:00
Rhefew
f7f6526c16 feat(admin): layout log files page side-by-side when viewing 2026-08-13 08:51:08 +03:00
Rhefew
31a5e64f40 feat(admin): implement live log viewer and searcher in log files page 2026-08-13 08:51:07 +03:00
Acentech Dev
790a101f23 Cover the configuration change publishing filter with its test
The Castle Siege persistence import (#860) brought
EntityFrameworkContextBase.PublishesConfigurationChanges into the tree,
but not the test which pins its behaviour, and not the guard which keeps
the two initialization test fixtures from configuring the connection
twice.

Add both, unchanged from upstream.
2026-08-13 08:50:53 +03:00
nolt
52950362ff Document the cross-player persistence lock-ordering invariant
The packet handler funnel now holds each player's persistence lock for the
whole handler. Acquiring a second player's lock from inside a handler is
therefore a lock-ordering hazard; note the invariant on the guard method so a
future cross-player save cannot silently open an AB-BA cycle. Documentation
only, no behavioural change.
2026-08-13 08:46:48 +03:00
nolt
d3dd57f620 Absorb the remaining off-funnel mutation races against the periodic save
The per-player persistence lock serializes the packet handler funnel and the
save, but a few structural mutations happen off that funnel: the offline/bot
MuHelper loots and maintains its inventory on a 500ms timer, and combat
destroys depleted ammunition or a dead pet on the attacker's or a monster's
thread. Those can still run while the periodic save enumerates the change
tracker and corrupt it.

Two additions:

- Run the whole offline MuHelper tick under the player's persistence lock.
  Bots are in the saved player list and loot continuously, so this was the
  most likely remaining reproducer. The tick has no internal delays, so the
  lock is held only briefly, and it is the bot's own lock (no cross-player
  deadlock).

- Retry the save a bounded number of times on the transient exceptions a
  concurrent change-tracker mutation produces. The corruption surfaces as
  several types depending on where change detection was (a modified
  collection, a transiently-null key, an out-of-range index), so the retry
  covers that family rather than a single type. A genuinely persistent error
  rethrows once the attempts are exhausted. This absorbs the rare, bursty
  combat sources that no lock is held for.
2026-08-13 08:46:47 +03:00
nolt
7671fe2d94 Serialize player context mutations against the periodic save
A player's progress is persisted from two unrelated flows: action
handlers triggered by incoming packets (sequential per connection) and
the periodic save, which runs on an independent timer. Action handlers
mutate tracked entities with plain field and collection writes - for
example crafting toggles item.ItemOptions, and item stacking and NPC
selling delete item rows - which bypass the persistence context's own
lock. When such a mutation runs while SaveChangesAsync enumerates the
change tracker, the save throws (collection-modified, or a
DbUpdateConcurrency "affected 0 rows" since the context has no
concurrency tokens). SaveChanges is atomic, so every following save
fails too and the whole session never persists: on relog the player
rolls back, losing progress and items.

Add a per-player re-entrant persistence lock and acquire it around both
the packet-handling funnel and SaveProgressAsync, so a player's
mutations and saves can never overlap. The lock is re-entrant per
asynchronous flow (an instance AsyncLocal), so an inline save inside an
already-serialized handler does not deadlock; and it is per player, so a
trade still acquires the trading partner's lock separately.

Add regression tests covering mutual exclusion and re-entrancy of the
lock.
2026-08-13 08:45:38 +03:00
nolt
ebf6958068 bots: accept party invitations regardless of level gap
The reset-aware level gate in BotPartyHandler rejected nearly every party
invitation on servers with resets, because it folded reset count into the level
scale (one reset ~= 400 points against a 500 cap). A veteran player inviting a
freshly generated bot was always over the limit, so the invitation was silently
declined. Remove the gate so a bot accepts any inviter who is alive and in the
world, matching OpenMU's own party action; the situational safeguards (shopping,
revenge, mini game, pending invite, human companion) stay in place.
2026-08-13 08:45:29 +03:00
41 changed files with 1387 additions and 177 deletions

View File

@@ -9,7 +9,7 @@ services:
# sessizce calismaya devam eder (2026-07-31'de tam olarak bu oldu: sunucu 26 Temmuz
# image'ini calistiriyordu). SHA etiketi daha once gorulmedigi icin pull zorunlu olur.
# Yeni surum yayinlarken bu satiri yeni SHA ile guncelle.
image: ${OPENMU_IMAGE:-atfatmc/adamu-openmu:ede52f4176a3}
image: ${OPENMU_IMAGE:-atfatmc/adamu-openmu:d60aad7d2f55}
container_name: openmu-startup
networks:
- coolify

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,439 @@
@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()
// The admin panel publishes its static web assets under _content/<assembly>, so a path relative
// to the page url ("./Pages/...") does not resolve. Mirrors ThemeSelector and MapEditor.
private static readonly string JsModulePath =
$"./_content/{typeof(LogFiles).Assembly.GetName().Name}/Pages/{nameof(LogFiles)}.razor.js";
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", JsModulePath);
}
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.");
}
}