baseline: OpenMU upstream b5a0961 (fresh source)

This commit is contained in:
Acentech Dev
2026-07-14 19:00:35 +03:00
parent 450402e47d
commit 36fc125d5c
11968 changed files with 748705 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
// <copyright file="DockerConnectServerInstanceManager.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AdminPanel.Host;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// An implementation of <see cref="IConnectServerInstanceManager"/>.
/// </summary>
public class DockerConnectServerInstanceManager : IConnectServerInstanceManager
{
private readonly IServerProvider _serverProvider;
/// <summary>
/// Initializes a new instance of the <see cref="DockerConnectServerInstanceManager"/> class.
/// </summary>
/// <param name="serverProvider">The server provider.</param>
public DockerConnectServerInstanceManager(IServerProvider serverProvider)
{
this._serverProvider = serverProvider;
}
/// <inheritdoc />
public async ValueTask InitializeConnectServerAsync(Guid connectServerDefinitionId)
{
// TODO: Implement this... by starting a new docker container
}
/// <inheritdoc />
public async ValueTask RemoveConnectServerAsync(Guid connectServerDefinitionId)
{
var connectServers = this._serverProvider.Servers
.Where(server => server.Type == ServerType.ConnectServer)
.FirstOrDefault(server => server.ConfigurationId == connectServerDefinitionId);
if (connectServers is not null)
{
await connectServers.ShutdownAsync().ConfigureAwait(false);
// TODO: Remove the docker container
}
}
}

View File

@@ -0,0 +1,56 @@
// <copyright file="DockerGameServerInstanceManager.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AdminPanel.Host;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// An implementation of <see cref="IGameServerInstanceManager"/>.
/// </summary>
public class DockerGameServerInstanceManager : IGameServerInstanceManager
{
private readonly IServerProvider _serverProvider;
/// <summary>
/// Initializes a new instance of the <see cref="DockerGameServerInstanceManager"/> class.
/// </summary>
/// <param name="serverProvider">The server provider.</param>
public DockerGameServerInstanceManager(IServerProvider serverProvider)
{
this._serverProvider = serverProvider;
}
/// <inheritdoc />
public async ValueTask RestartAllAsync(bool onDatabaseInit)
{
var gameServers = this._serverProvider.Servers.Where(server => server.Type == ServerType.GameServer).ToList();
foreach (var gameServer in gameServers)
{
await gameServer.ShutdownAsync().ConfigureAwait(false);
// It's started again automatically by the docker host.
}
}
/// <inheritdoc />
public async ValueTask InitializeGameServerAsync(byte serverId)
{
// TODO: Implement this... by starting a new docker container
}
/// <inheritdoc />
public async ValueTask RemoveGameServerAsync(byte serverId)
{
var gameServer = this._serverProvider.Servers
.Where(server => server.Type == ServerType.GameServer)
.FirstOrDefault(server => server.Id == serverId);
if (gameServer is not null)
{
await gameServer.ShutdownAsync().ConfigureAwait(false);
// TODO: Remove the docker container
}
}
}

View File

@@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base
WORKDIR /app
EXPOSE 8080
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY ["Directory.Packages.props", "."]
COPY ["Directory.Build.props", "."]
COPY ["Dapr/AdminPanel.Host/MUnique.OpenMU.AdminPanel.Host.csproj", "Dapr/AdminPanel.Host/"]
RUN dotnet restore "Dapr/AdminPanel.Host/MUnique.OpenMU.AdminPanel.Host.csproj"
COPY . .
WORKDIR "/src/Dapr/AdminPanel.Host"
RUN dotnet build "MUnique.OpenMU.AdminPanel.Host.csproj" -c Release -o /app/build -p:ci=true
FROM build AS publish
RUN dotnet publish "MUnique.OpenMU.AdminPanel.Host.csproj" -c Release -o /app/publish -p:ci=true
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "MUnique.OpenMU.AdminPanel.Host.dll"]

View File

@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile></DocumentationFile>
<DockerComposeProjectPath>..\..\..\deploy\distributed\docker-compose.dcproj</DockerComposeProjectPath>
<UserSecretsId>c2dfacaa-6b66-4d66-9c79-de236b5c05b5</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Web\AdminPanel\MUnique.OpenMU.Web.AdminPanel.csproj" />
<ProjectReference Include="..\Common\MUnique.OpenMU.Dapr.Common.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="FixNetSdkDiscoverAssetsBug" BeforeTargets="ResolveBuildCompressedStaticWebAssetsConfiguration">
<!-- Workaround for:
The "DiscoverPrecompressedAssets" task failed unexpectedly.
System.ArgumentException: An item with the same key has already been added. Key: C:\Users\[user]\.nuget\packages\microsoft.aspnetcore.app.internal.assets\10.0.0\_framework\blazor.web.js
https://github.com/dotnet/sdk/issues/52089
This is caused by a Microsoft.NET.Sdk.Web project (like this) referencing another project that also uses Microsoft.NET.Sdk.Web.
It appears to be a .NET SDK bug, and we're not the only one hitting it.
-->
<ItemGroup>
<!-- Remove any duplicate StaticWebAsset and StaticWebAssetEndpoint items -->
<_StaticWebAsset Include="@(StaticWebAsset)" />
<StaticWebAsset Remove="@(StaticWebAsset)" />
<StaticWebAsset Include="@(_StaticWebAsset->Distinct())" />
</ItemGroup>
</Target>
</Project>

View File

@@ -0,0 +1,44 @@
// <copyright file="Program.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using MUnique.OpenMU.AdminPanel.Host;
using MUnique.OpenMU.Dapr.Common;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
using MUnique.OpenMU.ServerClients;
using MUnique.OpenMU.Web.AdminPanel;
var builder = DaprService.CreateBuilder("AdminPanel", args);
var plugInConfigurations = new List<PlugInConfiguration>();
var services = builder.Services;
services.AddPeristenceProvider(true)
.AddPlugInManager(plugInConfigurations)
.AddManageableServerRegistry()
.AddSingleton<ILoginServer, LoginServer>()
.AddSingleton<IGameServerInstanceManager, DockerGameServerInstanceManager>()
.AddSingleton<IConnectServerInstanceManager, DockerConnectServerInstanceManager>();
builder.AddAdminPanel();
var metricsRegistry = new MetricsRegistry();
// todo: add some meaningful metrics
builder.AddOpenTelemetryMetrics(metricsRegistry);
var app = builder.BuildAndConfigure(false);
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<MUnique.OpenMU.Web.AdminPanel.Components.App>()
.AddInteractiveServerRenderMode();
await app.WaitForDatabaseConnectionInitializationAsync().ConfigureAwait(false);
await app.Services.TryLoadPlugInConfigurationsAsync(plugInConfigurations).ConfigureAwait(false);
app.Run();

View File

@@ -0,0 +1,10 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.AdminPanel.Host")]

View File

@@ -0,0 +1,50 @@
// <copyright file="ServerStateController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AdminPanel.Host;
using global::Dapr;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Dapr.Common;
/// <summary>
/// Controller which receives server state updates from the pub/sub component.
/// </summary>
[ApiController]
[Route("")]
public class ServerStateController
{
private readonly ManagableServerRegistry _registry;
private readonly ILogger<ServerStateController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ServerStateController"/> class.
/// </summary>
/// <param name="registry">The registry.</param>
/// <param name="logger">The logger.</param>
public ServerStateController(ManagableServerRegistry registry, ILogger<ServerStateController> logger)
{
this._registry = registry;
this._logger = logger;
}
/// <summary>
/// Handles the server state update.
/// </summary>
/// <param name="data">The data.</param>
[HttpPost(ManagableServerStatePublisher.TopicName)]
[Topic("pubsub", ManagableServerStatePublisher.TopicName)]
public void ServerStateUpdate([FromBody] ServerStateData data)
{
try
{
this._registry.HandleUpdate(data);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error updating the ManagableServerRegistry");
}
}
}

View File

@@ -0,0 +1,4 @@
{
"DetailedErrors": true,
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,70 @@
// <copyright file="ChatServerController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer.Host;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.ServerClients;
/// <summary>
/// The API controller for the <see cref="IChatServer"/>.
/// </summary>
[ApiController]
[Route("")]
public class ChatServerController : ControllerBase
{
private readonly IChatServer _chatServer;
private readonly ILogger<ChatServerController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ChatServerController"/> class.
/// </summary>
/// <param name="chatServer">The chat server.</param>
/// <param name="logger">The logger.</param>
public ChatServerController(IChatServer chatServer, ILogger<ChatServerController> logger)
{
this._chatServer = chatServer;
this._logger = logger;
}
/// <summary>
/// Registers the client to the server.
/// </summary>
/// <param name="data">The registration arguments.</param>
/// <returns>The authentication info.</returns>
[HttpPost(nameof(IChatServer.RegisterClientAsync))]
public async ValueTask<ChatServerAuthenticationInfo?> RegisterClientAsync([FromBody] RegisterChatClientArguments data)
{
try
{
return await this._chatServer.RegisterClientAsync(data.RoomId, data.ClientName).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, $"Error during registration of {data.ClientName} for room {data.RoomId}.");
throw;
}
}
/// <summary>
/// Creates the chat room.
/// </summary>
/// <returns>The id of the created chat room.</returns>
[HttpPost(nameof(IChatServer.CreateChatRoomAsync))]
public async ValueTask<ushort> CreateChatRoomAsync()
{
try
{
return await this._chatServer.CreateChatRoomAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error creating a new chat room.");
throw;
}
}
}

View File

@@ -0,0 +1,46 @@
// <copyright file="ChatServerDefinitionExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer.Host;
using MUnique.OpenMU.ChatServer;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Network.PlugIns;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Extensions for <see cref="ChatServerDefinition"/>.
/// </summary>
public static class ChatServerDefinitionExtensions
{
/// <summary>
/// Converts the <see cref="ChatServerDefinition"/> into corresponding <see cref="ChatServerSettings"/>.
/// </summary>
/// <param name="definition">The definition.</param>
/// <returns>The settings.</returns>
public static ChatServerSettings ConvertToSettings(this ChatServerDefinition definition)
{
var result = new ChatServerSettings
{
Id = definition.GetId(),
MaximumConnections = definition.MaximumConnections,
ClientTimeout = definition.ClientTimeout,
ClientCleanUpInterval = definition.ClientCleanUpInterval,
RoomCleanUpInterval = definition.RoomCleanUpInterval,
Description = definition.Description,
ServerId = definition.ServerId + SpecialServerIds.ChatServer,
};
foreach (var endpoint in definition.Endpoints)
{
result.Endpoints.Add(new OpenMU.ChatServer.ChatServerEndpoint
{
ClientVersion = new ClientVersion(endpoint.Client!.Season, endpoint.Client.Episode, endpoint.Client.Language),
NetworkPort = endpoint.NetworkPort,
});
}
return result;
}
}

View File

@@ -0,0 +1,76 @@
// <copyright file="ChatServerHostedServiceWrapper.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer.Host;
using System.Collections.Generic;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MUnique.OpenMU.Dapr.Common;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.PlugIns;
using ChatServer = MUnique.OpenMU.ChatServer.ChatServer;
/// <summary>
/// A wrapper which takes a <see cref="ChatServer"/> and wraps it as <see cref="IHostedLifecycleService"/>,
/// so that additional initialization can be done before actually starting it.
/// The actual server start is deferred to <see cref="StartedAsync"/> which is called after the web application
/// has started (i.e. the HTTP API is already available), breaking the circular startup dependency with the Dapr sidecar.
/// TODO: listen to configuration changes/database reinit.
/// See also: ServerContainerBase.
/// </summary>
public class ChatServerHostedServiceWrapper : IHostedLifecycleService
{
private readonly IServiceProvider _serviceProvider;
private ChatServer? _chatServer;
/// <summary>
/// Initializes a new instance of the <see cref="ChatServerHostedServiceWrapper"/> class.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
public ChatServerHostedServiceWrapper(IServiceProvider serviceProvider)
{
this._serviceProvider = serviceProvider;
}
/// <inheritdoc/>
public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc/>
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc/>
public async Task StartedAsync(CancellationToken cancellationToken)
{
await this._serviceProvider.WaitForDatabaseInitializationAsync(cancellationToken).ConfigureAwait(false);
if (this._serviceProvider.GetService<ICollection<PlugInConfiguration>>() is { } plugInCollection)
{
if (plugInCollection is not List<PlugInConfiguration> plugInConfigurations)
{
throw new InvalidOperationException($"The registered {nameof(ICollection<PlugInConfiguration>)} must be a {nameof(List<PlugInConfiguration>)} to be able to load plugin configurations.");
}
await this._serviceProvider.TryLoadPlugInConfigurationsAsync(plugInConfigurations).ConfigureAwait(false);
}
var settings = this._serviceProvider.GetRequiredService<ChatServerDefinition>().ConvertToSettings();
this._chatServer = this._serviceProvider.GetRequiredService<ChatServer>();
this._chatServer.Initialize(settings);
await this._chatServer.StartAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken)
{
return this._chatServer?.StopAsync(cancellationToken) ?? Task.CompletedTask;
}
/// <inheritdoc/>
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 55980
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY ["Directory.Packages.props", "."]
COPY ["Directory.Build.props", "."]
COPY ["Dapr/ChatServer.Host/MUnique.OpenMU.ChatServer.Host.csproj", "Dapr/ChatServer.Host/"]
RUN dotnet restore "Dapr/ChatServer.Host/MUnique.OpenMU.ChatServer.Host.csproj"
COPY . .
WORKDIR "/src/Dapr/ChatServer.Host"
RUN dotnet build "MUnique.OpenMU.ChatServer.Host.csproj" -c Release -o /app/build -p:ci=true
FROM build AS publish
RUN dotnet publish "MUnique.OpenMU.ChatServer.Host.csproj" -c Release -o /app/publish -p:ci=true
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "MUnique.OpenMU.ChatServer.Host.dll"]

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<DockerComposeProjectPath>..\..\..\deploy\distributed\docker-compose.dcproj</DockerComposeProjectPath>
<UserSecretsId>6c345677-49b4-4872-833e-65acbe67fcae</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile></DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\ChatServer\MUnique.OpenMU.ChatServer.csproj" />
<ProjectReference Include="..\Common\MUnique.OpenMU.Dapr.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,35 @@
// <copyright file="Program.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using MUnique.OpenMU.ChatServer.Host;
using MUnique.OpenMU.Dapr.Common;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.PlugIns;
using ChatServer = MUnique.OpenMU.ChatServer.ChatServer;
var plugInConfigurations = new List<PlugInConfiguration>();
var builder = DaprService.CreateBuilder("ChatServer", args);
// Add services to the container.
var services = builder.Services;
services.AddSingleton<ChatServer>()
.AddPeristenceProvider() // todo: Config API instead of using persistence?
.AddPlugInManager(plugInConfigurations)
.AddIpResolver(args)
.AddPersistentSingleton<ChatServerDefinition>();
services.AddHostedService<ChatServerHostedServiceWrapper>();
services.PublishManageableServer<ChatServer>();
var metricsRegistry = new MetricsRegistry();
metricsRegistry.AddNetworkMeters();
builder.AddOpenTelemetryMetrics(metricsRegistry);
var app = builder.BuildAndConfigure();
await app.WaitForDatabaseConnectionInitializationAsync().ConfigureAwait(false);
app.Run();

View File

@@ -0,0 +1,10 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.ChatServer.Host")]

View File

@@ -0,0 +1,10 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.Dapr.Common")]

View File

@@ -0,0 +1,10 @@
// <copyright file="ConfigurationChangeArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Dapr.Common;
/// <summary>
/// Arguments for the change notifications of <see cref="ConfigurationChangePublisher"/>.
/// </summary>
public record class ConfigurationChangeArguments(Type Type, Guid Id, object? Configuration);

View File

@@ -0,0 +1,67 @@
// <copyright file="ConfigurationChangePublisher.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Dapr.Common;
using global::Dapr.Client;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Publisher for configuration changes.
/// Changes are published to the configured pub/sub Dapr component.
/// </summary>
public class ConfigurationChangePublisher : IConfigurationChangePublisher
{
private readonly DaprClient _daprClient;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationChangePublisher"/> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
public ConfigurationChangePublisher(DaprClient daprClient)
{
this._daprClient = daprClient;
}
/// <summary>
/// Publishes a changed, previously existing configuration.
/// </summary>
/// <param name="type">The type of the configuration.</param>
/// <param name="id">The identifier of the changed configuration.</param>
/// <param name="configuration">The changed configuration.</param>
public Task ConfigurationChangedAsync(Type type, Guid id, object configuration)
{
return this._daprClient.PublishEventAsync(
"pubsub",
nameof(this.ConfigurationChangedAsync),
new ConfigurationChangeArguments(type, id, configuration));
}
/// <summary>
/// Publishes an added configuration.
/// </summary>
/// <param name="type">The type of the configuration.</param>
/// <param name="id">The identifier of the added configuration.</param>
/// <param name="configuration">The added configuration.</param>
public Task ConfigurationAddedAsync(Type type, Guid id, object configuration)
{
return this._daprClient.PublishEventAsync(
"pubsub",
nameof(this.ConfigurationAddedAsync),
new ConfigurationChangeArguments(type, id, configuration));
}
/// <summary>
/// Publishes a removed, previously existing configuration.
/// </summary>
/// <param name="type">The type of the configuration.</param>
/// <param name="id">The identifier of the removed configuration.</param>
public Task ConfigurationRemovedAsync(Type type, Guid id)
{
return this._daprClient.PublishEventAsync(
"pubsub",
nameof(this.ConfigurationRemovedAsync),
new ConfigurationChangeArguments(type, id, null));
}
}

View File

@@ -0,0 +1,70 @@
// <copyright file="DaprService.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Dapr.Common;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.OpenApi.Models;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence.EntityFramework;
using MUnique.OpenMU.Persistence.EntityFramework.Json;
using MUnique.OpenMU.PlugIns;
using OpenTelemetry.Logs;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
/// <summary>
/// Helper class to create an <see cref="WebApplicationBuilder"/> which predefined common
/// services for all of our service applications.
/// </summary>
public static class DaprService
{
/// <summary>
/// Initializes a new instance of the <see cref="WebApplicationBuilder" /> class with preconfigured defaults.
/// </summary>
/// <param name="serviceName">Name of the service, used for the OpenAPI and OpenTelemetry tracing.</param>
/// <param name="args">Command line arguments.</param>
/// <returns>
/// The <see cref="WebApplicationBuilder" />.
/// </returns>
public static WebApplicationBuilder CreateBuilder(string serviceName, string[] args)
{
JsonConverterRegistry.RegisterConverter(new LocalizedStringJsonConverter());
JsonConverterRegistry.RegisterConverter(new BinaryAsHexJsonConverter());
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls($"http://*:8080");
var services = builder.Services;
services.AddControllers();
services.AddDaprClient();
services.AddOpenTelemetry()
.WithTracing(t =>
{
t.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName))
.AddAspNetCoreInstrumentation()
.AddZipkinExporter(o => o.Endpoint = new Uri("http://zipkin:9411/api/v2/spans"));
});
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = serviceName, Version = "v1" });
});
services.AddSingleton<IDatabaseConnectionSettingProvider, SecretStoreDatabaseConnectionSettingsProvider>();
// Logging:
builder.UseLoki(serviceName);
builder.Logging.AddOpenTelemetry(options => options.AddOtlpExporter());
return builder;
}
}

View File

@@ -0,0 +1,352 @@
// <copyright file="Extensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Dapr.Common;
using System.Text.Json.Serialization;
using System.Threading;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Persistence.EntityFramework;
using MUnique.OpenMU.PlugIns;
using Nito.AsyncEx.Synchronous;
using OpenTelemetry.Exporter;
using OpenTelemetry.Metrics;
using Prometheus;
using Serilog;
using Serilog.Debugging;
using Serilog.Events;
using Serilog.Filters;
using Serilog.Sinks.Grafana.Loki;
/// <summary>
/// Common extensions for the building of daprized services.
/// </summary>
public static class Extensions
{
/// <summary>
/// Adds the <see cref="PersistenceContextProvider"/>.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="publishConfigChanges">If set to <c>true</c>, configuration changes are published to other Dapr services.</param>
/// <returns>The modified service collection.</returns>
public static IServiceCollection AddPeristenceProvider(this IServiceCollection services, bool publishConfigChanges = false)
{
services.AddSingleton<IConfigurationChangeListener, ConfigurationChangeListener>();
if (publishConfigChanges)
{
services.AddSingleton<IConfigurationChangePublisher, ConfigurationChangePublisher>();
}
else
{
services.AddSingleton(e => IConfigurationChangePublisher.None);
}
return services
.AddSingleton<IMigratableDatabaseContextProvider, PersistenceContextProvider>()
.AddSingleton(s => (PersistenceContextProvider)s.GetService<IMigratableDatabaseContextProvider>()!)
.AddSingleton(s => (IPersistenceContextProvider)s.GetService<IMigratableDatabaseContextProvider>()!)
.AddSingleton(s => new Lazy<IPersistenceContextProvider>(s.GetRequiredService<IPersistenceContextProvider>));
}
/// <summary>
/// Adds the plug in manager.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="plugInConfigurations">The plug in configurations.</param>
/// <returns>The modified service collection.</returns>
public static IServiceCollection AddPlugInManager(this IServiceCollection services, ICollection<PlugInConfiguration> plugInConfigurations)
{
return services
.AddSingleton(plugInConfigurations)
.AddSingleton<PlugInManager>()
.AddTransient<ReferenceHandler, ByDataSourceReferenceHandler>(provider =>
{
var persistenceContextProvider = provider.GetService<IPersistenceContextProvider>();
var dataSource = new GameConfigurationDataSource(
provider.GetService<ILogger<GameConfigurationDataSource>>()!,
persistenceContextProvider!);
var configId = persistenceContextProvider!.CreateNewConfigurationContext().GetDefaultGameConfigurationIdAsync(default).AsTask().WaitAndUnwrapException();
dataSource.GetOwnerAsync(configId!.Value).AsTask().WaitAndUnwrapException();
var referenceHandler = new ByDataSourceReferenceHandler(dataSource);
return referenceHandler;
});
}
/// <summary>
/// Tries to load the plug in configurations.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
/// <param name="plugInConfigurations">The list of plug in configurations, where the loaded configurations will be added.</param>
public static async ValueTask TryLoadPlugInConfigurationsAsync(this IServiceProvider serviceProvider, List<PlugInConfiguration> plugInConfigurations)
{
if (serviceProvider.GetService<IMigratableDatabaseContextProvider>() is not { } persistenceContextProvider)
{
throw new Exception($"{nameof(IPersistenceContextProvider)} not registered.");
}
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
if (!await persistenceContextProvider.CanConnectToDatabaseAsync(cts.Token).ConfigureAwait(false)
|| !await persistenceContextProvider.DatabaseExistsAsync(cts.Token).ConfigureAwait(false))
{
return;
}
var configs = await persistenceContextProvider.CreateNewTypedContext(typeof(PlugInConfiguration), false).GetAsync<PlugInConfiguration>().ConfigureAwait(false);
plugInConfigurations.AddRange(configs);
}
catch
{
// If we can't load it yet, because the database is not initialized, we just return
}
}
/// <summary>
/// Adds a persistent object as singleton to the services.
/// </summary>
/// <typeparam name="T">The base type of the persistent object.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="predicate">The predicate to select actual object.</param>
/// <returns>The modified service collection.</returns>
public static IServiceCollection AddPersistentSingleton<T>(this IServiceCollection services, Func<T, bool>? predicate = null)
where T : class
{
return services.AddPersistentSingleton<T, T>(predicate);
}
/// <summary>
/// Adds the persistent object as singleton to the services.
/// </summary>
/// <typeparam name="TTarget">The target, exposed type of the persistent object, usually an interface.</typeparam>
/// <typeparam name="TActual">The actual base type of the persistent object.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="predicate">The predicate to select the actual object.</param>
/// <returns>The modified service collection.</returns>
public static IServiceCollection AddPersistentSingleton<TTarget, TActual>(this IServiceCollection services, Func<TActual, bool>? predicate = null)
where TActual : class, TTarget
where TTarget : class
{
return services.AddSingleton(s =>
{
if (s.GetService<IPersistenceContextProvider>() is not { } persistenceContextProvider)
{
throw new Exception($"{nameof(IPersistenceContextProvider)} not registered.");
}
var objects = persistenceContextProvider.CreateNewConfigurationContext().GetAsync<TActual>().AsTask().WaitAndUnwrapException();
return (TTarget)objects.First(predicate ?? (_ => true))!;
});
}
/// <summary>
/// Adds the <see cref="ManagableServerRegistry"/> to the services.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The modified service collection.</returns>
public static IServiceCollection AddManageableServerRegistry(this IServiceCollection services)
{
services.AddSingleton<ManagableServerRegistry>()
.AddSingleton<IServerProvider>(s => s.GetService<ManagableServerRegistry>()!);
return services;
}
/// <summary>
/// Publishes the server to other daprized services by registering a <see cref="ManagableServerStatePublisher"/>.
/// </summary>
/// <typeparam name="TServer">The type of the server.</typeparam>
/// <param name="services">The service collection.</param>
/// <returns>The modified service collection.</returns>
public static IServiceCollection PublishManageableServer<TServer>(this IServiceCollection services)
where TServer : IManageableServer
{
services.AddSingleton<IManageableServer>(s => s.GetService<TServer>()!)
.AddHostedService<ManagableServerStatePublisher>()
.AddControllers().AddApplicationPart(typeof(ManageableServerController).Assembly);
return services;
}
/// <summary>
/// Configures the usage of logging to loki.
/// </summary>
/// <param name="builder">The web application builder.</param>
/// <param name="serviceName">Name of the service.</param>
/// <returns>The configured web application builder.</returns>
public static WebApplicationBuilder UseLoki(this WebApplicationBuilder builder, string serviceName)
{
// We just want to transmit some static labels, as suggested in the best practice in the Loki documentation
var includeLabels = new[] { "Account", "Character", "Connection", "ServiceName", "SourceContext" };
var logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.WithProperty("ServiceName", serviceName)
.Enrich.FromLogContext()
.WriteTo
.GrafanaLoki(
uri: "http://loki:3100",
propertiesAsLabels: includeLabels)
.WriteTo
.Console(LogEventLevel.Information)
.Filter.ByExcluding(Matching.FromSource("Microsoft")) // We don't want all of the ASP.NET logging, because that really keeps loki and the console pretty busy
.CreateLogger();
SelfLog.Enable(Console.Error);
builder.Host.ConfigureLogging((_, loggingBuilder) => loggingBuilder.ClearProviders());
builder.Host.UseSerilog(logger);
return builder;
}
/// <summary>
/// Adds the open telemetry metrics.
/// </summary>
/// <param name="builder">The web application builder.</param>
/// <param name="registry">The registry.</param>
/// <returns>The configured web application builder.</returns>
public static WebApplicationBuilder AddOpenTelemetryMetrics(this WebApplicationBuilder builder, MetricsRegistry registry)
{
builder.Services.AddOpenTelemetry()
.WithMetrics(x =>
{
x.AddMeter(registry.Meters.ToArray());
x.AddPrometheusExporter();
x.AddOtlpExporter();
});
builder.Services.AddHealthChecks().ForwardToPrometheus();
return builder;
}
/// <summary>
/// Builds and configures the web application.
/// </summary>
/// <param name="builder">The builder.</param>
/// <param name="addBlazor">If set to <c>true</c>, it configures the application to provide a blazor server app.</param>
/// <returns>The built and configured web application.</returns>
public static WebApplication BuildAndConfigure(this WebApplicationBuilder builder, bool addBlazor = false)
{
var pathBase = Environment.GetEnvironmentVariable("PATH_BASE");
var useReverseProxy = !string.IsNullOrWhiteSpace(pathBase);
var app = builder.Build();
if (useReverseProxy)
{
app.UsePathBase(pathBase!.TrimEnd('/'));
app.UseForwardedHeaders();
}
app.ConfigureDaprService(addBlazor);
app.MapPrometheusScrapingEndpoint();
return app;
}
/// <summary>
/// Configures the web application as dapr service.
/// </summary>
/// <param name="app">The application.</param>
/// <param name="addBlazor">If set to <c>true</c>, it configures the application to provide a blazor server app.</param>
/// <returns>The configured web application.</returns>
public static WebApplication ConfigureDaprService(this WebApplication app, bool addBlazor = false)
{
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCloudEvents();
app.MapControllers();
app.MapSubscribeHandler();
return app;
}
/// <summary>
/// Waits for the completion of outstanding database updates.
/// </summary>
/// <param name="app">The application.</param>
public static async Task WaitForUpdatedDatabaseAsync(this WebApplication app)
{
await app.WaitForDatabaseConnectionInitializationAsync().ConfigureAwait(false);
await app.Services.GetService<PersistenceContextProvider>()!
.WaitForUpdatedDatabaseAsync()
.ConfigureAwait(false);
}
/// <summary>
/// Waits for database connection (settings) initialization.
/// </summary>
/// <param name="app">The application.</param>
public static async Task WaitForDatabaseConnectionInitializationAsync(this WebApplication app)
{
await app.Services.GetService<IDatabaseConnectionSettingProvider>()!
.InitializeAsync(default)
.ConfigureAwait(false);
}
/// <summary>
/// Waits for the database secrets to be loaded and then for the database to be up-to-date.
/// This is intended to be called from <see cref="Microsoft.Extensions.Hosting.IHostedLifecycleService.StartedAsync"/>
/// which is called after the web server has already started, breaking the circular dependency where:
/// the Dapr sidecar needs the app HTTP API to be up before it initializes its secret store,
/// but the app needs Dapr secrets to connect to the database.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public static async Task WaitForDatabaseInitializationAsync(this IServiceProvider serviceProvider, CancellationToken cancellationToken = default)
{
var dbConnectionProvider = serviceProvider.GetRequiredService<IDatabaseConnectionSettingProvider>();
if (dbConnectionProvider.Initialization is { } initTask)
{
await initTask.WaitAsync(cancellationToken).ConfigureAwait(false);
}
await serviceProvider.GetRequiredService<PersistenceContextProvider>()
.WaitForUpdatedDatabaseAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Adds the ip resolver to the collection, depending on the command line arguments
/// and the <see cref="SystemConfiguration"/> in the database.
/// </summary>
/// <param name="serviceCollection">The service collection.</param>
/// <param name="args">The arguments.</param>
/// <returns>The <paramref name="serviceCollection"/>.</returns>
public static IServiceCollection AddIpResolver(this IServiceCollection serviceCollection, string[] args)
{
return serviceCollection.AddSingleton(serviceProvider =>
{
(IpResolverType IpResolver, string? IpResolverParameter)? settings = default;
try
{
var persistenceContextProvider = serviceProvider.GetService<IPersistenceContextProvider>() ?? throw new Exception($"{nameof(IPersistenceContextProvider)} not registered.");
using var context = persistenceContextProvider.CreateNewTypedContext(typeof(SystemConfiguration), false);
// TODO: this may lead to a deadlock?
var configuration = context.GetAsync<SystemConfiguration>().AsTask().WaitAndUnwrapException().FirstOrDefault();
if (configuration is not null)
{
settings = (configuration.IpResolver, configuration.IpResolverParameter);
}
}
catch (Exception ex)
{
serviceProvider.GetService<ILogger<IIpAddressResolver>>()?.LogError(ex, "Unexpected error when trying to load the system configuration during ip resolver creation.");
}
return IpAddressResolverFactory.CreateIpResolver(args, settings, serviceProvider.GetService<ILoggerFactory>()!);
});
}
}

View File

@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile></DocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Properties\**" />
<EmbeddedResource Remove="Properties\**" />
<None Remove="Properties\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapr.AspNetCore" />
<PackageReference Include="Dapr.Client" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Exporter.Zipkin" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="prometheus-net.AspNetCore.HealthChecks" />
<PackageReference Include="Serilog.Extensions.Hosting" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.Grafana.Loki" />
<PackageReference Include="SharpAbp.Abp.OpenTelemetry.Exporter.Prometheus.AspNetCore" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Interfaces\MUnique.OpenMU.Interfaces.csproj" />
<ProjectReference Include="..\..\Persistence\EntityFramework\MUnique.OpenMU.Persistence.EntityFramework.csproj" />
<ProjectReference Include="..\ServerClients\MUnique.OpenMU.ServerClients.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,116 @@
// <copyright file="ManagableServerRegistry.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Dapr.Common;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading;
using global::Dapr.Client;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// A registry for all <see cref="IManageableServer"/>s in the system.
/// </summary>
public class ManagableServerRegistry : IServerProvider, IDisposable
{
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(20);
private readonly CancellationTokenSource _disposeCts = new();
private readonly ILogger<ManagableServerRegistry> _logger;
private readonly DaprClient _daprClient;
private readonly ConcurrentDictionary<int, ManageableServerClient> _serverClients = new();
/// <summary>
/// Initializes a new instance of the <see cref="ManagableServerRegistry" /> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="logger">The logger.</param>
public ManagableServerRegistry(DaprClient daprClient, ILogger<ManagableServerRegistry> logger)
{
this._daprClient = daprClient;
this._logger = logger;
async Task RunTimeoutLoop()
{
try
{
await this.TimeoutLoopAsync(this._disposeCts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error in cleanup loop");
}
}
_ = RunTimeoutLoop();
}
/// <inheritdoc />
public event PropertyChangedEventHandler? PropertyChanged;
/// <inheritdoc />
public IList<IManageableServer> Servers => this._serverClients.Values.OfType<IManageableServer>().ToList();
/// <summary>
/// Handles an update of a server state.
/// </summary>
/// <param name="serverData">The server data.</param>
public void HandleUpdate(ServerStateData serverData)
{
var isNew = false;
this._serverClients.AddOrUpdate(
serverData.Id,
_ =>
{
isNew = true;
return new ManageableServerClient(this._daprClient, serverData);
},
(_, client) =>
{
client.Update(serverData);
return client;
});
if (isNew)
{
this.RaisePropertyChanged(nameof(this.Servers));
}
}
/// <inheritdoc />
public void Dispose()
{
this._disposeCts.Cancel();
this._disposeCts.Dispose();
}
/// <summary>
/// Raises the <see cref="PropertyChanged"/> event with the specified parameters.
/// </summary>
/// <param name="propertyName">Name of the property.</param>
protected virtual void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
{
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private async Task TimeoutLoopAsync(CancellationToken cancellationToken)
{
while (!this._disposeCts.IsCancellationRequested)
{
await Task.Delay(2000, cancellationToken).ConfigureAwait(false);
foreach (var server in this._serverClients.Values)
{
var diff = DateTime.UtcNow - server.LastUpdate;
if (diff > this._timeout)
{
this._logger.LogInformation("Difference of {0} higher than timeout for server {1}", diff, server.Id);
server.ServerState = ServerState.Timeout;
}
}
}
}
}

View File

@@ -0,0 +1,178 @@
// <copyright file="ManagableServerStatePublisher.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Dapr.Common;
using System;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using global::Dapr.Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
using Nito.AsyncEx;
using Nito.AsyncEx.Synchronous;
/// <summary>
/// A state publisher for a <see cref="IManageableServer"/>,
/// which can be handled with a corresponding <see cref="ManagableServerRegistry"/>.
/// The server registration is deferred to <see cref="StartedAsync"/> which is called after the web application
/// has started (i.e. the HTTP API is already available), breaking the circular startup dependency with the Dapr sidecar.
/// </summary>
public sealed class ManagableServerStatePublisher : IHostedLifecycleService, IDisposable
{
/// <summary>
/// The topic name for the state updates.
/// </summary>
public const string TopicName = "ServerState";
private readonly ILogger<ManagableServerStatePublisher> _logger;
private readonly DaprClient _daprClient;
private readonly IServiceProvider _serviceProvider;
private readonly AsyncLock _lock = new();
private IManageableServer? _server;
private ServerStateData? _data;
private Task? _heartbeatTask;
private CancellationTokenSource? _heartbeatCancellationTokenSource;
/// <summary>
/// Initializes a new instance of the <see cref="ManagableServerStatePublisher"/> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="serviceProvider">The service provider used to lazily resolve <see cref="IManageableServer"/>.</param>
/// <param name="logger">The logger.</param>
public ManagableServerStatePublisher(DaprClient daprClient, IServiceProvider serviceProvider, ILogger<ManagableServerStatePublisher> logger)
{
this._daprClient = daprClient;
this._serviceProvider = serviceProvider;
this._logger = logger;
}
/// <inheritdoc />
public void Dispose()
{
this.StopAsync(default).WaitAndUnwrapException();
}
/// <inheritdoc />
public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task StartedAsync(CancellationToken cancellationToken)
{
this._heartbeatCancellationTokenSource = new CancellationTokenSource();
async Task RunHeartbeatTask()
{
try
{
await this.HeartbeatLoopAsync(this._heartbeatCancellationTokenSource.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error in heartbeat loop.");
}
}
this._heartbeatTask = RunHeartbeatTask();
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
await (this._heartbeatCancellationTokenSource?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false);
this._heartbeatCancellationTokenSource?.Dispose();
if (this._heartbeatTask is { } heartbeatTask)
{
this._heartbeatTask = null;
await heartbeatTask.ConfigureAwait(false);
}
}
/// <inheritdoc />
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task HeartbeatLoopAsync(CancellationToken cancellationToken)
{
await this.InitializeServerAsync(cancellationToken).ConfigureAwait(false);
while (!cancellationToken.IsCancellationRequested)
{
await this.PublishCurrentStateAsync().ConfigureAwait(false);
await Task.Delay(5000, cancellationToken).ConfigureAwait(false);
}
}
private async Task InitializeServerAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested && this._server is null)
{
try
{
var server = this._serviceProvider.GetRequiredService<IManageableServer>();
server.PropertyChanged -= this.OnPropertyChanged; // Ensure single subscription in case of retry
server.PropertyChanged += this.OnPropertyChanged;
this._data = new ServerStateData(server);
this._server = server;
}
catch (Exception ex)
{
this._logger.LogWarning(ex, "Could not resolve IManageableServer yet, retrying...");
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
}
}
private async Task PublishCurrentStateAsync()
{
if (this._server is null || this._data is null)
{
return;
}
using var asyncLock = await this._lock.LockAsync(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
if (asyncLock is null)
{
return;
}
try
{
this._data.UpdateState(this._server);
await this._daprClient.PublishEventAsync("pubsub", TopicName, this._data).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error sending server status update");
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Exceptions are catched.")]
private async void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
try
{
if (e.PropertyName == nameof(IManageableServer.ServerState))
{
await this.PublishCurrentStateAsync().ConfigureAwait(false);
}
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error when publishing current state after property change");
}
}
}

View File

@@ -0,0 +1,138 @@
// <copyright file="ManageableServerClient.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Dapr.Common;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading;
using global::Dapr.Client;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// A client to control a <seealso cref="IManageableServer"/>.
/// </summary>
/// <seealso cref="MUnique.OpenMU.Interfaces.IManageableServer" />
internal class ManageableServerClient : IManageableServer
{
private readonly DaprClient _daprClient;
private readonly string _targetAppId;
private ServerState _serverState;
private int _currentConnections;
/// <summary>
/// Initializes a new instance of the <see cref="ManageableServerClient"/> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="serverData">The server data.</param>
public ManageableServerClient(DaprClient daprClient, ServerStateData serverData)
{
this._daprClient = daprClient;
this._targetAppId = serverData.AppId;
this.Id = serverData.Id;
this.Description = serverData.Description;
this.ConfigurationId = serverData.ConfigurationId;
this.CurrentConnections = serverData.CurrentConnections;
this.MaximumConnections = serverData.MaximumConnections;
this.ServerState = serverData.State;
this.Type = serverData.Type;
this.LastUpdate = DateTime.UtcNow;
}
/// <inheritdoc/>
public event PropertyChangedEventHandler? PropertyChanged;
/// <inheritdoc/>
public int Id { get; }
/// <inheritdoc/>
public Guid ConfigurationId { get; }
/// <inheritdoc/>
public string Description { get; }
/// <inheritdoc/>
public ServerType Type { get; }
/// <inheritdoc/>
public int MaximumConnections { get; }
/// <summary>
/// Gets the timestamp of the last update.
/// </summary>
public DateTime LastUpdate { get; private set; }
/// <inheritdoc/>
public ServerState ServerState
{
get => this._serverState;
set
{
if (this._serverState == value)
{
return;
}
this._serverState = value;
this.RaisePropertyChanged();
}
}
/// <inheritdoc/>
public int CurrentConnections
{
get => this._currentConnections;
set
{
if (this._currentConnections == value)
{
return;
}
this._currentConnections = value;
this.RaisePropertyChanged();
}
}
/// <inheritdoc/>
public Task StartAsync(CancellationToken cancellationToken)
{
return this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(IManageableServer.StartAsync), cancellationToken);
}
/// <inheritdoc/>
public async ValueTask StartAsync()
{
await this.StartAsync(default).ConfigureAwait(false);
}
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken)
{
return this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(IManageableServer.ShutdownAsync), cancellationToken);
}
/// <inheritdoc/>
public async ValueTask ShutdownAsync()
{
await this.StopAsync(default).ConfigureAwait(false);
}
/// <summary>
/// Updates the specified server data.
/// </summary>
/// <param name="serverData">The server data.</param>
public void Update(ServerStateData serverData)
{
this.ServerState = serverData.State;
this.CurrentConnections = serverData.CurrentConnections;
this.LastUpdate = DateTime.UtcNow;
}
private void RaisePropertyChanged([CallerMemberName] string propertyName = "")
{
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@@ -0,0 +1,45 @@
// <copyright file="ManageableServerController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Dapr.Common;
using Microsoft.AspNetCore.Mvc;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// An api controller to control a <see cref="IManageableServer"/>.
/// </summary>
[ApiController]
[Route("")]
public class ManageableServerController
{
private readonly IManageableServer _manageableServer;
/// <summary>
/// Initializes a new instance of the <see cref="ManageableServerController"/> class.
/// </summary>
/// <param name="manageableServer">The manageable server.</param>
public ManageableServerController(IManageableServer manageableServer)
{
this._manageableServer = manageableServer;
}
/// <summary>
/// Shuts the manageable server down.
/// </summary>
[HttpPost(nameof(IManageableServer.ShutdownAsync))]
public async Task ShutdownAsync()
{
await this._manageableServer.ShutdownAsync().ConfigureAwait(false);
}
/// <summary>
/// Starts the manageable server.
/// </summary>
[HttpPost(nameof(IManageableServer.StartAsync))]
public async Task StartAsync()
{
await this._manageableServer.StartAsync().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,47 @@
// <copyright file="MetricsRegistry.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Dapr.Common;
/// <summary>
/// Registry for metrics which should be exposed by the application.
/// </summary>
public class MetricsRegistry
{
private readonly HashSet<string> _meters = new HashSet<string>();
/// <summary>
/// Gets the registered meters.
/// </summary>
public IEnumerable<string> Meters => this._meters;
/// <summary>
/// Adds the meter with the specified name.
/// </summary>
/// <param name="meterName">Name of the meter.</param>
public void AddMeter(string meterName)
{
this._meters.Add(meterName);
}
/// <summary>
/// Adds the meters with the specified names.
/// </summary>
/// <param name="meterNames">The names of the meters.</param>
public void AddMeters(IEnumerable<string> meterNames)
{
foreach (var meter in meterNames)
{
this.AddMeter(meter);
}
}
/// <summary>
/// Adds the network meters.
/// </summary>
public void AddNetworkMeters()
{
this.AddMeters(MUnique.OpenMU.Network.Metrics.Meters);
}
}

View File

@@ -0,0 +1,107 @@
// <copyright file="SecretStoreDatabaseConnectionSettingsProvider.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Dapr.Common;
using System.Threading;
using global::Dapr;
using global::Dapr.Client;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Persistence.EntityFramework;
/// <summary>
/// Implementation of <see cref="IDatabaseConnectionSettingProvider"/> which retrieves the settings from the
/// configured Dapr secret storage.
/// </summary>
public class SecretStoreDatabaseConnectionSettingsProvider : IDatabaseConnectionSettingProvider
{
private const string SecretStoreName = "secrets";
private readonly DaprClient _daprClient;
private readonly ILogger<SecretStoreDatabaseConnectionSettingsProvider> _logger;
private readonly Dictionary<string, ConnectionSetting> _connectionSettings = new(StringComparer.InvariantCultureIgnoreCase);
private bool _isInitialized;
/// <summary>
/// Initializes a new instance of the <see cref="SecretStoreDatabaseConnectionSettingsProvider" /> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="logger">The logger.</param>
public SecretStoreDatabaseConnectionSettingsProvider(DaprClient daprClient, ILogger<SecretStoreDatabaseConnectionSettingsProvider> logger)
{
this._daprClient = daprClient;
this._logger = logger;
}
/// <inheritdoc />
public Task? Initialization { get; private set; }
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken)
{
if (this._isInitialized)
{
return Task.CompletedTask;
}
this.Initialization = Task.Run(
async () =>
{
this._isInitialized = false;
while (!this._isInitialized && !cancellationToken.IsCancellationRequested)
{
try
{
Console.WriteLine("trying to get secrets ...");
var secrets = await this._daprClient.GetBulkSecretAsync(SecretStoreName, cancellationToken: cancellationToken).ConfigureAwait(false);
foreach (var secret in secrets.Where(kvp => string.Equals(kvp.Key.Split(':')[0], "connectionStrings", StringComparison.InvariantCultureIgnoreCase)))
{
var contextTypeName = secret.Value.Keys.First().Split(':').Last();
var setting = new ConnectionSetting
{
ContextTypeName = contextTypeName,
ConnectionString = secret.Value.Values.First()!,
DatabaseEngine = DatabaseEngine.Npgsql,
};
this._connectionSettings.Add(contextTypeName, setting);
}
Console.WriteLine("secrets retrieved :)");
this._isInitialized = true;
}
catch (DaprException ex)
{
// This should never happen - however, it may happen when we are using a Dapr secret store.
// It may not be started yet, and the implementation to get it does retrieve it in the constructor already.
this._logger.LogWarning(ex, "Error occurred when retrieving the connection strings from the secrets store. Trying again in 3 seconds...");
Console.WriteLine("Error occurred when retrieving the connection strings from the secrets store. Trying again in 3 seconds...");
await Task.Delay(3000, cancellationToken).ConfigureAwait(false);
}
}
});
ConnectionConfigurator.Initialize(this);
return Task.CompletedTask;
}
/// <inheritdoc />
public ConnectionSetting GetConnectionSetting<TContextType>()
where TContextType : DbContext
{
return this.GetConnectionSetting(typeof(TContextType));
}
/// <inheritdoc />
public ConnectionSetting GetConnectionSetting(Type contextType)
{
if (this._connectionSettings.TryGetValue(contextType.FullName ?? contextType.Name, out var result))
{
return result;
}
throw new InvalidOperationException($"No connection string for type '{contextType.FullName}' stored in the secret store.");
}
}

View File

@@ -0,0 +1,96 @@
// <copyright file="ServerStateData.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Dapr.Common;
using System.Diagnostics;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Data about the state of a server.
/// </summary>
public class ServerStateData
{
private readonly Stopwatch _stopwatch = new();
/// <summary>
/// Initializes a new instance of the <see cref="ServerStateData"/> class.
/// </summary>
public ServerStateData()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ServerStateData"/> class.
/// </summary>
/// <param name="server">The server.</param>
/// <exception cref="System.InvalidOperationException">Add the environment variable 'APPID' with the app-id of this dapr app.</exception>
public ServerStateData(IManageableServer server)
{
this.AppId = Environment.GetEnvironmentVariable("APPID") ?? throw new InvalidOperationException("Add the environment variable 'APPID' with the app-id of this dapr app.");
this.Id = server.Id;
this.Description = server.Description;
this.ConfigurationId = server.ConfigurationId;
this.Type = server.Type;
this.MaximumConnections = server.MaximumConnections;
this._stopwatch.Start();
this.UpdateState(server);
}
/// <summary>
/// Gets or sets the (dapr) application identifier.
/// </summary>
public string AppId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the identifier of the server.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the description of the server.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the identifier of the configuration of the server.
/// </summary>
public Guid ConfigurationId { get; set; }
/// <summary>
/// Gets or sets the type of the server.
/// </summary>
public ServerType Type { get; set; }
/// <summary>
/// Gets or sets the state of the server.
/// </summary>
public ServerState State { get; set; }
/// <summary>
/// Gets or sets the current connection count of the server.
/// </summary>
public int CurrentConnections { get; set; }
/// <summary>
/// Gets or sets the maximum connection count which the server supports.
/// </summary>
public int MaximumConnections { get; set; }
/// <summary>
/// Gets or sets the up time of the server.
/// </summary>
public TimeSpan UpTime { get; set; }
/// <summary>
/// Updates the state of the server.
/// </summary>
/// <param name="server">The server.</param>
public void UpdateState(IManageableServer server)
{
this.State = server.ServerState;
this.CurrentConnections = server.CurrentConnections;
this.UpTime = this._stopwatch.Elapsed;
}
}

View File

@@ -0,0 +1,51 @@
// <copyright file="ConnectServerController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer.Host;
using System.Net;
using global::Dapr;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.ServerClients;
/// <summary>
/// API Controller that receives messages from other services.
/// </summary>
[ApiController]
[Route("")]
public class ConnectServerController : ControllerBase
{
private readonly GameServerRegistry _registry;
private readonly ILogger<ConnectServerController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ConnectServerController"/> class.
/// </summary>
/// <param name="registry">The registry.</param>
/// <param name="logger">The logger.</param>
public ConnectServerController(GameServerRegistry registry, ILogger<ConnectServerController> logger)
{
this._registry = registry;
this._logger = logger;
}
/// <summary>
/// Handles the game server heartbeat.
/// </summary>
/// <param name="data">The data.</param>
[HttpPost("GameServerHeartbeat")]
[Topic("pubsub", "GameServerHeartbeat")]
public async Task GameServerHeartbeatAsync([FromBody] GameServerHeartbeatArguments data)
{
try
{
await this._registry.UpdateRegistrationAsync(data.ServerInfo, IPEndPoint.Parse(data.PublicEndPoint)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error updating the GameServerRegistry");
}
}
}

View File

@@ -0,0 +1,59 @@
// <copyright file="ConnectServerHostedServiceWrapper.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer.Host;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MUnique.OpenMU.Dapr.Common;
/// <summary>
/// A wrapper which takes a <see cref="Interfaces.IConnectServer"/> and wraps it as <see cref="IHostedLifecycleService"/>,
/// so that additional initialization can be done before actually starting it.
/// The actual server start is deferred to <see cref="StartedAsync"/> which is called after the web application
/// has started (i.e. the HTTP API is already available), breaking the circular startup dependency with the Dapr sidecar.
/// TODO: listen to configuration changes/database reinit.
/// See also: ServerContainerBase.
/// </summary>
public class ConnectServerHostedServiceWrapper : IHostedLifecycleService
{
private readonly IServiceProvider _serviceProvider;
private ConnectServer? _connectServer;
/// <summary>
/// Initializes a new instance of the <see cref="ConnectServerHostedServiceWrapper"/> class.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
public ConnectServerHostedServiceWrapper(IServiceProvider serviceProvider)
{
this._serviceProvider = serviceProvider;
}
/// <inheritdoc/>
public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc/>
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc/>
public async Task StartedAsync(CancellationToken cancellationToken)
{
await this._serviceProvider.WaitForDatabaseInitializationAsync(cancellationToken).ConfigureAwait(false);
this._connectServer = this._serviceProvider.GetRequiredService<ConnectServer>();
await this._connectServer.StartAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken)
{
return this._connectServer?.StopAsync(cancellationToken) ?? Task.CompletedTask;
}
/// <inheritdoc/>
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 44405
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY ["Directory.Packages.props", "."]
COPY ["Directory.Build.props", "."]
COPY ["Dapr/ConnectServer.Host/MUnique.OpenMU.ConnectServer.Host.csproj", "Dapr/ConnectServer.Host/"]
RUN dotnet restore "Dapr/ConnectServer.Host/MUnique.OpenMU.ConnectServer.Host.csproj"
COPY . .
WORKDIR "/src/Dapr/ConnectServer.Host"
RUN dotnet build "MUnique.OpenMU.ConnectServer.Host.csproj" -c Release -o /app/build -p:ci=true
FROM build AS publish
RUN dotnet publish "MUnique.OpenMU.ConnectServer.Host.csproj" -c Release -o /app/publish -p:ci=true
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "MUnique.OpenMU.ConnectServer.Host.dll"]

View File

@@ -0,0 +1,106 @@
// <copyright file="GameServerRegistry.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer.Host;
using System.Net;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using Nito.AsyncEx;
/// <summary>
/// A registry which keeps track of available <see cref="IGameServer"/>s.
/// </summary>
/// <seealso cref="System.IDisposable" />
public sealed class GameServerRegistry : IDisposable
{
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(10);
private readonly CancellationTokenSource _disposeCts = new();
private readonly IConnectServer _connectServer;
private readonly ILogger<GameServerRegistry> _logger;
private readonly Dictionary<ushort, DateTime> _entries = new();
private readonly AsyncLock _lock = new();
/// <summary>
/// Initializes a new instance of the <see cref="GameServerRegistry"/> class.
/// </summary>
/// <param name="connectServer">The connect server.</param>
/// <param name="logger">The logger.</param>
public GameServerRegistry(IConnectServer connectServer, ILogger<GameServerRegistry> logger)
{
this._connectServer = connectServer;
this._logger = logger;
async Task RunCleanupLoop()
{
try
{
await this.CleanupLoopAsync(this._disposeCts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error in cleanup loop");
}
}
_ = RunCleanupLoop();
}
/// <inheritdoc />
public void Dispose()
{
this._disposeCts.Cancel();
this._disposeCts.Dispose();
}
/// <summary>
/// Updates the registration.
/// </summary>
/// <param name="serverInfo">The server information.</param>
/// <param name="publicEndPoint">The public end point.</param>
public async Task UpdateRegistrationAsync(ServerInfo serverInfo, IPEndPoint publicEndPoint)
{
using var l = await this._lock.LockAsync().ConfigureAwait(false);
var isNew = !this._entries.ContainsKey(serverInfo.Id);
this._entries[serverInfo.Id] = DateTime.UtcNow;
if (isNew)
{
this._connectServer.RegisterGameServer(serverInfo, publicEndPoint);
}
else
{
this._connectServer.CurrentConnectionsChanged(serverInfo.Id, serverInfo.CurrentConnections);
}
}
private async Task CleanupLoopAsync(CancellationToken cancellationToken)
{
var tempRemoved = new List<ushort>();
while (!this._disposeCts.IsCancellationRequested)
{
await Task.Delay(2000, cancellationToken).ConfigureAwait(false);
using var l = await this._lock.LockAsync(cancellationToken).ConfigureAwait(false);
foreach (var serverId in this._entries.Keys)
{
var lastUpdate = this._entries[serverId];
var diff = DateTime.UtcNow - lastUpdate;
if (diff > this._timeout)
{
this._logger.LogInformation("Difference of {0} higher than timeout for server {1}", diff, serverId);
this._connectServer.UnregisterGameServer(serverId);
tempRemoved.Add(serverId);
}
}
foreach (var serverId in tempRemoved)
{
this._entries.Remove(serverId, out _);
}
tempRemoved.Clear();
}
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<DockerComposeProjectPath>..\..\..\deploy\distributed\docker-compose.dcproj</DockerComposeProjectPath>
<UserSecretsId>50035c44-3745-4419-bbb9-71c2d6592b6e</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile></DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\ConnectServer\MUnique.OpenMU.ConnectServer.csproj" />
<ProjectReference Include="..\Common\MUnique.OpenMU.Dapr.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,31 @@
// <copyright file="Program.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using MUnique.OpenMU.ConnectServer;
using MUnique.OpenMU.ConnectServer.Host;
using MUnique.OpenMU.Dapr.Common;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
var builder = DaprService.CreateBuilder("ConnectServer", args);
// Add services to the container.
var services = builder.Services;
services.AddSingleton<ConnectServer>()
.AddSingleton<GameServerRegistry>()
.AddSingleton<IConnectServer>(s => s.GetService<ConnectServer>()!)
.AddPeristenceProvider()
.AddPersistentSingleton<IConnectServerSettings, ConnectServerDefinition>()
.AddHostedService<ConnectServerHostedServiceWrapper>()
.PublishManageableServer<IConnectServer>();
var metricsRegistry = new MetricsRegistry();
metricsRegistry.AddNetworkMeters();
builder.AddOpenTelemetryMetrics(metricsRegistry);
var app = builder.BuildAndConfigure();
await app.WaitForDatabaseConnectionInitializationAsync().ConfigureAwait(false);
app.Run();

View File

@@ -0,0 +1,10 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.ConnectServer.Host")]

View File

@@ -0,0 +1,79 @@
// <copyright file="ServerInfoController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer.Host;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// API Controller which provides information about the connection and game servers.
/// </summary>
[ApiController]
[Route("[controller]")]
public class ServerInfoController : ControllerBase
{
private readonly ConnectServer _connectServer;
/// <summary>
/// Initializes a new instance of the <see cref="ServerInfoController"/> class.
/// </summary>
/// <param name="connectServer">The connect server.</param>
public ServerInfoController(ConnectServer connectServer)
{
this._connectServer = connectServer;
}
/// <summary>
/// Gets the complete information about the connection server and all known online game servers.
/// </summary>
/// <returns>The complete information about the connection server and all known online game servers.</returns>
[HttpGet]
public object GetCompleteInfo()
{
return new
{
PatchAddress = this._connectServer.Settings.PatchAddress,
CurrentPatchVersion = this._connectServer.Settings.CurrentPatchVersion,
Version = this._connectServer.Settings.Client.Version,
Season = this._connectServer.Settings.Client.Season,
Episode = this._connectServer.Settings.Client.Episode,
Port = this._connectServer.Settings.ClientListenerPort,
State = this._connectServer.ServerState,
GameServers = this._connectServer.RegisteredGameServers
.OrderBy(gs => gs.ServerId)
.Select(gs => new
{
gs.ServerId,
EndPoint = gs.EndPoint.ToString(),
gs.ServerLoadPercentage,
gs.CurrentConnections,
}).ToList(),
};
}
/// <summary>
/// Gets the connection count of all game servers.
/// </summary>
/// <returns>The overall count of current connections.</returns>
[HttpGet("playerCount")]
public int GetOverallConnectionCount()
{
return this._connectServer.CurrentGameServerConnections;
}
/// <summary>
/// Gets the connection count of all game servers of a realm.
/// </summary>
/// <param name="realmIndex">Index of the realm.</param>
/// <returns>The connection count of all game servers of a realm.</returns>
[HttpGet("{realmIndex}/playerCount")]
public int GetRealmConnectionCount(byte realmIndex)
{
const int realmOffset = 20;
return this._connectServer.RegisteredGameServers
.Where(gs => gs.ServerId >= realmIndex * realmOffset && gs.ServerId < (realmIndex + 1) * realmOffset)
.Sum(gs => gs.CurrentConnections);
}
}

View File

@@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base
WORKDIR /app
EXPOSE 8080
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY ["Directory.Packages.props", "."]
COPY ["Directory.Build.props", "."]
COPY ["Dapr/FriendServer.Host/MUnique.OpenMU.FriendServer.Host.csproj", "Dapr/FriendServer.Host/"]
RUN dotnet restore "Dapr/FriendServer.Host/MUnique.OpenMU.FriendServer.Host.csproj"
COPY . .
WORKDIR "/src/Dapr/FriendServer.Host"
RUN dotnet build "MUnique.OpenMU.FriendServer.Host.csproj" -c Release -o /app/build -p:ci=true
FROM build AS publish
RUN dotnet publish "MUnique.OpenMU.FriendServer.Host.csproj" -c Release -o /app/publish -p:ci=true
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "MUnique.OpenMU.FriendServer.Host.dll"]

View File

@@ -0,0 +1,114 @@
// <copyright file="FriendNotifier.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.FriendServer.Host;
using System.Collections.ObjectModel;
using global::Dapr.Client;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.ServerClients;
/// <summary>
/// Implementation of a <see cref="IFriendNotifier"/> which notifies the game server
/// about notifications for a player about changes in the friend system.
/// </summary>
public class FriendNotifier : IFriendNotifier
{
private readonly DaprClient _daprClient;
private readonly ILogger<FriendNotifier> _logger;
private readonly IReadOnlyDictionary<int, string> _appIds;
/// <summary>
/// Initializes a new instance of the <see cref="FriendNotifier" /> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="logger">The logger.</param>
public FriendNotifier(DaprClient daprClient, ILogger<FriendNotifier> logger)
{
this._daprClient = daprClient;
this._logger = logger;
var appIds = new Dictionary<int, string>();
for (int i = 0; i < 100; i++)
{
appIds.Add(i, $"gameServer{i + 1}");
}
this._appIds = new ReadOnlyDictionary<int, string>(appIds);
}
/// <inheritdoc />
public async ValueTask FriendRequestAsync(string requester, string receiver, int serverId)
{
try
{
await this._daprClient.InvokeMethodAsync(this._appIds[serverId], nameof(IGameServer.FriendRequestAsync), new RequestArguments(requester, receiver)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.FriendRequestAsync));
}
}
/// <inheritdoc />
/// <remarks>It's usually never called here, but at <see cref="FriendServer.ForwardLetterAsync"/>.</remarks>
public async ValueTask LetterReceivedAsync(LetterHeader letter)
{
try
{
await this._daprClient.PublishEventAsync("pubsub", nameof(IGameServer.LetterReceivedAsync), letter).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.FriendRequestAsync));
}
}
/// <inheritdoc />
public async ValueTask FriendOnlineStateChangedAsync(int playerServerId, string player, string friend, int friendServerId)
{
try
{
// todo: find out if this is correct when logging out
if (this._appIds.TryGetValue(playerServerId, out var gameServer))
{
await this._daprClient.InvokeMethodAsync(gameServer, nameof(IGameServer.FriendOnlineStateChangedAsync), new FriendOnlineStateChangedArguments(player, friend, friendServerId)).ConfigureAwait(false);
}
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.FriendRequestAsync));
}
}
/// <inheritdoc />
public async ValueTask ChatRoomCreatedAsync(int serverId, ChatServerAuthenticationInfo playerAuthenticationInfo, string friendName)
{
try
{
await this._daprClient.InvokeMethodAsync(this._appIds[serverId], nameof(IGameServer.ChatRoomCreatedAsync), new ChatRoomCreationArguments(playerAuthenticationInfo, friendName)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.FriendRequestAsync));
}
}
/// <inheritdoc />
public async ValueTask InitializeMessengerAsync(int serverId, MessengerInitializationData initializationData)
{
try
{
if (this._appIds.TryGetValue(serverId, out var gameServer))
{
await this._daprClient.InvokeMethodAsync(gameServer, nameof(IGameServer.InitializeMessengerAsync), initializationData).ConfigureAwait(false);
}
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.FriendRequestAsync));
}
}
}

View File

@@ -0,0 +1,132 @@
// <copyright file="FriendServerController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.FriendServer.Host;
using global::Dapr;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.ServerClients;
/// <summary>
/// API Controller which handles the calls from the <see cref="ServerClients.FriendServer"/>.
/// </summary>
[ApiController]
[Route("")]
public class FriendServerController : ControllerBase
{
private readonly IFriendServer _friendServer;
private readonly ILogger<FriendServerController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FriendServerController"/> class.
/// </summary>
/// <param name="friendServer">The friend server.</param>
/// <param name="logger">The logger.</param>
public FriendServerController(IFriendServer friendServer, ILogger<FriendServerController> logger)
{
this._friendServer = friendServer;
this._logger = logger;
}
/// <summary>
/// Is called when a player entered the game.
/// It will cause a response with <see cref="IFriendSystemSubscriber.InitializeMessengerAsync" />
/// and a state update for friends.
/// </summary>
/// <param name="data">The data.</param>
[Topic("pubsub", nameof(IEventPublisher.PlayerEnteredGameAsync))]
[HttpPost(nameof(IEventPublisher.PlayerEnteredGameAsync))]
public async Task PlayerEnteredGameAsync([FromBody] PlayerOnlineStateArguments data)
{
await this._friendServer.PlayerEnteredGameAsync(data.ServerId, data.CharacterId, data.CharacterName).ConfigureAwait(false);
}
/// <summary>
/// Is called when a player leaves the game.
/// It will cause a state update for friends.
/// </summary>
/// <param name="data">The data.</param>
[Topic("pubsub", nameof(IEventPublisher.PlayerLeftGameAsync))]
[HttpPost(nameof(IEventPublisher.PlayerLeftGameAsync))]
public async Task PlayerLeftGameAsync([FromBody] PlayerOnlineStateArguments data)
{
await this._friendServer.PlayerLeftGameAsync(data.CharacterId, data.CharacterName).ConfigureAwait(false);
}
/// <summary>
/// Sets the online visibility state of a character.
/// </summary>
/// <param name="data">The data.</param>
[HttpPost(nameof(IFriendServer.SetPlayerVisibilityStateAsync))]
public async Task SetPlayerInvisibilityStateAsync([FromBody] PlayerFriendOnlineStateArguments data)
{
await this._friendServer.SetPlayerVisibilityStateAsync(data.ServerId, data.CharacterId, data.CharacterName, data.IsVisible).ConfigureAwait(false);
}
/// <summary>
/// Handles the friend request response.
/// </summary>
/// <param name="data">The data.</param>
[HttpPost(nameof(IFriendServer.FriendResponseAsync))]
public async Task FriendResponseAsync([FromBody] FriendResponseArguments data)
{
await this._friendServer.FriendResponseAsync(data.CharacterName, data.FriendName, data.Accepted).ConfigureAwait(false);
}
/// <summary>
/// Determines whether two players are friends.
/// </summary>
/// <param name="data">The data.</param>
/// <returns>True if the two players are friends; otherwise false.</returns>
[HttpPost(nameof(IFriendServer.IsFriendAsync))]
public async Task<bool> IsFriendAsync([FromBody] RequestArguments data)
{
return await this._friendServer.IsFriendAsync(data.Requester, data.Receiver).ConfigureAwait(false);
}
/// <summary>
/// Sends a friend request to the friend, and adds a new friend view item to the players friend list.
/// </summary>
/// <param name="data">The data.</param>
/// <returns>If a new friend view item got added to the players friend list.</returns>
[HttpPost(nameof(IFriendServer.FriendRequestAsync))]
public async Task<bool> FriendRequestAsync([FromBody] RequestArguments data)
{
return await this._friendServer.FriendRequestAsync(data.Requester, data.Receiver).ConfigureAwait(false);
}
/// <summary>
/// Deletes the friend.
/// </summary>
/// <param name="data">The data.</param>
[HttpPost(nameof(IFriendServer.DeleteFriendAsync))]
public async Task DeleteFriendAsync([FromBody] RequestArguments data)
{
await this._friendServer.DeleteFriendAsync(data.Requester, data.Receiver).ConfigureAwait(false);
}
/// <summary>
/// Creates a new chat room.
/// </summary>
/// <param name="data">The data.</param>
[HttpPost(nameof(IFriendServer.CreateChatRoomAsync))]
public async Task CreateChatRoomAsync([FromBody] RequestArguments data)
{
await this._friendServer.CreateChatRoomAsync(data.Requester, data.Receiver).ConfigureAwait(false);
}
/// <summary>
/// Invites a friend to an existing chat room.
/// </summary>
/// <param name="data">The data.</param>
/// <returns>The success of the invitation.</returns>
[HttpPost(nameof(IFriendServer.InviteFriendToChatRoomAsync))]
public async Task<bool> InviteFriendToChatRoomAsync([FromBody] ChatRoomInvitationArguments data)
{
return await this._friendServer.InviteFriendToChatRoomAsync(data.CharacterName, data.FriendName, data.RoomNumber).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<DockerComposeProjectPath>..\..\..\deploy\distributed\docker-compose.dcproj</DockerComposeProjectPath>
<UserSecretsId>7b192ead-65a8-467f-a045-715183400bd9</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile></DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\FriendServer\MUnique.OpenMU.FriendServer.csproj" />
<ProjectReference Include="..\Common\MUnique.OpenMU.Dapr.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,31 @@
// <copyright file="Program.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using MUnique.OpenMU.Dapr.Common;
using MUnique.OpenMU.FriendServer;
using MUnique.OpenMU.FriendServer.Host;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.ServerClients;
using FriendServer = MUnique.OpenMU.FriendServer.FriendServer;
var builder = DaprService.CreateBuilder("FriendServer", args);
// Add services to the container.
var services = builder.Services;
services.AddSingleton<IFriendServer, FriendServer>()
.AddSingleton<IChatServer, ChatServer>()
.AddSingleton<IFriendNotifier, FriendNotifier>()
.AddPeristenceProvider();
var metricsRegistry = new MetricsRegistry();
// todo: add some meaningful metrics
builder.AddOpenTelemetryMetrics(metricsRegistry);
var app = builder.BuildAndConfigure();
await app.WaitForDatabaseConnectionInitializationAsync().ConfigureAwait(false);
app.Run();

View File

@@ -0,0 +1,10 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.FriendServer.Host")]

View File

@@ -0,0 +1,57 @@
@using MUnique.OpenMU.GameServer.Host.Layout
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenMU Map</title>
<base href="/" />
<ResourcePreloader />
<link href="@Assets["MUnique.OpenMU.GameServer.Host.styles.css"]" rel="stylesheet" />
@foreach (var stylesheetSrc in Web.Map.Exports.Stylesheets)
{
<link href="@Assets[stylesheetSrc]" rel="stylesheet" />
}
<ImportMap />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
@foreach (var scriptSrc in Web.Map.Exports.Scripts)
{
<script src="@Assets[scriptSrc]"></script>
}
@if (Web.Map.Exports.ScriptMappings.Any())
{
var sb = new StringBuilder();
sb.AppendLine("System.config({").AppendLine(" map: {");
bool isFirst = true;
foreach (var scriptMapping in Web.Map.Exports.ScriptMappings)
{
if (!isFirst)
{
sb.AppendLine(",");
}
isFirst = false;
sb.Append($"'{scriptMapping.Key}': '")
.Append(scriptMapping.Path)
.Append("'");
}
sb.AppendLine(" }")
.AppendLine("});");
<script>
// To be able to resolve three etc. in our TS files and the resulting javascript, SystemJS needs to be configured.
// These modules are loaded by SystemJS when they get requested the first time. No need to manually load them.
@((MarkupString)sb.ToString())
</script>
}
</body>
</html>

View File

@@ -0,0 +1,72 @@
// <copyright file="ConfigurationChangeController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameServer.Host;
using global::Dapr;
using Microsoft.AspNetCore.Mvc;
using MUnique.OpenMU.Dapr.Common;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence.EntityFramework;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// The API controller which handles the calls of the <see cref="ConfigurationChangePublisher"/>
/// from other services, such as the AdminPanel.
/// It forwards the changes to a <see cref="IConfigurationChangeListener"/>, so
/// that the caches are updated and the game logic can react to that.
/// </summary>
[ApiController]
[Route("")]
public class ConfigurationChangeController : ControllerBase
{
private readonly IConfigurationChangeListener _changeListener;
private readonly PlugInManager _plugInManager;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationChangeController" /> class.
/// </summary>
/// <param name="changeListener">The change listener.</param>
/// <param name="plugInManager">The plugin manager.</param>
public ConfigurationChangeController(IConfigurationChangeListener changeListener, PlugInManager plugInManager)
{
this._changeListener = changeListener;
this._plugInManager = plugInManager;
}
/// <summary>
/// Called when a configuration got added on the admin panel.
/// </summary>
/// <param name="arguments">The message arguments.</param>
[HttpPost(nameof(IConfigurationChangePublisher.ConfigurationAddedAsync))]
[Topic("pubsub", nameof(IConfigurationChangePublisher.ConfigurationAddedAsync))]
public ValueTask ConfigurationAddedAsync([FromBody] ConfigurationChangeArguments arguments)
{
return this._changeListener.ConfigurationAddedAsync(arguments.Type, arguments.Id, arguments.Configuration!, null, null);
}
/// <summary>
/// Called when a configuration got added on the admin panel.
/// </summary>
/// <param name="arguments">The message arguments.</param>
[HttpPost(nameof(IConfigurationChangePublisher.ConfigurationChangedAsync))]
[Topic("pubsub", nameof(IConfigurationChangePublisher.ConfigurationChangedAsync))]
public ValueTask ConfigurationChangedAsync([FromBody] ConfigurationChangeArguments arguments)
{
this._plugInManager.ApplyChangedConfiguration(arguments.Type, arguments.Id, arguments.Configuration);
return this._changeListener.ConfigurationChangedAsync(arguments.Type, arguments.Id, arguments.Configuration!, null);
}
/// <summary>
/// Called when a configuration got removed on the admin panel.
/// </summary>
/// <param name="arguments">The message arguments.</param>
[HttpPost(nameof(IConfigurationChangePublisher.ConfigurationRemovedAsync))]
[Topic("pubsub", nameof(IConfigurationChangePublisher.ConfigurationRemovedAsync))]
public ValueTask ConfigurationRemovedAsync([FromBody] ConfigurationChangeArguments arguments)
{
this._plugInManager.ApplyRemovedConfiguration(arguments.Type, arguments.Id);
return this._changeListener.ConfigurationRemovedAsync(arguments.Type, arguments.Id, null, null);
}
}

View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base
WORKDIR /app
EXPOSE 8080
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY ["Directory.Packages.props", "."]
COPY ["Directory.Build.props", "."]
COPY ["Dapr/GameServer.Host/MUnique.OpenMU.GameServer.Host.csproj", "Dapr/GameServer.Host/"]
RUN dotnet restore "Dapr/GameServer.Host/MUnique.OpenMU.GameServer.Host.csproj"
COPY . .
WORKDIR "/src/Dapr/GameServer.Host"
RUN dotnet build "MUnique.OpenMU.GameServer.Host.csproj" -c Release -o /app/build -p:ci=true
FROM build AS publish
RUN dotnet publish "MUnique.OpenMU.GameServer.Host.csproj" -c Release -o /app/publish -p:ci=true
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "MUnique.OpenMU.GameServer.Host.dll"]

View File

@@ -0,0 +1,122 @@
// <copyright file="EventPublisher.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameServer.Host;
using global::Dapr.Client;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.ServerClients;
/// <summary>
/// Implementation of a <see cref="IEventPublisher"/> which publishes the events
/// through the Dapr pub/sub component.
/// </summary>
public class EventPublisher : IEventPublisher
{
private const string PubSubName = "pubsub";
private readonly DaprClient _daprClient;
private readonly ILogger<GameServerStatePublisher> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="EventPublisher"/> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="logger">The logger.</param>
public EventPublisher(DaprClient daprClient, ILogger<GameServerStatePublisher> logger)
{
this._daprClient = daprClient;
this._logger = logger;
}
/// <inheritdoc />
public async ValueTask PlayerEnteredGameAsync(byte serverId, Guid characterId, string characterName)
{
try
{
await this._daprClient
.PublishEventAsync(
PubSubName,
nameof(IEventPublisher.PlayerEnteredGameAsync),
new PlayerOnlineStateArguments(characterId, characterName, serverId)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when publishing a guild message");
}
}
/// <inheritdoc />
public async ValueTask PlayerLeftGameAsync(byte serverId, Guid characterId, string characterName, uint guildId = 0)
{
try
{
await this._daprClient
.PublishEventAsync(
PubSubName,
nameof(IEventPublisher.PlayerLeftGameAsync),
new PlayerOnlineStateArguments(characterId, characterName, serverId, guildId)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when publishing a guild message");
}
}
/// <inheritdoc />
public async ValueTask GuildMessageAsync(uint guildId, string sender, string message)
{
try
{
await this._daprClient
.PublishEventAsync(
PubSubName,
nameof(IGameServer.GuildChatMessageAsync),
new GuildMessageArguments(guildId, sender, message)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when publishing a guild message");
}
}
/// <inheritdoc />
public async ValueTask AllianceMessageAsync(uint guildId, string sender, string message)
{
try
{
await this._daprClient
.PublishEventAsync(
PubSubName,
nameof(IGameServer.AllianceChatMessageAsync),
new GuildMessageArguments(guildId, sender, message)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when publishing an alliance message");
}
}
/// <summary>
/// Notifies that a client tried to log into an already logged-in account.
/// The connected player can be notified about that.
/// </summary>
/// <param name="serverId">The identifier of the server on which the client tried to enter.</param>
/// <param name="loginName">The login name.</param>
public async ValueTask PlayerAlreadyLoggedInAsync(byte serverId, string loginName)
{
try
{
await this._daprClient
.PublishEventAsync(
PubSubName,
nameof(IGameServer.PlayerAlreadyLoggedInAsync),
new PlayerLoggedInArguments(serverId, loginName)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when publishing an alliance message");
}
}
}

View File

@@ -0,0 +1,187 @@
// <copyright file="GameServerController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameServer.Host;
using global::Dapr;
using Microsoft.AspNetCore.Mvc;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.ServerClients;
/// <summary>
/// The API controller for the game server which handles the calls from other services.
/// </summary>
[ApiController]
[Route("")]
public class GameServerController : ControllerBase
{
private readonly IGameServer _gameServer;
/// <summary>
/// Initializes a new instance of the <see cref="GameServerController"/> class.
/// </summary>
/// <param name="gameServer">The game server.</param>
public GameServerController(GameServer gameServer)
{
this._gameServer = gameServer;
}
/// <summary>
/// Shuts down the server gracefully.
/// </summary>
[HttpPost(nameof(IGameServer.ShutdownAsync))]
public async ValueTask ShutdownAsync()
{
await this._gameServer.ShutdownAsync().ConfigureAwait(false);
Environment.Exit(0);
}
/// <summary>
/// Sends a chat message to all connected guild members.
/// </summary>
/// <param name="data">The message arguments.</param>
[HttpPost(nameof(IGameServer.GuildChatMessageAsync))]
[Topic("pubsub", nameof(IGameServer.GuildChatMessageAsync))]
public ValueTask GuildChatMessageAsync([FromBody] GuildMessageArguments data)
{
return this._gameServer.GuildChatMessageAsync(data.GuildId, data.Sender, data.Message);
}
/// <summary>
/// Sends a chat message to all connected alliance members.
/// </summary>
/// <param name="data">The message arguments.</param>
[HttpPost(nameof(IGameServer.AllianceChatMessageAsync))]
[Topic("pubsub", nameof(IGameServer.AllianceChatMessageAsync))]
public ValueTask AllianceChatMessageAsync([FromBody] GuildMessageArguments data)
{
return this._gameServer.AllianceChatMessageAsync(data.GuildId, data.Sender, data.Message);
}
/// <summary>
/// Notifies the game server that a guild got deleted.
/// </summary>
/// <param name="guildId">The guild identifier.</param>
[HttpPost(nameof(IGameServer.GuildDeletedAsync))]
[Topic("pubsub", nameof(GuildDeletedAsync))]
public ValueTask GuildDeletedAsync([FromBody] uint guildId)
{
return this._gameServer.GuildDeletedAsync(guildId);
}
/// <summary>
/// Notifies the game server that a guild member got removed from a guild.
/// </summary>
/// <param name="playerName">Name of the player which got removed from a guild.</param>
[HttpPost(nameof(IGameServer.GuildPlayerKickedAsync))]
[Topic("pubsub", nameof(IGameServer.GuildPlayerKickedAsync))]
public ValueTask GuildPlayerKickedAsync([FromBody] string playerName)
{
return this._gameServer.GuildPlayerKickedAsync(playerName);
}
/// <summary>
/// Notifies the game server that a letter got received for an online player.
/// </summary>
/// <param name="letter">The letter header.</param>
[HttpPost(nameof(IGameServer.LetterReceivedAsync))]
[Topic("pubsub", nameof(IGameServer.LetterReceivedAsync))]
public ValueTask LetterReceivedAsync([FromBody] LetterHeader letter)
{
return this._gameServer.LetterReceivedAsync(letter);
}
/// <summary>
/// Assigns the guild to the player.
/// </summary>
/// <param name="data">The assignment arguments.</param>
[HttpPost(nameof(IGameServer.AssignGuildToPlayerAsync))]
public ValueTask AssignGuildToPlayerAsync([FromBody] GuildMemberAssignArguments data)
{
return this._gameServer.AssignGuildToPlayerAsync(data.CharacterName, data.MemberStatus);
}
/// <summary>
/// Initializes the messenger of a player.
/// </summary>
/// <param name="initializationData">The initialization data.</param>
[HttpPost(nameof(IGameServer.InitializeMessengerAsync))]
public ValueTask InitializeMessengerAsync([FromBody] MessengerInitializationData initializationData)
{
return this._gameServer.InitializeMessengerAsync(initializationData);
}
/// <summary>
/// Sends a global message to all connected players with the specified message type.
/// </summary>
/// <param name="data">The message arguments.</param>
[HttpPost(nameof(IGameServer.SendGlobalMessageAsync))]
public ValueTask SendGlobalMessageAsync([FromBody] MessageArguments data)
{
return this._gameServer.SendGlobalMessageAsync(data.Message, data.Type);
}
/// <summary>
/// Notifies the server that a player made a friend request to another player, which is online on this server.
/// </summary>
/// <param name="data">The request arguments.</param>
[HttpPost(nameof(IGameServer.FriendRequestAsync))]
public ValueTask FriendRequestAsync([FromBody] RequestArguments data)
{
return this._gameServer.FriendRequestAsync(data.Requester, data.Receiver);
}
/// <summary>
/// Notifies the game server that a friend online state changed.
/// </summary>
/// <param name="data">The state change arguments.</param>
[HttpPost(nameof(IGameServer.FriendOnlineStateChangedAsync))]
public ValueTask FriendOnlineStateChangedAsync([FromBody] FriendOnlineStateChangedArguments data)
{
return this._gameServer.FriendOnlineStateChangedAsync(data.Player, data.Friend, data.ServerId);
}
/// <summary>
/// Notifies the game server that a chat room got created on the chat server for a player which is online on this game server.
/// </summary>
/// <param name="data">The chat room creation arguments.</param>
[HttpPost(nameof(IGameServer.ChatRoomCreatedAsync))]
public ValueTask ChatRoomCreatedAsync([FromBody] ChatRoomCreationArguments data)
{
return this._gameServer.ChatRoomCreatedAsync(data.AuthenticationInfo, data.FriendName);
}
/// <summary>
/// Disconnects the player from the game.
/// </summary>
/// <param name="playerName">Name of the player.</param>
/// <returns>True, if the player has been disconnected; False, otherwise.</returns>
[HttpPost(nameof(IGameServer.DisconnectPlayerAsync))]
public ValueTask<bool> DisconnectPlayerAsync([FromBody] string playerName)
{
return this._gameServer.DisconnectPlayerAsync(playerName);
}
/// <summary>
/// Disconnects the account from the game.
/// </summary>
/// <param name="accountName">Name of the account.</param>
/// <returns>True, if the player has been disconnected; False, otherwise.</returns>
[HttpPost(nameof(IGameServer.DisconnectPlayerAsync))]
public ValueTask<bool> DisconnectAccountAsync([FromBody] string accountName)
{
return this._gameServer.DisconnectAccountAsync(accountName);
}
/// <summary>
/// Bans the player from the game.
/// </summary>
/// <param name="playerName">Name of the player.</param>
/// <returns>True, if the player has been banned; False, otherwise.</returns>
[HttpPost(nameof(IGameServer.BanPlayerAsync))]
public ValueTask<bool> BanPlayerAsync([FromBody] string playerName)
{
return this._gameServer.BanPlayerAsync(playerName);
}
}

View File

@@ -0,0 +1,80 @@
// <copyright file="GameServerHostedServiceWrapper.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameServer.Host;
using System.Collections.Generic;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MUnique.OpenMU.Dapr.Common;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
using MUnique.OpenMU.Web.Map;
/// <summary>
/// A wrapper which takes a <see cref="Interfaces.IGameServer"/> and wraps it as <see cref="IHostedLifecycleService"/>,
/// so that additional initialization can be done before actually starting it.
/// The actual server start is deferred to <see cref="StartedAsync"/> which is called after the web application
/// has started (i.e. the HTTP API is already available), breaking the circular startup dependency with the Dapr sidecar.
/// TODO: listen to configuration changes/database reinit.
/// See also: ServerContainerBase.
/// </summary>
public class GameServerHostedServiceWrapper : IHostedLifecycleService
{
private readonly IServiceProvider _serviceProvider;
private IGameServer? _gameServer;
/// <summary>
/// Initializes a new instance of the <see cref="GameServerHostedServiceWrapper"/> class.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
public GameServerHostedServiceWrapper(IServiceProvider serviceProvider)
{
this._serviceProvider = serviceProvider;
}
/// <inheritdoc/>
public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc/>
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc/>
public async Task StartedAsync(CancellationToken cancellationToken)
{
await this._serviceProvider.WaitForDatabaseInitializationAsync(cancellationToken).ConfigureAwait(false);
if (this._serviceProvider.GetService<ICollection<PlugInConfiguration>>() is { } plugInCollection)
{
if (plugInCollection is not List<PlugInConfiguration> plugInConfigurations)
{
throw new InvalidOperationException($"The registered {nameof(ICollection<PlugInConfiguration>)} must be a {nameof(List<PlugInConfiguration>)} to be able to load plugin configurations.");
}
await this._serviceProvider.TryLoadPlugInConfigurationsAsync(plugInConfigurations).ConfigureAwait(false);
}
this._gameServer = this._serviceProvider.GetRequiredService<IGameServer>();
var initializer = this._serviceProvider.GetRequiredService<GameServerInitializer>();
await initializer.InitializeAsync().ConfigureAwait(false);
await ((ObservableGameServerAdapter)this._serviceProvider.GetRequiredService<IObservableGameServer>())
.InitializeAsync().ConfigureAwait(false);
await this._gameServer.StartAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken)
{
return this._gameServer?.StopAsync(cancellationToken) ?? Task.CompletedTask;
}
/// <inheritdoc/>
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -0,0 +1,80 @@
// <copyright file="GameServerInitializer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameServer.Host;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.Network.PlugIns;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Initialization of a <see cref="GameServer"/> which is executed before starting it.
/// </summary>
public class GameServerInitializer
{
private readonly GameServer _gameServer;
private readonly GameServerDefinition _definition;
private readonly IIpAddressResolver _ipResolver;
private readonly ILoggerFactory _loggerFactory;
private readonly IGameServerStateObserver _stateObserver;
private readonly IPersistenceContextProvider _contextProvider;
/// <summary>
/// Initializes a new instance of the <see cref="GameServerInitializer"/> class.
/// </summary>
/// <param name="gameServer">The game server.</param>
/// <param name="definition">The definition.</param>
/// <param name="ipResolver">The ip resolver.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="stateObserver">The state observer.</param>
/// <param name="contextProvider">The context provider.</param>
public GameServerInitializer(GameServer gameServer, GameServerDefinition definition, IIpAddressResolver ipResolver, ILoggerFactory loggerFactory, IGameServerStateObserver stateObserver, IPersistenceContextProvider contextProvider)
{
this._gameServer = gameServer;
this._definition = definition;
this._ipResolver = ipResolver;
this._loggerFactory = loggerFactory;
this._stateObserver = stateObserver;
this._contextProvider = contextProvider;
}
/// <summary>
/// Initializes the game server.
/// </summary>
public async ValueTask InitializeAsync()
{
foreach (var endpoint in this._definition.Endpoints)
{
this._gameServer.AddListener(new DefaultTcpGameServerListener(
endpoint,
this._gameServer.CreateServerInfo(),
this._gameServer.Context,
this._stateObserver,
this._ipResolver,
this._loggerFactory));
}
using var context = this._contextProvider.CreateNewConfigurationContext();
await this.LoadGameClientDefinitionsAsync(context).ConfigureAwait(false);
}
private async ValueTask LoadGameClientDefinitionsAsync(IContext persistenceContext)
{
var versions = (await persistenceContext.GetAsync<GameClientDefinition>().ConfigureAwait(false)).ToList();
foreach (var gameClientDefinition in versions)
{
ClientVersionResolver.Register(
gameClientDefinition.Version,
new ClientVersion(gameClientDefinition.Season, gameClientDefinition.Episode, gameClientDefinition.Language));
}
if (versions.FirstOrDefault() is { } firstVersion)
{
ClientVersionResolver.DefaultVersion = ClientVersionResolver.Resolve(firstVersion.Version);
}
}
}

View File

@@ -0,0 +1,129 @@
// <copyright file="GameServerStatePublisher.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameServer.Host;
using System.Diagnostics;
using System.Net;
using System.Threading;
using global::Dapr.Client;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.ServerClients;
using Nito.AsyncEx.Synchronous;
/// <summary>
/// Implementation of <see cref="IGameServerStateObserver"/> which publishes the state
/// by sending a heartbeat to a Dapr pub/sub component.
/// </summary>
public sealed class GameServerStatePublisher : IGameServerStateObserver, IDisposable
{
private const string PubSubName = "pubsub";
private readonly DaprClient _daprClient;
private readonly ILogger<GameServerStatePublisher> _logger;
private int _currentConnections;
private ServerInfo? _serverInfo;
private IPEndPoint? _publicEndPoint;
private CancellationTokenSource? _heartbeatCancellationTokenSource;
/// <summary>
/// Initializes a new instance of the <see cref="GameServerStatePublisher"/> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="logger">The logger.</param>
public GameServerStatePublisher(DaprClient daprClient, ILogger<GameServerStatePublisher> logger)
{
this._daprClient = daprClient;
this._logger = logger;
}
/// <inheritdoc />
public void Dispose()
{
this._heartbeatCancellationTokenSource?.Cancel();
this._heartbeatCancellationTokenSource?.Dispose();
}
/// <inheritdoc />
public void RegisterGameServer(ServerInfo serverInfo, IPEndPoint publicEndPoint)
{
this._heartbeatCancellationTokenSource?.Cancel(false);
this._serverInfo = serverInfo;
this._publicEndPoint = publicEndPoint;
this._heartbeatCancellationTokenSource = new();
try
{
this._logger.LogInformation("Starting heartbeat thread ...");
var heartbeatThread = new Thread(
() =>
{
try
{
this.HeartbeatLoop(this._heartbeatCancellationTokenSource.Token);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error in heartbeat loop.");
}
})
{
Name = "Heartbeat",
};
heartbeatThread.Start();
this._logger.LogInformation("...started heartbeat thread.");
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when publishing the game server registration.");
}
}
/// <inheritdoc />
public void UnregisterGameServer(ushort serverId)
{
this._logger.LogInformation("Stopping heartbeat thread");
this._heartbeatCancellationTokenSource?.Cancel();
}
/// <inheritdoc />
public void CurrentConnectionsChanged(ushort serverId, int currentConnections)
{
this._currentConnections = currentConnections;
}
private void HeartbeatLoop(CancellationToken cancellationToken)
{
if (this._serverInfo is not { } serverInfo
|| this._publicEndPoint is not { } publicEndPoint)
{
return;
}
var stopWatch = new Stopwatch();
stopWatch.Start();
var publicEndPointString = publicEndPoint.ToString();
var arguments = new GameServerHeartbeatArguments(serverInfo, publicEndPointString, stopWatch.Elapsed);
while (!cancellationToken.IsCancellationRequested)
{
serverInfo.CurrentConnections = this._currentConnections;
arguments.UpTime = stopWatch.Elapsed;
try
{
this._daprClient.PublishEventAsync(PubSubName, "GameServerHeartbeat", arguments, cancellationToken).WaitAndUnwrapException(cancellationToken);
}
catch (Exception ex)
{
this._logger.LogDebug(ex, "Error when publishing game server heartbeat");
}
Thread.Sleep(5000);
}
}
}

View File

@@ -0,0 +1,26 @@
@inherits LayoutComponentBase
<div class="page">
<!--
<div class="sidebar">
<NavMenu />
</div>
<BlazoredToasts />
-->
<main>
<div class="top-row px-4">
<BreadcrumbNavigation />
<a href="https://munique.net" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View File

@@ -0,0 +1,44 @@
@using MUnique.OpenMU.DataModel.Configuration
@using MUnique.OpenMU.Persistence;
<nav class="rounded-bottom shadow-lg">
<div class="top-row ps-4 navbar navbar-dark">
<a class="navbar-brand" href="">OpenMU</a>
<button class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
<!-- Hiding it programmatically is not a good idea here, because we want to hide it based on the width of the device/window. -->
<div class="@NavMenuCssClass">
<ul class="nav flex-column">
<li class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="maps">
<span class="oi oi-project" aria-hidden="true"></span> Maps
</NavLink>
</li>
</ul>
</div>
</nav>
@code {
private bool _collapseNavMenu = true;
/// <summary>
/// Returns the class for the entries of the navigation menu.
/// "collapse" is a class of bootstrap which hides it.
/// In our css we also define to show it anyway, if the width sufficient.
/// </summary>
private string NavMenuCssClass => _collapseNavMenu ? "collapse" : string.Empty;
private void ToggleNavMenu()
{
_collapseNavMenu = !_collapseNavMenu;
}
}

View File

@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<button id="components-resume-button" class="components-pause-visible">
Resume
</button>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please reload the page.
</p>
</div>
</dialog>

View File

@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: var(--omu-bg);
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: var(--omu-link-primary);
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: var(--omu-link-primary-border);
}
#components-reconnect-modal button:active {
background-color: var(--omu-link-primary);
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}

View File

@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
location.reload();
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

View File

@@ -0,0 +1,8 @@
<Router AppAssembly="@typeof(Program).Assembly" NotFoundPage="@typeof(Pages.NotFound)">
<Found Context="routeData">
<ModalContainer>
<RouteView RouteData="routeData" DefaultLayout="@typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</ModalContainer>
</Found>
</Router>

View File

@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<DockerComposeProjectPath>..\..\..\deploy\distributed\docker-compose.dcproj</DockerComposeProjectPath>
<UserSecretsId>f08a5e15-a10b-4b4c-b193-2ebf20e8ca71</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile></DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Web\Map\MUnique.OpenMU.Web.Map.csproj" />
<ProjectReference Include="..\Common\MUnique.OpenMU.Dapr.Common.csproj" />
<ProjectReference Include="..\..\GameServer\MUnique.OpenMU.GameServer.csproj" />
</ItemGroup>
<Target Name="FixNetSdkDiscoverAssetsBug" BeforeTargets="ResolveBuildCompressedStaticWebAssetsConfiguration">
<!-- Workaround for:
The "DiscoverPrecompressedAssets" task failed unexpectedly.
System.ArgumentException: An item with the same key has already been added. Key: C:\Users\[user]\.nuget\packages\microsoft.aspnetcore.app.internal.assets\10.0.0\_framework\blazor.web.js
https://github.com/dotnet/sdk/issues/52089
This is caused by a Microsoft.NET.Sdk.Web project (like this) referencing another project that also uses Microsoft.NET.Sdk.Web.
It appears to be a .NET SDK bug, and we're not the only one hitting it.
-->
<ItemGroup>
<!-- Remove any duplicate StaticWebAsset and StaticWebAssetEndpoint items -->
<_StaticWebAsset Include="@(StaticWebAsset)" />
<StaticWebAsset Remove="@(StaticWebAsset)" />
<StaticWebAsset Include="@(_StaticWebAsset-&gt;Distinct())" />
</ItemGroup>
</Target>
</Project>

View File

@@ -0,0 +1,16 @@
@page "/error"
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

View File

@@ -0,0 +1,35 @@
@page "/"
@using MUnique.OpenMU.Web.Map
@using MUnique.OpenMU.Web.Map.Components
@using MUnique.OpenMU.GameLogic
@inject IObservableGameServer _gameServer;
@inject IGameServerContext _gameServerContext;
<p>
<h3>
<span class="badge badge-secondary">
GameServer Id: <span class="badge badge-light">@this._gameServer.Id</span>
</span>
<span class="badge badge-primary">
Players: <span class="badge badge-light">@_gameServerContext.PlayerCount / @_gameServerContext.ServerConfiguration.MaximumPlayers</span>
</span>
<span class="badge badge-primary">
Hosted Maps: <span class="badge badge-light">@_gameServer.Maps.Count</span>
</span>
<span class="badge badge-primary">
Experience Rate: <span class="badge badge-light">@_gameServerContext.ExperienceRate</span>
</span>
<span class="badge badge-primary">
PVP Enabled: <span class="badge badge-light">@_gameServerContext.PvpEnabled</span>
</span>
</h3>
</p>
<p>
<h2>Hosted Maps</h2>
<CascadingValue Name="@nameof(MapCard.LiveMapRoute)" TValue="string" Value="MapPage.LiveMapRoute">
<MapCards GameServer="@_gameServer" />
</CascadingValue>
</p>

View File

@@ -0,0 +1,42 @@
@page "/map/{mapId:guid}"
@using MUnique.OpenMU.Web.Map.Components
@using MUnique.OpenMU.Web.Map
@if (this.GameServer is not null)
{
<div>
<NavLink href="" Match="NavLinkMatch.All">
<!--<span class="oi oi-home" aria-hidden="true"></span>-->
<span>All</span>
</NavLink>
<span> / @this._map?.MapName</span>
</div>
<Map Server="@GameServer" MapId="@MapId"></Map>
}
@code {
internal const string LiveMapRoute = "map/";
private IGameMapInfo? _map;
/// <summary>
/// Gets or sets the server id on which the map is hosted.
/// </summary>
[Inject]
public IObservableGameServer? GameServer { get; set; }
/// <summary>
/// Gets or sets the map id.
/// </summary>
[Parameter]
public Guid MapId { get; set; }
/// <inheritdoc />
protected override Task OnInitializedAsync()
{
this._map = this.GameServer!.Maps.First(m => m.Id == this.MapId);
return base.OnInitializedAsync();
}
}

View File

@@ -0,0 +1,6 @@
@page "/not-found"
@using MUnique.OpenMU.GameServer.Host.Layout
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View File

@@ -0,0 +1,63 @@
// <copyright file="Program.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using MUnique.OpenMU.Dapr.Common;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameServer.Host;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
using MUnique.OpenMU.ServerClients;
using MUnique.OpenMU.Web.Map;
using GameServer = MUnique.OpenMU.GameServer.GameServer;
// Ensure GameLogic and GameServer Assemblies are loaded
_ = MUnique.OpenMU.GameLogic.Rand.NextInt(1, 2);
_ = MUnique.OpenMU.GameServer.ClientVersionResolver.DefaultVersion;
var gameServerId = byte.Parse(Environment.GetEnvironmentVariable("GS_ID") ?? "0");
var serviceName = $"GameServer{gameServerId + 1}";
var builder = DaprService.CreateBuilder(serviceName, args);
var plugInConfigurations = new List<PlugInConfiguration>();
// Add services to the container.
var services = builder.Services;
services.AddSingleton<GameServer>()
.AddSingleton<IGameServer>(s => s.GetService<GameServer>()!)
.AddSingleton<IList<IManageableServer>>(s => new List<IManageableServer>() { s.GetService<GameServer>()! })
.AddSingleton(s => s.GetService<GameServer>()!.Context)
.AddSingleton<IGameServerStateObserver, GameServerStatePublisher>()
.AddSingleton<ConfigurationChangeMediator>()
.AddSingleton<IConfigurationChangeMediator>(s => s.GetRequiredService<ConfigurationChangeMediator>())
.AddSingleton<IConfigurationChangeMediatorListener>(s => s.GetRequiredService<ConfigurationChangeMediator>())
.AddSingleton<ILoginServer, LoginServer>()
.AddSingleton<IGuildServer, GuildServer>()
.AddSingleton<IEventPublisher, EventPublisher>()
.AddSingleton<IFriendServer, FriendServer>()
.AddSingleton<GameServerInitializer>()
.AddSingleton<IObservableGameServer, ObservableGameServerAdapter>()
.AddPersistentSingleton<GameServerDefinition>(def => def.ServerID == gameServerId)
.AddPeristenceProvider()
.AddPlugInManager(plugInConfigurations)
.AddIpResolver(args)
.AddHostedService<GameServerHostedServiceWrapper>()
.PublishManageableServer<IGameServer>();
builder.AddMapApp();
var metricsRegistry = new MetricsRegistry();
metricsRegistry.AddNetworkMeters();
metricsRegistry.AddMeters(MUnique.OpenMU.GameLogic.Metrics.Meters);
builder.AddOpenTelemetryMetrics(metricsRegistry);
var app = builder.BuildAndConfigure(false);
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<MUnique.OpenMU.GameServer.Host.App>()
.AddInteractiveServerRenderMode();
await app.WaitForDatabaseConnectionInitializationAsync().ConfigureAwait(false);
app.Run();

View File

@@ -0,0 +1,10 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.GameServer.Host")]

View File

@@ -0,0 +1,21 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Blazored.Toast
@using Blazored.Toast.Services
@using BlazorInputFile
@using MUnique.OpenMU.Web.Shared
@using MUnique.OpenMU.Web.Shared.Components
@using MUnique.OpenMU.Web.Shared.Components.Modal
@using MUnique.OpenMU.Web.Shared.Components.Form
@using MUnique.OpenMU.Web.Shared.Services

View File

@@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base
WORKDIR /app
EXPOSE 8080
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY ["Directory.Packages.props", "."]
COPY ["Directory.Build.props", "."]
COPY ["Dapr/GuildServer.Host/MUnique.OpenMU.GuildServer.Host.csproj", "Dapr/GuildServer.Host/"]
RUN dotnet restore "Dapr/GuildServer.Host/MUnique.OpenMU.GuildServer.Host.csproj"
COPY . .
WORKDIR "/src/Dapr/GuildServer.Host"
RUN dotnet build "MUnique.OpenMU.GuildServer.Host.csproj" -c Release -o /app/build -p:ci=true
FROM build AS publish
RUN dotnet publish "MUnique.OpenMU.GuildServer.Host.csproj" -c Release -o /app/publish -p:ci=true
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "MUnique.OpenMU.GuildServer.Host.dll"]

View File

@@ -0,0 +1,109 @@
// <copyright file="GuildChangePublisher.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GuildServer.Host;
using global::Dapr.Client;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence.EntityFramework.Model;
using MUnique.OpenMU.ServerClients;
/// <summary>
/// Publisher for guild changes over Dapr.
/// </summary>
public class GuildChangePublisher : IGuildChangePublisher
{
private readonly DaprClient _daprClient;
private readonly ILogger<GuildChangePublisher> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="GuildChangePublisher" /> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="logger">The logger.</param>
public GuildChangePublisher(DaprClient daprClient, ILogger<GuildChangePublisher> logger)
{
this._daprClient = daprClient;
this._logger = logger;
}
/// <inheritdoc />
public async ValueTask GuildPlayerKickedAsync(string playerName)
{
try
{
await this._daprClient.PublishEventAsync("pubsub", nameof(IGameServer.GuildPlayerKickedAsync), playerName).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.GuildPlayerKickedAsync));
}
}
/// <inheritdoc />
public async ValueTask GuildDeletedAsync(uint guildId)
{
try
{
await this._daprClient.PublishEventAsync("pubsub", nameof(IGameServer.GuildDeletedAsync), guildId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.GuildDeletedAsync));
}
}
/// <inheritdoc />
public async ValueTask AssignGuildToPlayerAsync(byte serverId, string characterName, GuildMemberStatus status)
{
try
{
await this._daprClient.InvokeMethodAsync($"gameServer{serverId + 1}", nameof(IGameServer.AssignGuildToPlayerAsync), new GuildMemberAssignArguments(characterName, status)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.AssignGuildToPlayerAsync));
}
}
/// <inheritdoc />
public async ValueTask AllianceCreatedAsync(uint masterGuildId, uint memberGuildId)
{
try
{
await this._daprClient.InvokeMethodAsync("pubsub", nameof(IGameServer.AllianceCreatedAsync), (masterGuildId, memberGuildId)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.AllianceCreatedAsync));
}
}
/// <inheritdoc />
public async ValueTask AllianceDisbandedAsync(uint masterGuildId, uint memberGuildId)
{
try
{
await this._daprClient.InvokeMethodAsync("pubsub", nameof(IGameServer.AllianceDisbandedAsync), (masterGuildId, memberGuildId)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.AllianceDisbandedAsync));
}
}
/// <inheritdoc />
public async ValueTask GuildHostilityChangedAsync(uint guildIdA, IReadOnlyList<uint> allianceGuildIdsA, uint guildIdB, IReadOnlyList<uint> allianceGuildIdsB, bool created)
{
try
{
await this._daprClient.PublishEventAsync("pubsub", nameof(IGameServer.GuildHostilityChangedAsync), (guildIdA, allianceGuildIdsA, guildIdB, allianceGuildIdsB, created)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.GuildHostilityChangedAsync));
}
}
}

View File

@@ -0,0 +1,163 @@
// <copyright file="GuildServerController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GuildServer.Host;
using System.Collections.Immutable;
using global::Dapr;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.ServerClients;
/// <summary>
/// Controller which handles external requests coming from other dapr applications,
/// most probably <see cref="MUnique.OpenMU.ServerClients.GuildServer"/>.
/// </summary>
[ApiController]
[Route("")]
public class GuildServerController : ControllerBase
{
private readonly IGuildServer _guildServer;
private readonly ILogger<GuildServerController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="GuildServerController"/> class.
/// </summary>
/// <param name="guildServer">The guild server.</param>
/// <param name="logger">The logger.</param>
public GuildServerController(IGuildServer guildServer, ILogger<GuildServerController> logger)
{
this._guildServer = guildServer;
this._logger = logger;
}
/// <summary>
/// Checks if the guild with the specified name exists.
/// </summary>
/// <param name="guildName">Name of the guild.</param>
/// <returns>True, if the guild exists; False, otherwise.</returns>
[HttpPost(nameof(IGuildServer.GuildExistsAsync))]
public ValueTask<bool> GuildExistsAsync([FromBody] string guildName)
{
return this._guildServer.GuildExistsAsync(guildName);
}
/// <summary>
/// Gets the guild by the guild identifier.
/// </summary>
/// <param name="guildId">The guild identifier.</param>
/// <returns>The guild.</returns>
[HttpPost(nameof(IGuildServer.GetGuildAsync))]
public ValueTask<Guild?> GetGuildAsync([FromBody] uint guildId)
{
return this._guildServer.GetGuildAsync(guildId);
}
/// <summary>
/// Gets the guild id by the guild name.
/// </summary>
/// <param name="guildName">The guild name.</param>
/// <returns>The guild id. <c>0</c>, if not found.</returns>
[HttpPost(nameof(IGuildServer.GetGuildIdByNameAsync))]
public ValueTask<uint> GetGuildIdByNameAsync([FromBody] string guildName)
{
return this._guildServer.GetGuildIdByNameAsync(guildName);
}
/// <summary>
/// Creates the guild and sets the guild master online at the guild server. A separate call to <see cref="PlayerEnteredGameAsync" /> is not required.
/// </summary>
/// <param name="data">The guild creation arguments.</param>
[HttpPost(nameof(IGuildServer.CreateGuildAsync))]
public ValueTask<bool> CreateGuildAsync([FromBody] GuildCreationArguments data)
{
return this._guildServer.CreateGuildAsync(data.Name, data.MasterName, data.MasterId, data.Logo, data.ServerId);
}
/// <summary>
/// Creates the guild member and sets it online at the guild server. A separate call to <see cref="PlayerEnteredGameAsync" /> is not required.
/// </summary>
/// <param name="data">The guild member creation arguments.</param>
[HttpPost(nameof(IGuildServer.CreateGuildMemberAsync))]
public ValueTask CreateGuildMemberAsync([FromBody] GuildMemberCreationArguments data)
{
return this._guildServer.CreateGuildMemberAsync(data.GuildId, data.CharacterId, data.CharacterName, data.Role, data.ServerId);
}
/// <summary>
/// Updates the guild member position.
/// </summary>
/// <param name="data">The change arguments.</param>
[HttpPost(nameof(IGuildServer.ChangeGuildMemberPositionAsync))]
public ValueTask ChangeGuildMemberPositionAsync([FromBody] GuildMemberRoleChangeArguments data)
{
return this._guildServer.ChangeGuildMemberPositionAsync(data.GuildId, data.CharacterId, data.NewRole);
}
/// <summary>
/// Notifies the guild server that a player (potential guild member) entered the game.
/// </summary>
/// <param name="data">The arguments of the changed player.</param>
[Topic("pubsub", nameof(IEventPublisher.PlayerEnteredGameAsync))]
[HttpPost(nameof(IEventPublisher.PlayerEnteredGameAsync))]
public ValueTask PlayerEnteredGameAsync([FromBody] PlayerOnlineStateArguments data)
{
return this._guildServer.PlayerEnteredGameAsync(data.CharacterId, data.CharacterName, data.ServerId);
}
/// <summary>
/// Notifies the guild server that a guild member left the game.
/// </summary>
/// <param name="data">The arguments of the changed player.</param>
[Topic("pubsub", nameof(IEventPublisher.PlayerLeftGameAsync))]
[HttpPost(nameof(IEventPublisher.PlayerLeftGameAsync))]
public ValueTask PlayerLeftGameAsync([FromBody] PlayerOnlineStateArguments data)
{
return this._guildServer.GuildMemberLeftGameAsync(data.GuildId, data.CharacterId, data.ServerId);
}
/// <summary>
/// Gets the guild member list.
/// </summary>
/// <param name="guildId">The guild identifier.</param>
/// <returns>The guild member list.</returns>
[HttpPost(nameof(IGuildServer.GetGuildListAsync))]
public ValueTask<IImmutableList<GuildListEntry>> GetGuildListAsync([FromBody] uint guildId)
{
return this._guildServer.GetGuildListAsync(guildId);
}
/// <summary>
/// Kicks a guild member from a guild.
/// </summary>
/// <param name="data">The guild member arguments.</param>
[HttpPost(nameof(IGuildServer.KickMemberAsync))]
public ValueTask KickMemberAsync([FromBody] GuildMemberArguments data)
{
return this._guildServer.KickMemberAsync(data.GuildId, data.PlayerName);
}
/// <summary>
/// Gets the guild position of a specific character.
/// </summary>
/// <param name="characterId">The character identifier.</param>
/// <returns>The guild position.</returns>
[HttpPost(nameof(IGuildServer.GetGuildPositionAsync))]
public ValueTask<GuildPosition> GetGuildPositionAsync([FromBody] Guid characterId)
{
return this._guildServer.GetGuildPositionAsync(characterId);
}
/// <summary>
/// Increases the guild score by one.
/// </summary>
/// <param name="guildId">The identifier of the guild.</param>
[HttpPost(nameof(IGuildServer.IncreaseGuildScoreAsync))]
public ValueTask IncreaseGuildScoreAsync([FromBody] uint guildId)
{
return this._guildServer.IncreaseGuildScoreAsync(guildId);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<DockerComposeProjectPath>..\..\..\deploy\distributed\docker-compose.dcproj</DockerComposeProjectPath>
<UserSecretsId>b7d410a1-1a65-4459-aab9-843bdcd2ba3e</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile></DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\GuildServer\MUnique.OpenMU.GuildServer.csproj" />
<ProjectReference Include="..\Common\MUnique.OpenMU.Dapr.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,28 @@
// <copyright file="Program.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using MUnique.OpenMU.Dapr.Common;
using MUnique.OpenMU.GuildServer;
using MUnique.OpenMU.GuildServer.Host;
using MUnique.OpenMU.Interfaces;
var builder = DaprService.CreateBuilder("GuildServer", args);
// Add services to the container.
var services = builder.Services;
services.AddSingleton<IGuildServer, GuildServer>()
.AddSingleton<IGuildChangePublisher, GuildChangePublisher>()
.AddPeristenceProvider();
var metricsRegistry = new MetricsRegistry();
// todo: add some meaningful metrics
builder.AddOpenTelemetryMetrics(metricsRegistry);
var app = builder.BuildAndConfigure();
await app.WaitForDatabaseConnectionInitializationAsync().ConfigureAwait(false);
app.Run();

View File

@@ -0,0 +1,10 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.GuildServer.Host")]

View File

@@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base
WORKDIR /app
EXPOSE 8080
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY ["Directory.Packages.props", "."]
COPY ["Directory.Build.props", "."]
COPY ["Dapr/LoginServer.Host/MUnique.OpenMU.LoginServer.Host.csproj", "Dapr/LoginServer.Host/"]
RUN dotnet restore "Dapr/LoginServer.Host/MUnique.OpenMU.LoginServer.Host.csproj"
COPY . .
WORKDIR "/src/Dapr/LoginServer.Host"
RUN dotnet build "MUnique.OpenMU.LoginServer.Host.csproj" -c Release -o /app/build -p:ci=true
FROM build AS publish
RUN dotnet publish "MUnique.OpenMU.LoginServer.Host.csproj" -c Release -o /app/publish -p:ci=true
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "MUnique.OpenMU.LoginServer.Host.dll"]

View File

@@ -0,0 +1,121 @@
// <copyright file="GameServerRegistry.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.LoginServer.Host;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.PlugIns;
using Nito.AsyncEx;
/// <summary>
/// The registry for game servers.
/// </summary>
/// <seealso cref="System.IDisposable" />
public sealed class GameServerRegistry : IDisposable
{
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(20);
private readonly TimeSpan _newServerUptimeLimit = TimeSpan.FromSeconds(60);
private readonly CancellationTokenSource _disposeCts = new();
private readonly ILogger<GameServerRegistry> _logger;
private readonly Dictionary<ushort, DateTime> _entries = new();
private readonly AsyncLock _lock = new();
/// <summary>
/// Initializes a new instance of the <see cref="GameServerRegistry"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
public GameServerRegistry(ILogger<GameServerRegistry> logger)
{
this._logger = logger;
async Task RunCleanupLoop()
{
try
{
await this.CleanupLoopAsync(this._disposeCts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error in cleanup loop");
}
}
_ = RunCleanupLoop();
}
/// <summary>
/// Occurs when a game server was added to the registry.
/// </summary>
public event AsyncEventHandler<ushort>? GameServerAdded;
/// <summary>
/// Occurs when a new (=freshly started) game server was added to the registry.
/// </summary>
public event AsyncEventHandler<ushort>? NewGameServerAdded;
/// <summary>
/// Occurs when a game server was removed from the registry.
/// </summary>
public event AsyncEventHandler<ushort>? GameServerRemoved;
/// <inheritdoc />
public void Dispose()
{
this._disposeCts.Cancel();
this._disposeCts.Dispose();
}
/// <summary>
/// Updates the registration of the game server.
/// </summary>
/// <param name="gameServerId">The game server identifier.</param>
/// <param name="upTime">The up-time of the server.</param>
public async Task UpdateRegistrationAsync(ushort gameServerId, TimeSpan upTime)
{
using var l = await this._lock.LockAsync();
var timestamp = DateTime.UtcNow;
if (this._entries.TryAdd(gameServerId, timestamp))
{
if (upTime <= this._newServerUptimeLimit)
{
this.NewGameServerAdded?.SafeInvokeAsync(gameServerId);
}
else
{
this.GameServerAdded?.SafeInvokeAsync(gameServerId);
}
}
else
{
this._entries[gameServerId] = timestamp;
}
}
private async Task CleanupLoopAsync(CancellationToken cancellationToken)
{
var tempRemoved = new List<ushort>();
while (!this._disposeCts.IsCancellationRequested)
{
await Task.Delay(2000, cancellationToken).ConfigureAwait(false);
using var l = await this._lock.LockAsync();
foreach (var serverId in this._entries.Keys)
{
var lastUpdate = this._entries[serverId];
var diff = DateTime.UtcNow - lastUpdate;
if (diff > this._timeout)
{
this._logger.LogInformation("Difference of {0} higher than timeout for server {1}", diff, serverId);
this.GameServerRemoved?.SafeInvokeAsync(serverId);
tempRemoved.Add(serverId);
}
}
foreach (var serverId in tempRemoved)
{
this._entries.Remove(serverId, out _);
}
}
}
}

View File

@@ -0,0 +1,85 @@
// <copyright file="LoginServerController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.LoginServer.Host;
using global::Dapr;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.ServerClients;
/// <summary>
/// The API controller for the login server.
/// </summary>
[ApiController]
[Route("")]
public class LoginServerController : ControllerBase
{
private readonly ILoginServer _loginServer;
private readonly ILogger<LoginServerController> _logger;
private readonly GameServerRegistry _registry;
/// <summary>
/// Initializes a new instance of the <see cref="LoginServerController"/> class.
/// </summary>
/// <param name="loginServer">The login server.</param>
/// <param name="logger">The logger.</param>
/// <param name="registry">The registry.</param>
public LoginServerController(PersistentLoginServer loginServer, ILogger<LoginServerController> logger, GameServerRegistry registry)
{
this._loginServer = loginServer;
this._logger = logger;
this._registry = registry;
}
/// <summary>
/// Tries to login the account on the specified server.
/// </summary>
/// <param name="data">The login data.</param>
/// <returns>The success.</returns>
[HttpPost(nameof(TryLoginAsync))]
public async Task<bool> TryLoginAsync([FromBody] LoginArguments data)
{
try
{
return await this._loginServer.TryLoginAsync(data.AccountName, data.ServerId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when calling TryLogin on the login server. Data: {0}", data);
return false;
}
}
/// <summary>
/// Logs the account off from the specified server.
/// </summary>
/// <param name="data">The login data.</param>
[HttpPost(nameof(LogOffAsync))]
public async Task LogOffAsync([FromBody] LoginArguments data)
{
try
{
await this._loginServer.LogOffAsync(data.AccountName, data.ServerId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when calling LogOff on the login server. Data: {0}", data);
}
}
/// <summary>
/// Handles the game server heartbeat by updating the registry.
/// </summary>
/// <param name="data">The game server heartbeat arguments.</param>
[HttpPost("GameServerHeartbeat")]
[Topic("pubsub", "GameServerHeartbeat")]
public Task GameServerHeartbeatAsync([FromBody] GameServerHeartbeatArguments data)
{
return this._registry.UpdateRegistrationAsync(data.ServerInfo.Id, data.UpTime);
}
}

View File

@@ -0,0 +1,84 @@
// <copyright file="LoginStateCleanup.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.LoginServer.Host;
using System.Threading;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
/// <summary>
/// A <see cref="IHostedService"/> which cleans up logged in accounts of
/// the game servers from the <see cref="PersistentLoginServer"/>, when a game server
/// is getting removed from the <see cref="GameServerRegistry"/>.
/// </summary>
public sealed class LoginStateCleanup : IHostedService
{
private readonly GameServerRegistry _registry;
private readonly PersistentLoginServer _loginServer;
private readonly ILogger<LoginStateCleanup> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="LoginStateCleanup"/> class.
/// </summary>
/// <param name="registry">The registry.</param>
/// <param name="loginServer">The login server.</param>
/// <param name="logger">The logger.</param>
public LoginStateCleanup(GameServerRegistry registry, PersistentLoginServer loginServer, ILogger<LoginStateCleanup> logger)
{
this._registry = registry;
this._loginServer = loginServer;
this._logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
this._registry.NewGameServerAdded += this.OnNewGameServerAddedAsync;
this._registry.GameServerRemoved += this.OnGameServerRemovedAsync;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
this._registry.NewGameServerAdded -= this.OnNewGameServerAddedAsync;
this._registry.GameServerRemoved -= this.OnGameServerRemovedAsync;
return Task.CompletedTask;
}
/// <summary>
/// It's called when a new server which recently started, is added.
/// We clean up the login states for this server as well.
/// </summary>
/// <param name="serverId">The id of the started server.</param>
/// <remarks>
/// We handle here the <see cref="GameServerRegistry.NewGameServerAdded"/> instead of the <see cref="GameServerRegistry.GameServerAdded"/>,
/// because only then it makes sense to clean the login states of this server.
/// Otherwise, it might be possible, that the login server itself just crashed and recognized a longer running game server. In that case, cleaning the states is not wanted.
/// </remarks>
private async ValueTask OnNewGameServerAddedAsync(ushort serverId)
{
try
{
await this._loginServer.RemoveServerAsync((byte)serverId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error when adding server {0}", serverId);
}
}
private async ValueTask OnGameServerRemovedAsync(ushort serverId)
{
try
{
await this._loginServer.RemoveServerAsync((byte)serverId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error when removing server {0}", serverId);
}
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<DockerComposeProjectPath>..\..\..\deploy\distributed\docker-compose.dcproj</DockerComposeProjectPath>
<UserSecretsId>b7d410a1-1a65-4459-aab9-843bdcd2ba3e</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile></DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Common\MUnique.OpenMU.Dapr.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,187 @@
// <copyright file="PersistentLoginServer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.LoginServer.Host;
using global::Dapr.Client;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// An implementation of a <see cref="ILoginServer"/> which persists the login state in a dapr state store.
/// </summary>
public sealed class PersistentLoginServer : ILoginServer
{
private const string StoreName = "login-state";
private const int OfflineServerId = -1;
private readonly ILogger<PersistentLoginServer> _logger;
private readonly DaprClient _daprClient;
/// <summary>
/// Initializes a new instance of the <see cref="PersistentLoginServer"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="daprClient">The dapr client.</param>
public PersistentLoginServer(ILogger<PersistentLoginServer> logger, DaprClient daprClient)
{
this._logger = logger;
this._daprClient = daprClient;
}
/// <summary>
/// Removes the server.
/// </summary>
/// <param name="serverId">The server identifier.</param>
public async Task RemoveServerAsync(byte serverId)
{
var indexName = $"serverindex-{serverId}";
var (serverIndex, eTag) = await this._daprClient.GetStateAndETagAsync<HashSet<string>>(StoreName, indexName, ConsistencyMode.Strong).ConfigureAwait(false);
if (serverIndex is null || serverIndex.Count == 0)
{
return;
}
foreach (var accountName in serverIndex)
{
await this.SetAccountOfflineAsync(accountName).ConfigureAwait(false);
}
serverIndex.Clear();
if (!await this._daprClient.TrySaveStateAsync(StoreName, indexName, serverIndex, eTag).ConfigureAwait(false))
{
// try again, if it failed
await this.RemoveServerAsync(serverId).ConfigureAwait(false);
}
}
/// <inheritdoc />
public async Task<bool> TryLoginAsync(string accountName, byte serverId)
{
try
{
var (currentServerId, eTag) = await this._daprClient.GetStateAndETagAsync<int?>(StoreName, accountName, ConsistencyMode.Strong).ConfigureAwait(false);
if (currentServerId is >= 0)
{
return false;
}
if (string.IsNullOrEmpty(eTag))
{
// Never logged in, so first insert a fresh state and try again.
// We never want to have the same account logged in twice, because that may lead to game mechanic exploits.
await this._daprClient.SaveStateAsync<int?>(StoreName, accountName, OfflineServerId, new StateOptions { Concurrency = ConcurrencyMode.FirstWrite, Consistency = ConsistencyMode.Strong }).ConfigureAwait(false);
return await this.TryLoginAsync(accountName, serverId).ConfigureAwait(false);
}
var success = await this._daprClient.TrySaveStateAsync(StoreName, accountName, serverId, eTag).ConfigureAwait(false);
await this.AddToIndexAsync(accountName, serverId).ConfigureAwait(false);
return success;
}
catch (Exception ex)
{
this._logger.LogError(ex, "Couldn't get/set logged-in state for account {0}", accountName);
return false;
}
}
/// <inheritdoc />
public async ValueTask LogOffAsync(string accountName, byte serverId)
{
try
{
await this.SetAccountOfflineAsync(accountName).ConfigureAwait(false);
await this.RemoveFromIndexAsync(accountName, serverId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when removing account {0} from server {1}", accountName, serverId);
}
}
/// <inheritdoc />
public async ValueTask<Dictionary<string, byte>> GetSnapshotAsync()
{
var result = new Dictionary<string, byte>();
for (int i = 0; i < 20; i++)
{
var indexName = $"serverindex-{i}";
var (serverIndex, eTag) = await this._daprClient.GetStateAndETagAsync<HashSet<string>>(StoreName, indexName, ConsistencyMode.Strong).ConfigureAwait(false);
if (serverIndex is null)
{
continue;
}
foreach (var accountName in serverIndex)
{
result[accountName] = (byte)i;
}
}
return result;
}
private async Task AddToIndexAsync(string accountName, byte serverId)
{
var indexName = $"serverindex-{serverId}";
var (serverIndex, eTag) = await this._daprClient.GetStateAndETagAsync<HashSet<string>>(StoreName, indexName, ConsistencyMode.Strong).ConfigureAwait(false);
if (serverIndex is null)
{
serverIndex = new HashSet<string>();
serverIndex.Add(accountName);
await this._daprClient.SaveStateAsync(StoreName, indexName, serverIndex).ConfigureAwait(false);
return;
}
if (serverIndex.Add(accountName))
{
if (!await this._daprClient.TrySaveStateAsync(StoreName, indexName, serverIndex, eTag).ConfigureAwait(false))
{
// try again, if it failed
await this.AddToIndexAsync(accountName, serverId).ConfigureAwait(false);
}
}
}
private async Task RemoveFromIndexAsync(string accountName, byte serverId)
{
var indexName = $"serverindex-{serverId}";
var (serverIndex, eTag) = await this._daprClient.GetStateAndETagAsync<HashSet<string>>(StoreName, indexName, ConsistencyMode.Strong).ConfigureAwait(false);
if (serverIndex is null)
{
return;
}
if (serverIndex.Remove(accountName))
{
if (!await this._daprClient.TrySaveStateAsync(StoreName, indexName, serverIndex, eTag).ConfigureAwait(false))
{
// try again, if it failed
await this.RemoveFromIndexAsync(accountName, serverId).ConfigureAwait(false);
}
}
}
private async Task SetAccountOfflineAsync(string accountName)
{
try
{
var (currentServerId, eTag) = await this._daprClient.GetStateAndETagAsync<int?>(StoreName, accountName, ConsistencyMode.Strong).ConfigureAwait(false);
if (currentServerId == OfflineServerId)
{
return;
}
await this._daprClient.TrySaveStateAsync<int?>(StoreName, accountName, OfflineServerId, eTag).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Couldn't get/set logged-out state for account {0}", accountName);
}
}
}

View File

@@ -0,0 +1,24 @@
// <copyright file="Program.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using Microsoft.Extensions.DependencyInjection;
using MUnique.OpenMU.Dapr.Common;
using MUnique.OpenMU.LoginServer.Host;
var builder = DaprService.CreateBuilder("LoginServer", args);
// Add services to the container.
builder.Services
.AddSingleton<PersistentLoginServer>()
.AddHostedService<LoginStateCleanup>()
.AddSingleton<GameServerRegistry>();
var metricsRegistry = new MetricsRegistry();
// todo: add some meaningful metrics
builder.AddOpenTelemetryMetrics(metricsRegistry);
var app = builder.BuildAndConfigure();
app.Run();

View File

@@ -0,0 +1,10 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.LoginServer.Host")]

View File

@@ -0,0 +1,10 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.ServerClients")]

View File

@@ -0,0 +1,16 @@
// <copyright file="ChatRoomCreationArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Arguments for a chat room creation.
/// </summary>
/// <param name="AuthenticationInfo">Authentication information of the player who should get notified about the created chat room.</param>
/// <param name="FriendName">Name of the friend player which is expected to be in the chat room.</param>
public record ChatRoomCreationArguments(
ChatServerAuthenticationInfo AuthenticationInfo,
string FriendName);

View File

@@ -0,0 +1,13 @@
// <copyright file="ChatRoomInvitationArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
/// <summary>
/// The arguments for a chat room invitation.
/// </summary>
public record ChatRoomInvitationArguments(
string CharacterName,
string FriendName,
ushort RoomNumber);

View File

@@ -0,0 +1,114 @@
// <copyright file="ChatServer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
using System.ComponentModel;
using Dapr.Client;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Implementation of an <see cref="IChatServer"/> which accesses another chat server remotely over Dapr.
/// </summary>
public class ChatServer : IChatServer
{
private readonly DaprClient _daprClient;
private readonly ILogger<ChatServer> _logger;
private readonly string _targetAppId;
/// <summary>
/// Initializes a new instance of the <see cref="ChatServer"/> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="logger">The logger.</param>
public ChatServer(DaprClient daprClient, ILogger<ChatServer> logger)
{
this._daprClient = daprClient;
this._logger = logger;
this._targetAppId = "chatServer";
}
/// <inheritdoc />
public event PropertyChangedEventHandler? PropertyChanged;
/// <inheritdoc />
public int Id { get; }
/// <inheritdoc />
public Guid ConfigurationId { get; }
/// <inheritdoc />
public string Description => "Chat Server";
/// <inheritdoc />
public ServerType Type => ServerType.ChatServer;
/// <inheritdoc />
public ServerState ServerState => ServerState.Started;
/// <inheritdoc />
public int MaximumConnections { get; }
/// <inheritdoc />
public int CurrentConnections { get; }
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public ValueTask StartAsync()
{
throw new NotImplementedException();
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public ValueTask ShutdownAsync()
{
throw new NotImplementedException();
}
/// <inheritdoc />
public async ValueTask<ChatServerAuthenticationInfo?> RegisterClientAsync(ushort roomId, string clientName)
{
try
{
return await this._daprClient.InvokeMethodAsync<RegisterChatClientArguments, ChatServerAuthenticationInfo>(
this._targetAppId,
nameof(IChatServer.RegisterClientAsync),
new RegisterChatClientArguments(roomId, clientName))
.ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.RegisterClientAsync));
}
return null;
}
/// <inheritdoc />
public async ValueTask<ushort> CreateChatRoomAsync()
{
try
{
return await this._daprClient.InvokeMethodAsync<ushort>(this._targetAppId, nameof(IChatServer.CreateChatRoomAsync)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, nameof(this.CreateChatRoomAsync));
}
return default;
}
}

View File

@@ -0,0 +1,10 @@
// <copyright file="FriendOnlineStateChangedArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
/// <summary>
/// The arguments for a friend online state change.
/// </summary>
public record FriendOnlineStateChangedArguments(string Player, string Friend, int ServerId);

View File

@@ -0,0 +1,10 @@
// <copyright file="FriendResponseArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
/// <summary>
/// The arguments for a friend response.
/// </summary>
public record FriendResponseArguments(string CharacterName, string FriendName, bool Accepted);

View File

@@ -0,0 +1,152 @@
// <copyright file="FriendServer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
using Dapr.Client;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Implementation of an <see cref="IFriendServer"/> which accesses another friend server remotely over Dapr.
/// </summary>
public class FriendServer : IFriendServer
{
private readonly DaprClient _daprClient;
private readonly ILogger<FriendServer> _logger;
private readonly string _targetAppId;
/// <summary>
/// Initializes a new instance of the <see cref="FriendServer"/> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="logger">The logger.</param>
public FriendServer(DaprClient daprClient, ILogger<FriendServer> logger)
{
this._daprClient = daprClient;
this._logger = logger;
this._targetAppId = "friendServer";
}
/// <inheritdoc />
public async ValueTask ForwardLetterAsync(LetterHeader letter)
{
try
{
await this._daprClient.PublishEventAsync("pubsub", nameof(IGameServer.LetterReceivedAsync), letter).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when forwarding a letter.");
}
}
/// <inheritdoc />
public async ValueTask FriendResponseAsync(string characterName, string friendName, bool accepted)
{
try
{
await this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(this.FriendResponseAsync), new FriendResponseArguments(characterName, friendName, accepted)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when sending a friend response.");
}
}
/// <inheritdoc />
public ValueTask PlayerEnteredGameAsync(byte serverId, Guid characterId, string characterName)
{
// no action required - the friend server listens to the common pubsub, published by EventPublisher
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask PlayerLeftGameAsync(Guid characterId, string characterName)
{
// no action required - the friend server listens to the common pubsub, published by EventPublisher
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask SetPlayerVisibilityStateAsync(byte serverId, Guid characterId, string characterName, bool isVisible)
{
try
{
await this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(this.SetPlayerVisibilityStateAsync), new PlayerFriendOnlineStateArguments(characterId, characterName, serverId, isVisible)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when setting the friend visibility state.");
}
}
/// <inheritdoc />
public async ValueTask<bool> IsFriendAsync(string characterName, string friendName)
{
try
{
return await this._daprClient.InvokeMethodAsync<RequestArguments, bool>(this._targetAppId, nameof(this.IsFriendAsync), new RequestArguments(characterName, friendName)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when checking friendship.");
return false;
}
}
/// <inheritdoc />
public async ValueTask<bool> FriendRequestAsync(string playerName, string friendName)
{
try
{
return await this._daprClient.InvokeMethodAsync<RequestArguments, bool>(this._targetAppId, nameof(this.FriendRequestAsync), new RequestArguments(playerName, friendName)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when sending a friend request.");
return false;
}
}
/// <inheritdoc />
public async ValueTask DeleteFriendAsync(string name, string friendName)
{
try
{
await this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(this.DeleteFriendAsync), new RequestArguments(name, friendName)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when deleting a friend.");
}
}
/// <inheritdoc />
public async ValueTask CreateChatRoomAsync(string playerName, string friendName)
{
try
{
await this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(this.CreateChatRoomAsync), new RequestArguments(playerName, friendName)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when creating a chat room.");
}
}
/// <inheritdoc />
public async ValueTask<bool> InviteFriendToChatRoomAsync(string selectedCharacterName, string friendName, ushort roomNumber)
{
try
{
return await this._daprClient.InvokeMethodAsync<ChatRoomInvitationArguments, bool>(this._targetAppId, nameof(this.InviteFriendToChatRoomAsync), new ChatRoomInvitationArguments(selectedCharacterName, friendName, roomNumber)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when inviting a friend to a chat room.");
return false;
}
}
}

View File

@@ -0,0 +1,199 @@
// <copyright file="GameServer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
using System.ComponentModel;
using Dapr.Client;
using MUnique.OpenMU.Interfaces;
using Nito.AsyncEx.Synchronous;
/// <summary>
/// Implementation of an <see cref="IGameServer"/> which accesses another game server remotely over Dapr.
/// </summary>
public class GameServer : IGameServer
{
private readonly DaprClient _client;
private readonly string _targetAppId;
/// <summary>
/// Initializes a new instance of the <see cref="GameServer"/> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="serverId">The server identifier.</param>
public GameServer(DaprClient daprClient, int serverId)
{
this.Id = serverId;
this.MaximumConnections = 1000;
this.CurrentConnections = 0;
this._client = daprClient;
this._targetAppId = $"gameServer{serverId}";
this.Description = $"Game Server {serverId}";
async Task InitAsync()
{
this.ConfigurationId = await this._client.InvokeMethodAsync<Guid>(this._targetAppId, $"get{nameof(this.ConfigurationId)}").ConfigureAwait(false);
}
_ = InitAsync();
}
/// <inheritdoc />
public event PropertyChangedEventHandler? PropertyChanged;
/// <inheritdoc />
public int Id { get; }
/// <inheritdoc />
public Guid ConfigurationId { get; private set; }
/// <inheritdoc />
public string Description { get; }
/// <inheritdoc />
public ServerType Type => ServerType.GameServer;
/// <inheritdoc />
public ServerState ServerState => this._client.InvokeMethodAsync<ServerState>(this._targetAppId, nameof(this.ServerState)).WaitAndUnwrapException();
/// <inheritdoc />
public int MaximumConnections { get; } // TODO
/// <inheritdoc />
public int CurrentConnections { get; } // TODO
/// <inheritdoc />
public async ValueTask StartAsync()
{
await this.StartAsync(default).ConfigureAwait(false);
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
return this._client.InvokeMethodAsync(this._targetAppId, nameof(this.StartAsync), cancellationToken);
}
/// <inheritdoc />
public async ValueTask ShutdownAsync()
{
await this.StopAsync(default).ConfigureAwait(false);
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
return this._client.InvokeMethodAsync(this._targetAppId, nameof(this.ShutdownAsync), cancellationToken);
}
/// <inheritdoc />
public async ValueTask GuildChatMessageAsync(uint guildId, string sender, string message)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.GuildChatMessageAsync), new GuildMessageArguments(guildId, sender, message)).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask AllianceChatMessageAsync(uint guildId, string sender, string message)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.AllianceChatMessageAsync), new GuildMessageArguments(guildId, sender, message)).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask GuildDeletedAsync(uint guildId)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.GuildDeletedAsync), guildId).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask GuildPlayerKickedAsync(string playerName)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.GuildPlayerKickedAsync), playerName).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask AssignGuildToPlayerAsync(string characterName, GuildMemberStatus guildStatus)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.AssignGuildToPlayerAsync), new GuildMemberAssignArguments(characterName, guildStatus)).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask PlayerAlreadyLoggedInAsync(byte serverId, string loginName)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.PlayerAlreadyLoggedInAsync), new PlayerLoggedInArguments(serverId, loginName)).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask LetterReceivedAsync(LetterHeader letter)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.LetterReceivedAsync), letter).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask InitializeMessengerAsync(MessengerInitializationData initializationData)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.InitializeMessengerAsync), initializationData).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask SendGlobalMessageAsync(string message, MessageType messageType)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.SendGlobalMessageAsync), new MessageArguments(message, messageType)).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask FriendRequestAsync(string requester, string receiver)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.FriendRequestAsync), new RequestArguments(requester, receiver)).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask FriendOnlineStateChangedAsync(string player, string friend, int serverId)
{
// TODO: Use PubSub!
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.FriendOnlineStateChangedAsync), new FriendOnlineStateChangedArguments(player, friend, serverId)).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask ChatRoomCreatedAsync(ChatServerAuthenticationInfo playerAuthenticationInfo, string friendName)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.ChatRoomCreatedAsync), new ChatRoomCreationArguments(playerAuthenticationInfo, friendName)).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<bool> DisconnectPlayerAsync(string playerName)
{
return await this._client.InvokeMethodAsync<string, bool>(this._targetAppId, nameof(this.DisconnectPlayerAsync), playerName).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<bool> DisconnectAccountAsync(string playerName)
{
return await this._client.InvokeMethodAsync<string, bool>(this._targetAppId, nameof(this.DisconnectAccountAsync), playerName).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<bool> BanPlayerAsync(string playerName)
{
return await this._client.InvokeMethodAsync<string, bool>(this._targetAppId, nameof(this.BanPlayerAsync), playerName).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask AllianceCreatedAsync(uint masterGuildId, uint memberGuildId)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.AllianceCreatedAsync), (masterGuildId, memberGuildId)).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask AllianceDisbandedAsync(uint masterGuildId, uint memberGuildId)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.AllianceDisbandedAsync), (masterGuildId, memberGuildId)).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask GuildHostilityChangedAsync(uint guildIdA, IReadOnlyList<uint> allianceGuildIdsA, uint guildIdB, IReadOnlyList<uint> allianceGuildIdsB, bool created)
{
await this._client.InvokeMethodAsync(this._targetAppId, nameof(this.GuildHostilityChangedAsync), (guildIdA, allianceGuildIdsA, guildIdB, allianceGuildIdsB, created)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,18 @@
// <copyright file="GameServerHeartbeatArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Arguments for a game server heartbeat.
/// </summary>
public record GameServerHeartbeatArguments(ServerInfo ServerInfo, string PublicEndPoint, TimeSpan UpTime)
{
/// <summary>
/// Gets or sets the up-time of the server.
/// </summary>
public TimeSpan UpTime { get; set; } = UpTime;
}

View File

@@ -0,0 +1,10 @@
// <copyright file="GuildCreationArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
/// <summary>
/// Arguments for a guild creation.
/// </summary>
public record GuildCreationArguments(string Name, string MasterName, Guid MasterId, byte[] Logo, byte ServerId);

View File

@@ -0,0 +1,10 @@
// <copyright file="GuildMemberArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
/// <summary>
/// Arguments for a guild member action.
/// </summary>
public record GuildMemberArguments(uint GuildId, string PlayerName);

View File

@@ -0,0 +1,12 @@
// <copyright file="GuildMemberAssignArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Arguments for a guild member assignment.
/// </summary>
public record GuildMemberAssignArguments(string CharacterName, GuildMemberStatus MemberStatus);

View File

@@ -0,0 +1,12 @@
// <copyright file="GuildMemberCreationArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Arguments for a guild member creation.
/// </summary>
public record GuildMemberCreationArguments(uint GuildId, Guid CharacterId, string CharacterName, GuildPosition Role, byte ServerId);

View File

@@ -0,0 +1,12 @@
// <copyright file="GuildMemberRoleChangeArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Arguments for a guild member role change.
/// </summary>
public record GuildMemberRoleChangeArguments(uint GuildId, Guid CharacterId, GuildPosition NewRole);

View File

@@ -0,0 +1,10 @@
// <copyright file="GuildMessageArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
/// <summary>
/// Arguments for a guild chat message.
/// </summary>
public record GuildMessageArguments(uint GuildId, string Sender, string Message);

View File

@@ -0,0 +1,10 @@
// <copyright file="GuildPlayerLeftGameArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
/// <summary>
/// Arguments for a notification about a game-leaving guild member.
/// </summary>
public record GuildPlayerLeftGameArguments(uint GuildId, Guid GuildMemberId, byte ServerId);

View File

@@ -0,0 +1,266 @@
// <copyright file="GuildServer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
using System.Collections.Immutable;
using Dapr.Client;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Implementation of an <see cref="IGuildServer"/> which accesses the guild server remotely over Dapr.
/// </summary>
public class GuildServer : IGuildServer
{
private readonly DaprClient _daprClient;
private readonly ILogger<GuildServer> _logger;
private readonly string _targetAppId;
/// <summary>
/// Initializes a new instance of the <see cref="GuildServer"/> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="logger">The logger.</param>
public GuildServer(DaprClient daprClient, ILogger<GuildServer> logger)
{
this._daprClient = daprClient;
this._logger = logger;
this._targetAppId = "guildServer";
}
/// <inheritdoc />
public async ValueTask<bool> GuildExistsAsync(string guildName)
{
try
{
return await this._daprClient.InvokeMethodAsync<string, bool>(this._targetAppId, nameof(this.GuildExistsAsync), guildName).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when checking a guild existence.");
throw;
}
}
/// <inheritdoc />
public async ValueTask<Guild?> GetGuildAsync(uint guildId)
{
try
{
return await this._daprClient.InvokeMethodAsync<uint, Guild?>(this._targetAppId, nameof(this.GetGuildAsync), guildId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when sending a guild retrieval message.");
return null;
}
}
/// <inheritdoc />
public async ValueTask<uint> GetGuildIdByNameAsync(string guildName)
{
try
{
return await this._daprClient.InvokeMethodAsync<string, uint>(this._targetAppId, nameof(this.GetGuildIdByNameAsync), guildName).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when getting the id by guild name.");
return 0;
}
}
/// <inheritdoc />
public async ValueTask<bool> CreateGuildAsync(string name, string masterName, Guid masterId, byte[] logo, byte serverId)
{
try
{
return await this._daprClient.InvokeMethodAsync<GuildCreationArguments, bool>(this._targetAppId, nameof(this.CreateGuildAsync), new GuildCreationArguments(name, masterName, masterId, logo, serverId)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when sending a guild creation message.");
return false;
}
}
/// <inheritdoc />
public async ValueTask CreateGuildMemberAsync(uint guildId, Guid characterId, string characterName, GuildPosition role, byte serverId)
{
try
{
await this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(this.CreateGuildMemberAsync), new GuildMemberCreationArguments(guildId, characterId, characterName, role, serverId)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when sending a guild member creation.");
}
}
/// <inheritdoc />
public async ValueTask ChangeGuildMemberPositionAsync(uint guildId, Guid characterId, GuildPosition role)
{
try
{
await this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(this.ChangeGuildMemberPositionAsync), new GuildMemberRoleChangeArguments(guildId, characterId, role)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when sending a guild member position change.");
}
}
/// <inheritdoc />
public ValueTask PlayerEnteredGameAsync(Guid characterId, string characterName, byte serverId)
{
// Handled by EventPublisher, through pub/sub component.
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask GuildMemberLeftGameAsync(uint guildId, Guid guildMemberId, byte serverId)
{
// Handled by EventPublisher, through pub/sub component.
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask<IImmutableList<GuildListEntry>> GetGuildListAsync(uint guildId)
{
try
{
return await this._daprClient.InvokeMethodAsync<uint, IImmutableList<GuildListEntry>>(this._targetAppId, nameof(this.GetGuildListAsync), guildId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when getting a guild list.");
return ImmutableList<GuildListEntry>.Empty;
}
}
/// <inheritdoc />
public async ValueTask KickMemberAsync(uint guildId, string playerName)
{
try
{
await this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(this.KickMemberAsync), new GuildMemberArguments(guildId, playerName)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when kicking a guild member.");
}
}
/// <inheritdoc />
public async ValueTask<GuildPosition> GetGuildPositionAsync(Guid characterId)
{
try
{
return await this._daprClient.InvokeMethodAsync<Guid, GuildPosition>(this._targetAppId, nameof(this.GetGuildPositionAsync), characterId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when retrieving a guild position.");
return GuildPosition.Undefined;
}
}
/// <inheritdoc />
public async ValueTask IncreaseGuildScoreAsync(uint guildId)
{
try
{
await this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(this.IncreaseGuildScoreAsync), guildId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when sending a guild score increase.");
}
}
/// <inheritdoc />
public async ValueTask<AllianceCreationResult> CreateAllianceAsync(uint masterGuildId, uint targetGuildId)
{
try
{
return await this._daprClient.InvokeMethodAsync<(uint, uint), AllianceCreationResult>(this._targetAppId, nameof(this.CreateAllianceAsync), (masterGuildId, targetGuildId)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when creating an alliance.");
return AllianceCreationResult.Error;
}
}
/// <inheritdoc />
public async ValueTask<bool> RemoveAllianceAsync(uint targetGuildId)
{
try
{
return await this._daprClient.InvokeMethodAsync<uint, bool>(this._targetAppId, nameof(this.RemoveAllianceAsync), targetGuildId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when removing a guild from alliance.");
return false;
}
}
/// <inheritdoc />
public async ValueTask<IImmutableList<AllianceGuildEntry>> GetAllianceGuildsAsync(uint guildId)
{
try
{
return await this._daprClient.InvokeMethodAsync<uint, IImmutableList<AllianceGuildEntry>>(this._targetAppId, nameof(this.GetAllianceGuildsAsync), guildId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when getting alliance guilds.");
return [];
}
}
/// <inheritdoc />
public async ValueTask<bool> IsAllianceMasterAsync(uint guildId)
{
try
{
return await this._daprClient.InvokeMethodAsync<uint, bool>(this._targetAppId, nameof(this.IsAllianceMasterAsync), guildId).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when checking alliance master.");
return false;
}
}
/// <inheritdoc />
public async ValueTask<bool> SetHostilityAsync(uint guildIdA, uint guildIdB, bool create)
{
try
{
return await this._daprClient.InvokeMethodAsync<(uint, uint, bool), bool>(this._targetAppId, nameof(this.SetHostilityAsync), (guildIdA, guildIdB, create)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when setting hostility.");
return false;
}
}
/// <inheritdoc />
public async ValueTask<GuildRelationship> GetGuildRelationshipAsync(uint guild1, uint guild2)
{
try
{
return await this._daprClient.InvokeMethodAsync<(uint, uint), GuildRelationship>(this._targetAppId, nameof(this.GetGuildRelationshipAsync), (guild1, guild2)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when getting guild relationship.");
return GuildRelationship.None;
}
}
}

View File

@@ -0,0 +1,10 @@
// <copyright file="LoginArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
/// <summary>
/// Arguments for an account login.
/// </summary>
public record LoginArguments(string AccountName, byte ServerId);

View File

@@ -0,0 +1,73 @@
// <copyright file="LoginServer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
using Dapr.Client;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Implementation of an <see cref="ILoginServer"/> which accesses the login server remotely over Dapr.
/// </summary>
public class LoginServer : ILoginServer
{
private readonly DaprClient _daprClient;
private readonly ILogger<LoginServer> _logger;
private readonly string _targetAppId;
/// <summary>
/// Initializes a new instance of the <see cref="LoginServer"/> class.
/// </summary>
/// <param name="daprClient">The dapr client.</param>
/// <param name="logger">The logger.</param>
public LoginServer(DaprClient daprClient, ILogger<LoginServer> logger)
{
this._daprClient = daprClient;
this._logger = logger;
this._targetAppId = "loginServer";
}
/// <inheritdoc />
public async Task<bool> TryLoginAsync(string accountName, byte serverId)
{
try
{
return await this._daprClient.InvokeMethodAsync<LoginArguments, bool>(this._targetAppId, nameof(this.TryLoginAsync), new LoginArguments(accountName, serverId)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when trying to call TryLogin on the login server.");
return false;
}
}
/// <inheritdoc />
public async ValueTask LogOffAsync(string accountName, byte serverId)
{
try
{
await this._daprClient.InvokeMethodAsync(this._targetAppId, nameof(this.LogOffAsync), new LoginArguments(accountName, serverId)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when trying to call LogOff on the login server.");
}
}
/// <inheritdoc />
public async ValueTask<Dictionary<string, byte>> GetSnapshotAsync()
{
try
{
return await this._daprClient.InvokeMethodAsync<Dictionary<string, byte>>(this._targetAppId, nameof(this.GetSnapshotAsync)).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Unexpected error when trying to call GetSnapshotAsync on the login server.");
}
return [];
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile></DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapr.Client" />
<PackageReference Include="Nito.AsyncEx" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\DataModel\MUnique.OpenMU.DataModel.csproj" />
<ProjectReference Include="..\..\Interfaces\MUnique.OpenMU.Interfaces.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
// <copyright file="MessageArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Arguments for a game message.
/// </summary>
public record MessageArguments(string Message, MessageType Type);

View File

@@ -0,0 +1,10 @@
// <copyright file="PlayerFriendOnlineStateArguments.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ServerClients;
/// <summary>
/// Arguments for a friend online state change.
/// </summary>
public record PlayerFriendOnlineStateArguments(Guid CharacterId, string CharacterName, byte ServerId, bool IsVisible);

Some files were not shown because too many files have changed in this diff Show More