diff --git a/Maple2.Model/Game/User/PlayerInfo.cs b/Maple2.Model/Game/User/PlayerInfo.cs index 323b4240e..1fb9d1a7b 100644 --- a/Maple2.Model/Game/User/PlayerInfo.cs +++ b/Maple2.Model/Game/User/PlayerInfo.cs @@ -126,7 +126,7 @@ public class CharacterInfo { public DeathState DeathState { get; set; } // Location public int MapId { get; set; } - public short Channel { get; set; } + public short Channel { get; set; } = -1; public long LastOnlineTime { get; set; } // Guild @@ -134,7 +134,7 @@ public class CharacterInfo { public string GuildName { get; set; } public long UpdateTime { get; set; } - public bool Online => Channel != 0; + public bool Online => Channel >= 0; public CharacterInfo(long accountId, long characterId, string name, string motto, string picture, Gender gender, Job job, short level) { AccountId = accountId; diff --git a/Maple2.Model/Metadata/Constants.cs b/Maple2.Model/Metadata/Constants.cs index e82d360ab..84b3a0af8 100644 --- a/Maple2.Model/Metadata/Constants.cs +++ b/Maple2.Model/Metadata/Constants.cs @@ -113,6 +113,8 @@ public static class Constant { public const bool EnableRollEverywhere = false; public const bool HideHomeCommands = true; + public static int MaxAllowedLatency = TimeSpan.FromSeconds(2).Milliseconds; + public static IReadOnlyDictionary ContentRewards { get; } = new Dictionary { {"miniGame", 1005}, {"dungeonHelper", 1006}, diff --git a/Maple2.Server.Core/Constants/Target.cs b/Maple2.Server.Core/Constants/Target.cs index 313a83945..2b0597171 100644 --- a/Maple2.Server.Core/Constants/Target.cs +++ b/Maple2.Server.Core/Constants/Target.cs @@ -10,6 +10,8 @@ public static class Target { public static readonly IPAddress LoginIp = IPAddress.Loopback; public static readonly ushort LoginPort = 20001; + public static readonly ushort GrpcLoginPort = 21000; + public static readonly IPAddress GameIp = IPAddress.Loopback; public static readonly ushort BaseGamePort = 20002; diff --git a/Maple2.Server.Core/Network/Session.cs b/Maple2.Server.Core/Network/Session.cs index 8906fbeef..b25fb0686 100644 --- a/Maple2.Server.Core/Network/Session.cs +++ b/Maple2.Server.Core/Network/Session.cs @@ -1,11 +1,8 @@ -using System; -using System.Buffers; +using System.Buffers; using System.Collections.Concurrent; using System.IO.Pipelines; using System.Net.Sockets; using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; using Maple2.Model.Enum; using Maple2.PacketLib.Crypto; using Maple2.PacketLib.Tools; diff --git a/Maple2.Server.Core/proto/channel/channel.proto b/Maple2.Server.Core/proto/channel/channel.proto index f7cdcdda2..e60fa4614 100644 --- a/Maple2.Server.Core/proto/channel/channel.proto +++ b/Maple2.Server.Core/proto/channel/channel.proto @@ -42,6 +42,10 @@ service Channel { rpc PlayerWarp(maple2.PlayerWarpRequest) returns (maple2.PlayerWarpResponse); // Admin rpc Admin(maple2.AdminRequest) returns (maple2.AdminResponse); + // Disconnect + rpc Disconnect(maple2.DisconnectRequest) returns (maple2.DisconnectResponse); + // Heartbeat + rpc Heartbeat(maple2.HeartbeatRequest) returns (maple2.HeartbeatResponse); } message GuildRequest { diff --git a/Maple2.Server.Core/proto/common.proto b/Maple2.Server.Core/proto/common.proto index 1d5c66730..b63dafaf1 100644 --- a/Maple2.Server.Core/proto/common.proto +++ b/Maple2.Server.Core/proto/common.proto @@ -333,3 +333,19 @@ message AdminResponse { int32 error = 1; string message = 2; } + +message DisconnectRequest { + int64 character_id = 1; +} + +message DisconnectResponse { + bool success = 1; +} + +message HeartbeatRequest { + int64 character_id = 1; +} + +message HeartbeatResponse { + bool success = 1; +} diff --git a/Maple2.Server.Core/proto/login/login.proto b/Maple2.Server.Core/proto/login/login.proto new file mode 100644 index 000000000..618f2eda2 --- /dev/null +++ b/Maple2.Server.Core/proto/login/login.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package maple2.server.login.service; + +import "google/protobuf/empty.proto"; +import "common.proto"; + +service Login { + rpc Heartbeat(maple2.HeartbeatRequest) returns (maple2.HeartbeatResponse); +} diff --git a/Maple2.Server.Core/proto/sync.proto b/Maple2.Server.Core/proto/sync.proto index 9a10fba47..03d8d0549 100644 --- a/Maple2.Server.Core/proto/sync.proto +++ b/Maple2.Server.Core/proto/sync.proto @@ -77,6 +77,7 @@ message PlayerUpdateResponse { message PlayerInfoRequest { int64 character_id = 1; + int64 account_id = 2; } message PlayerInfoResponse { diff --git a/Maple2.Server.Core/proto/world/world.proto b/Maple2.Server.Core/proto/world/world.proto index f12e29fc1..e100578d4 100644 --- a/Maple2.Server.Core/proto/world/world.proto +++ b/Maple2.Server.Core/proto/world/world.proto @@ -36,6 +36,7 @@ service World { rpc GroupChat(GroupChatRequest) returns (GroupChatResponse); // Retrieve player info from online player. rpc PlayerInfo(maple2.PlayerInfoRequest) returns (maple2.PlayerInfoResponse); + rpc AccountInfo(maple2.PlayerInfoRequest) returns (maple2.PlayerInfoResponse); // Update player info rpc UpdatePlayer(maple2.PlayerUpdateRequest) returns (maple2.PlayerUpdateResponse); // Notify character about new mail. @@ -56,6 +57,8 @@ service World { rpc Admin(maple2.AdminRequest) returns (maple2.AdminResponse); // Buff rpc PlayerConfig(PlayerConfigRequest) returns (PlayerConfigResponse); + // Disconnect + rpc Disconnect(maple2.DisconnectRequest) returns (maple2.DisconnectResponse); } enum Server { diff --git a/Maple2.Server.Game/PacketHandlers/ResponseHeartbeatHandler.cs b/Maple2.Server.Game/PacketHandlers/ResponseHeartbeatHandler.cs new file mode 100644 index 000000000..c68d02f11 --- /dev/null +++ b/Maple2.Server.Game/PacketHandlers/ResponseHeartbeatHandler.cs @@ -0,0 +1,40 @@ +using Maple2.Model.Metadata; +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.Server.Core.PacketHandlers; +using Maple2.Server.Game.Session; + +namespace Maple2.Server.Game.PacketHandlers; + +public class ResponseHeartbeatHandler : PacketHandler { + public override RecvOp OpCode => RecvOp.ResponseHeartbeat; + + public override void Handle(GameSession session, IByteReader packet) { + int serverTick = packet.ReadInt(); + int clientTick = packet.ReadInt(); + + int latency = Environment.TickCount - serverTick; + if (latency > Constant.MaxAllowedLatency) { +#if !DEBUG + session.Disconnect(); +#endif + return; + } + + if (serverTick == 0 || clientTick == 0) { + return; + } + + if (session is { ClientTick: 0, ServerTick: 0 }) { + session.ClientTick = clientTick; + session.ServerTick = serverTick; + return; + } + + int serverDelta = serverTick - session.ServerTick; + int clientDelta = clientTick - session.ClientTick; + + session.ClientTick = clientTick; + session.ServerTick = serverTick; + } +} diff --git a/Maple2.Server.Game/Service/ChannelService.Heartbeat.cs b/Maple2.Server.Game/Service/ChannelService.Heartbeat.cs new file mode 100644 index 000000000..7a2d8494b --- /dev/null +++ b/Maple2.Server.Game/Service/ChannelService.Heartbeat.cs @@ -0,0 +1,21 @@ +using Grpc.Core; +using Maple2.Server.Core.Packets; +using Maple2.Server.Game.Session; + +namespace Maple2.Server.Game.Service; + +public partial class ChannelService { + public override Task Heartbeat(HeartbeatRequest request, ServerCallContext context) { + if (request.CharacterId == 0) { + throw new RpcException(new Status(StatusCode.NotFound, "Character ID is 0.")); + } + if (!server.GetSession(request.CharacterId, out GameSession? session)) { + throw new RpcException(new Status(StatusCode.NotFound, "Session not found.")); + } + + session.Send(RequestPacket.Heartbeat()); + return Task.FromResult(new HeartbeatResponse { + Success = true, + }); + } +} diff --git a/Maple2.Server.Game/Service/ChannelService.Sync.cs b/Maple2.Server.Game/Service/ChannelService.Sync.cs index 3c3b7d83d..11dd54034 100644 --- a/Maple2.Server.Game/Service/ChannelService.Sync.cs +++ b/Maple2.Server.Game/Service/ChannelService.Sync.cs @@ -1,4 +1,7 @@ using Grpc.Core; +using Maple2.Model.Game; +using Maple2.Server.Channel.Service; +using Maple2.Server.Core.Packets; using Maple2.Server.Game.Session; namespace Maple2.Server.Game.Service; @@ -11,11 +14,30 @@ public override Task MailNotification(MailNotification session.Mail.Notify(true); - return Task.FromResult(new MailNotificationResponse { Delivered = true }); + return Task.FromResult(new MailNotificationResponse { + Delivered = true, + }); } public override Task UpdatePlayer(PlayerUpdateRequest request, ServerCallContext context) { playerInfos.ReceiveUpdate(request); return Task.FromResult(new PlayerUpdateResponse()); } + + public override Task Disconnect(DisconnectRequest request, ServerCallContext context) { + if (request is { CharacterId: <= 0 }) { + throw new RpcException(new Status(StatusCode.InvalidArgument, $"CharacterId not specified")); + } + + if (!server.GetSession(request.CharacterId, out GameSession? session)) { + return Task.FromResult(new DisconnectResponse { + Success = false, + }); + } + + session.Disconnect(); + return Task.FromResult(new DisconnectResponse { + Success = true, + }); + } } diff --git a/Maple2.Server.Game/Session/GameSession.cs b/Maple2.Server.Game/Session/GameSession.cs index ebd0b5605..5fc9ef9d3 100644 --- a/Maple2.Server.Game/Session/GameSession.cs +++ b/Maple2.Server.Game/Session/GameSession.cs @@ -44,6 +44,11 @@ public sealed partial class GameSession : Core.Network.Session { public readonly CommandRouter CommandHandler; public readonly EventQueue Scheduler; + public int ServerTick; + public int ClientTick; + + public int Latency; + public long AccountId { get; private set; } public long CharacterId { get; private set; } public string PlayerName => Player.Value.Character.Name; @@ -711,7 +716,7 @@ protected override void Dispose(bool disposing) { CharacterId = CharacterId, LastOnlineTime = DateTime.UtcNow.ToEpochSeconds(), MapId = 0, - Channel = 0, + Channel = -1, Async = true, }); @@ -722,7 +727,7 @@ protected override void Dispose(bool disposing) { Scheduler.Stop(); server.OnDisconnected(this); LeaveField(); - Player.Value.Character.Channel = 0; + Player.Value.Character.Channel = -1; Player.Value.Account.Online = false; State = SessionState.Disconnected; Complete(); diff --git a/Maple2.Server.Login/LoginServer.cs b/Maple2.Server.Login/LoginServer.cs index 10ebfa7a5..b433b5492 100644 --- a/Maple2.Server.Login/LoginServer.cs +++ b/Maple2.Server.Login/LoginServer.cs @@ -78,4 +78,6 @@ public override Task StopAsync(CancellationToken cancellationToken) { return base.StopAsync(cancellationToken); } + + public List GetSessions() => sessions.Values.ToList(); } diff --git a/Maple2.Server.Login/PacketHandlers/LoginHandler.cs b/Maple2.Server.Login/PacketHandlers/LoginHandler.cs index 721207a9b..d2de849ff 100644 --- a/Maple2.Server.Login/PacketHandlers/LoginHandler.cs +++ b/Maple2.Server.Login/PacketHandlers/LoginHandler.cs @@ -30,36 +30,55 @@ private enum Command : byte { public override void Handle(LoginSession session, IByteReader packet) { var command = packet.Read(); + string user = packet.ReadUnicodeString(); + string pass = packet.ReadUnicodeString(); + packet.ReadShort(); // 1 + var machineId = packet.Read(); + + Logger.Debug("Logging in with user:{User}", user); + LoginResponse response = Global.Login(new LoginRequest { + Username = user, + Password = pass, + MachineId = machineId.ToString(), + }); + + if (response.Code != LoginResponse.Types.Code.Ok) { + session.Send(LoginResultPacket.Error((byte) response.Code, response.Message, response.AccountId)); + return; + } + + // Account is already logged into login server. + if (session.Server.GetSession(response.AccountId, out LoginSession? existing) && existing != session) { + existing.Disconnect(); + session.Send(LoginResultPacket.Error((byte) LoginResponse.Types.Code.AlreadyLogin, "", response.AccountId)); + return; + } + + PlayerInfoResponse? playerInfo = World.AccountInfo(new PlayerInfoRequest { + AccountId = response.AccountId, + }); + + if (playerInfo is not null && playerInfo.Channel > 0) { + DisconnectResponse? disconnectResponse = World.Disconnect(new DisconnectRequest { + CharacterId = playerInfo.CharacterId, + }); + if (disconnectResponse is null || !disconnectResponse.Success) { + Logger.Error("Failed to disconnect character: {CharacterId}", playerInfo.CharacterId); + } else { + Logger.Debug("Disconnected character: {CharacterId}", playerInfo.CharacterId); + } + + session.Send(LoginResultPacket.Error((byte) LoginResponse.Types.Code.AlreadyLogin, "", response.AccountId)); + return; + } + try { switch (command) { case Command.ServerList: session.ListServers(); + session.Disconnect(); return; case Command.CharacterList: - string user = packet.ReadUnicodeString(); - string pass = packet.ReadUnicodeString(); - packet.ReadShort(); // 1 - var machineId = packet.Read(); - - Logger.Debug("Logging in with user:{User}", user); - LoginResponse response = Global.Login(new LoginRequest { - Username = user, - Password = pass, - MachineId = machineId.ToString(), - }); - - if (response.Code != LoginResponse.Types.Code.Ok) { - session.Send(LoginResultPacket.Error((byte) response.Code, response.Message, response.AccountId)); - session.Disconnect(); - return; - } - - // TODO: Account is already logged into game server. - // if (World.PlayerInfo(new PlayerInfoRequest {AccountId = response.AccountId}).Location.Channel > 0) { - // session.Send(LoginResultPacket.Error((byte) LoginResponse.Types.Code.AlreadyLogin, "", response.AccountId)); - // return; - // } - session.Init(response.AccountId, machineId); session.Send(LoginResultPacket.Success(response.AccountId)); diff --git a/Maple2.Server.Login/PacketHandlers/QuitHandler.cs b/Maple2.Server.Login/PacketHandlers/QuitHandler.cs new file mode 100644 index 000000000..1468dd879 --- /dev/null +++ b/Maple2.Server.Login/PacketHandlers/QuitHandler.cs @@ -0,0 +1,14 @@ +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.Server.Core.PacketHandlers; +using Maple2.Server.Login.Session; + +namespace Maple2.Server.Login.PacketHandlers; + +public class QuitHandler : PacketHandler { + public override RecvOp OpCode => RecvOp.RequestQuit; + + public override void Handle(LoginSession session, IByteReader packet) { + session.Disconnect(); + } +} diff --git a/Maple2.Server.Login/PacketHandlers/ResponseHeartbeatHandler.cs b/Maple2.Server.Login/PacketHandlers/ResponseHeartbeatHandler.cs new file mode 100644 index 000000000..d25902708 --- /dev/null +++ b/Maple2.Server.Login/PacketHandlers/ResponseHeartbeatHandler.cs @@ -0,0 +1,33 @@ +using System; +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.Server.Core.PacketHandlers; +using Maple2.Server.Login.Session; + +namespace Maple2.Server.Login.PacketHandlers; + +public class ResponseHeartbeatHandler : PacketHandler { + public override RecvOp OpCode => RecvOp.ResponseHeartbeat; + + public override void Handle(LoginSession session, IByteReader packet) { + int serverTick = packet.ReadInt(); + int clientTick = packet.ReadInt(); + + + + if (serverTick == 0 || clientTick == 0) { + return; + } + + if (session is { ClientTick: 0, ServerTick: 0 }) { + session.ClientTick = clientTick; + session.ServerTick = serverTick; + return; + } + + int serverDelta = serverTick - session.ServerTick; + int clientDelta = clientTick - session.ClientTick; + session.ClientTick = clientTick; + session.ServerTick = serverTick; + } +} diff --git a/Maple2.Server.Login/Program.cs b/Maple2.Server.Login/Program.cs index d09cbef90..07c1105a0 100644 --- a/Maple2.Server.Login/Program.cs +++ b/Maple2.Server.Login/Program.cs @@ -1,4 +1,5 @@ -using System.Globalization; +using System; +using System.Globalization; using System.IO; using Autofac; using Autofac.Extensions.DependencyInjection; @@ -8,12 +9,19 @@ using Microsoft.Extensions.Logging; using Serilog; using System.Reflection; +using Maple2.Database.Storage; +using Maple2.Server.Core.Constants; using Maple2.Server.Core.Modules; using Maple2.Server.Core.Network; using Maple2.Server.Core.PacketHandlers; +using Maple2.Server.Login.Service; using Maple2.Server.Login.Session; using Microsoft.Extensions.DependencyInjection; using Maple2.Tools; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Diagnostics.HealthChecks; // Force Globalization to en-US because we use periods instead of commas for decimals CultureInfo.CurrentCulture = new("en-US"); @@ -28,34 +36,62 @@ .ReadFrom.Configuration(configRoot) .CreateLogger(); -await Host.CreateDefaultBuilder() - .ConfigureLogging(logging => { - logging.ClearProviders(); - logging.AddSerilog(dispose: true); - }) - .ConfigureServices(services => { - services.RegisterModule(); - services.AddSingleton(); - services.AddHostedService(provider => provider.GetService()!); - }) - .UseServiceProviderFactory(new AutofacServiceProviderFactory()) - .ConfigureContainer(autofac => { - autofac.RegisterType>() - .As>() - .SingleInstance(); - autofac.RegisterType() - .PropertiesAutowired() - .AsSelf(); - - // Database - autofac.RegisterModule(); - autofac.RegisterModule(); - - // Make all packet handlers available to PacketRouter - autofac.RegisterAssemblyTypes(Assembly.GetExecutingAssembly()) - .Where(type => typeof(PacketHandler).IsAssignableFrom(type)) - .As>() - .PropertiesAutowired() - .SingleInstance(); - }) - .RunConsoleAsync(); +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.WebHost.UseKestrel(options => { + options.ListenAnyIP(Target.GrpcLoginPort, listen => { + listen.Protocols = HttpProtocols.Http2; + }); +}); + +builder.Logging.ClearProviders(); +builder.Logging.AddSerilog(dispose: true); + +builder.Services.AddGrpc(); +builder.Services.RegisterModule(); +builder.Services.AddMemoryCache(); + +builder.Services.AddSingleton(provider => new LoginServer( + provider.GetRequiredService>(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService() +)); +builder.Services.AddHostedService(provider => provider.GetService()!); + +builder.Services.AddGrpcHealthChecks(); +builder.Services.Configure(options => { + options.Delay = TimeSpan.Zero; + options.Period = TimeSpan.FromSeconds(10); +}); +builder.Services.AddHealthChecks() + .AddCheck("login_health_check"); + +builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()); +builder.Host.ConfigureContainer(autofac => { + // Database + autofac.RegisterModule(); + autofac.RegisterModule(); + + autofac.RegisterType>() + .As>() + .SingleInstance(); + + autofac.RegisterType() + .PropertiesAutowired() + .AsSelf(); + + // Make all packet handlers available to PacketRouter + autofac.RegisterAssemblyTypes(Assembly.GetExecutingAssembly()) + .Where(type => typeof(PacketHandler).IsAssignableFrom(type)) + .As>() + .PropertiesAutowired() + .SingleInstance(); +}); + + +WebApplication app = builder.Build(); +app.UseRouting(); +app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client."); +app.MapGrpcService(); + +await app.RunAsync(); diff --git a/Maple2.Server.Login/Service/LoginService.Heartbeat.cs b/Maple2.Server.Login/Service/LoginService.Heartbeat.cs new file mode 100644 index 000000000..1979c64c5 --- /dev/null +++ b/Maple2.Server.Login/Service/LoginService.Heartbeat.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using Grpc.Core; +using Maple2.Server.Core.Packets; +using Maple2.Server.Login.Session; + +namespace Maple2.Server.Login.Service; + +public partial class LoginService { + public override Task Heartbeat(HeartbeatRequest request, ServerCallContext context) { + foreach (LoginSession loginSession in loginServer.GetSessions()) { + loginSession.Send(RequestPacket.Heartbeat()); + } + return Task.FromResult(new HeartbeatResponse()); + } +} diff --git a/Maple2.Server.Login/Service/LoginService.cs b/Maple2.Server.Login/Service/LoginService.cs new file mode 100644 index 000000000..af93a8e17 --- /dev/null +++ b/Maple2.Server.Login/Service/LoginService.cs @@ -0,0 +1,14 @@ +using Grpc.Core; +using Serilog; + +namespace Maple2.Server.Login.Service; + +public partial class LoginService : Login.LoginBase { + private readonly LoginServer loginServer; + private readonly ILogger logger; + + public LoginService(LoginServer loginServer) { + this.loginServer = loginServer; + logger = Log.Logger.ForContext(); + } +} diff --git a/Maple2.Server.Login/Session/LoginSession.cs b/Maple2.Server.Login/Session/LoginSession.cs index 620a8e2ed..58a67c9a6 100644 --- a/Maple2.Server.Login/Session/LoginSession.cs +++ b/Maple2.Server.Login/Session/LoginSession.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Sockets; +using System.Threading; using Maple2.Database.Storage; using Maple2.Model.Enum; using Maple2.Model.Game; @@ -35,6 +36,9 @@ public class LoginSession : Core.Network.Session { private Account account = null!; + public int ServerTick; + public int ClientTick; + public LoginSession(TcpClient tcpClient, LoginServer server) : base(tcpClient) { Server = server; State = SessionState.ChangeMap; @@ -44,14 +48,6 @@ public void Init(long accountId, Guid machineId) { AccountId = accountId; MachineId = machineId; - // Account is already logged into login server. - if (Server.GetSession(accountId, out LoginSession? existing) && existing != this) { - Send(LoginResultPacket.Error((byte) LoginResponse.Types.Code.AlreadyLogin, "", accountId)); - existing.Disconnect(); - Disconnect(); - return; - } - State = SessionState.Connected; Server.OnConnected(this); } @@ -115,7 +111,9 @@ public void CreateCharacter(Character createCharacter, List createOutfits) Send(CharacterListPacket.SetMax(account.MaxCharacters, Constant.ServerMaxCharacters)); Send(CharacterListPacket.AppendEntry(account, character, - new Dictionary> { { ItemGroup.Outfit, outfits } })); + new Dictionary> { + { ItemGroup.Outfit, outfits } + })); } #region Dispose diff --git a/Maple2.Server.World/Containers/PlayerInfoLookup.cs b/Maple2.Server.World/Containers/PlayerInfoLookup.cs index 8ecad9461..8e22599a8 100644 --- a/Maple2.Server.World/Containers/PlayerInfoLookup.cs +++ b/Maple2.Server.World/Containers/PlayerInfoLookup.cs @@ -7,6 +7,7 @@ using Grpc.Core; using Maple2.Database.Storage; using Maple2.Model.Game; +using Maple2.Server.Channel.Service; using Maple2.Server.Core.Sync; using Serilog; @@ -60,8 +61,9 @@ public bool TryGet(long characterId, [NotNullWhen(true)] out PlayerInfo? info) { // Try to get a cached player by account id and that's online, since we need a ChannelClient to notify. // If the player is not cached just return false since it was never online. // This is a very specific use case, and should be used with caution, mainly used for account wide mail notifications. - public PlayerInfo? TryGetByAccountId(long accountId) { - return cache.Values.FirstOrDefault(player => player.AccountId == accountId && player.Online); + public bool TryGetByAccountId(long accountId, [NotNullWhen(true)] out PlayerInfo? info) { + info = cache.Values.FirstOrDefault(player => player.AccountId == accountId && player.Online); + return info is not null; } public bool Update(PlayerUpdateRequest request) { @@ -142,4 +144,10 @@ private record Notification(PlayerInfo Info) { TryGet(id, out PlayerInfo? info); return info; } + + public List GetOnlinePlayerInfos() { + return cache.Values + .Where(player => player.Online) + .ToList(); + } } diff --git a/Maple2.Server.World/Program.cs b/Maple2.Server.World/Program.cs index 7545d0afe..e0a0c5f1d 100644 --- a/Maple2.Server.World/Program.cs +++ b/Maple2.Server.World/Program.cs @@ -6,6 +6,7 @@ using Maple2.Server.Core.Constants; using Maple2.Server.Core.Modules; using Maple2.Server.Global.Service; +using Maple2.Server.Login.Service; using Maple2.Server.World; using Maple2.Server.World.Containers; using Maple2.Server.World.Service; @@ -45,6 +46,11 @@ builder.Services.AddGrpc(); builder.Services.AddMemoryCache(); +builder.Services.AddGrpcClient(options => { + string loginService = Environment.GetEnvironmentVariable("GRPC_LOGIN_IP") ?? IPAddress.Loopback.ToString(); + options.Address = new Uri($"http://{loginService}:{Target.GrpcLoginPort}"); +}); + builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()); builder.Host.ConfigureContainer(autofac => { // Database diff --git a/Maple2.Server.World/Service/WorldService.Sync.cs b/Maple2.Server.World/Service/WorldService.Sync.cs index 29f05fb48..9f319f49e 100644 --- a/Maple2.Server.World/Service/WorldService.Sync.cs +++ b/Maple2.Server.World/Service/WorldService.Sync.cs @@ -9,7 +9,7 @@ namespace Maple2.Server.World.Service; public partial class WorldService { public override Task PlayerInfo(PlayerInfoRequest request, ServerCallContext context) { - if (request.CharacterId <= 0) { + if (request is { CharacterId: <= 0 }) { throw new RpcException(new Status(StatusCode.InvalidArgument, $"AccountId and CharacterId not specified")); } @@ -17,52 +17,23 @@ public override Task PlayerInfo(PlayerInfoRequest request, S throw new RpcException(new Status(StatusCode.NotFound, $"Invalid character: {request.CharacterId}")); } - return Task.FromResult(new PlayerInfoResponse { - AccountId = info.AccountId, - CharacterId = info.CharacterId, - UpdateTime = info.UpdateTime, - Name = info.Name, - Motto = info.Motto, - Picture = info.Picture, - Gender = (int) info.Gender, - Job = (int) info.Job, - Level = info.Level, - GearScore = info.GearScore, - PremiumTime = info.PremiumTime, - LastOnlineTime = info.LastOnlineTime, - MapId = info.MapId, - Channel = info.Channel, - Health = new HealthUpdate { - CurrentHp = info.CurrentHp, - TotalHp = info.TotalHp, - }, - DeathState = (int) info.DeathState, - GuildName = info.GuildName, - GuildId = info.GuildId, - Home = new HomeUpdate { - Name = info.HomeName, - MapId = info.PlotMapId, - PlotNumber = info.PlotNumber, - ApartmentNumber = info.ApartmentNumber, - ExpiryTime = new Timestamp { - Seconds = info.PlotExpiryTime, - }, - }, - Trophy = new TrophyUpdate { - Combat = info.AchievementInfo.Combat, - Adventure = info.AchievementInfo.Adventure, - Lifestyle = info.AchievementInfo.Lifestyle, - }, - Clubs = { info.ClubIds.Select(id => new ClubUpdate { Id = id }) }, - DungeonEnterLimits = { info.DungeonEnterLimits.Select(dungeon => new DungeonEnterLimitUpdate { - DungeonId = dungeon.Key, - Limit = (int) dungeon.Value, - }) }, - }); + return Task.FromResult(PlayerInfoResponse(info)); + } + + public override Task AccountInfo(PlayerInfoRequest request, ServerCallContext context) { + if (request is { AccountId: <= 0 }) { + throw new RpcException(new Status(StatusCode.InvalidArgument, $"AccountId not specified")); + } + + if (!playerLookup.TryGetByAccountId(request.AccountId, out PlayerInfo? info)) { + return Task.FromResult(new PlayerInfoResponse()); + } + + return Task.FromResult(PlayerInfoResponse(info)); } public override Task UpdatePlayer(PlayerUpdateRequest request, ServerCallContext context) { - if (request.HasGender && request.Gender is not 0 or 1) { + if (request is { HasGender: true, Gender: not (0 or 1) }) { throw new RpcException(new Status(StatusCode.InvalidArgument, $"Player updated with invalid gender: {request.Gender}")); } if (request.HasJob && !Enum.IsDefined((Job) request.Job)) { @@ -75,13 +46,12 @@ public override Task UpdatePlayer(PlayerUpdateRequest requ } public override Task MailNotification(MailNotificationRequest request, ServerCallContext context) { - if (request.CharacterId <= 0 && request.AccountId <= 0) { + if (request is { CharacterId: <= 0, AccountId: <= 0 }) { throw new RpcException(new Status(StatusCode.InvalidArgument, "AccountId and CharacterId not specified")); } if (!playerLookup.TryGet(request.CharacterId, out PlayerInfo? info)) { - info = playerLookup.TryGetByAccountId(request.AccountId); - if (info == null) { + if (!playerLookup.TryGetByAccountId(request.AccountId, out info)) { return Task.FromResult(new MailNotificationResponse()); } } @@ -103,4 +73,74 @@ public override Task MailNotification(MailNotification return Task.FromResult(new MailNotificationResponse()); } } + + public override Task Disconnect(DisconnectRequest request, ServerCallContext context) { + if (request is { CharacterId: <= 0 }) { + throw new RpcException(new Status(StatusCode.InvalidArgument, $"CharacterId not specified")); + } + + if (!playerLookup.TryGet(request.CharacterId, out PlayerInfo? info) || info is { Online: false }) { + throw new RpcException(new Status(StatusCode.NotFound, $"Unable to find: {request.CharacterId}")); + } + + if (!channelClients.TryGetClient(info.Channel, out ChannelClient? channelClient)) { + logger.Error("No registry for channel: {Channel}", info.Channel); + return Task.FromResult(new DisconnectResponse()); + } + + return Task.FromResult(channelClient.Disconnect(new DisconnectRequest { + CharacterId = info.CharacterId, + })); + } + + private static PlayerInfoResponse PlayerInfoResponse(PlayerInfo info) { + return new PlayerInfoResponse { + AccountId = info.AccountId, + CharacterId = info.CharacterId, + UpdateTime = info.UpdateTime, + Name = info.Name, + Motto = info.Motto, + Picture = info.Picture, + Gender = (int) info.Gender, + Job = (int) info.Job, + Level = info.Level, + GearScore = info.GearScore, + PremiumTime = info.PremiumTime, + LastOnlineTime = info.LastOnlineTime, + MapId = info.MapId, + Channel = info.Channel, + Health = new HealthUpdate { + CurrentHp = info.CurrentHp, + TotalHp = info.TotalHp, + }, + DeathState = (int) info.DeathState, + GuildName = info.GuildName, + GuildId = info.GuildId, + Home = new HomeUpdate { + Name = info.HomeName, + MapId = info.PlotMapId, + PlotNumber = info.PlotNumber, + ApartmentNumber = info.ApartmentNumber, + ExpiryTime = new Timestamp { + Seconds = info.PlotExpiryTime, + }, + }, + Trophy = new TrophyUpdate { + Combat = info.AchievementInfo.Combat, + Adventure = info.AchievementInfo.Adventure, + Lifestyle = info.AchievementInfo.Lifestyle, + }, + Clubs = { + info.ClubIds.Select(id => new ClubUpdate { + Id = id, + }), + }, + DungeonEnterLimits = { + info.DungeonEnterLimits.Select(dungeon => new DungeonEnterLimitUpdate { + DungeonId = dungeon.Key, + Limit = (int) dungeon.Value, + }), + }, + }; + } } diff --git a/Maple2.Server.World/WorldServer.cs b/Maple2.Server.World/WorldServer.cs index c23f2999b..15ba455c4 100644 --- a/Maple2.Server.World/WorldServer.cs +++ b/Maple2.Server.World/WorldServer.cs @@ -1,14 +1,19 @@ using System.Collections.Concurrent; +using Grpc.Core; using Maple2.Database.Extensions; using Maple2.Database.Storage; using Maple2.Model.Enum; +using Maple2.Model.Game; using Maple2.Model.Game.Event; using Maple2.Model.Metadata; using Maple2.Server.Channel.Service; +using Maple2.Server.Core.Sync; using Maple2.Server.World.Containers; using Maple2.Tools.Scheduler; using Serilog; using ChannelClient = Maple2.Server.Channel.Service.Channel.ChannelClient; +using LoginClient = Maple2.Server.Login.Service.Login.LoginClient; + namespace Maple2.Server.World; @@ -17,7 +22,9 @@ public class WorldServer { private readonly ChannelClientLookup channelClients; private readonly ServerTableMetadataStorage serverTableMetadata; private readonly GlobalPortalLookup globalPortalLookup; + private readonly PlayerInfoLookup playerInfoLookup; private readonly Thread thread; + private readonly Thread heartbeatThread; private readonly EventQueue scheduler; private readonly CancellationTokenSource tokenSource = new(); private readonly ConcurrentDictionary memoryStringBoards; @@ -25,11 +32,15 @@ public class WorldServer { private readonly ILogger logger = Log.ForContext(); - public WorldServer(GameStorage gameStorage, ChannelClientLookup channelClients, ServerTableMetadataStorage serverTableMetadata, GlobalPortalLookup globalPortalLookup) { + private readonly LoginClient login; + + public WorldServer(GameStorage gameStorage, ChannelClientLookup channelClients, ServerTableMetadataStorage serverTableMetadata, GlobalPortalLookup globalPortalLookup, PlayerInfoLookup playerInfoLookup, LoginClient login) { this.gameStorage = gameStorage; this.channelClients = channelClients; this.serverTableMetadata = serverTableMetadata; this.globalPortalLookup = globalPortalLookup; + this.playerInfoLookup = playerInfoLookup; + this.login = login; scheduler = new EventQueue(); scheduler.Start(); memoryStringBoards = []; @@ -39,6 +50,37 @@ public WorldServer(GameStorage gameStorage, ChannelClientLookup channelClients, ScheduleGameEvents(); thread = new Thread(Loop); thread.Start(); + + heartbeatThread = new Thread(Heartbeat); + heartbeatThread.Start(); + } + + private void Heartbeat() { + while (!tokenSource.Token.IsCancellationRequested) { + try { + Task.Delay(TimeSpan.FromSeconds(30), tokenSource.Token); + + login.Heartbeat(new HeartbeatRequest(), cancellationToken: tokenSource.Token); + + foreach (PlayerInfo playerInfo in playerInfoLookup.GetOnlinePlayerInfos()) { + if (playerInfo.CharacterId == 0) continue; + if (!channelClients.TryGetClient(playerInfo.Channel, out ChannelClient? channel)) continue; + + try { + channel.Heartbeat(new HeartbeatRequest { + CharacterId = playerInfo.CharacterId, + }, + cancellationToken: tokenSource.Token); + } catch (RpcException) { + playerInfo.Channel = -1; + } + } + } catch (TaskCanceledException) { + break; // graceful shutdown + } catch (Exception ex) { + logger.Warning(ex, "Heartbeat loop error"); + } + } } private void Loop() {