diff --git a/Maple2.Server.Core/proto/world/world.proto b/Maple2.Server.Core/proto/world/world.proto index e100578d4..a14a9f36f 100644 --- a/Maple2.Server.Core/proto/world/world.proto +++ b/Maple2.Server.Core/proto/world/world.proto @@ -59,6 +59,9 @@ service World { rpc PlayerConfig(PlayerConfigRequest) returns (PlayerConfigResponse); // Disconnect rpc Disconnect(maple2.DisconnectRequest) returns (maple2.DisconnectResponse); + // Acquire and release locks for database operations. + rpc AcquireLock (LockRequest) returns (LockResponse); + rpc ReleaseLock (LockRequest) returns (LockResponse); } enum Server { @@ -572,3 +575,11 @@ message DeathInfo { int32 ms_remaining = 2; int64 stop_time = 3; } + +message LockRequest { + int64 accountId = 1; +} + +message LockResponse { + string error = 1; +} diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs index e35be8ffa..68a054b84 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs @@ -514,7 +514,7 @@ public bool UsePortal(GameSession session, int portalId, string password) { session.Send(PortalPacket.MoveByPortal(session.Player, destinationCube.Position, default)); return true; case CubePortalDestination.SelectedMap: - session.MigrateOutOfInstance(srcPortal.TargetMapId); + session.Migrate(srcPortal.TargetMapId); return true; case CubePortalDestination.FriendHome: Home? home = GetHome(session, fieldPortal); @@ -523,7 +523,7 @@ public bool UsePortal(GameSession session, int portalId, string password) { return false; } - session.MigrateToInstance(home.Indoor.MapId, home.Indoor.OwnerId); + session.Migrate(home.Indoor.MapId, home.Indoor.OwnerId); return true; } return false; diff --git a/Maple2.Server.Game/PacketHandlers/GuildHandler.cs b/Maple2.Server.Game/PacketHandlers/GuildHandler.cs index b0062ca54..911611257 100644 --- a/Maple2.Server.Game/PacketHandlers/GuildHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/GuildHandler.cs @@ -647,7 +647,7 @@ private void HandleEnterHouse(GameSession session) { return; } - session.MigrateToInstance(house.MapId, session.Guild.Id); + session.Migrate(house.MapId, session.Guild.Id); } private void HandleSendGift(GameSession session, IByteReader packet) { diff --git a/Maple2.Server.Game/PacketHandlers/HomeHandler.cs b/Maple2.Server.Game/PacketHandlers/HomeHandler.cs index 7aced9c68..e5b000053 100644 --- a/Maple2.Server.Game/PacketHandlers/HomeHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/HomeHandler.cs @@ -119,6 +119,6 @@ private void HandleWarp(GameSession session, IByteReader packet) { session.Housing.InitNewHome(session.Player.Value.Character.Name, exportedUgcMap); } - session.MigrateToInstance(home.Indoor.MapId, home.Indoor.OwnerId); + session.Migrate(home.Indoor.MapId, home.Indoor.OwnerId); } } diff --git a/Maple2.Server.Game/PacketHandlers/MoveFieldHandler.cs b/Maple2.Server.Game/PacketHandlers/MoveFieldHandler.cs index 987f8ee30..6beeb6f7c 100644 --- a/Maple2.Server.Game/PacketHandlers/MoveFieldHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/MoveFieldHandler.cs @@ -101,7 +101,7 @@ private void HandleVisitHome(GameSession session, IByteReader packet) { } } - session.MigrateToInstance(home.Indoor.MapId, home.Indoor.OwnerId); + session.Migrate(home.Indoor.MapId, home.Indoor.OwnerId); } private void HandleReturn(GameSession session) { diff --git a/Maple2.Server.Game/PacketHandlers/QuestHandler.cs b/Maple2.Server.Game/PacketHandlers/QuestHandler.cs index 6a7538e03..e894a84f4 100644 --- a/Maple2.Server.Game/PacketHandlers/QuestHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/QuestHandler.cs @@ -203,7 +203,7 @@ private void HandleMapleGuide(GameSession session, IByteReader packet) { } if (metadata.GoToMapId is Constant.DefaultHomeMapId) { - session.MigrateToInstance(Constant.DefaultHomeMapId, session.AccountId); + session.Migrate(Constant.DefaultHomeMapId, session.AccountId); return; } diff --git a/Maple2.Server.Game/Session/GameSession.cs b/Maple2.Server.Game/Session/GameSession.cs index 09a6623dd..a074af7b3 100644 --- a/Maple2.Server.Game/Session/GameSession.cs +++ b/Maple2.Server.Game/Session/GameSession.cs @@ -3,6 +3,7 @@ using System.Net; using System.Net.Sockets; using System.Numerics; +using System.Runtime.CompilerServices; using Autofac; using Grpc.Core; using Maple2.Database.Extensions; @@ -56,6 +57,7 @@ public sealed partial class GameSession : Core.Network.Session { #region Autofac Autowired // ReSharper disable MemberCanBePrivate.Global + // ReSharper disable UnusedAutoPropertyAccessor.Global public required GameStorage GameStorage { get; init; } public required WorldClient World { get; init; } public required ItemMetadataStorage ItemMetadata { get; init; } @@ -73,6 +75,7 @@ public sealed partial class GameSession : Core.Network.Session { public required ItemStatsCalculator ItemStatsCalc { private get; init; } public required PlayerInfoStorage PlayerInfo { get; init; } // ReSharper restore All + // ReSharper restore UnusedAutoPropertyAccessor.Global #endregion public ConfigManager Config { get; set; } = null!; @@ -146,13 +149,19 @@ public bool EnterServer(long accountId, Guid machineId, MigrateInResponse migrat using GameStorage.Request db = GameStorage.Context(); db.BeginTransaction(); int objectId = FieldManager.NextGlobalId(); - Player? player = db.LoadPlayer(AccountId, CharacterId, objectId, GameServer.GetChannel()); + Player? player; + try { + AcquireLock(CharacterId, 5); + player = db.LoadPlayer(AccountId, CharacterId, objectId, GameServer.GetChannel()); + db.Commit(); + } finally { + ReleaseLock(CharacterId); + } if (player == null) { Logger.Warning("Failed to load player from database: {AccountId}, {CharacterId}", AccountId, CharacterId); Send(MigrationPacket.MoveResult(MigrationError.s_move_err_default)); return false; } - db.Commit(); Player = new FieldPlayer(this, player); Animation = new AnimationManager(this); @@ -386,7 +395,7 @@ private bool PrepareFieldInternal(int mapId, out FieldManager? newField, int por } else if (mapId == dungeonField.Lobby.MapId) { newField = dungeonField.Lobby; } else { - MigrateOutOfInstance(mapId); + Migrate(mapId); newField = null; return false; } @@ -504,7 +513,7 @@ public void ReturnField() { character.ReturnPosition = default; if (character.MapId is Constant.DefaultHomeMapId) { - MigrateOutOfInstance(mapId); + Migrate(mapId); return; } @@ -515,7 +524,7 @@ public void ReturnField() { houseRank.TryGetValue(Guild.Guild.HouseTheme, out GuildTable.House? house); if (house?.MapId == character.MapId) { - MigrateOutOfInstance(mapId); + Migrate(mapId); return; } } @@ -659,8 +668,8 @@ public void MigrateToPlanner(PlotMode plotMode) { } } - public void MigrateToInstance(int mapId, long ownerId) { - bool instancedContent = ServerTableMetadata.InstanceFieldTable.Entries.ContainsKey(mapId); + public void Migrate(int mapId, long ownerId = 0) { + bool isInstanced = ServerTableMetadata.InstanceFieldTable.Entries.ContainsKey(mapId); try { var request = new MigrateOutRequest { @@ -670,13 +679,20 @@ public void MigrateToInstance(int mapId, long ownerId) { Server = Server.World.Service.Server.Game, MapId = mapId, OwnerId = ownerId, - InstancedContent = instancedContent, + InstancedContent = isInstanced, }; MigrateOutResponse response = World.MigrateOut(request); var endpoint = new IPEndPoint(IPAddress.Parse(response.IpAddress), response.Port); Send(MigrationPacket.GameToGame(endpoint, response.Token, mapId)); - Player.Value.Character.ReturnChannel = Player.Value.Character.Channel; + + if (isInstanced) { + Player.Value.Character.ReturnChannel = Player.Value.Character.Channel; + } else { + Player.Value.Character.MapId = mapId; + Player.Value.Character.ReturnChannel = 0; + } + State = SessionState.ChangeMap; } catch (RpcException ex) { Send(MigrationPacket.GameToGameError(MigrationError.s_move_err_default)); @@ -686,31 +702,38 @@ public void MigrateToInstance(int mapId, long ownerId) { } } - public void MigrateOutOfInstance(int mapId) { - try { - var request = new MigrateOutRequest { - AccountId = AccountId, - CharacterId = CharacterId, - MachineId = MachineId.ToString(), - Server = Server.World.Service.Server.Game, - Channel = Player.Value.Character.ReturnChannel, - MapId = mapId, - }; + private void AcquireLock(long accountId, int maxRetries = 3) { + int retryCount = 0; + const int backoffMs = 500; - MigrateOutResponse response = World.MigrateOut(request); - var endpoint = new IPEndPoint(IPAddress.Parse(response.IpAddress), response.Port); - Send(MigrationPacket.GameToGame(endpoint, response.Token, mapId)); - Player.Value.Character.MapId = mapId; - Player.Value.Character.ReturnChannel = 0; - State = SessionState.ChangeMap; - } catch (RpcException ex) { - Send(MigrationPacket.GameToGameError(MigrationError.s_move_err_default)); - Send(NoticePacket.Disconnect(new InterfaceText(ex.Message))); - } finally { - Disconnect(); + while (retryCount < maxRetries) { + LockResponse? response = World.AcquireLock(new LockRequest { + AccountId = accountId, + }); + + if (string.IsNullOrEmpty(response.Error)) { + return; + } + + retryCount++; + Thread.Sleep(backoffMs); } + + Logger.Error("Failed to acquire lock for account {AccountId} after {MaxRetries} retries", accountId, maxRetries); } + private void ReleaseLock(long accountId) { + try { + LockResponse response = World.ReleaseLock(new LockRequest { + AccountId = accountId, + }); + if (!string.IsNullOrEmpty(response.Error)) { + Logger.Warning("Failed to release lock for account {AccountId}: {ErrorMessage}", accountId, response.Error); + } + } catch (RpcException ex) { + Logger.Error(ex, "Failed to release lock for account {AccountId}", accountId); + } + } #region Dispose ~GameSession() => Dispose(false); @@ -741,7 +764,28 @@ protected override void Dispose(bool disposing) { Player.Value.Account.Online = false; State = SessionState.Disconnected; Complete(); + + SaveCacheConfig(); + AcquireLock(CharacterId); + using GameStorage.Request db = GameStorage.Context(); + db.BeginTransaction(); + db.SavePlayer(Player); + UgcMarket.Save(db); + Config.Save(db); + Shop.Save(db); + Item.Save(db); + Survival.Save(db); + Housing.Save(db); + GameEvent.Save(db); + Achievement.Save(db); + Quest.Save(db); + Dungeon.Save(db); + db.Commit(); + db.SaveChanges(); + } catch (Exception ex) { + Logger.Error(ex, "Error during session cleanup for {Player}", PlayerName); } finally { + ReleaseLock(CharacterId); Guild.Dispose(); Buddy.Dispose(); Party.Dispose(); @@ -753,23 +797,6 @@ protected override void Dispose(bool disposing) { club.Dispose(); } - SaveCacheConfig(); - using (GameStorage.Request db = GameStorage.Context()) { - db.BeginTransaction(); - db.SavePlayer(Player); - UgcMarket.Save(db); - Config.Save(db); - Shop.Save(db); - Item.Save(db); - Survival.Save(db); - Housing.Save(db); - GameEvent.Save(db); - Achievement.Save(db); - Quest.Save(db); - Dungeon.Save(db); - db.Commit(); - db.SaveChanges(); - } Player.Dispose(); base.Dispose(disposing); } diff --git a/Maple2.Server.Login/Session/LoginSession.cs b/Maple2.Server.Login/Session/LoginSession.cs index 58a67c9a6..401bf5041 100644 --- a/Maple2.Server.Login/Session/LoginSession.cs +++ b/Maple2.Server.Login/Session/LoginSession.cs @@ -3,6 +3,7 @@ using System.Net; using System.Net.Sockets; using System.Threading; +using Grpc.Core; using Maple2.Database.Storage; using Maple2.Model.Enum; using Maple2.Model.Game; @@ -52,6 +53,39 @@ public void Init(long accountId, Guid machineId) { Server.OnConnected(this); } + private void AcquireLock(long accountId, int maxRetries = 3) { + int retryCount = 0; + const int backoffMs = 500; + + while (retryCount < maxRetries) { + LockResponse? response = World.AcquireLock(new LockRequest { + AccountId = accountId, + }); + + if (string.IsNullOrEmpty(response.Error)) { + return; + } + + retryCount++; + Thread.Sleep(backoffMs); + } + + Logger.Error("Failed to acquire lock for account {AccountId} after {MaxRetries} retries", accountId, maxRetries); + } + + private void ReleaseLock(long accountId) { + try { + LockResponse response = World.ReleaseLock(new LockRequest { + AccountId = accountId, + }); + if (!string.IsNullOrEmpty(response.Error)) { + Logger.Warning("Failed to release lock for account {AccountId}: {ErrorMessage}", accountId, response.Error); + } + } catch (RpcException ex) { + Logger.Error(ex, "Failed to release lock for account {AccountId}", accountId); + } + } + public void ListServers() { ChannelsResponse response = World.Channels(new ChannelsRequest()); Send(BannerListPacket.Load(Server.GetSystemBanners())); @@ -60,7 +94,14 @@ public void ListServers() { public void ListCharacters() { using GameStorage.Request db = GameStorage.Context(); - (Account? readAccount, IList? characters) = db.ListCharacters(AccountId); + AcquireLock(AccountId); + Account? readAccount; + IList? characters; + try { + (readAccount, characters) = db.ListCharacters(AccountId); + } finally { + ReleaseLock(AccountId); + } if (readAccount == null || characters == null) { throw new InvalidOperationException($"Failed to load characters for account: {AccountId}"); } diff --git a/Maple2.Server.World/Service/WorldService.Locks.cs b/Maple2.Server.World/Service/WorldService.Locks.cs new file mode 100644 index 000000000..6ab2f3a9d --- /dev/null +++ b/Maple2.Server.World/Service/WorldService.Locks.cs @@ -0,0 +1,39 @@ +using System.Collections.Concurrent; +using Grpc.Core; + +namespace Maple2.Server.World.Service; + +public partial class WorldService { + private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(30); + private static readonly ConcurrentDictionary Locks = new ConcurrentDictionary(); + + public override Task AcquireLock(LockRequest request, ServerCallContext context) { + DateTime now = DateTime.UtcNow; + + // Remove expired lock if present + if (Locks.TryGetValue(request.AccountId, out DateTime timestamp)) { + if (now - timestamp > LockTimeout) { + Locks.TryRemove(request.AccountId, out _); + } + } + + // Try to acquire lock + bool acquired = Locks.TryAdd(request.AccountId, now); + if (acquired) { + return Task.FromResult(new LockResponse()); + } + return Task.FromResult(new LockResponse { + Error = "Lock already held.", + }); + } + + public override Task ReleaseLock(LockRequest request, ServerCallContext context) { + bool removed = Locks.TryRemove(request.AccountId, out _); + if (removed) { + return Task.FromResult(new LockResponse()); + } + return Task.FromResult(new LockResponse { + Error = "Lock not held.", + }); + } +}