diff --git a/src/Dapr/GameServer.Host/_Imports.razor b/src/Dapr/GameServer.Host/_Imports.razor
index 4a18a36..61d3ed5 100644
--- a/src/Dapr/GameServer.Host/_Imports.razor
+++ b/src/Dapr/GameServer.Host/_Imports.razor
@@ -9,9 +9,6 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
-@using Blazored.Toast
-@using Blazored.Toast.Services
-
@using BlazorInputFile
@using MUnique.OpenMU.Web.Shared
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index 91b3051..5c2708a 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -6,7 +6,6 @@
-
diff --git a/src/GameLogic/Bots/BotPartyHandler.cs b/src/GameLogic/Bots/BotPartyHandler.cs
index c93e17b..8ce440b 100644
--- a/src/GameLogic/Bots/BotPartyHandler.cs
+++ b/src/GameLogic/Bots/BotPartyHandler.cs
@@ -11,22 +11,13 @@ using MUnique.OpenMU.GameLogic.Offline;
///
): the invitation is accepted after a short
/// human-like delay, and the bot then follows the leader like any party member (see the follow logic
/// in
) until it gets bored and politely leaves. Safeguards keep it
-/// believable and abuse-free: no grouping across an absurd level gap, no acceptance while the bot is
-/// on an errand (shopping trip) or has unfinished business (revenge), and the invitation is
-/// re-validated when the delay has passed - the inviter may have joined another party or left.
+/// believable and abuse-free: no acceptance while the bot is on an errand (shopping trip) or has
+/// unfinished business (revenge), and the invitation is re-validated when the delay has passed - the
+/// inviter may have joined another party or left. There is no level gate, matching OpenMU's own party
+/// action: it is the player who invites, and the bot leaves again once it gets bored.
///
internal static class BotPartyHandler
{
- ///
- /// The maximum difference of the reset-aware effective level (see
- /// ) between the bot and the inviter. Within one
- /// reset worth of levels plus some slack, hunting together still makes sense for both; grouping a
- /// fresh character with a 15-resets veteran would only be a power-leveling service. On servers
- /// without the reset feature the plain levels always lie within this bound, matching OpenMU's own
- /// party action, which has no level gate at all.
- ///
- private const int MaxEffectiveLevelGap = 500;
-
///
Lower bound of the human-like delay before the bot answers an invitation.
private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2);
@@ -67,7 +58,7 @@ internal static class BotPartyHandler
return false;
}
- if (!IsRequesterEligible(bot, requester))
+ if (!IsRequesterEligible(requester))
{
return false;
}
@@ -141,7 +132,7 @@ internal static class BotPartyHandler
{
// Re-validate: between the invitation and this answer, the bot may have joined a human's party
// and the inviter may have died, left the game or joined another party.
- if (HasHumanCompanion(bot) || !IsRequesterEligible(bot, requester))
+ if (HasHumanCompanion(bot) || !IsRequesterEligible(requester))
{
bot.Logger.LogInformation("Bot '{Name}' dropped the party invitation of '{Requester}' - the situation changed.", bot.Name, requester.Name);
return;
@@ -209,14 +200,8 @@ internal static class BotPartyHandler
await party.KickMySelfAsync(bot).ConfigureAwait(false);
}
- private static bool IsRequesterEligible(OfflinePlayer bot, Player requester)
+ private static bool IsRequesterEligible(Player requester)
{
- if (!requester.IsAlive || requester.PlayerState.CurrentState != PlayerState.EnteredWorld)
- {
- return false;
- }
-
- var levelGap = Math.Abs(BotResetHandler.GetEffectiveLevel(bot) - BotResetHandler.GetEffectiveLevel(requester));
- return levelGap <= MaxEffectiveLevelGap;
+ return requester.IsAlive && requester.PlayerState.CurrentState == PlayerState.EnteredWorld;
}
}
diff --git a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs
index 73bc9a1..c36bb58 100644
--- a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs
+++ b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs
@@ -141,7 +141,11 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
{
try
{
- await this.TickAsync(cancellationToken).ConfigureAwait(false);
+ // Run the whole tick under the player's persistence lock so its structural mutations
+ // (loot pickup, combat ammo/pet destruction, queued equip/jewel actions) never overlap
+ // this bot's periodic progress save, which runs on a separate timer. The tick has no
+ // internal delays, so the lock is held only for its brief duration.
+ await this._player.RunPersistenceExclusiveAsync(() => this.TickAsync(cancellationToken)).ConfigureAwait(false);
this._player.OnAiTickSucceeded();
}
catch (OperationCanceledException)
diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs
index 5b9928b..6e55ec8 100644
--- a/src/GameLogic/Player.cs
+++ b/src/GameLogic/Player.cs
@@ -54,6 +54,21 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
private readonly AsyncLock _moveLock = new();
private readonly AsyncLock _experienceLock = new();
+ ///
+ /// Serializes context mutations done by this player's action handlers against the periodic and
+ /// disconnect progress saves, which run on an independent timer flow. See
+ /// .
+ ///
+ private readonly AsyncLock _persistenceLock = new();
+
+ ///
+ /// Tracks, per asynchronous flow, whether is already held, so the
+ /// lock can be re-entered (Nito's is not reentrant). It is an instance
+ /// field on purpose: reentrancy must be tracked per player, so a flow holding player A's lock
+ /// still acquires player B's lock (e.g. during a trade) instead of wrongly skipping it.
+ ///
+ private readonly AsyncLocal
_persistenceLockHeld = new();
+
private readonly Walker _walker;
private readonly AppearanceDataAdapter _appearanceData;
@@ -1879,12 +1894,86 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
/// Success of the save operation.
public async ValueTask SaveProgressAsync(CancellationToken cancellationToken = default)
{
- if (!this.IsTemplatePlayer)
+ if (this.IsTemplatePlayer)
{
- return await this.PersistenceContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return true;
}
- return true;
+ return await this.RunPersistenceExclusiveAsync(
+ () => this.PersistenceContext.SaveChangesAsync(cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Runs the given operation while holding this player's persistence lock, so that context
+ /// mutations and progress saves for the player never run concurrently.
+ ///
+ ///
+ /// The periodic progress save () runs on an
+ /// independent timer flow. Action handlers mutate tracked entities with plain field/collection
+ /// writes (e.g. crafting toggling item.ItemOptions) which bypass the persistence context's
+ /// own lock; if such a mutation runs while enumerates the
+ /// change tracker, the save throws (collection-modified / DbUpdateConcurrency) and every following
+ /// save fails too, so the whole session is lost on relog. Serializing the packet handler funnel
+ /// and the save against each other closes that window. The lock is re-entrant per asynchronous
+ /// flow, so an inline save inside an already-serialized handler does not deadlock.
+ ///
+ /// Invariant: never acquire another player's persistence lock (via their
+ /// or ) from inside a
+ /// packet handler, which already holds this player's lock, unless a global lock order is enforced.
+ /// Today only the trade accept does a cross-player save, and it cannot form a cycle because a trade
+ /// has a single accepting side (so the A-then-B acquisition order has no concurrent B-then-A
+ /// counterpart). A second cross-player caller with the opposite order could deadlock.
+ ///
+ ///
+ /// The result type of the operation.
+ /// The operation to run exclusively.
+ /// The cancellation token.
+ /// The result of the operation.
+ public async ValueTask RunPersistenceExclusiveAsync(Func> operation, CancellationToken cancellationToken = default)
+ {
+ if (this._persistenceLockHeld.Value)
+ {
+ return await operation().ConfigureAwait(false);
+ }
+
+ using var l = await this._persistenceLock.LockAsync(cancellationToken).ConfigureAwait(false);
+ this._persistenceLockHeld.Value = true;
+ try
+ {
+ return await operation().ConfigureAwait(false);
+ }
+ finally
+ {
+ this._persistenceLockHeld.Value = false;
+ }
+ }
+
+ ///
+ /// Runs the given operation while holding this player's persistence lock.
+ /// See for the rationale.
+ ///
+ /// The operation to run exclusively.
+ /// The cancellation token.
+ /// A value task which completes when the operation completed.
+ public async ValueTask RunPersistenceExclusiveAsync(Func operation, CancellationToken cancellationToken = default)
+ {
+ if (this._persistenceLockHeld.Value)
+ {
+ await operation().ConfigureAwait(false);
+ return;
+ }
+
+ using var l = await this._persistenceLock.LockAsync(cancellationToken).ConfigureAwait(false);
+ this._persistenceLockHeld.Value = true;
+ try
+ {
+ await operation().ConfigureAwait(false);
+ }
+ finally
+ {
+ this._persistenceLockHeld.Value = false;
+ }
}
///
diff --git a/src/GameServer/RemoteView/RemotePlayer.cs b/src/GameServer/RemoteView/RemotePlayer.cs
index 2c0b584..d7687f7 100644
--- a/src/GameServer/RemoteView/RemotePlayer.cs
+++ b/src/GameServer/RemoteView/RemotePlayer.cs
@@ -151,7 +151,7 @@ public class RemotePlayer : Player, IClientVersionProvider, IHasIpAddress
this.Logger.LogDebug("[C->S] {0}", buffer.ToArray().AsString());
}
- await this.MainPacketHandler.HandlePacketAsync(this, buffer).ConfigureAwait(false);
+ await this.RunPersistenceExclusiveAsync(() => this.MainPacketHandler.HandlePacketAsync(this, buffer)).ConfigureAwait(false);
}
finally
{
diff --git a/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs b/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs
index ff16a74..459443d 100644
--- a/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs
+++ b/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs
@@ -92,69 +92,6 @@ internal class EntityFrameworkContextBase : IContext
}
}
- ///
- /// Determines whether the exception is a transient conflict caused by a concurrent entity mutation
- /// racing this save, and is therefore worth retrying.
- ///
- /// The exception thrown by the save.
- /// true if the save should be retried.
- private static bool IsTransientConcurrencyConflict(Exception exception)
- {
- // A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration.
- // Depending on exactly where change detection was, it surfaces as one of several types - a
- // modified collection (InvalidOperationException), a transiently-null internal key
- // (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient:
- // the racing mutation is a single quick operation, so a bounded retry lands on a stable moment.
- // A genuinely persistent error of the same type is not masked - it rethrows once the retries
- // are exhausted. The deterministic serialization (per-player persistence lock) is the primary
- // guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for.
- return exception is DbUpdateConcurrencyException
- or InvalidOperationException
- or ArgumentNullException
- or NullReferenceException
- or IndexOutOfRangeException
- or KeyNotFoundException;
- }
-
- private async ValueTask SaveChangesCoreAsync(CancellationToken cancellationToken)
- {
- using var l = await this._lock.LockAsync();
-
- // when we have a change publisher attached, we want to get the changed entries before accepting them.
- // Otherwise, we can accept them.
- var acceptChanges = true;
-
- object? sender = null;
- SavedChangesEventArgs? args = null;
- if (this._changeListener is { })
- {
- this.Context.SavedChanges += OnSavedChanges;
- acceptChanges = false;
- }
-
- try
- {
- await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false);
-
- if (args is not null)
- {
- await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false);
- }
- }
- finally
- {
- this.Context.SavedChanges -= OnSavedChanges;
- }
-
- return true;
-
- void OnSavedChanges(object? s, SavedChangesEventArgs e)
- {
- sender = s;
- args = e;
- }
- }
-
///
public IDisposable SuspendChangeNotifications()
{
@@ -323,6 +260,69 @@ internal class EntityFrameworkContextBase : IContext
this.Context.Dispose();
}
+ ///
+ /// Determines whether the exception is a transient conflict caused by a concurrent entity mutation
+ /// racing this save, and is therefore worth retrying.
+ ///
+ /// The exception thrown by the save.
+ /// true if the save should be retried.
+ private static bool IsTransientConcurrencyConflict(Exception exception)
+ {
+ // A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration.
+ // Depending on exactly where change detection was, it surfaces as one of several types - a
+ // modified collection (InvalidOperationException), a transiently-null internal key
+ // (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient:
+ // the racing mutation is a single quick operation, so a bounded retry lands on a stable moment.
+ // A genuinely persistent error of the same type is not masked - it rethrows once the retries
+ // are exhausted. The deterministic serialization (per-player persistence lock) is the primary
+ // guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for.
+ return exception is DbUpdateConcurrencyException
+ or InvalidOperationException
+ or ArgumentNullException
+ or NullReferenceException
+ or IndexOutOfRangeException
+ or KeyNotFoundException;
+ }
+
+ private async ValueTask SaveChangesCoreAsync(CancellationToken cancellationToken)
+ {
+ using var l = await this._lock.LockAsync();
+
+ // when we have a change publisher attached, we want to get the changed entries before accepting them.
+ // Otherwise, we can accept them.
+ var acceptChanges = true;
+
+ object? sender = null;
+ SavedChangesEventArgs? args = null;
+ if (this._changeListener is { })
+ {
+ this.Context.SavedChanges += OnSavedChanges;
+ acceptChanges = false;
+ }
+
+ try
+ {
+ await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false);
+
+ if (args is not null)
+ {
+ await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ this.Context.SavedChanges -= OnSavedChanges;
+ }
+
+ return true;
+
+ void OnSavedChanges(object? s, SavedChangesEventArgs e)
+ {
+ sender = s;
+ args = e;
+ }
+ }
+
private bool DetachInternal(object item)
{
var entry = this.Context.Entry(item);
diff --git a/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs b/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs
index d1bceb0..d40faa7 100644
--- a/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs
+++ b/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs
@@ -34,7 +34,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
TaxStore = table.Column(type: "smallint", nullable: false),
TaxHunt = table.Column(type: "integer", nullable: false),
IsHuntZoneEnabled = table.Column(type: "boolean", nullable: false),
- TributeMoney = table.Column(type: "bigint", nullable: false)
+ TributeMoney = table.Column(type: "bigint", nullable: false),
},
constraints: table =>
{
@@ -53,7 +53,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
DefenseLevel = table.Column(type: "smallint", nullable: false),
RegenLevel = table.Column(type: "smallint", nullable: false),
LifeLevel = table.Column(type: "smallint", nullable: false),
- CurrentHp = table.Column(type: "integer", nullable: false)
+ CurrentHp = table.Column(type: "integer", nullable: false),
},
constraints: table =>
{
@@ -87,7 +87,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
GuildScoreCastleSiege = table.Column(type: "integer", nullable: false),
GuildScoreCastleSiegeMembers = table.Column(type: "integer", nullable: false),
GateBuyPrice = table.Column(type: "integer", nullable: false),
- StatueBuyPrice = table.Column(type: "integer", nullable: false)
+ StatueBuyPrice = table.Column(type: "integer", nullable: false),
},
constraints: table =>
{
@@ -125,7 +125,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
DefaultSide = table.Column(type: "smallint", nullable: false),
SpawnX = table.Column(type: "smallint", nullable: false),
SpawnY = table.Column(type: "smallint", nullable: false),
- Direction = table.Column(type: "integer", nullable: false)
+ Direction = table.Column(type: "integer", nullable: false),
},
constraints: table =>
{
@@ -155,7 +155,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
State = table.Column(type: "smallint", nullable: false),
DayOfWeek = table.Column(type: "integer", nullable: false),
Hour = table.Column(type: "smallint", nullable: false),
- Minute = table.Column(type: "smallint", nullable: false)
+ Minute = table.Column(type: "smallint", nullable: false),
},
constraints: table =>
{
@@ -183,7 +183,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
Level = table.Column(type: "smallint", nullable: false),
RequiredJewelOfGuardianCount = table.Column(type: "integer", nullable: false),
RequiredZen = table.Column(type: "integer", nullable: false),
- Value = table.Column(type: "integer", nullable: false)
+ Value = table.Column(type: "integer", nullable: false),
},
constraints: table =>
{
@@ -236,7 +236,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
X1 = table.Column(type: "smallint", nullable: false),
Y1 = table.Column(type: "smallint", nullable: false),
X2 = table.Column(type: "smallint", nullable: false),
- Y2 = table.Column(type: "smallint", nullable: false)
+ Y2 = table.Column(type: "smallint", nullable: false),
},
constraints: table =>
{
diff --git a/src/Web/AdminPanel/Components/Layout/CreationPanel.razor b/src/Web/AdminPanel/Components/Layout/CreationPanel.razor
index 855e43d..e6dad5d 100644
--- a/src/Web/AdminPanel/Components/Layout/CreationPanel.razor
+++ b/src/Web/AdminPanel/Components/Layout/CreationPanel.razor
@@ -8,7 +8,7 @@
@implements IDisposable
@inject CreationPanelService Panel
-@inject Blazored.Toast.Services.IToastService ToastService
+@inject IToastService ToastService
@if (this.Panel.Current is { } session)
{
diff --git a/src/Web/AdminPanel/Components/Layout/MainLayout.razor b/src/Web/AdminPanel/Components/Layout/MainLayout.razor
index 429c724..ff9f4f4 100644
--- a/src/Web/AdminPanel/Components/Layout/MainLayout.razor
+++ b/src/Web/AdminPanel/Components/Layout/MainLayout.razor
@@ -39,7 +39,7 @@