From 34491f12d4fde29f50166d69e10cd48e085c9118 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 21:29:26 +0000 Subject: [PATCH 1/5] Archive the traffic of observed accounts An account can be marked with the new flag IsNetworkObservationActive. While it's set, the game server archives the traffic of each session of that account on the file system, so that it can be analyzed later - for example when a player is suspected of cheating. The archive is a feature of the game server, not of the admin panel: it's registered in the all-in-one host and in the Dapr game server host, so an observed account is archived even when no admin panel is running. * A session is a directory below the one of its account, which holds the packets in one or more files of the analyzer tool format plus the metadata as json. That way a session can be opened, copied or deleted as a whole - and each of its files can be loaded by the WinForms tool. * The packets are written by an own task and flushed as soon as its queue ran empty, so that the network thread never waits for the file system, a running session can be read, and a crash doesn't lose everything. * Each line carries a sequence number as a fifth field, which the loader of the analyzer tool ignores. It makes a gap visible - the writer drops packets rather than slowing the connection of the player down when the file system can't keep up. * Rotation, quota and retention keep the archive bounded. The housekeeping runs when a session starts and when one ends; a running session is never removed. * The archive starts when the player is logged in, so the version check and the login request are not part of it. Capturing them would mean to capture every connection unconditionally, which is exactly what this feature avoids. The archive path, the rotation size, the quota, the retention and the live buffer size of the analyzer page are new properties of the system configuration, so they can be edited in the admin panel. Player got a PlayerLoggedIn event, raised by the new SetAccountAsync: at the moment the state machine reaches "Authenticated", the account of the player isn't assigned yet, so the state change is not the right moment to decide whether a session has to be archived. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pb82LmoaUVdZtBtQs7xrtA --- .gitignore | 3 + src/Dapr/GameServer.Host/Program.cs | 2 + .../Configuration/SystemConfiguration.cs | 56 + src/DataModel/Entities/Account.cs | 7 + .../Properties/Resources.Designer.cs | 99 + src/DataModel/Properties/Resources.resx | 33 + src/GameLogic/Player.cs | 17 + src/GameLogic/PlayerActions/LoginAction.cs | 2 +- src/GameServer/GameServer.cs | 12 +- .../NetworkObservationExtensions.cs | 94 + src/GameServer/NetworkObservationHandler.cs | 145 + .../Analyzer/Archive/ArchivedSession.cs | 124 + .../Analyzer/Archive/ArchivedSessionInfo.cs | 69 + .../Archive/ArchivedSessionMetadata.cs | 85 + .../Analyzer/Archive/ArchivedSessionWriter.cs | 313 + .../Analyzer/Archive/IPacketArchive.cs | 53 + .../Archive/NetworkObservationOptions.cs | 86 + src/Network/Analyzer/Archive/PacketArchive.cs | 282 + ...29211237_AddNetworkObservation.Designer.cs | 5926 +++++++++++++++++ .../20260829211237_AddNetworkObservation.cs | 96 + .../EntityDataContextModelSnapshot.cs | 18 + src/Startup/GameServerContainer.cs | 10 +- src/Startup/Program.cs | 14 +- .../PacketArchiveTest.cs | 423 ++ .../NetworkObservationTests.cs | 281 + 25 files changed, 8245 insertions(+), 5 deletions(-) create mode 100644 src/GameServer/NetworkObservationExtensions.cs create mode 100644 src/GameServer/NetworkObservationHandler.cs create mode 100644 src/Network/Analyzer/Archive/ArchivedSession.cs create mode 100644 src/Network/Analyzer/Archive/ArchivedSessionInfo.cs create mode 100644 src/Network/Analyzer/Archive/ArchivedSessionMetadata.cs create mode 100644 src/Network/Analyzer/Archive/ArchivedSessionWriter.cs create mode 100644 src/Network/Analyzer/Archive/IPacketArchive.cs create mode 100644 src/Network/Analyzer/Archive/NetworkObservationOptions.cs create mode 100644 src/Network/Analyzer/Archive/PacketArchive.cs create mode 100644 src/Persistence/EntityFramework/Migrations/20260829211237_AddNetworkObservation.Designer.cs create mode 100644 src/Persistence/EntityFramework/Migrations/20260829211237_AddNetworkObservation.cs create mode 100644 tests/MUnique.OpenMU.Network.Tests/PacketArchiveTest.cs create mode 100644 tests/MUnique.OpenMU.Tests/NetworkObservationTests.cs diff --git a/.gitignore b/.gitignore index 15b839f1ab..dd969b7806 100644 --- a/.gitignore +++ b/.gitignore @@ -325,3 +325,6 @@ src/AdminPanel/wwwroot/content/js/app.js.map .opencode/ # Keys which are generated when the server runs locally. data-protection-keys/ + +# The archive of the network observation, when the server is started from the repository. +captures/ diff --git a/src/Dapr/GameServer.Host/Program.cs b/src/Dapr/GameServer.Host/Program.cs index dd22a8bdb4..4081bdbc88 100644 --- a/src/Dapr/GameServer.Host/Program.cs +++ b/src/Dapr/GameServer.Host/Program.cs @@ -7,6 +7,7 @@ using MUnique.OpenMU.Dapr.Common; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameServer; using MUnique.OpenMU.GameServer.Host; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.PlugIns; @@ -43,6 +44,7 @@ .AddPeristenceProvider() .AddPlugInManager(plugInConfigurations) .AddIpResolver(args) + .AddNetworkObservation() .AddHostedService() .PublishManageableServer(); diff --git a/src/DataModel/Configuration/SystemConfiguration.cs b/src/DataModel/Configuration/SystemConfiguration.cs index b373ce175d..3028e16cf7 100644 --- a/src/DataModel/Configuration/SystemConfiguration.cs +++ b/src/DataModel/Configuration/SystemConfiguration.cs @@ -88,6 +88,62 @@ public partial class SystemConfiguration ResourceType = typeof(Resources))] public string? TimeZoneId { get; set; } + /// + /// Gets or sets the number of data packets which are kept in memory per connection which + /// is watched in the network analyzer page of the admin panel. + /// + [Display( + Order = 7, + Name = nameof(Resources.SystemConfiguration_NetworkAnalyzerLiveBufferSize_Name), + Description = nameof(Resources.SystemConfiguration_NetworkAnalyzerLiveBufferSize_Description), + GroupName = nameof(Resources.SystemConfiguration_NetworkAnalyzer_Name), + ResourceType = typeof(Resources))] + public int NetworkAnalyzerLiveBufferSize { get; set; } = 5000; + + /// + /// Gets or sets the path in which the traffic of the observed accounts is archived. + /// + [Display( + Order = 8, + Name = nameof(Resources.SystemConfiguration_NetworkObservationArchivePath_Name), + Description = nameof(Resources.SystemConfiguration_NetworkObservationArchivePath_Description), + GroupName = nameof(Resources.SystemConfiguration_NetworkAnalyzer_Name), + ResourceType = typeof(Resources))] + public string? NetworkObservationArchivePath { get; set; } = "captures"; + + /// + /// Gets or sets the maximum size of one file of an archived session, in megabytes. + /// + [Display( + Order = 9, + Name = nameof(Resources.SystemConfiguration_NetworkObservationMaxSessionSizeMb_Name), + Description = nameof(Resources.SystemConfiguration_NetworkObservationMaxSessionSizeMb_Description), + GroupName = nameof(Resources.SystemConfiguration_NetworkAnalyzer_Name), + ResourceType = typeof(Resources))] + public int NetworkObservationMaxSessionSizeMb { get; set; } = 50; + + /// + /// Gets or sets the maximum size of the whole observation archive, in megabytes. + /// + [Display( + Order = 10, + Name = nameof(Resources.SystemConfiguration_NetworkObservationMaxTotalSizeMb_Name), + Description = nameof(Resources.SystemConfiguration_NetworkObservationMaxTotalSizeMb_Description), + GroupName = nameof(Resources.SystemConfiguration_NetworkAnalyzer_Name), + ResourceType = typeof(Resources))] + public int NetworkObservationMaxTotalSizeMb { get; set; } = 1000; + + /// + /// Gets or sets the number of days after which an archived session is removed. + /// + [Display( + Order = 11, + Name = nameof(Resources.SystemConfiguration_NetworkObservationRetentionDays_Name), + Description = nameof(Resources.SystemConfiguration_NetworkObservationRetentionDays_Description), + GroupName = nameof(Resources.SystemConfiguration_NetworkAnalyzer_Name), + ResourceType = typeof(Resources))] + public int NetworkObservationRetentionDays { get; set; } = 30; + /// public override string ToString() { diff --git a/src/DataModel/Entities/Account.cs b/src/DataModel/Entities/Account.cs index 9a543c4c2d..f23742efe9 100644 --- a/src/DataModel/Entities/Account.cs +++ b/src/DataModel/Entities/Account.cs @@ -137,6 +137,13 @@ public class Account /// public bool IsBot { get; set; } + /// + /// Gets or sets a value indicating whether the network traffic of this account is + /// observed. The game server then archives the traffic of each of its sessions, so that + /// it can be analyzed later - for example when the account is suspected of cheating. + /// + public bool IsNetworkObservationActive { get; set; } + /// /// Gets or sets the characters. /// diff --git a/src/DataModel/Properties/Resources.Designer.cs b/src/DataModel/Properties/Resources.Designer.cs index 82f8b816fe..7dfd43b6df 100644 --- a/src/DataModel/Properties/Resources.Designer.cs +++ b/src/DataModel/Properties/Resources.Designer.cs @@ -235,5 +235,104 @@ public static string SystemConfiguration_TimeZoneId_Description { return ResourceManager.GetString("SystemConfiguration_TimeZoneId_Description", resourceCulture); } } + + /// + /// Looks up a localized string similar to Network Analyzer. + /// + public static string SystemConfiguration_NetworkAnalyzer_Name { + get { + return ResourceManager.GetString("SystemConfiguration_NetworkAnalyzer_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Live buffer size. + /// + public static string SystemConfiguration_NetworkAnalyzerLiveBufferSize_Name { + get { + return ResourceManager.GetString("SystemConfiguration_NetworkAnalyzerLiveBufferSize_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The number of data packets which are kept in memory per connection which is watched in the network analyzer page. When more packets arrive, the oldest ones are dropped.. + /// + public static string SystemConfiguration_NetworkAnalyzerLiveBufferSize_Description { + get { + return ResourceManager.GetString("SystemConfiguration_NetworkAnalyzerLiveBufferSize_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Observation archive path. + /// + public static string SystemConfiguration_NetworkObservationArchivePath_Name { + get { + return ResourceManager.GetString("SystemConfiguration_NetworkObservationArchivePath_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The path in which the traffic of the observed accounts is archived. A relative path is resolved against the directory of the application. It must not point into a folder which is served by the web server - an archived session contains the login packet of the player in plain text.. + /// + public static string SystemConfiguration_NetworkObservationArchivePath_Description { + get { + return ResourceManager.GetString("SystemConfiguration_NetworkObservationArchivePath_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Maximum session size (MB). + /// + public static string SystemConfiguration_NetworkObservationMaxSessionSizeMb_Name { + get { + return ResourceManager.GetString("SystemConfiguration_NetworkObservationMaxSessionSizeMb_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The maximum size of one file of an archived session, in megabytes. A session which grows bigger is continued in another file.. + /// + public static string SystemConfiguration_NetworkObservationMaxSessionSizeMb_Description { + get { + return ResourceManager.GetString("SystemConfiguration_NetworkObservationMaxSessionSizeMb_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Maximum archive size (MB). + /// + public static string SystemConfiguration_NetworkObservationMaxTotalSizeMb_Name { + get { + return ResourceManager.GetString("SystemConfiguration_NetworkObservationMaxTotalSizeMb_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The maximum size of the whole observation archive, in megabytes. When it's exceeded, the oldest sessions are removed. A value of 0 means that the size is unlimited.. + /// + public static string SystemConfiguration_NetworkObservationMaxTotalSizeMb_Description { + get { + return ResourceManager.GetString("SystemConfiguration_NetworkObservationMaxTotalSizeMb_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Archive retention (days). + /// + public static string SystemConfiguration_NetworkObservationRetentionDays_Name { + get { + return ResourceManager.GetString("SystemConfiguration_NetworkObservationRetentionDays_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The number of days after which an archived session is removed. A value of 0 means that the sessions are kept forever.. + /// + public static string SystemConfiguration_NetworkObservationRetentionDays_Description { + get { + return ResourceManager.GetString("SystemConfiguration_NetworkObservationRetentionDays_Description", resourceCulture); + } + } } } diff --git a/src/DataModel/Properties/Resources.resx b/src/DataModel/Properties/Resources.resx index 699ad0dd42..4e856da2e0 100644 --- a/src/DataModel/Properties/Resources.resx +++ b/src/DataModel/Properties/Resources.resx @@ -160,4 +160,37 @@ or behind a firewall) and you want to use it within your computer or private net S + + Network Analyzer + + + Live buffer size + + + The number of data packets which are kept in memory per connection which is watched in the network analyzer page. When more packets arrive, the oldest ones are dropped. + + + Observation archive path + + + The path in which the traffic of the observed accounts is archived. A relative path is resolved against the directory of the application. It must not point into a folder which is served by the web server - an archived session contains the login packet of the player in plain text. + + + Maximum session size (MB) + + + The maximum size of one file of an archived session, in megabytes. A session which grows bigger is continued in another file. + + + Maximum archive size (MB) + + + The maximum size of the whole observation archive, in megabytes. When it's exceeded, the oldest sessions are removed. A value of 0 means that the size is unlimited. + + + Archive retention (days) + + + The number of days after which an archived session is removed. A value of 0 means that the sessions are kept forever. + \ No newline at end of file diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index b44b8da88e..8ddb997441 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -122,6 +122,11 @@ public Player(IGameContext gameContext) /// public event AsyncEventHandler? PlayerEnteredWorld; + /// + /// Occurs when the player has been logged in, so that its is known. + /// + public event AsyncEventHandler? PlayerLoggedIn; + /// /// Occurs when the player left the world with his selected character. /// @@ -581,6 +586,18 @@ public IPetCommandManager? PetCommandManager /// protected virtual bool IsPlayerStoreOpeningAfterEnterSupported => true; + /// + /// Sets the account of the player after a successful login and notifies the subscribers + /// of . + /// + /// The account of the player. + /// The async task. + public async ValueTask SetAccountAsync(Account account) + { + this.Account = account; + await this.PlayerLoggedIn.SafeInvokeAsync(this).ConfigureAwait(false); + } + /// /// Sets the selected character. /// diff --git a/src/GameLogic/PlayerActions/LoginAction.cs b/src/GameLogic/PlayerActions/LoginAction.cs index 8dea16293d..e23289670a 100644 --- a/src/GameLogic/PlayerActions/LoginAction.cs +++ b/src/GameLogic/PlayerActions/LoginAction.cs @@ -200,7 +200,7 @@ private async ValueTask HandleAlreadyConnectedAsync(Player player, string userna private async ValueTask FinishLoginAsync(Player player, string username, Account account) { - player.Account = account; + await player.SetAccountAsync(account).ConfigureAwait(false); player.Logger.LogDebug("Login successful, username: [{Username}].", username); if (player.IsTemplatePlayer) diff --git a/src/GameServer/GameServer.cs b/src/GameServer/GameServer.cs index 72b9c3cd23..853d56d43c 100644 --- a/src/GameServer/GameServer.cs +++ b/src/GameServer/GameServer.cs @@ -20,6 +20,7 @@ namespace MUnique.OpenMU.GameServer; using MUnique.OpenMU.GameServer.RemoteView; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Network.Analyzer; +using MUnique.OpenMU.Network.Analyzer.Archive; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.PlugIns; using Nito.AsyncEx; @@ -35,6 +36,8 @@ public sealed class GameServer : IGameServer, IDisposable, IGameServerContextPro private readonly ICollection _listeners = new List(); + private readonly NetworkObservationHandler? _observationHandler; + private ServerState _serverState; /// @@ -49,6 +52,8 @@ public sealed class GameServer : IGameServer, IDisposable, IGameServerContextPro /// The logger factory. /// The plug in manager. /// The change mediatior. + /// The archive for the traffic of observed accounts. It's only + /// available when the network observation is configured. public GameServer( GameServerDefinition gameServerDefinition, IGuildServer guildServer, @@ -58,12 +63,16 @@ public GameServer( IFriendServer friendServer, ILoggerFactory loggerFactory, PlugInManager plugInManager, - IConfigurationChangeMediator changeMediator) + IConfigurationChangeMediator changeMediator, + IPacketArchive? packetArchive = null) { this.Id = gameServerDefinition.ServerID; this.Description = gameServerDefinition.Description; this.ConfigurationId = gameServerDefinition.GetId(); this._logger = loggerFactory.CreateLogger(); + this._observationHandler = packetArchive is null + ? null + : new NetworkObservationHandler(packetArchive, this.Id, this.Description, loggerFactory.CreateLogger()); try { var gameConfiguration = gameServerDefinition.GameConfiguration ?? throw Error.NotInitializedProperty(gameServerDefinition, nameof(gameServerDefinition.GameConfiguration)); @@ -473,6 +482,7 @@ private async ValueTask RemovePlayerFromGuildAsync(Player player, bool unregiste private async ValueTask OnPlayerConnectedAsync(PlayerConnectedEventArgs e) { var player = e.ConntectedPlayer; + this._observationHandler?.Watch(player); await this._gameContext.AddPlayerAsync(player).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowLoginWindowAsync()).ConfigureAwait(false); await player.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); diff --git a/src/GameServer/NetworkObservationExtensions.cs b/src/GameServer/NetworkObservationExtensions.cs new file mode 100644 index 0000000000..3aa32015ea --- /dev/null +++ b/src/GameServer/NetworkObservationExtensions.cs @@ -0,0 +1,94 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.Network.Analyzer.Archive; +using MUnique.OpenMU.Persistence; +using Nito.AsyncEx.Synchronous; + +/// +/// Extensions to register the archive for the traffic of observed accounts. +/// +public static class NetworkObservationExtensions +{ + /// + /// Adds the archive for the traffic of observed accounts, configured by the system + /// configuration of the database. + /// + /// The service collection. + /// The service collection. + /// + /// It belongs to the game server, not to the admin panel: an observed account is archived + /// as soon as it plays, no matter whether an admin panel is running somewhere. + /// + public static IServiceCollection AddNetworkObservation(this IServiceCollection services) + { + return services + .AddSingleton(CreateOptions) + .AddSingleton(); + } + + /// + /// Creates the options of the network observation from the given system configuration. + /// + /// The system configuration, if it exists. + /// The options of the network observation. + public static NetworkObservationOptions CreateOptions(SystemConfiguration? configuration) + { + var options = new NetworkObservationOptions(); + if (configuration is null) + { + return options; + } + + // A value which was never configured is left at its default, so that an existing + // database doesn't end up with an unlimited archive. + if (!string.IsNullOrWhiteSpace(configuration.NetworkObservationArchivePath)) + { + options.ArchivePath = configuration.NetworkObservationArchivePath; + } + + if (configuration.NetworkObservationMaxSessionSizeMb > 0) + { + options.MaximumSessionSizeMb = configuration.NetworkObservationMaxSessionSizeMb; + } + + if (configuration.NetworkObservationMaxTotalSizeMb > 0) + { + options.MaximumTotalSizeMb = configuration.NetworkObservationMaxTotalSizeMb; + } + + if (configuration.NetworkObservationRetentionDays > 0) + { + options.RetentionDays = configuration.NetworkObservationRetentionDays; + } + + return options; + } + + private static NetworkObservationOptions CreateOptions(IServiceProvider serviceProvider) + { + try + { + if (serviceProvider.GetService() is { } contextProvider) + { + using var context = contextProvider.CreateNewTypedContext(typeof(SystemConfiguration), false); + var configuration = context.GetAsync().AsTask().WaitAndUnwrapException().FirstOrDefault(); + return CreateOptions(configuration); + } + } + catch (Exception ex) + { + serviceProvider.GetService()? + .CreateLogger(typeof(NetworkObservationExtensions)) + .LogWarning(ex, "Could not read the configuration of the network observation. The defaults are used."); + } + + return new NetworkObservationOptions(); + } +} diff --git a/src/GameServer/NetworkObservationHandler.cs b/src/GameServer/NetworkObservationHandler.cs new file mode 100644 index 0000000000..d8651e886f --- /dev/null +++ b/src/GameServer/NetworkObservationHandler.cs @@ -0,0 +1,145 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer; + +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameServer.RemoteView; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Analyzer.Archive; + +/// +/// Archives the traffic of the players whose account is observed. +/// +/// +/// It's part of the game server and not of the admin panel, so that the traffic of an observed +/// account is archived in a distributed deployment as well. The archive starts when the player +/// is logged in - the few packets before that (version check, login request) are not part of +/// it, because capturing them would mean to capture every connection unconditionally. +/// +internal sealed class NetworkObservationHandler +{ + private readonly IPacketArchive _archive; + + private readonly int _serverId; + + private readonly string _serverDescription; + + private readonly ILogger _logger; + + private readonly ConcurrentDictionary _sessions = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The archive in which the sessions are written. + /// The identifier of the game server. + /// The description of the game server. + /// The logger. + public NetworkObservationHandler(IPacketArchive archive, int serverId, string serverDescription, ILogger logger) + { + this._archive = archive; + this._serverId = serverId; + this._serverDescription = serverDescription; + this._logger = logger; + } + + /// + /// Starts to watch the specified player, so that its traffic is archived when it logs in + /// with an observed account. + /// + /// The player which just connected. + public void Watch(Player player) + { + if (player is not RemotePlayer) + { + // Without a connection there is no traffic - an offline player has none. + return; + } + + player.PlayerLoggedIn += this.OnPlayerLoggedInAsync; + player.PlayerEnteredWorld += this.OnPlayerEnteredWorldAsync; + player.PlayerDisconnected += this.OnPlayerDisconnectedAsync; + } + + private async ValueTask OnPlayerLoggedInAsync(Player player) + { + try + { + if (player.Account is not { IsNetworkObservationActive: true } account + || player is not RemotePlayer { Connection: { } connection } remotePlayer + || this._sessions.ContainsKey(player)) + { + return; + } + + var metadata = new ArchivedSessionMetadata + { + AccountName = account.LoginName, + ServerType = ServerType.GameServer, + ServerId = this._serverId, + ServerDescription = this._serverDescription, + RemoteEndPoint = connection.EndPoint?.ToString(), + ClientVersion = remotePlayer.ClientVersion, + StartTimestamp = DateTime.UtcNow, + }; + + if (await this._archive.StartSessionAsync(metadata).ConfigureAwait(false) is not { } writer) + { + return; + } + + if (!this._sessions.TryAdd(player, new ObservedSession(writer, connection))) + { + await writer.DisposeAsync().ConfigureAwait(false); + return; + } + + connection.AddCaptureSink(writer); + } + catch (Exception ex) + { + // The observation must never break the session of the player. + this._logger.LogWarning(ex, "Could not start to archive the traffic of {Player}.", player); + } + } + + private async ValueTask OnPlayerEnteredWorldAsync(Player player) + { + if (this._sessions.TryGetValue(player, out var session) + && player.SelectedCharacter?.Name is { } characterName) + { + await session.Writer.AddCharacterNameAsync(characterName).ConfigureAwait(false); + } + } + + private async ValueTask OnPlayerDisconnectedAsync(Player player) + { + player.PlayerLoggedIn -= this.OnPlayerLoggedInAsync; + player.PlayerEnteredWorld -= this.OnPlayerEnteredWorldAsync; + player.PlayerDisconnected -= this.OnPlayerDisconnectedAsync; + + if (!this._sessions.TryRemove(player, out var session)) + { + return; + } + + try + { + // The connection is taken from the session: the player may not have it anymore + // when it's already tearing down. + session.Connection.RemoveCaptureSink(session.Writer); + await session.Writer.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "Could not finish the archived session of {Player}.", player); + } + } + + private sealed record ObservedSession(ArchivedSessionWriter Writer, IConnection Connection); +} diff --git a/src/Network/Analyzer/Archive/ArchivedSession.cs b/src/Network/Analyzer/Archive/ArchivedSession.cs new file mode 100644 index 0000000000..156388f559 --- /dev/null +++ b/src/Network/Analyzer/Archive/ArchivedSession.cs @@ -0,0 +1,124 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Network.Analyzer.Archive; + +using System.ComponentModel; +using System.Globalization; +using System.IO; + +/// +/// An archived session of an observed account, which is read from the archive. +/// +/// +/// The files can be read while the session is still running: they are opened without blocking +/// the writer, and a line which isn't completely written yet is simply skipped. +/// +public sealed class ArchivedSession : ICapturedConnection +{ + /// + /// The minimum size of a data packet: a type byte, the length and the code. + /// + private const int MinimumPacketSize = 3; + + private ArchivedSession(ArchivedSessionInfo info, BindingList packets) + { + this.Info = info; + this.PacketList = packets; + this.Name = info.DisplayName; + this.StartTimestamp = info.Metadata.StartTimestamp; + } + + /// + /// Gets the information about the session. + /// + public ArchivedSessionInfo Info { get; } + + /// + public string Name { get; } + + /// + public BindingList PacketList { get; } + + /// + public DateTime StartTimestamp { get; } + + /// + /// Loads the packets of the specified session. + /// + /// The information about the session. + /// The maximum number of packets which are loaded. When + /// the session contains more, only the newest ones are returned. + /// The loaded session. + public static async ValueTask LoadAsync(ArchivedSessionInfo info, int maximumPacketCount) + { + var packets = new List(); + foreach (var part in info.Metadata.Parts) + { + var path = Path.Combine(info.DirectoryPath, part); + if (!File.Exists(path)) + { + continue; + } + + await ReadPartAsync(path, packets).ConfigureAwait(false); + } + + if (maximumPacketCount > 0 && packets.Count > maximumPacketCount) + { + packets.RemoveRange(0, packets.Count - maximumPacketCount); + } + + return new ArchivedSession(info, new BindingList(packets)); + } + + private static async ValueTask ReadPartAsync(string path, List packets) + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + + // The first line is the timestamp of the session, which is already in the metadata. + _ = await reader.ReadLineAsync().ConfigureAwait(false); + while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line) + { + if (TryParsePacket(line, out var packet)) + { + packets.Add(packet); + } + } + } + + private static bool TryParsePacket(string line, out Packet packet) + { + packet = default!; + + // The fifth field is the sequence number of the packet, which is only used to see + // whether packets are missing - the analyzer tool ignores it. + var fields = line.Split(';'); + if (fields.Length < 4 + || !long.TryParse(fields[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ticks) + || !bool.TryParse(fields[1], out var toServer) + || !int.TryParse(fields[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var size) + || size < MinimumPacketSize) + { + return false; + } + + try + { + if (!CapturedConnectionExtensions.TryParseArray(fields[3], out var data) || data.Length != size) + { + return false; + } + + packet = new Packet(new TimeSpan(ticks), data, toServer); + return true; + } + catch (Exception) + { + // The line isn't completely written yet, or it's not a packet at all. + return false; + } + } +} diff --git a/src/Network/Analyzer/Archive/ArchivedSessionInfo.cs b/src/Network/Analyzer/Archive/ArchivedSessionInfo.cs new file mode 100644 index 0000000000..b04e5e5f41 --- /dev/null +++ b/src/Network/Analyzer/Archive/ArchivedSessionInfo.cs @@ -0,0 +1,69 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Network.Analyzer.Archive; + +using System.Globalization; + +/// +/// The information about one session in the archive. +/// +public sealed class ArchivedSessionInfo +{ + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the session, which is its path relative to the + /// archive. + /// The full path of the directory of the session. + /// The metadata of the session. + /// The size of the session on the file system. + /// If set to true, the session is currently being written. + public ArchivedSessionInfo(string id, string directoryPath, ArchivedSessionMetadata metadata, long sizeInBytes, bool isRunning) + { + this.Id = id; + this.DirectoryPath = directoryPath; + this.Metadata = metadata; + this.SizeInBytes = sizeInBytes; + this.IsRunning = isRunning; + } + + /// + /// Gets the identifier of the session, which is its path relative to the archive. + /// + public string Id { get; } + + /// + /// Gets the full path of the directory of the session. + /// + public string DirectoryPath { get; } + + /// + /// Gets the metadata of the session. + /// + public ArchivedSessionMetadata Metadata { get; } + + /// + /// Gets the size of the session on the file system, in bytes. + /// + public long SizeInBytes { get; } + + /// + /// Gets a value indicating whether the session is currently being written, because the + /// observed player is still online. + /// + public bool IsRunning { get; } + + /// + /// Gets the duration of the session. For a running session, it's the duration so far. + /// + public TimeSpan Duration => (this.Metadata.EndTimestamp ?? DateTime.UtcNow) - this.Metadata.StartTimestamp; + + /// + /// Gets the name which should be shown for this session. + /// + public string DisplayName => string.Create( + CultureInfo.InvariantCulture, + $"{this.Metadata.AccountName} ({this.Metadata.StartTimestamp:yyyy-MM-dd HH:mm:ss})"); +} diff --git a/src/Network/Analyzer/Archive/ArchivedSessionMetadata.cs b/src/Network/Analyzer/Archive/ArchivedSessionMetadata.cs new file mode 100644 index 0000000000..43cc924347 --- /dev/null +++ b/src/Network/Analyzer/Archive/ArchivedSessionMetadata.cs @@ -0,0 +1,85 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Network.Analyzer.Archive; + +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Network.PlugIns; + +/// +/// The metadata of an archived session, which is saved next to the captured packets. +/// +/// +/// The packets themselves are saved in the same format as the capture files of the analyzer +/// tool, which has no place for this kind of information. +/// +public sealed class ArchivedSessionMetadata +{ + /// + /// Gets or sets the name of the observed account. + /// + public string AccountName { get; set; } = string.Empty; + + /// + /// Gets or sets the names of the characters which were played during the session. + /// + public IList CharacterNames { get; set; } = new List(); + + /// + /// Gets or sets the type of the server which handled the connection. + /// + public ServerType ServerType { get; set; } = ServerType.GameServer; + + /// + /// Gets or sets the identifier of the server which handled the connection. + /// + public int ServerId { get; set; } + + /// + /// Gets or sets the description of the server which handled the connection. + /// + public string? ServerDescription { get; set; } + + /// + /// Gets or sets the remote endpoint of the connection. + /// + public string? RemoteEndPoint { get; set; } + + /// + /// Gets or sets the client version which applied to the connection. + /// + /// + /// The archive starts when the player is logged in, so the version is already the one + /// which the client reported at the version check - it doesn't change afterwards. + /// + public ClientVersion ClientVersion { get; set; } + + /// + /// Gets or sets the point in time (UTC) at which the session started. + /// + public DateTime StartTimestamp { get; set; } + + /// + /// Gets or sets the point in time (UTC) at which the session ended. It's null as + /// long as it's running - and stays null when the server process died. + /// + public DateTime? EndTimestamp { get; set; } + + /// + /// Gets or sets the number of packets which were archived. + /// + public long PacketCount { get; set; } + + /// + /// Gets or sets the number of packets which had to be dropped, because they arrived + /// faster than they could be written. + /// + public long DroppedPacketCount { get; set; } + + /// + /// Gets or sets the names of the files which contain the packets of this session, in the + /// order in which they were written. + /// + public IList Parts { get; set; } = new List(); +} diff --git a/src/Network/Analyzer/Archive/ArchivedSessionWriter.cs b/src/Network/Analyzer/Archive/ArchivedSessionWriter.cs new file mode 100644 index 0000000000..43cabb0a0e --- /dev/null +++ b/src/Network/Analyzer/Archive/ArchivedSessionWriter.cs @@ -0,0 +1,313 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Network.Analyzer.Archive; + +using System.Globalization; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; + +/// +/// Writes the captured packets of an observed session into the archive, while it's running. +/// +/// +/// The packets are written by an own task, so that the network thread which captured a packet +/// is never waiting for the file system. They are appended and flushed as soon as the queue +/// ran empty, so that a running session can be read - and survives a crash of the process. +/// +public sealed class ArchivedSessionWriter : IPacketCaptureSink, IAsyncDisposable +{ + /// + /// The name of the file which contains the metadata of the session. + /// + public const string MetadataFileName = "session.json"; + + /// + /// The extension of the files which contain the packets. It's the one of the analyzer + /// tool, so that an archived session can be opened with it. + /// + public const string PartFileExtension = ".mucap"; + + /// + /// The maximum number of packets which are waiting to be written. When the file system + /// can't keep up with the traffic, the packets above it are dropped - the connection of + /// the player must not be slowed down by the observation. + /// + private const int QueueCapacity = 10000; + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private readonly string _directoryPath; + + private readonly ArchivedSessionMetadata _metadata; + + private readonly long _maximumPartSize; + + private readonly ILogger _logger; + + private readonly Func? _onClosedAsync; + + private readonly Channel _channel = Channel.CreateBounded( + new BoundedChannelOptions(QueueCapacity) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); + + private readonly SemaphoreSlim _metadataSemaphore = new(1); + + private readonly Task _writeLoop; + + private StreamWriter? _currentPart; + + private long _currentPartSize; + + private long _sequence; + + private long _droppedPacketCount; + + private bool _isDisposed; + + /// + /// Initializes a new instance of the class. + /// + /// The path of the directory of this session. + /// The metadata of the session. + /// The maximum size of one file of the session, in bytes. + /// The logger. + /// The callback which is invoked when the session is closed. + public ArchivedSessionWriter( + string directoryPath, + ArchivedSessionMetadata metadata, + long maximumPartSize, + ILogger logger, + Func? onClosedAsync = null) + { + this._directoryPath = directoryPath; + this._metadata = metadata; + this._maximumPartSize = maximumPartSize; + this._logger = logger; + this._onClosedAsync = onClosedAsync; + this._writeLoop = Task.Run(this.WriteLoopAsync); + } + + /// + /// Gets the metadata of the session. + /// + public ArchivedSessionMetadata Metadata => this._metadata; + + /// + /// Adds the name of a character which is played in this session. + /// + /// The name of the character. + /// The async task. + public async ValueTask AddCharacterNameAsync(string characterName) + { + if (string.IsNullOrWhiteSpace(characterName)) + { + return; + } + + await this._metadataSemaphore.WaitAsync().ConfigureAwait(false); + try + { + if (this._metadata.CharacterNames.Contains(characterName)) + { + return; + } + + this._metadata.CharacterNames.Add(characterName); + } + finally + { + this._metadataSemaphore.Release(); + } + + await this.SaveMetadataAsync().ConfigureAwait(false); + } + + /// + /// Saves the current state of the metadata, so that the session is visible in the archive + /// while it's still running. + /// + /// The async task. + public async ValueTask SaveMetadataAsync() + { + await this._metadataSemaphore.WaitAsync().ConfigureAwait(false); + try + { + this._metadata.PacketCount = Interlocked.Read(ref this._sequence); + this._metadata.DroppedPacketCount = Interlocked.Read(ref this._droppedPacketCount); + var json = JsonSerializer.Serialize(this._metadata, JsonOptions); + var path = Path.Combine(this._directoryPath, MetadataFileName); + await File.WriteAllTextAsync(path, json).ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "Could not save the metadata of the archived session at {Path}.", this._directoryPath); + } + finally + { + this._metadataSemaphore.Release(); + } + } + + /// + public void PacketCaptured(ReadOnlySpan packet, bool sent) + { + // A packet which was sent to the remote endpoint of a server connection is a packet + // which goes to the client; a received one goes to the server. + var captured = new CapturedPacket( + DateTime.UtcNow - this._metadata.StartTimestamp, + packet.ToArray(), + !sent, + Interlocked.Increment(ref this._sequence)); + + if (!this._channel.Writer.TryWrite(captured)) + { + // The sequence numbers make such a gap visible in the archived session. + Interlocked.Increment(ref this._droppedPacketCount); + } + } + + /// + public async ValueTask DisposeAsync() + { + if (this._isDisposed) + { + return; + } + + this._isDisposed = true; + this._channel.Writer.TryComplete(); + try + { +#pragma warning disable VSTHRD003 // The loop is started by this instance, so awaiting it here can't deadlock. + await this._writeLoop.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "Error while finishing the archived session at {Path}.", this._directoryPath); + } + + await this.CloseCurrentPartAsync().ConfigureAwait(false); + this._metadata.EndTimestamp = DateTime.UtcNow; + await this.SaveMetadataAsync().ConfigureAwait(false); + this._metadataSemaphore.Dispose(); + + if (this._onClosedAsync is { } onClosedAsync) + { + await onClosedAsync().ConfigureAwait(false); + } + } + + private async Task WriteLoopAsync() + { + var reader = this._channel.Reader; + while (await reader.WaitToReadAsync().ConfigureAwait(false)) + { + while (reader.TryRead(out var packet)) + { + await this.WritePacketAsync(packet).ConfigureAwait(false); + } + + if (this._currentPart is { } currentPart) + { + try + { + await currentPart.FlushAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "Could not flush the archived session at {Path}.", this._directoryPath); + } + } + } + } + + private async ValueTask WritePacketAsync(CapturedPacket packet) + { + try + { + var writer = await this.GetCurrentPartAsync().ConfigureAwait(false); + if (writer is null) + { + return; + } + + var data = new Packet(packet.Timestamp, packet.Data, packet.ToServer); + var line = string.Create( + CultureInfo.InvariantCulture, + $"{packet.Timestamp.Ticks};{packet.ToServer};{data.Size};{data.PacketData};{packet.Sequence}"); + await writer.WriteLineAsync(line).ConfigureAwait(false); + this._currentPartSize += line.Length + Environment.NewLine.Length; + if (this._currentPartSize >= this._maximumPartSize) + { + // The session continues in another file, so that a single one stays small + // enough to be opened by the analyzer tool. + await this.CloseCurrentPartAsync().ConfigureAwait(false); + } + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "Could not write a packet of the archived session at {Path}.", this._directoryPath); + } + } + + private async ValueTask GetCurrentPartAsync() + { + if (this._currentPart is { } currentPart) + { + return currentPart; + } + + var partName = string.Create(CultureInfo.InvariantCulture, $"part-{this._metadata.Parts.Count:D3}{PartFileExtension}"); + var stream = new FileStream( + Path.Combine(this._directoryPath, partName), + FileMode.Create, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + this._currentPart = new StreamWriter(stream); + this._currentPartSize = 0; + + // Each part starts with the timestamp of the session, so that it can be loaded on its + // own - the timestamps of the packets are relative to it. + var startTimestamp = this._metadata.StartTimestamp.ToString("O", CultureInfo.InvariantCulture); + await this._currentPart.WriteLineAsync(startTimestamp).ConfigureAwait(false); + await this._currentPart.FlushAsync().ConfigureAwait(false); + + await this._metadataSemaphore.WaitAsync().ConfigureAwait(false); + try + { + this._metadata.Parts.Add(partName); + } + finally + { + this._metadataSemaphore.Release(); + } + + await this.SaveMetadataAsync().ConfigureAwait(false); + return this._currentPart; + } + + private async ValueTask CloseCurrentPartAsync() + { + if (this._currentPart is not { } currentPart) + { + return; + } + + this._currentPart = null; + try + { + await currentPart.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "Could not close a file of the archived session at {Path}.", this._directoryPath); + } + } + + private readonly record struct CapturedPacket(TimeSpan Timestamp, byte[] Data, bool ToServer, long Sequence); +} diff --git a/src/Network/Analyzer/Archive/IPacketArchive.cs b/src/Network/Analyzer/Archive/IPacketArchive.cs new file mode 100644 index 0000000000..922402ae8b --- /dev/null +++ b/src/Network/Analyzer/Archive/IPacketArchive.cs @@ -0,0 +1,53 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Network.Analyzer.Archive; + +/// +/// The archive which holds the traffic of the sessions of observed accounts. +/// +/// +/// It's a feature of the game server: an observed account is archived as soon as it plays, +/// regardless of whether an admin panel is running somewhere. +/// +public interface IPacketArchive +{ + /// + /// Starts a new session in the archive. + /// + /// The metadata of the session. Its start timestamp is used as the + /// reference for the timestamps of the packets. + /// The writer of the session, which is a sink of the connection; Or + /// , if the session couldn't be started. + ValueTask StartSessionAsync(ArchivedSessionMetadata metadata); + + /// + /// Gets the sessions which are in the archive, newest first. + /// + /// The name of the account, if only its sessions are wanted. + /// The sessions which are in the archive. + ValueTask> GetSessionsAsync(string? accountName = null); + + /// + /// Gets the information about the session with the specified identifier. + /// + /// The identifier of the session. + /// The information about the session, if it exists; Otherwise, + /// . + ValueTask GetSessionAsync(string sessionId); + + /// + /// Deletes the session with the specified identifier. + /// + /// The identifier of the session. + /// , if the session has been deleted. + ValueTask DeleteSessionAsync(string sessionId); + + /// + /// Applies the retention and the size limit of the archive, by removing the oldest + /// sessions. A session which is currently written is never removed. + /// + /// The async task. + ValueTask ApplyHousekeepingAsync(); +} diff --git a/src/Network/Analyzer/Archive/NetworkObservationOptions.cs b/src/Network/Analyzer/Archive/NetworkObservationOptions.cs new file mode 100644 index 0000000000..57d0661871 --- /dev/null +++ b/src/Network/Analyzer/Archive/NetworkObservationOptions.cs @@ -0,0 +1,86 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Network.Analyzer.Archive; + +using System.IO; + +/// +/// The options of the network observation, which archives the traffic of the accounts whose +/// observation is active. +/// +/// +/// They are configured in the system configuration of the database. This class is the plain +/// counterpart of it, so that the archive doesn't need to know the data model. +/// +public sealed class NetworkObservationOptions +{ + /// + /// The default path of the archive, relative to the directory of the application. + /// + public const string DefaultArchivePath = "captures"; + + /// + /// The default maximum size of one file of a session, in megabytes. + /// + public const int DefaultMaximumSessionSizeMb = 50; + + /// + /// The default maximum size of the whole archive, in megabytes. + /// + public const int DefaultMaximumTotalSizeMb = 1000; + + /// + /// The default number of days after which an archived session is removed. + /// + public const int DefaultRetentionDays = 30; + + /// + /// Gets or sets the path of the archive. A relative path is resolved against the directory + /// of the application. + /// + /// + /// It must not point into a folder which is served by the web server - an archived session + /// contains the login packet of the player in plain text. + /// + public string ArchivePath { get; set; } = DefaultArchivePath; + + /// + /// Gets or sets the maximum size of one file of a session, in megabytes. A session which + /// grows bigger is continued in another file. + /// + public int MaximumSessionSizeMb { get; set; } = DefaultMaximumSessionSizeMb; + + /// + /// Gets or sets the maximum size of the whole archive, in megabytes. When it's exceeded, + /// the oldest sessions are removed. A value of 0 or less means that the size is unlimited. + /// + public int MaximumTotalSizeMb { get; set; } = DefaultMaximumTotalSizeMb; + + /// + /// Gets or sets the number of days after which an archived session is removed. A value of + /// 0 or less means that the sessions are kept forever. + /// + public int RetentionDays { get; set; } = DefaultRetentionDays; + + /// + /// Gets the maximum size of one file of a session, in bytes. + /// + /// The maximum size of one file of a session, in bytes. + public long GetMaximumSessionSizeInBytes() + { + var sizeInMegabytes = this.MaximumSessionSizeMb > 0 ? this.MaximumSessionSizeMb : DefaultMaximumSessionSizeMb; + return (long)sizeInMegabytes * 1024 * 1024; + } + + /// + /// Gets the full path of the archive. + /// + /// The full path of the archive. + public string GetFullArchivePath() + { + var path = string.IsNullOrWhiteSpace(this.ArchivePath) ? DefaultArchivePath : this.ArchivePath; + return Path.GetFullPath(Path.IsPathRooted(path) ? path : Path.Combine(AppContext.BaseDirectory, path)); + } +} diff --git a/src/Network/Analyzer/Archive/PacketArchive.cs b/src/Network/Analyzer/Archive/PacketArchive.cs new file mode 100644 index 0000000000..df95af76f1 --- /dev/null +++ b/src/Network/Analyzer/Archive/PacketArchive.cs @@ -0,0 +1,282 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Network.Analyzer.Archive; + +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +/// +/// The implementation of the , which keeps the sessions on the +/// file system. +/// +/// +/// A session is a directory below the one of its account, which holds the captured packets in +/// one or more files of the analyzer tool format, plus the metadata as json. That way, a +/// session can be deleted, copied or opened as a whole. +/// +public sealed class PacketArchive : IPacketArchive +{ + private readonly NetworkObservationOptions _options; + + private readonly ILogger _logger; + + private readonly ConcurrentDictionary _runningSessions = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The options of the network observation. + /// The logger. + public PacketArchive(NetworkObservationOptions options, ILogger logger) + { + this._options = options; + this._logger = logger; + } + + /// + /// Gets the full path of the archive. + /// + public string ArchivePath => this._options.GetFullArchivePath(); + + /// + public async ValueTask StartSessionAsync(ArchivedSessionMetadata metadata) + { + await this.ApplyHousekeepingAsync().ConfigureAwait(false); + + if (metadata.StartTimestamp == default) + { + metadata.StartTimestamp = DateTime.UtcNow; + } + + string sessionId; + string directoryPath; + try + { + var accountDirectory = GetSafeName(metadata.AccountName); + var sessionDirectory = string.Create( + CultureInfo.InvariantCulture, + $"{metadata.StartTimestamp:yyyy-MM-dd_HH-mm-ss}_{metadata.ServerId}"); + sessionId = $"{accountDirectory}/{sessionDirectory}"; + directoryPath = Path.Combine(this.ArchivePath, accountDirectory, sessionDirectory); + Directory.CreateDirectory(directoryPath); + } + catch (Exception ex) + { + this._logger.LogError(ex, "Could not create the archive directory for the account {AccountName}.", metadata.AccountName); + return null; + } + + var writer = new ArchivedSessionWriter( + directoryPath, + metadata, + this._options.GetMaximumSessionSizeInBytes(), + this._logger, + () => this.OnSessionClosedAsync(sessionId)); + this._runningSessions[sessionId] = writer; + await writer.SaveMetadataAsync().ConfigureAwait(false); + + // Observing a player is an intrusion into their privacy, so it leaves a trace. + this._logger.LogInformation( + "Started to archive the traffic of the observed account {AccountName} at {SessionId}.", + metadata.AccountName, + sessionId); + return writer; + } + + /// + public async ValueTask> GetSessionsAsync(string? accountName = null) + { + var archivePath = this.ArchivePath; + if (!Directory.Exists(archivePath)) + { + return []; + } + + var accountDirectories = string.IsNullOrEmpty(accountName) + ? Directory.EnumerateDirectories(archivePath) + : [Path.Combine(archivePath, GetSafeName(accountName))]; + + var result = new List(); + foreach (var accountDirectory in accountDirectories) + { + if (!Directory.Exists(accountDirectory)) + { + continue; + } + + foreach (var sessionDirectory in Directory.EnumerateDirectories(accountDirectory)) + { + if (await this.TryReadSessionAsync(sessionDirectory).ConfigureAwait(false) is { } session) + { + result.Add(session); + } + } + } + + result.Sort((left, right) => right.Metadata.StartTimestamp.CompareTo(left.Metadata.StartTimestamp)); + return result; + } + + /// + public async ValueTask GetSessionAsync(string sessionId) + { + if (this.TryGetSessionDirectory(sessionId) is not { } directoryPath) + { + return null; + } + + return await this.TryReadSessionAsync(directoryPath).ConfigureAwait(false); + } + + /// + public ValueTask DeleteSessionAsync(string sessionId) + { + if (this._runningSessions.ContainsKey(sessionId)) + { + this._logger.LogInformation("The archived session {SessionId} is still running and was not deleted.", sessionId); + return ValueTask.FromResult(false); + } + + if (this.TryGetSessionDirectory(sessionId) is not { } directoryPath || !Directory.Exists(directoryPath)) + { + return ValueTask.FromResult(false); + } + + try + { + Directory.Delete(directoryPath, true); + this.RemoveAccountDirectoryIfEmpty(Path.GetDirectoryName(directoryPath)); + this._logger.LogInformation("The archived session {SessionId} has been deleted.", sessionId); + return ValueTask.FromResult(true); + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "Could not delete the archived session {SessionId}.", sessionId); + return ValueTask.FromResult(false); + } + } + + /// + public async ValueTask ApplyHousekeepingAsync() + { + try + { + var sessions = await this.GetSessionsAsync().ConfigureAwait(false); + var removable = sessions.Where(session => !session.IsRunning).ToList(); + + if (this._options.RetentionDays > 0) + { + var oldestAllowedStart = DateTime.UtcNow.AddDays(-this._options.RetentionDays); + foreach (var session in removable.Where(session => session.Metadata.StartTimestamp < oldestAllowedStart).ToList()) + { + await this.DeleteSessionAsync(session.Id).ConfigureAwait(false); + removable.Remove(session); + } + } + + if (this._options.MaximumTotalSizeMb <= 0) + { + return; + } + + var maximumTotalSize = (long)this._options.MaximumTotalSizeMb * 1024 * 1024; + var totalSize = sessions.Sum(session => session.SizeInBytes); + + // The oldest sessions are removed first - the newest traffic is the interesting one. + foreach (var session in removable.OrderBy(session => session.Metadata.StartTimestamp)) + { + if (totalSize <= maximumTotalSize) + { + return; + } + + if (await this.DeleteSessionAsync(session.Id).ConfigureAwait(false)) + { + totalSize -= session.SizeInBytes; + } + } + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "Error during the housekeeping of the packet archive."); + } + } + + private static string GetSafeName(string name) + { + var safeName = string.Join('_', name.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries)); + return string.IsNullOrWhiteSpace(safeName) ? "unknown" : safeName; + } + + private async ValueTask OnSessionClosedAsync(string sessionId) + { + this._runningSessions.TryRemove(sessionId, out _); + this._logger.LogInformation("Finished the archived session {SessionId}.", sessionId); + await this.ApplyHousekeepingAsync().ConfigureAwait(false); + } + + private string? TryGetSessionDirectory(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return null; + } + + var archivePath = this.ArchivePath; + var directoryPath = Path.GetFullPath(Path.Combine(archivePath, sessionId.Replace('/', Path.DirectorySeparatorChar))); + + // The identifier comes from the outside, so it must not be able to point somewhere else. + if (!directoryPath.StartsWith(archivePath + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + this._logger.LogWarning("The session identifier {SessionId} points outside of the archive.", sessionId); + return null; + } + + return directoryPath; + } + + private async ValueTask TryReadSessionAsync(string directoryPath) + { + var metadataPath = Path.Combine(directoryPath, ArchivedSessionWriter.MetadataFileName); + if (!File.Exists(metadataPath)) + { + return null; + } + + try + { + await using var stream = new FileStream(metadataPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + if (await JsonSerializer.DeserializeAsync(stream).ConfigureAwait(false) is not { } metadata) + { + return null; + } + + var archivePath = this.ArchivePath; + var sessionId = Path.GetRelativePath(archivePath, directoryPath).Replace(Path.DirectorySeparatorChar, '/'); + var size = new DirectoryInfo(directoryPath).EnumerateFiles().Sum(file => file.Length); + return new ArchivedSessionInfo(sessionId, directoryPath, metadata, size, this._runningSessions.ContainsKey(sessionId)); + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "Could not read the archived session at {Path}.", directoryPath); + return null; + } + } + + private void RemoveAccountDirectoryIfEmpty(string? accountDirectory) + { + if (accountDirectory is null + || !Directory.Exists(accountDirectory) + || Directory.EnumerateFileSystemEntries(accountDirectory).Any()) + { + return; + } + + Directory.Delete(accountDirectory); + } +} diff --git a/src/Persistence/EntityFramework/Migrations/20260829211237_AddNetworkObservation.Designer.cs b/src/Persistence/EntityFramework/Migrations/20260829211237_AddNetworkObservation.Designer.cs new file mode 100644 index 0000000000..d67698a790 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20260829211237_AddNetworkObservation.Designer.cs @@ -0,0 +1,5926 @@ +// +using System; +using MUnique.OpenMU.Persistence.EntityFramework; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations +{ + [DbContext(typeof(EntityDataContext))] + [Migration("20260829211237_AddNetworkObservation")] + partial class AddNetworkObservation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatBanUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("EMail") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsBot") + .HasColumnType("boolean"); + + b.Property("IsNetworkObservationActive") + .HasColumnType("boolean"); + + b.Property("IsTemplate") + .HasColumnType("boolean"); + + b.Property("IsVaultExtended") + .HasColumnType("boolean"); + + b.Property("LanguageIsoCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("en"); + + b.Property("LoginName") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegistrationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("TimeZone") + .HasColumnType("smallint"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.Property("VaultPassword") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LoginName") + .IsUnique(); + + b.HasIndex("VaultId") + .IsUnique(); + + b.ToTable("Account", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AccountCharacterClass", b => + { + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.HasKey("AccountId", "CharacterClassId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("AccountCharacterClass", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("FullAncientSetEquipped") + .HasColumnType("boolean"); + + b.Property("Pose") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("AppearanceData", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AreaSkillSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DelayBetweenHits") + .HasColumnType("interval"); + + b.Property("DelayPerOneDistance") + .HasColumnType("interval"); + + b.Property("EffectRange") + .HasColumnType("integer"); + + b.Property("FrustumDistance") + .HasColumnType("real"); + + b.Property("FrustumEndWidth") + .HasColumnType("real"); + + b.Property("FrustumStartWidth") + .HasColumnType("real"); + + b.Property("HitChancePerDistanceMultiplier") + .HasColumnType("real"); + + b.Property("MaximumNumberOfHitsPerAttack") + .HasColumnType("integer"); + + b.Property("MaximumNumberOfHitsPerTarget") + .HasColumnType("integer"); + + b.Property("MinimumNumberOfHitsPerAttack") + .HasColumnType("integer"); + + b.Property("MinimumNumberOfHitsPerTarget") + .HasColumnType("integer"); + + b.Property("ProjectileCount") + .HasColumnType("integer"); + + b.Property("TargetAreaDiameter") + .HasColumnType("real"); + + b.Property("UseDeferredHits") + .HasColumnType("boolean"); + + b.Property("UseFrustumFilter") + .HasColumnType("boolean"); + + b.Property("UseTargetAreaFilter") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("AreaSkillSettings", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Designation") + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MaximumValue") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("AttributeDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateType") + .HasColumnType("integer"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("InputAttributeId") + .HasColumnType("uuid"); + + b.Property("InputOperand") + .HasColumnType("real"); + + b.Property("InputOperator") + .HasColumnType("integer"); + + b.Property("OperandAttributeId") + .HasColumnType("uuid"); + + b.Property("PowerUpDefinitionValueId") + .HasColumnType("uuid"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CharacterClassId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("InputAttributeId"); + + b.HasIndex("OperandAttributeId"); + + b.HasIndex("PowerUpDefinitionValueId"); + + b.HasIndex("SkillId"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("AttributeRelationship", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeId") + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("MinimumValue") + .HasColumnType("integer"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("SkillId1") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AttributeId"); + + b.HasIndex("GameMapDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("SkillId"); + + b.HasIndex("SkillId1"); + + b.ToTable("AttributeRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroundId") + .HasColumnType("uuid"); + + b.Property("LeftGoalId") + .HasColumnType("uuid"); + + b.Property("LeftTeamSpawnPointX") + .HasColumnType("smallint"); + + b.Property("LeftTeamSpawnPointY") + .HasColumnType("smallint"); + + b.Property("RightGoalId") + .HasColumnType("uuid"); + + b.Property("RightTeamSpawnPointX") + .HasColumnType("smallint"); + + b.Property("RightTeamSpawnPointY") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroundId") + .IsUnique(); + + b.HasIndex("LeftGoalId") + .IsUnique(); + + b.HasIndex("RightGoalId") + .IsUnique(); + + b.ToTable("BattleZoneDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Buff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MagicEffectDefinitionId") + .HasColumnType("uuid"); + + b.Property("MaximumLevel") + .HasColumnType("integer"); + + b.Property("MinimumLevel") + .HasColumnType("integer"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MagicEffectDefinitionId") + .IsUnique(); + + b.HasIndex("MonsterDefinitionId"); + + b.ToTable("Buff", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttackRespawnAreaId") + .HasColumnType("uuid"); + + b.Property("CastleSiegeMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("CrownHoldTimeSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(30); + + b.Property("DefenseRespawnAreaId") + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("GateBuyPrice") + .HasColumnType("integer"); + + b.Property("GateRepairCostPerHealthPoint") + .HasColumnType("integer"); + + b.Property("GuildScoreCastleSiege") + .HasColumnType("integer"); + + b.Property("GuildScoreCastleSiegeMembers") + .HasColumnType("integer"); + + b.Property("LandOfTrialsMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("MaxAttackingGuilds") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ParticipantRewardMinSeconds") + .HasColumnType("integer"); + + b.Property("RegisterMinLevel") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(200); + + b.Property("RegisterMinMembers") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(20); + + b.Property("RepairCostPerUpgradeLevel") + .HasColumnType("integer"); + + b.Property("RewardItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("SignOfLordItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("SignOfLordItemLevel") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)3); + + b.Property("StatueBuyPrice") + .HasColumnType("integer"); + + b.Property("StatueRepairCostPerHealthPoint") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AttackRespawnAreaId") + .IsUnique(); + + b.HasIndex("CastleSiegeMapDefinitionId"); + + b.HasIndex("DefenseRespawnAreaId") + .IsUnique(); + + b.HasIndex("LandOfTrialsMapDefinitionId"); + + b.HasIndex("RewardItemDefinitionId"); + + b.HasIndex("SignOfLordItemDefinitionId"); + + b.ToTable("CastleSiegeConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsHuntZoneEnabled") + .HasColumnType("boolean"); + + b.Property("IsOccupied") + .HasColumnType("boolean"); + + b.Property("OwnerGuildId") + .HasColumnType("uuid"); + + b.Property("TaxChaos") + .HasColumnType("smallint"); + + b.Property("TaxHunt") + .HasColumnType("integer"); + + b.Property("TaxStore") + .HasColumnType("smallint"); + + b.Property("TributeMoney") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OwnerGuildId"); + + b.ToTable("CastleSiegeData", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeGuild", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastleSiegeDataId") + .HasColumnType("uuid"); + + b.Property("GuildId") + .HasColumnType("uuid"); + + b.Property("GuildName") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("IsAllianceMaster") + .HasColumnType("boolean"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("Side") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("CastleSiegeDataId"); + + b.HasIndex("GuildId") + .IsUnique(); + + b.ToTable("CastleSiegeGuild", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeGuildRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GuildId") + .HasColumnType("uuid"); + + b.Property("GuildName") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Marks") + .HasColumnType("integer"); + + b.Property("RegistrationOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GuildId") + .IsUnique(); + + b.ToTable("CastleSiegeGuildRegistration", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeNpcDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastleSiegeConfigurationId") + .HasColumnType("uuid"); + + b.Property("DefaultSide") + .HasColumnType("smallint"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("InstanceId") + .HasColumnType("smallint"); + + b.Property("IsPersistedToDatabase") + .HasColumnType("boolean"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("SpawnX") + .HasColumnType("smallint"); + + b.Property("SpawnY") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("CastleSiegeConfigurationId"); + + b.HasIndex("MonsterDefinitionId", "InstanceId"); + + b.ToTable("CastleSiegeNpcDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeNpcState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastleSiegeDataId") + .HasColumnType("uuid"); + + b.Property("CurrentHp") + .HasColumnType("integer"); + + b.Property("DefenseLevel") + .HasColumnType("smallint"); + + b.Property("InstanceId") + .HasColumnType("smallint"); + + b.Property("LifeLevel") + .HasColumnType("smallint"); + + b.Property("MonsterNumber") + .HasColumnType("smallint"); + + b.Property("RegenLevel") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("CastleSiegeDataId"); + + b.HasIndex("MonsterNumber", "InstanceId") + .IsUnique(); + + b.ToTable("CastleSiegeNpcState", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegePendingReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ItemDefinitionId"); + + b.ToTable("CastleSiegePendingReward", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeStateScheduleEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastleSiegeConfigurationId") + .HasColumnType("uuid"); + + b.Property("DayOfWeek") + .HasColumnType("integer"); + + b.Property("Hour") + .HasColumnType("smallint"); + + b.Property("Minute") + .HasColumnType("smallint"); + + b.Property("State") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("CastleSiegeConfigurationId"); + + b.ToTable("CastleSiegeStateScheduleEntry", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeUpgradeDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastleSiegeConfigurationId") + .HasColumnType("uuid"); + + b.Property("CastleSiegeConfigurationId1") + .HasColumnType("uuid"); + + b.Property("CastleSiegeConfigurationId2") + .HasColumnType("uuid"); + + b.Property("CastleSiegeConfigurationId3") + .HasColumnType("uuid"); + + b.Property("CastleSiegeConfigurationId4") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.Property("RequiredJewelOfGuardianCount") + .HasColumnType("integer"); + + b.Property("RequiredZen") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CastleSiegeConfigurationId"); + + b.HasIndex("CastleSiegeConfigurationId1"); + + b.HasIndex("CastleSiegeConfigurationId2"); + + b.HasIndex("CastleSiegeConfigurationId3"); + + b.HasIndex("CastleSiegeConfigurationId4"); + + b.ToTable("CastleSiegeUpgradeDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeZoneDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CastleSiegeConfigurationId") + .HasColumnType("uuid"); + + b.Property("CastleSiegeConfigurationId1") + .HasColumnType("uuid"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("CastleSiegeConfigurationId"); + + b.HasIndex("CastleSiegeConfigurationId1"); + + b.ToTable("CastleSiegeZoneDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("CharacterSlot") + .HasColumnType("smallint"); + + b.Property("CharacterStatus") + .HasColumnType("integer"); + + b.Property("CreateDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMapId") + .HasColumnType("uuid"); + + b.Property("Experience") + .HasColumnType("bigint"); + + b.Property("InventoryExtensions") + .HasColumnType("integer"); + + b.Property("InventoryId") + .HasColumnType("uuid"); + + b.Property("IsStoreOpened") + .HasColumnType("boolean"); + + b.Property("KeyConfiguration") + .HasColumnType("bytea"); + + b.Property("LevelUpPoints") + .HasColumnType("integer"); + + b.Property("MasterExperience") + .HasColumnType("bigint"); + + b.Property("MasterLevelUpPoints") + .HasColumnType("integer"); + + b.Property("MuHelperConfiguration") + .HasColumnType("bytea"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("PlayerKillCount") + .HasColumnType("integer"); + + b.Property("Pose") + .HasColumnType("smallint"); + + b.Property("PositionX") + .HasColumnType("smallint"); + + b.Property("PositionY") + .HasColumnType("smallint"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("StateRemainingSeconds") + .HasColumnType("integer"); + + b.Property("StoreName") + .HasColumnType("text"); + + b.Property("UsedFruitPoints") + .HasColumnType("integer"); + + b.Property("UsedNegFruitPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CharacterClassId"); + + b.HasIndex("CurrentMapId"); + + b.HasIndex("InventoryId") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Character", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanGetCreated") + .HasColumnType("boolean"); + + b.Property("ComboDefinitionId") + .HasColumnType("uuid"); + + b.Property("CreationAllowedFlag") + .HasColumnType("smallint"); + + b.Property("FruitCalculation") + .HasColumnType("integer"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("HomeMapId") + .HasColumnType("uuid"); + + b.Property("IsMasterClass") + .HasColumnType("boolean"); + + b.Property("LevelRequirementByCreation") + .HasColumnType("smallint"); + + b.Property("LevelWarpRequirementReductionPercent") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NextGenerationClassId") + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ComboDefinitionId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("HomeMapId"); + + b.HasIndex("NextGenerationClassId"); + + b.ToTable("CharacterClass", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterDropItemGroup", b => + { + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.HasKey("CharacterId", "DropItemGroupId"); + + b.HasIndex("DropItemGroupId"); + + b.ToTable("CharacterDropItemGroup", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActiveQuestId") + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("ClientActionPerformed") + .HasColumnType("boolean"); + + b.Property("Group") + .HasColumnType("smallint"); + + b.Property("LastFinishedQuestId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActiveQuestId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("LastFinishedQuestId"); + + b.ToTable("CharacterQuestState", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientCleanUpInterval") + .HasColumnType("interval"); + + b.Property("ClientTimeout") + .HasColumnType("interval"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaximumConnections") + .HasColumnType("integer"); + + b.Property("RoomCleanUpInterval") + .HasColumnType("interval"); + + b.Property("ServerId") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("ChatServerDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerEndpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatServerDefinitionId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("NetworkPort") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChatServerDefinitionId"); + + b.HasIndex("ClientId"); + + b.ToTable("ChatServerEndpoint", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CombinationBonusRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemOptionCombinationBonusId") + .HasColumnType("uuid"); + + b.Property("MinimumCount") + .HasColumnType("integer"); + + b.Property("OptionTypeId") + .HasColumnType("uuid"); + + b.Property("SubOptionType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemOptionCombinationBonusId"); + + b.HasIndex("OptionTypeId"); + + b.ToTable("CombinationBonusRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConfigurationUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ConfigurationUpdate", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConfigurationUpdateState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CurrentInstalledVersion") + .HasColumnType("integer"); + + b.Property("InitializationKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ConfigurationUpdateState", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConnectServerDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheckMaxConnectionsPerAddress") + .HasColumnType("boolean"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientListenerPort") + .HasColumnType("integer"); + + b.Property("CurrentPatchVersion") + .HasColumnType("bytea"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DisconnectOnUnknownPacket") + .HasColumnType("boolean"); + + b.Property("ListenerBacklog") + .HasColumnType("integer"); + + b.Property("MaxConnections") + .HasColumnType("integer"); + + b.Property("MaxConnectionsPerAddress") + .HasColumnType("integer"); + + b.Property("MaxFtpRequests") + .HasColumnType("integer"); + + b.Property("MaxIpRequests") + .HasColumnType("integer"); + + b.Property("MaxServerListRequests") + .HasColumnType("integer"); + + b.Property("MaximumReceiveSize") + .HasColumnType("smallint"); + + b.Property("PatchAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServerId") + .HasColumnType("smallint"); + + b.Property("Timeout") + .HasColumnType("interval"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ConnectServerDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConstValueAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateType") + .HasColumnType("integer"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("CharacterClassId"); + + b.HasIndex("DefinitionId"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ConstValueAttribute", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Chance") + .HasColumnType("double precision"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("ItemLevel") + .HasColumnType("smallint"); + + b.Property("ItemType") + .HasColumnType("integer"); + + b.Property("MaximumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MinimumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MonsterId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MonsterId"); + + b.ToTable("DropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroupItemDefinition", b => + { + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("DropItemGroupId", "ItemDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.ToTable("DropItemGroupItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DuelConfigurationId") + .HasColumnType("uuid"); + + b.Property("FirstPlayerGateId") + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("smallint"); + + b.Property("SecondPlayerGateId") + .HasColumnType("uuid"); + + b.Property("SpectatorsGateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DuelConfigurationId"); + + b.HasIndex("FirstPlayerGateId"); + + b.HasIndex("SecondPlayerGateId"); + + b.HasIndex("SpectatorsGateId"); + + b.ToTable("DuelArea", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntranceFee") + .HasColumnType("integer"); + + b.Property("ExitId") + .HasColumnType("uuid"); + + b.Property("MaximumScore") + .HasColumnType("integer"); + + b.Property("MaximumSpectatorsPerDuelRoom") + .HasColumnType("integer"); + + b.Property("MinimumCharacterLevel") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExitId"); + + b.ToTable("DuelConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.EnterGate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("LevelRequirement") + .HasColumnType("smallint"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("TargetGateId") + .HasColumnType("uuid"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GameMapDefinitionId"); + + b.HasIndex("TargetGateId"); + + b.ToTable("EnterGate", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("IsSpawnGate") + .HasColumnType("boolean"); + + b.Property("MapId") + .HasColumnType("uuid"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("MapId"); + + b.ToTable("ExitGate", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Friend", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Accepted") + .HasColumnType("boolean"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("FriendId") + .HasColumnType("uuid"); + + b.Property("RequestOpen") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasAlternateKey("CharacterId", "FriendId"); + + b.ToTable("Friend", "friend"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Episode") + .HasColumnType("smallint"); + + b.Property("Language") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("smallint"); + + b.Property("Serial") + .HasColumnType("bytea"); + + b.Property("Version") + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.ToTable("GameClientDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AreaSkillHitsPlayer") + .HasColumnType("boolean"); + + b.Property("CastleSiegeConfigurationId") + .HasColumnType("uuid"); + + b.Property("CharacterNameRegex") + .HasColumnType("text"); + + b.Property("ClampMoneyOnPickup") + .HasColumnType("boolean"); + + b.Property("DamagePerOneItemDurability") + .HasColumnType("double precision"); + + b.Property("DamagePerOnePetDurability") + .HasColumnType("double precision"); + + b.Property("DuelConfigurationId") + .HasColumnType("uuid"); + + b.Property("ExcellentItemDropLevelDelta") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)25); + + b.Property("ExperienceFormula") + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("if(level == 0, 0, if(level < 256, 10 * (level + 8) * (level - 1) * (level - 1), (10 * (level + 8) * (level - 1) * (level - 1)) + (1000 * (level - 247) * (level - 256) * (level - 256))))"); + + b.Property("ExperienceRate") + .HasColumnType("real"); + + b.Property("HitsPerOneItemDurability") + .HasColumnType("double precision"); + + b.Property("InfoRange") + .HasColumnType("smallint"); + + b.Property("ItemDropDuration") + .ValueGeneratedOnAdd() + .HasColumnType("interval") + .HasDefaultValue(new TimeSpan(0, 0, 1, 0, 0)); + + b.Property("LetterSendPrice") + .HasColumnType("integer"); + + b.Property("MasterExperienceFormula") + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("(505 * level * level * level) + (35278500 * level) + (228045 * level * level)"); + + b.Property("MasterExperienceRate") + .HasColumnType("real"); + + b.Property("MaximumCharactersPerAccount") + .HasColumnType("smallint"); + + b.Property("MaximumInventoryMoney") + .HasColumnType("integer"); + + b.Property("MaximumItemOptionLevelDrop") + .HasColumnType("smallint"); + + b.Property("MaximumLetters") + .HasColumnType("integer"); + + b.Property("MaximumLevel") + .HasColumnType("smallint"); + + b.Property("MaximumMasterLevel") + .HasColumnType("smallint"); + + b.Property("MaximumPartySize") + .HasColumnType("smallint"); + + b.Property("MaximumPasswordLength") + .HasColumnType("integer"); + + b.Property("MaximumVaultMoney") + .HasColumnType("integer"); + + b.Property("MinimumMonsterLevelForMasterExperience") + .HasColumnType("smallint"); + + b.Property("PreventExperienceOverflow") + .HasColumnType("boolean"); + + b.Property("RecoveryInterval") + .HasColumnType("integer"); + + b.Property("ShouldDropMoney") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CastleSiegeConfigurationId") + .IsUnique(); + + b.HasIndex("DuelConfigurationId") + .IsUnique(); + + b.ToTable("GameConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BattleZoneId") + .HasColumnType("uuid"); + + b.Property("Discriminator") + .HasColumnType("integer"); + + b.Property("ExpMultiplier") + .HasColumnType("double precision"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("SafezoneMapId") + .HasColumnType("uuid"); + + b.Property("TerrainData") + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("BattleZoneId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("SafezoneMapId"); + + b.ToTable("GameMapDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinitionDropItemGroup", b => + { + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.HasKey("GameMapDefinitionId", "DropItemGroupId"); + + b.HasIndex("DropItemGroupId"); + + b.ToTable("GameMapDefinitionDropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MaximumPlayers") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("GameServerConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfigurationGameMapDefinition", b => + { + b.Property("GameServerConfigurationId") + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("GameServerConfigurationId", "GameMapDefinitionId"); + + b.HasIndex("GameMapDefinitionId"); + + b.ToTable("GameServerConfigurationGameMapDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExperienceRate") + .HasColumnType("real"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("PvpEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ServerConfigurationId") + .HasColumnType("uuid"); + + b.Property("ServerID") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("ServerConfigurationId"); + + b.ToTable("GameServerDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerEndpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AlternativePublishedPort") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("GameServerDefinitionId") + .HasColumnType("uuid"); + + b.Property("NetworkPort") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("GameServerDefinitionId"); + + b.ToTable("GameServerEndpoint", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllianceGuildId") + .HasColumnType("uuid"); + + b.Property("HostilityId") + .HasColumnType("uuid"); + + b.Property("Logo") + .HasColumnType("bytea"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Notice") + .HasColumnType("text"); + + b.Property("Score") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AllianceGuildId"); + + b.HasIndex("HostilityId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Guild", "guild"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GuildMember", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GuildId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.ToTable("GuildMember", "guild"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemOptionDefinitionId") + .HasColumnType("uuid"); + + b.Property("LevelType") + .HasColumnType("integer"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("OptionTypeId") + .HasColumnType("uuid"); + + b.Property("PowerUpDefinitionId") + .HasColumnType("uuid"); + + b.Property("SubOptionType") + .HasColumnType("integer"); + + b.Property("Weight") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ItemOptionDefinitionId"); + + b.HasIndex("OptionTypeId"); + + b.HasIndex("PowerUpDefinitionId") + .IsUnique(); + + b.ToTable("IncreasableItemOption", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("Durability") + .HasColumnType("double precision"); + + b.Property("HasSkill") + .HasColumnType("boolean"); + + b.Property("ItemSlot") + .HasColumnType("smallint"); + + b.Property("ItemStorageId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.Property("PetExperience") + .HasColumnType("integer"); + + b.Property("SocketCount") + .HasColumnType("integer"); + + b.Property("StorePrice") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId"); + + b.HasIndex("ItemStorageId"); + + b.ToTable("Item", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppearanceDataId") + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemSlot") + .HasColumnType("smallint"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("AppearanceDataId"); + + b.HasIndex("DefinitionId"); + + b.ToTable("ItemAppearance", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearanceItemOptionType", b => + { + b.Property("ItemAppearanceId") + .HasColumnType("uuid"); + + b.Property("ItemOptionTypeId") + .HasColumnType("uuid"); + + b.HasKey("ItemAppearanceId", "ItemOptionTypeId"); + + b.HasIndex("ItemOptionTypeId"); + + b.ToTable("ItemAppearanceItemOptionType", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemBasePowerUpDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateType") + .HasColumnType("integer"); + + b.Property("BaseValue") + .HasColumnType("real"); + + b.Property("BonusPerLevelTableId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BonusPerLevelTableId"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("ItemBasePowerUpDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCrafting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemCraftingHandlerClassName") + .IsRequired() + .HasColumnType("text"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("SimpleCraftingSettingsId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MonsterDefinitionId"); + + b.HasIndex("SimpleCraftingSettingsId") + .IsUnique(); + + b.ToTable("ItemCrafting", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddPercentage") + .HasColumnType("smallint"); + + b.Property("FailResult") + .HasColumnType("integer"); + + b.Property("MaximumAmount") + .HasColumnType("smallint"); + + b.Property("MaximumItemLevel") + .HasColumnType("smallint"); + + b.Property("MinimumAmount") + .HasColumnType("smallint"); + + b.Property("MinimumItemLevel") + .HasColumnType("smallint"); + + b.Property("NpcPriceDivisor") + .HasColumnType("integer"); + + b.Property("Reference") + .HasColumnType("smallint"); + + b.Property("SimpleCraftingSettingsId") + .HasColumnType("uuid"); + + b.Property("SuccessResult") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SimpleCraftingSettingsId"); + + b.ToTable("ItemCraftingRequiredItem", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemDefinition", b => + { + b.Property("ItemCraftingRequiredItemId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("ItemCraftingRequiredItemId", "ItemDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.ToTable("ItemCraftingRequiredItemItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemOptionType", b => + { + b.Property("ItemCraftingRequiredItemId") + .HasColumnType("uuid"); + + b.Property("ItemOptionTypeId") + .HasColumnType("uuid"); + + b.HasKey("ItemCraftingRequiredItemId", "ItemOptionTypeId"); + + b.HasIndex("ItemOptionTypeId"); + + b.ToTable("ItemCraftingRequiredItemItemOptionType", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingResultItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddLevel") + .HasColumnType("smallint"); + + b.Property("Durability") + .HasColumnType("smallint"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("RandomMaximumLevel") + .HasColumnType("smallint"); + + b.Property("RandomMinimumLevel") + .HasColumnType("smallint"); + + b.Property("Reference") + .HasColumnType("smallint"); + + b.Property("SimpleCraftingSettingsId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("SimpleCraftingSettingsId"); + + b.ToTable("ItemCraftingResultItem", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumeEffectId") + .HasColumnType("uuid"); + + b.Property("DropLevel") + .HasColumnType("smallint"); + + b.Property("DropsFromMonsters") + .HasColumnType("boolean"); + + b.Property("Durability") + .HasColumnType("smallint"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Group") + .HasColumnType("smallint"); + + b.Property("Height") + .HasColumnType("smallint"); + + b.Property("IsAmmunition") + .HasColumnType("boolean"); + + b.Property("IsBoundToCharacter") + .HasColumnType("boolean"); + + b.Property("IsQuestItem") + .HasColumnType("boolean"); + + b.Property("ItemSlotId") + .HasColumnType("uuid"); + + b.Property("MaximumDropLevel") + .HasColumnType("smallint"); + + b.Property("MaximumItemLevel") + .HasColumnType("smallint"); + + b.Property("MaximumSockets") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("PetExperienceFormula") + .HasColumnType("text"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("StorageLimitPerCharacter") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("integer"); + + b.Property("Width") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ConsumeEffectId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("ItemSlotId"); + + b.HasIndex("SkillId"); + + b.ToTable("ItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionCharacterClass", b => + { + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.HasKey("ItemDefinitionId", "CharacterClassId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("ItemDefinitionCharacterClass", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemOptionDefinition", b => + { + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemOptionDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("ItemDefinitionId", "ItemOptionDefinitionId"); + + b.HasIndex("ItemOptionDefinitionId"); + + b.ToTable("ItemDefinitionItemOptionDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemSetGroup", b => + { + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemSetGroupId") + .HasColumnType("uuid"); + + b.HasKey("ItemDefinitionId", "ItemSetGroupId"); + + b.HasIndex("ItemSetGroupId"); + + b.ToTable("ItemDefinitionItemSetGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Chance") + .HasColumnType("double precision"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DropEffect") + .HasColumnType("integer"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemLevel") + .HasColumnType("smallint"); + + b.Property("ItemType") + .HasColumnType("integer"); + + b.Property("MaximumLevel") + .HasColumnType("smallint"); + + b.Property("MaximumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MinimumLevel") + .HasColumnType("smallint"); + + b.Property("MinimumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MoneyAmount") + .HasColumnType("integer"); + + b.Property("MonsterId") + .HasColumnType("uuid"); + + b.Property("RequiredCharacterLevel") + .HasColumnType("smallint"); + + b.Property("SourceItemLevel") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("MonsterId"); + + b.ToTable("ItemDropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroupItemDefinition", b => + { + b.Property("ItemDropItemGroupId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("ItemDropItemGroupId", "ItemDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.ToTable("ItemDropItemGroupItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemItemOfItemSet", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemOfItemSetId") + .HasColumnType("uuid"); + + b.HasKey("ItemId", "ItemOfItemSetId"); + + b.HasIndex("ItemOfItemSetId"); + + b.ToTable("ItemItemOfItemSet", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemLevelBonusTable", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOfItemSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AncientSetDiscriminator") + .HasColumnType("integer"); + + b.Property("BonusOptionId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemSetGroupId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BonusOptionId"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("ItemSetGroupId"); + + b.ToTable("ItemOfItemSet", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("OptionTypeId") + .HasColumnType("uuid"); + + b.Property("PowerUpDefinitionId") + .HasColumnType("uuid"); + + b.Property("SubOptionType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OptionTypeId"); + + b.HasIndex("PowerUpDefinitionId") + .IsUnique(); + + b.ToTable("ItemOption", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliesMultipleTimes") + .HasColumnType("boolean"); + + b.Property("BonusId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BonusId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemOptionCombinationBonus", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddChance") + .HasColumnType("real"); + + b.Property("AddsRandomly") + .HasColumnType("boolean"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MaximumOptionsPerItem") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemOptionDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemOptionId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.HasIndex("ItemOptionId"); + + b.ToTable("ItemOptionLink", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionOfLevel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IncreasableItemOptionId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("PowerUpDefinitionId") + .HasColumnType("uuid"); + + b.Property("RequiredItemLevel") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IncreasableItemOptionId"); + + b.HasIndex("PowerUpDefinitionId") + .IsUnique(); + + b.ToTable("ItemOptionOfLevel", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemOptionType", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AlwaysApplies") + .HasColumnType("boolean"); + + b.Property("CountDistinct") + .HasColumnType("boolean"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MinimumItemCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OptionsId") + .HasColumnType("uuid"); + + b.Property("SetLevel") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("OptionsId"); + + b.ToTable("ItemSetGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSlotType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("RawItemSlots") + .HasColumnType("text") + .HasColumnName("ItemSlots") + .HasJsonPropertyName("itemSlots"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemSlotType", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Money") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ItemStorage", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.JewelMix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MixedJewelId") + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("SingleJewelId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MixedJewelId"); + + b.HasIndex("SingleJewelId"); + + b.ToTable("JewelMix", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterBody", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Animation") + .HasColumnType("smallint"); + + b.Property("HeaderId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rotation") + .HasColumnType("smallint"); + + b.Property("SenderAppearanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HeaderId"); + + b.HasIndex("SenderAppearanceId") + .IsUnique(); + + b.ToTable("LetterBody", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterHeader", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("LetterDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadFlag") + .HasColumnType("boolean"); + + b.Property("ReceiverId") + .HasColumnType("uuid"); + + b.Property("SenderName") + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ReceiverId"); + + b.ToTable("LetterHeader", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LevelBonus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalValue") + .HasColumnType("real"); + + b.Property("ItemLevelBonusTableId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemLevelBonusTableId"); + + b.ToTable("LevelBonus", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChanceId") + .HasColumnType("uuid"); + + b.Property("ChancePvpId") + .HasColumnType("uuid"); + + b.Property("DurationDependsOnTargetLevel") + .HasColumnType("boolean"); + + b.Property("DurationId") + .HasColumnType("uuid"); + + b.Property("DurationPvpId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("InformObservers") + .HasColumnType("boolean"); + + b.Property("MonsterTargetLevelDivisor") + .HasColumnType("real"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("PlayerTargetLevelDivisor") + .HasColumnType("real"); + + b.Property("SendDuration") + .HasColumnType("boolean"); + + b.Property("StopByDeath") + .HasColumnType("boolean"); + + b.Property("SubType") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ChanceId") + .IsUnique(); + + b.HasIndex("ChancePvpId") + .IsUnique(); + + b.HasIndex("DurationId") + .IsUnique(); + + b.HasIndex("DurationPvpId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("MagicEffectDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Aggregation") + .HasColumnType("integer"); + + b.Property("DisplayValueFormula") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtendsDuration") + .HasColumnType("boolean"); + + b.Property("MaximumLevel") + .HasColumnType("smallint"); + + b.Property("MinimumLevel") + .HasColumnType("smallint"); + + b.Property("Rank") + .HasColumnType("smallint"); + + b.Property("ReplacedSkillId") + .HasColumnType("uuid"); + + b.Property("RootId") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.Property("ValueFormula") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedSkillId"); + + b.HasIndex("RootId"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("MasterSkillDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinitionSkill", b => + { + b.Property("MasterSkillDefinitionId") + .HasColumnType("uuid"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.HasKey("MasterSkillDefinitionId", "SkillId"); + + b.HasIndex("SkillId"); + + b.ToTable("MasterSkillDefinitionSkill", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillRoot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("MasterSkillRoot", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("MiniGameDefinitionId") + .HasColumnType("uuid"); + + b.Property("MinimumTargetLevel") + .HasColumnType("smallint"); + + b.Property("MultiplyKillsByPlayers") + .HasColumnType("boolean"); + + b.Property("NumberOfKills") + .HasColumnType("smallint"); + + b.Property("SpawnAreaId") + .HasColumnType("uuid"); + + b.Property("Target") + .HasColumnType("integer"); + + b.Property("TargetDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MiniGameDefinitionId"); + + b.HasIndex("SpawnAreaId") + .IsUnique(); + + b.HasIndex("TargetDefinitionId"); + + b.ToTable("MiniGameChangeEvent", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowParty") + .HasColumnType("boolean"); + + b.Property("ArePlayerKillersAllowedToEnter") + .HasColumnType("boolean"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnterDuration") + .HasColumnType("interval"); + + b.Property("EntranceFee") + .HasColumnType("integer"); + + b.Property("EntranceId") + .HasColumnType("uuid"); + + b.Property("ExitDuration") + .HasColumnType("interval"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("GameDuration") + .HasColumnType("interval"); + + b.Property("GameLevel") + .HasColumnType("smallint"); + + b.Property("MapCreationPolicy") + .HasColumnType("integer"); + + b.Property("MaximumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MaximumPlayerCount") + .HasColumnType("integer"); + + b.Property("MaximumSpecialCharacterLevel") + .HasColumnType("integer"); + + b.Property("MinimumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MinimumSpecialCharacterLevel") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequiresMasterClass") + .HasColumnType("boolean"); + + b.Property("SaveRankingStatistics") + .HasColumnType("boolean"); + + b.Property("TicketItemId") + .HasColumnType("uuid"); + + b.Property("TicketItemLevel") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntranceId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("TicketItemId"); + + b.ToTable("MiniGameDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameRankingEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("GameInstanceId") + .HasColumnType("uuid"); + + b.Property("MiniGameId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("MiniGameId"); + + b.ToTable("MiniGameRankingEntry", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemRewardId") + .HasColumnType("uuid"); + + b.Property("MiniGameDefinitionId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RequiredKillId") + .HasColumnType("uuid"); + + b.Property("RequiredSuccess") + .HasColumnType("integer"); + + b.Property("RewardAmount") + .HasColumnType("integer"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemRewardId"); + + b.HasIndex("MiniGameDefinitionId"); + + b.HasIndex("RequiredKillId"); + + b.ToTable("MiniGameReward", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameSpawnWave", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndTime") + .HasColumnType("interval"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("MiniGameDefinitionId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("interval"); + + b.Property("WaveNumber") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("MiniGameDefinitionId"); + + b.ToTable("MiniGameSpawnWave", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameTerrainChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EndX") + .HasColumnType("smallint"); + + b.Property("EndY") + .HasColumnType("smallint"); + + b.Property("IsClientUpdateRequired") + .HasColumnType("boolean"); + + b.Property("MiniGameChangeEventId") + .HasColumnType("uuid"); + + b.Property("SetTerrainAttribute") + .HasColumnType("boolean"); + + b.Property("StartX") + .HasColumnType("smallint"); + + b.Property("StartY") + .HasColumnType("smallint"); + + b.Property("TerrainAttribute") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MiniGameChangeEventId"); + + b.ToTable("MiniGameTerrainChange", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeDefinitionId") + .HasColumnType("uuid"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("AttributeDefinitionId"); + + b.HasIndex("MonsterDefinitionId"); + + b.ToTable("MonsterAttribute", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttackDelay") + .HasColumnType("interval"); + + b.Property("AttackRange") + .HasColumnType("smallint"); + + b.Property("AttackSkillId") + .HasColumnType("uuid"); + + b.Property("Attribute") + .HasColumnType("smallint"); + + b.Property("Designation") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("IntelligenceTypeName") + .HasColumnType("text"); + + b.Property("MerchantStoreId") + .HasColumnType("uuid"); + + b.Property("MoveDelay") + .HasColumnType("interval"); + + b.Property("MoveRange") + .HasColumnType("smallint"); + + b.Property("NpcWindow") + .HasColumnType("integer"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("NumberOfMaximumItemDrops") + .HasColumnType("integer"); + + b.Property("ObjectKind") + .HasColumnType("integer"); + + b.Property("RespawnDelay") + .HasColumnType("interval"); + + b.Property("ViewRange") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("AttackSkillId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MerchantStoreId") + .IsUnique(); + + b.ToTable("MonsterDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinitionDropItemGroup", b => + { + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.HasKey("MonsterDefinitionId", "DropItemGroupId"); + + b.HasIndex("DropItemGroupId"); + + b.ToTable("MonsterDefinitionDropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterSpawnArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("GameMapId") + .HasColumnType("uuid"); + + b.Property("MaximumHealthOverride") + .HasColumnType("integer"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("smallint"); + + b.Property("SpawnTrigger") + .HasColumnType("integer"); + + b.Property("WaveNumber") + .HasColumnType("smallint"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GameMapId"); + + b.HasIndex("MonsterDefinitionId"); + + b.ToTable("MonsterSpawnArea", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PlugInConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CustomConfiguration") + .HasColumnType("text"); + + b.Property("CustomPlugInSource") + .HasColumnType("text"); + + b.Property("ExternalAssemblyName") + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("TypeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("PlugInConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BoostId") + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("MagicEffectDefinitionId") + .HasColumnType("uuid"); + + b.Property("MagicEffectDefinitionId1") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BoostId") + .IsUnique(); + + b.HasIndex("GameMapDefinitionId"); + + b.HasIndex("MagicEffectDefinitionId"); + + b.HasIndex("MagicEffectDefinitionId1"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("PowerUpDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateType") + .HasColumnType("integer"); + + b.Property("MaximumValue") + .HasColumnType("real"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.ToTable("PowerUpDefinitionValue", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Group") + .HasColumnType("smallint"); + + b.Property("MaximumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MinimumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("QualifiedCharacterId") + .HasColumnType("uuid"); + + b.Property("QuestGiverId") + .HasColumnType("uuid"); + + b.Property("RefuseNumber") + .HasColumnType("smallint"); + + b.Property("Repeatable") + .HasColumnType("boolean"); + + b.Property("RequiredStartMoney") + .HasColumnType("integer"); + + b.Property("RequiresClientAction") + .HasColumnType("boolean"); + + b.Property("StartingNumber") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("MonsterDefinitionId"); + + b.HasIndex("QualifiedCharacterId"); + + b.HasIndex("QuestGiverId"); + + b.ToTable("QuestDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestItemRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("MinimumNumber") + .HasColumnType("integer"); + + b.Property("QuestDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DropItemGroupId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuestDefinitionId"); + + b.ToTable("QuestItemRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MinimumNumber") + .HasColumnType("integer"); + + b.Property("MonsterId") + .HasColumnType("uuid"); + + b.Property("QuestDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MonsterId"); + + b.HasIndex("QuestDefinitionId"); + + b.ToTable("QuestMonsterKillRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirementState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterQuestStateId") + .HasColumnType("uuid"); + + b.Property("KillCount") + .HasColumnType("integer"); + + b.Property("RequirementId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CharacterQuestStateId"); + + b.HasIndex("RequirementId"); + + b.ToTable("QuestMonsterKillRequirementState", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeRewardId") + .HasColumnType("uuid"); + + b.Property("ItemRewardId") + .HasColumnType("uuid"); + + b.Property("QuestDefinitionId") + .HasColumnType("uuid"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.Property("SkillRewardId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AttributeRewardId"); + + b.HasIndex("ItemRewardId") + .IsUnique(); + + b.HasIndex("QuestDefinitionId"); + + b.HasIndex("SkillRewardId"); + + b.ToTable("QuestReward", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("Rectangle", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MaximumSuccessPercent") + .HasColumnType("smallint"); + + b.Property("Money") + .HasColumnType("integer"); + + b.Property("MoneyPerFinalSuccessPercentage") + .HasColumnType("integer"); + + b.Property("MultipleAllowed") + .HasColumnType("boolean"); + + b.Property("NpcPriceDivisor") + .HasColumnType("integer"); + + b.Property("ResultItemExcellentOptionChance") + .HasColumnType("smallint"); + + b.Property("ResultItemLuckOptionChance") + .HasColumnType("smallint"); + + b.Property("ResultItemMaxExcOptionCount") + .HasColumnType("smallint"); + + b.Property("ResultItemSelect") + .HasColumnType("integer"); + + b.Property("ResultItemSkillChance") + .HasColumnType("smallint"); + + b.Property("SuccessPercent") + .HasColumnType("smallint"); + + b.Property("SuccessPercentageAdditionForAncientItem") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForExcellentItem") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForGuardianItem") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForLuck") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForSocketItem") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("SimpleCraftingSettings", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AreaSkillSettingsId") + .HasColumnType("uuid"); + + b.Property("AttackDamage") + .HasColumnType("integer"); + + b.Property("DamageType") + .HasColumnType("integer"); + + b.Property("ElementalModifierTargetId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("ImplicitTargetRange") + .HasColumnType("smallint"); + + b.Property("MagicEffectDefId") + .HasColumnType("uuid"); + + b.Property("MasterDefinitionId") + .HasColumnType("uuid"); + + b.Property("MovesTarget") + .HasColumnType("boolean"); + + b.Property("MovesToTarget") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("NumberOfHitsPerAttack") + .HasColumnType("smallint"); + + b.Property("Range") + .HasColumnType("smallint"); + + b.Property("SkillType") + .HasColumnType("integer"); + + b.Property("SkipElementalModifier") + .HasColumnType("boolean"); + + b.Property("Target") + .HasColumnType("integer"); + + b.Property("TargetRestriction") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AreaSkillSettingsId") + .IsUnique(); + + b.HasIndex("ElementalModifierTargetId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MagicEffectDefId"); + + b.HasIndex("MasterDefinitionId") + .IsUnique(); + + b.ToTable("Skill", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillCharacterClass", b => + { + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.HasKey("SkillId", "CharacterClassId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("SkillCharacterClass", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MaximumCompletionTime") + .HasColumnType("interval"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("SkillComboDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsFinalStep") + .HasColumnType("boolean"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("SkillComboDefinitionId") + .HasColumnType("uuid"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SkillComboDefinitionId"); + + b.HasIndex("SkillId"); + + b.ToTable("SkillComboStep", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("SkillId"); + + b.ToTable("SkillEntry", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("DefinitionId"); + + b.ToTable("StatAttribute", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttributeDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeId") + .HasColumnType("uuid"); + + b.Property("BaseValue") + .HasColumnType("real"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("IncreasableByPlayer") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AttributeId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("StatAttributeDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SystemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AutoStart") + .HasColumnType("boolean"); + + b.Property("AutoUpdateSchema") + .HasColumnType("boolean"); + + b.Property("IpResolver") + .HasColumnType("integer"); + + b.Property("IpResolverParameter") + .HasColumnType("text"); + + b.Property("NetworkAnalyzerLiveBufferSize") + .HasColumnType("integer"); + + b.Property("NetworkObservationArchivePath") + .HasColumnType("text"); + + b.Property("NetworkObservationMaxSessionSizeMb") + .HasColumnType("integer"); + + b.Property("NetworkObservationMaxTotalSizeMb") + .HasColumnType("integer"); + + b.Property("NetworkObservationRetentionDays") + .HasColumnType("integer"); + + b.Property("ReadConsoleInput") + .HasColumnType("boolean"); + + b.Property("TimeZoneId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("SystemConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.WarpInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Costs") + .HasColumnType("integer"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("GateId") + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("LevelRequirement") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("GateId"); + + b.ToTable("WarpInfo", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawVault") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", "VaultId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawVault"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AccountCharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", "Account") + .WithMany("JoinedUnlockedCharacterClasses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("CharacterClass"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawCharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId"); + + b.Navigation("RawCharacterClass"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawAttributes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRelationship", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", null) + .WithMany("RawAttributeCombinations") + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawGlobalAttributeCombinations") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawInputAttribute") + .WithMany() + .HasForeignKey("InputAttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawOperandAttribute") + .WithMany() + .HasForeignKey("OperandAttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", null) + .WithMany("RawRelatedValues") + .HasForeignKey("PowerUpDefinitionValueId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", null) + .WithMany("RawAttributeRelationships") + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawInputAttribute"); + + b.Navigation("RawOperandAttribute"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttribute") + .WithMany() + .HasForeignKey("AttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", null) + .WithMany("RawMapRequirements") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", null) + .WithMany("RawRequirements") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", null) + .WithMany("RawConsumeRequirements") + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", null) + .WithMany("RawRequirements") + .HasForeignKey("SkillId1") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", "RawGround") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "GroundId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", "RawLeftGoal") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "LeftGoalId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", "RawRightGoal") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "RightGoalId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawGround"); + + b.Navigation("RawLeftGoal"); + + b.Navigation("RawRightGoal"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Buff", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "RawMagicEffectDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Buff", "MagicEffectDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", null) + .WithMany("RawBuffs") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawMagicEffectDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeZoneDefinition", "RawAttackRespawnArea") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", "AttackRespawnAreaId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawCastleSiegeMapDefinition") + .WithMany() + .HasForeignKey("CastleSiegeMapDefinitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeZoneDefinition", "RawDefenseRespawnArea") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", "DefenseRespawnAreaId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawLandOfTrialsMapDefinition") + .WithMany() + .HasForeignKey("LandOfTrialsMapDefinitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawRewardItemDefinition") + .WithMany() + .HasForeignKey("RewardItemDefinitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawSignOfLordItemDefinition") + .WithMany() + .HasForeignKey("SignOfLordItemDefinitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("RawAttackRespawnArea"); + + b.Navigation("RawCastleSiegeMapDefinition"); + + b.Navigation("RawDefenseRespawnArea"); + + b.Navigation("RawLandOfTrialsMapDefinition"); + + b.Navigation("RawRewardItemDefinition"); + + b.Navigation("RawSignOfLordItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeData", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", null) + .WithMany() + .HasForeignKey("OwnerGuildId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeGuild", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeData", null) + .WithMany("RawGuilds") + .HasForeignKey("CastleSiegeDataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", null) + .WithMany() + .HasForeignKey("GuildId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeGuildRegistration", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", null) + .WithMany() + .HasForeignKey("GuildId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeNpcDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null) + .WithMany("RawNpcDefinitions") + .HasForeignKey("CastleSiegeConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonsterDefinition") + .WithMany() + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("RawMonsterDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeNpcState", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeData", null) + .WithMany("RawNpcStates") + .HasForeignKey("CastleSiegeDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegePendingReward", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", null) + .WithMany() + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeStateScheduleEntry", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null) + .WithMany("RawStateSchedule") + .HasForeignKey("CastleSiegeConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeUpgradeDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null) + .WithMany("RawGateDefenseUpgrades") + .HasForeignKey("CastleSiegeConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null) + .WithMany("RawGateLifeUpgrades") + .HasForeignKey("CastleSiegeConfigurationId1") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~1"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null) + .WithMany("RawStatueDefenseUpgrades") + .HasForeignKey("CastleSiegeConfigurationId2") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~2"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null) + .WithMany("RawStatueLifeUpgrades") + .HasForeignKey("CastleSiegeConfigurationId3") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~3"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null) + .WithMany("RawStatueRegenUpgrades") + .HasForeignKey("CastleSiegeConfigurationId4") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_CastleSiegeUpgradeDefinition_CastleSiegeConfiguration_Cast~4"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeZoneDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null) + .WithMany("RawAttackMachineZones") + .HasForeignKey("CastleSiegeConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", null) + .WithMany("RawDefenseMachineZones") + .HasForeignKey("CastleSiegeConfigurationId1") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_CastleSiegeZoneDefinition_CastleSiegeConfiguration_CastleS~1"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", null) + .WithMany("RawCharacters") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawCharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawCurrentMap") + .WithMany() + .HasForeignKey("CurrentMapId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawInventory") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "InventoryId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawCharacterClass"); + + b.Navigation("RawCurrentMap"); + + b.Navigation("RawInventory"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", "RawComboDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "ComboDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawCharacterClasses") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawHomeMap") + .WithMany() + .HasForeignKey("HomeMapId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawNextGenerationClass") + .WithMany() + .HasForeignKey("NextGenerationClassId"); + + b.Navigation("RawComboDefinition"); + + b.Navigation("RawHomeMap"); + + b.Navigation("RawNextGenerationClass"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "Character") + .WithMany("JoinedDropItemGroups") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("DropItemGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", "RawActiveQuest") + .WithMany() + .HasForeignKey("ActiveQuestId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany("RawQuestStates") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", "RawLastFinishedQuest") + .WithMany() + .HasForeignKey("LastFinishedQuestId"); + + b.Navigation("RawActiveQuest"); + + b.Navigation("RawLastFinishedQuest"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerEndpoint", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", null) + .WithMany("RawEndpoints") + .HasForeignKey("ChatServerDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", "RawClient") + .WithMany() + .HasForeignKey("ClientId"); + + b.Navigation("RawClient"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CombinationBonusRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", null) + .WithMany("RawRequirements") + .HasForeignKey("ItemOptionCombinationBonusId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "RawOptionType") + .WithMany() + .HasForeignKey("OptionTypeId"); + + b.Navigation("RawOptionType"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConnectServerDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", "RawClient") + .WithMany() + .HasForeignKey("ClientId"); + + b.Navigation("RawClient"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConstValueAttribute", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany("RawBaseAttributeValues") + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "GameConfiguration") + .WithMany("RawGlobalBaseAttributeValues") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("CharacterClass"); + + b.Navigation("GameConfiguration"); + + b.Navigation("RawDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawDropItemGroups") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonster") + .WithMany() + .HasForeignKey("MonsterId"); + + b.Navigation("RawMonster"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroupItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany("JoinedPossibleItems") + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DropItemGroup"); + + b.Navigation("ItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelArea", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", null) + .WithMany("RawDuelAreas") + .HasForeignKey("DuelConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawFirstPlayerGate") + .WithMany() + .HasForeignKey("FirstPlayerGateId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawSecondPlayerGate") + .WithMany() + .HasForeignKey("SecondPlayerGateId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawSpectatorsGate") + .WithMany() + .HasForeignKey("SpectatorsGateId"); + + b.Navigation("RawFirstPlayerGate"); + + b.Navigation("RawSecondPlayerGate"); + + b.Navigation("RawSpectatorsGate"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawExit") + .WithMany() + .HasForeignKey("ExitId"); + + b.Navigation("RawExit"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.EnterGate", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", null) + .WithMany("RawEnterGates") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawTargetGate") + .WithMany() + .HasForeignKey("TargetGateId"); + + b.Navigation("RawTargetGate"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawMap") + .WithMany("RawExitGates") + .HasForeignKey("MapId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawMap"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", "RawCastleSiegeConfiguration") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "CastleSiegeConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", "RawDuelConfiguration") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "DuelConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawCastleSiegeConfiguration"); + + b.Navigation("RawDuelConfiguration"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "RawBattleZone") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "BattleZoneId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMaps") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawSafezoneMap") + .WithMany() + .HasForeignKey("SafezoneMapId"); + + b.Navigation("RawBattleZone"); + + b.Navigation("RawSafezoneMap"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinitionDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "GameMapDefinition") + .WithMany("JoinedDropItemGroups") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DropItemGroup"); + + b.Navigation("GameMapDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfigurationGameMapDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "GameMapDefinition") + .WithMany() + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", "GameServerConfiguration") + .WithMany("JoinedMaps") + .HasForeignKey("GameServerConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GameMapDefinition"); + + b.Navigation("GameServerConfiguration"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "RawGameConfiguration") + .WithMany() + .HasForeignKey("GameConfigurationId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", "RawServerConfiguration") + .WithMany() + .HasForeignKey("ServerConfigurationId"); + + b.Navigation("RawGameConfiguration"); + + b.Navigation("RawServerConfiguration"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerEndpoint", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", "RawClient") + .WithMany() + .HasForeignKey("ClientId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", null) + .WithMany("RawEndpoints") + .HasForeignKey("GameServerDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawClient"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", "RawAllianceGuild") + .WithMany() + .HasForeignKey("AllianceGuildId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", "RawHostility") + .WithMany() + .HasForeignKey("HostilityId"); + + b.Navigation("RawAllianceGuild"); + + b.Navigation("RawHostility"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GuildMember", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", null) + .WithMany("RawMembers") + .HasForeignKey("GuildId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "Character") + .WithMany() + .HasForeignKey("Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", null) + .WithMany("RawPossibleOptions") + .HasForeignKey("ItemOptionDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "RawOptionType") + .WithMany() + .HasForeignKey("OptionTypeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawPowerUpDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", "PowerUpDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawOptionType"); + + b.Navigation("RawPowerUpDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawItemStorage") + .WithMany("RawItems") + .HasForeignKey("ItemStorageId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawDefinition"); + + b.Navigation("RawItemStorage"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", null) + .WithMany("RawEquippedItems") + .HasForeignKey("AppearanceDataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.Navigation("RawDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearanceItemOptionType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", "ItemAppearance") + .WithMany("JoinedVisibleOptions") + .HasForeignKey("ItemAppearanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "ItemOptionType") + .WithMany() + .HasForeignKey("ItemOptionTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemAppearance"); + + b.Navigation("ItemOptionType"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemBasePowerUpDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", "RawBonusPerLevelTable") + .WithMany() + .HasForeignKey("BonusPerLevelTableId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", null) + .WithMany("RawBasePowerUpAttributes") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawBonusPerLevelTable"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCrafting", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", null) + .WithMany("RawItemCraftings") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", "RawSimpleCraftingSettings") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCrafting", "SimpleCraftingSettingsId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawSimpleCraftingSettings"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", null) + .WithMany("RawRequiredItems") + .HasForeignKey("SimpleCraftingSettingsId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", "ItemCraftingRequiredItem") + .WithMany("JoinedPossibleItems") + .HasForeignKey("ItemCraftingRequiredItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemCraftingRequiredItem"); + + b.Navigation("ItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemOptionType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", "ItemCraftingRequiredItem") + .WithMany("JoinedRequiredItemOptions") + .HasForeignKey("ItemCraftingRequiredItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "ItemOptionType") + .WithMany() + .HasForeignKey("ItemOptionTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemCraftingRequiredItem"); + + b.Navigation("ItemOptionType"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingResultItem", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", null) + .WithMany("RawResultItems") + .HasForeignKey("SimpleCraftingSettingsId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "RawConsumeEffect") + .WithMany() + .HasForeignKey("ConsumeEffectId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItems") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSlotType", "RawItemSlot") + .WithMany() + .HasForeignKey("ItemSlotId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkill") + .WithMany() + .HasForeignKey("SkillId"); + + b.Navigation("RawConsumeEffect"); + + b.Navigation("RawItemSlot"); + + b.Navigation("RawSkill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionCharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany("JoinedQualifiedCharacters") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CharacterClass"); + + b.Navigation("ItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemOptionDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany("JoinedPossibleItemOptions") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", "ItemOptionDefinition") + .WithMany() + .HasForeignKey("ItemOptionDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemDefinition"); + + b.Navigation("ItemOptionDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemSetGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany("JoinedPossibleItemSetGroups") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", "ItemSetGroup") + .WithMany() + .HasForeignKey("ItemSetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemDefinition"); + + b.Navigation("ItemSetGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", null) + .WithMany("RawDropItems") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonster") + .WithMany() + .HasForeignKey("MonsterId"); + + b.Navigation("RawMonster"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroupItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", "ItemDropItemGroup") + .WithMany("JoinedPossibleItems") + .HasForeignKey("ItemDropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemDefinition"); + + b.Navigation("ItemDropItemGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemItemOfItemSet", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", "Item") + .WithMany("JoinedItemSetGroups") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOfItemSet", "ItemOfItemSet") + .WithMany() + .HasForeignKey("ItemOfItemSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemOfItemSet"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemLevelBonusTables") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOfItemSet", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", "RawBonusOption") + .WithMany() + .HasForeignKey("BonusOptionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", "RawItemSetGroup") + .WithMany("RawItems") + .HasForeignKey("ItemSetGroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawBonusOption"); + + b.Navigation("RawItemDefinition"); + + b.Navigation("RawItemSetGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOption", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "RawOptionType") + .WithMany() + .HasForeignKey("OptionTypeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawPowerUpDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOption", "PowerUpDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawOptionType"); + + b.Navigation("RawPowerUpDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawBonus") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", "BonusId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemOptionCombinationBonuses") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawBonus"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemOptions") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionLink", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", null) + .WithMany("RawItemOptions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", "RawItemOption") + .WithMany() + .HasForeignKey("ItemOptionId"); + + b.Navigation("RawItemOption"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionOfLevel", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", null) + .WithMany("RawLevelDependentOptions") + .HasForeignKey("IncreasableItemOptionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawPowerUpDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionOfLevel", "PowerUpDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawPowerUpDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemOptionTypes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemSetGroups") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", "RawOptions") + .WithMany() + .HasForeignKey("OptionsId"); + + b.Navigation("RawOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSlotType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemSlotTypes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.JewelMix", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawJewelMixes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawMixedJewel") + .WithMany() + .HasForeignKey("MixedJewelId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawSingleJewel") + .WithMany() + .HasForeignKey("SingleJewelId"); + + b.Navigation("RawMixedJewel"); + + b.Navigation("RawSingleJewel"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterBody", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterHeader", "RawHeader") + .WithMany() + .HasForeignKey("HeaderId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", "RawSenderAppearance") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterBody", "SenderAppearanceId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawHeader"); + + b.Navigation("RawSenderAppearance"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterHeader", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "Receiver") + .WithMany("RawLetters") + .HasForeignKey("ReceiverId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Receiver"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LevelBonus", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", null) + .WithMany("RawBonusPerLevel") + .HasForeignKey("ItemLevelBonusTableId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawChance") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "ChanceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawChancePvp") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "ChancePvpId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawDuration") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "DurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawDurationPvp") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "DurationPvpId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMagicEffects") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawChance"); + + b.Navigation("RawChancePvp"); + + b.Navigation("RawDuration"); + + b.Navigation("RawDurationPvp"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawReplacedSkill") + .WithMany() + .HasForeignKey("ReplacedSkillId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillRoot", "RawRoot") + .WithMany() + .HasForeignKey("RootId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawReplacedSkill"); + + b.Navigation("RawRoot"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinitionSkill", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", "MasterSkillDefinition") + .WithMany("JoinedRequiredMasterSkills") + .HasForeignKey("MasterSkillDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "Skill") + .WithMany() + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MasterSkillDefinition"); + + b.Navigation("Skill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillRoot", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMasterSkillRoots") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", null) + .WithMany("RawChangeEvents") + .HasForeignKey("MiniGameDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterSpawnArea", "RawSpawnArea") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", "SpawnAreaId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawTargetDefinition") + .WithMany() + .HasForeignKey("TargetDefinitionId"); + + b.Navigation("RawSpawnArea"); + + b.Navigation("RawTargetDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawEntrance") + .WithMany() + .HasForeignKey("EntranceId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMiniGameDefinitions") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawTicketItem") + .WithMany() + .HasForeignKey("TicketItemId"); + + b.Navigation("RawEntrance"); + + b.Navigation("RawTicketItem"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameRankingEntry", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "RawCharacter") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", "RawMiniGame") + .WithMany() + .HasForeignKey("MiniGameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawCharacter"); + + b.Navigation("RawMiniGame"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameReward", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "RawItemReward") + .WithMany() + .HasForeignKey("ItemRewardId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", null) + .WithMany("RawRewards") + .HasForeignKey("MiniGameDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawRequiredKill") + .WithMany() + .HasForeignKey("RequiredKillId"); + + b.Navigation("RawItemReward"); + + b.Navigation("RawRequiredKill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameSpawnWave", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", null) + .WithMany("RawSpawnWaves") + .HasForeignKey("MiniGameDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameTerrainChange", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", null) + .WithMany("RawTerrainChanges") + .HasForeignKey("MiniGameChangeEventId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterAttribute", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttributeDefinition") + .WithMany() + .HasForeignKey("AttributeDefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", null) + .WithMany("RawAttributes") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttributeDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawAttackSkill") + .WithMany() + .HasForeignKey("AttackSkillId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMonsters") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawMerchantStore") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "MerchantStoreId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttackSkill"); + + b.Navigation("RawMerchantStore"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinitionDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "MonsterDefinition") + .WithMany("JoinedDropItemGroups") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DropItemGroup"); + + b.Navigation("MonsterDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterSpawnArea", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawGameMap") + .WithMany("RawMonsterSpawns") + .HasForeignKey("GameMapId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonsterDefinition") + .WithMany() + .HasForeignKey("MonsterDefinitionId"); + + b.Navigation("RawGameMap"); + + b.Navigation("RawMonsterDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PlugInConfiguration", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawPlugInConfigurations") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawBoost") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "BoostId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", null) + .WithMany("RawCharacterPowerUpDefinitions") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", null) + .WithMany("RawPowerUpDefinitions") + .HasForeignKey("MagicEffectDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", null) + .WithMany("RawPowerUpDefinitionsPvp") + .HasForeignKey("MagicEffectDefinitionId1") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_PowerUpDefinition_MagicEffectDefinition_MagicEffectDefinit~1"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawBoost"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", null) + .WithMany("RawQuests") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawQualifiedCharacter") + .WithMany() + .HasForeignKey("QualifiedCharacterId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawQuestGiver") + .WithMany() + .HasForeignKey("QuestGiverId"); + + b.Navigation("RawQualifiedCharacter"); + + b.Navigation("RawQuestGiver"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestItemRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "RawDropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawItem") + .WithMany() + .HasForeignKey("ItemId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", null) + .WithMany("RawRequiredItems") + .HasForeignKey("QuestDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawDropItemGroup"); + + b.Navigation("RawItem"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonster") + .WithMany() + .HasForeignKey("MonsterId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", null) + .WithMany("RawRequiredMonsterKills") + .HasForeignKey("QuestDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawMonster"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirementState", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", null) + .WithMany("RawRequirementStates") + .HasForeignKey("CharacterQuestStateId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirement", "RawRequirement") + .WithMany() + .HasForeignKey("RequirementId"); + + b.Navigation("RawRequirement"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestReward", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttributeReward") + .WithMany() + .HasForeignKey("AttributeRewardId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", "RawItemReward") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestReward", "ItemRewardId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", null) + .WithMany("RawRewards") + .HasForeignKey("QuestDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkillReward") + .WithMany() + .HasForeignKey("SkillRewardId"); + + b.Navigation("RawAttributeReward"); + + b.Navigation("RawItemReward"); + + b.Navigation("RawSkillReward"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AreaSkillSettings", "RawAreaSkillSettings") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "AreaSkillSettingsId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawElementalModifierTarget") + .WithMany() + .HasForeignKey("ElementalModifierTargetId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawSkills") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "RawMagicEffectDef") + .WithMany() + .HasForeignKey("MagicEffectDefId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", "RawMasterDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "MasterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAreaSkillSettings"); + + b.Navigation("RawElementalModifierTarget"); + + b.Navigation("RawMagicEffectDef"); + + b.Navigation("RawMasterDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillCharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "Skill") + .WithMany("JoinedQualifiedCharacters") + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CharacterClass"); + + b.Navigation("Skill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboStep", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", null) + .WithMany("RawSteps") + .HasForeignKey("SkillComboDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkill") + .WithMany() + .HasForeignKey("SkillId"); + + b.Navigation("RawSkill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillEntry", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany("RawLearnedSkills") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkill") + .WithMany() + .HasForeignKey("SkillId"); + + b.Navigation("RawSkill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttribute", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", null) + .WithMany("RawAttributes") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany("RawAttributes") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.Navigation("RawDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttributeDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttribute") + .WithMany() + .HasForeignKey("AttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", null) + .WithMany("RawStatAttributes") + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.WarpInfo", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawWarpList") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawGate") + .WithMany() + .HasForeignKey("GateId"); + + b.Navigation("RawGate"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b => + { + b.Navigation("JoinedUnlockedCharacterClasses"); + + b.Navigation("RawAttributes"); + + b.Navigation("RawCharacters"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b => + { + b.Navigation("RawEquippedItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", b => + { + b.Navigation("RawAttackMachineZones"); + + b.Navigation("RawDefenseMachineZones"); + + b.Navigation("RawGateDefenseUpgrades"); + + b.Navigation("RawGateLifeUpgrades"); + + b.Navigation("RawNpcDefinitions"); + + b.Navigation("RawStateSchedule"); + + b.Navigation("RawStatueDefenseUpgrades"); + + b.Navigation("RawStatueLifeUpgrades"); + + b.Navigation("RawStatueRegenUpgrades"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeData", b => + { + b.Navigation("RawGuilds"); + + b.Navigation("RawNpcStates"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b => + { + b.Navigation("JoinedDropItemGroups"); + + b.Navigation("RawAttributes"); + + b.Navigation("RawLearnedSkills"); + + b.Navigation("RawLetters"); + + b.Navigation("RawQuestStates"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b => + { + b.Navigation("RawAttributeCombinations"); + + b.Navigation("RawBaseAttributeValues"); + + b.Navigation("RawStatAttributes"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b => + { + b.Navigation("RawRequirementStates"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", b => + { + b.Navigation("RawEndpoints"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", b => + { + b.Navigation("JoinedPossibleItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", b => + { + b.Navigation("RawDuelAreas"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", b => + { + b.Navigation("RawAttributes"); + + b.Navigation("RawCharacterClasses"); + + b.Navigation("RawDropItemGroups"); + + b.Navigation("RawGlobalAttributeCombinations"); + + b.Navigation("RawGlobalBaseAttributeValues"); + + b.Navigation("RawItemLevelBonusTables"); + + b.Navigation("RawItemOptionCombinationBonuses"); + + b.Navigation("RawItemOptionTypes"); + + b.Navigation("RawItemOptions"); + + b.Navigation("RawItemSetGroups"); + + b.Navigation("RawItemSlotTypes"); + + b.Navigation("RawItems"); + + b.Navigation("RawJewelMixes"); + + b.Navigation("RawMagicEffects"); + + b.Navigation("RawMaps"); + + b.Navigation("RawMasterSkillRoots"); + + b.Navigation("RawMiniGameDefinitions"); + + b.Navigation("RawMonsters"); + + b.Navigation("RawPlugInConfigurations"); + + b.Navigation("RawSkills"); + + b.Navigation("RawWarpList"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", b => + { + b.Navigation("JoinedDropItemGroups"); + + b.Navigation("RawCharacterPowerUpDefinitions"); + + b.Navigation("RawEnterGates"); + + b.Navigation("RawExitGates"); + + b.Navigation("RawMapRequirements"); + + b.Navigation("RawMonsterSpawns"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", b => + { + b.Navigation("JoinedMaps"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", b => + { + b.Navigation("RawEndpoints"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", b => + { + b.Navigation("RawMembers"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", b => + { + b.Navigation("RawLevelDependentOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", b => + { + b.Navigation("JoinedItemSetGroups"); + + b.Navigation("RawItemOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", b => + { + b.Navigation("JoinedVisibleOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", b => + { + b.Navigation("JoinedPossibleItems"); + + b.Navigation("JoinedRequiredItemOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", b => + { + b.Navigation("JoinedPossibleItemOptions"); + + b.Navigation("JoinedPossibleItemSetGroups"); + + b.Navigation("JoinedQualifiedCharacters"); + + b.Navigation("RawBasePowerUpAttributes"); + + b.Navigation("RawDropItems"); + + b.Navigation("RawRequirements"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", b => + { + b.Navigation("JoinedPossibleItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", b => + { + b.Navigation("RawBonusPerLevel"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", b => + { + b.Navigation("RawRequirements"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", b => + { + b.Navigation("RawPossibleOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", b => + { + b.Navigation("RawItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", b => + { + b.Navigation("RawItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", b => + { + b.Navigation("RawPowerUpDefinitions"); + + b.Navigation("RawPowerUpDefinitionsPvp"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", b => + { + b.Navigation("JoinedRequiredMasterSkills"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", b => + { + b.Navigation("RawTerrainChanges"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", b => + { + b.Navigation("RawChangeEvents"); + + b.Navigation("RawRewards"); + + b.Navigation("RawSpawnWaves"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", b => + { + b.Navigation("JoinedDropItemGroups"); + + b.Navigation("RawAttributes"); + + b.Navigation("RawBuffs"); + + b.Navigation("RawItemCraftings"); + + b.Navigation("RawQuests"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", b => + { + b.Navigation("RawRelatedValues"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", b => + { + b.Navigation("RawRequiredItems"); + + b.Navigation("RawRequiredMonsterKills"); + + b.Navigation("RawRewards"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", b => + { + b.Navigation("RawRequiredItems"); + + b.Navigation("RawResultItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", b => + { + b.Navigation("JoinedQualifiedCharacters"); + + b.Navigation("RawAttributeRelationships"); + + b.Navigation("RawConsumeRequirements"); + + b.Navigation("RawRequirements"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", b => + { + b.Navigation("RawSteps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/20260829211237_AddNetworkObservation.cs b/src/Persistence/EntityFramework/Migrations/20260829211237_AddNetworkObservation.cs new file mode 100644 index 0000000000..019e03a65d --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20260829211237_AddNetworkObservation.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations +{ + /// + public partial class AddNetworkObservation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NetworkAnalyzerLiveBufferSize", + schema: "config", + table: "SystemConfiguration", + type: "integer", + nullable: false, + defaultValue: 5000); + + migrationBuilder.AddColumn( + name: "NetworkObservationArchivePath", + schema: "config", + table: "SystemConfiguration", + type: "text", + nullable: true, + defaultValue: "captures"); + + migrationBuilder.AddColumn( + name: "NetworkObservationMaxSessionSizeMb", + schema: "config", + table: "SystemConfiguration", + type: "integer", + nullable: false, + defaultValue: 50); + + migrationBuilder.AddColumn( + name: "NetworkObservationMaxTotalSizeMb", + schema: "config", + table: "SystemConfiguration", + type: "integer", + nullable: false, + defaultValue: 1000); + + migrationBuilder.AddColumn( + name: "NetworkObservationRetentionDays", + schema: "config", + table: "SystemConfiguration", + type: "integer", + nullable: false, + defaultValue: 30); + + migrationBuilder.AddColumn( + name: "IsNetworkObservationActive", + schema: "data", + table: "Account", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "NetworkAnalyzerLiveBufferSize", + schema: "config", + table: "SystemConfiguration"); + + migrationBuilder.DropColumn( + name: "NetworkObservationArchivePath", + schema: "config", + table: "SystemConfiguration"); + + migrationBuilder.DropColumn( + name: "NetworkObservationMaxSessionSizeMb", + schema: "config", + table: "SystemConfiguration"); + + migrationBuilder.DropColumn( + name: "NetworkObservationMaxTotalSizeMb", + schema: "config", + table: "SystemConfiguration"); + + migrationBuilder.DropColumn( + name: "NetworkObservationRetentionDays", + schema: "config", + table: "SystemConfiguration"); + + migrationBuilder.DropColumn( + name: "IsNetworkObservationActive", + schema: "data", + table: "Account"); + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs b/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs index 2004f716a5..517184d7a1 100644 --- a/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs +++ b/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs @@ -38,6 +38,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsBot") .HasColumnType("boolean"); + b.Property("IsNetworkObservationActive") + .HasColumnType("boolean"); + b.Property("IsTemplate") .HasColumnType("boolean"); @@ -3849,6 +3852,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IpResolverParameter") .HasColumnType("text"); + b.Property("NetworkAnalyzerLiveBufferSize") + .HasColumnType("integer"); + + b.Property("NetworkObservationArchivePath") + .HasColumnType("text"); + + b.Property("NetworkObservationMaxSessionSizeMb") + .HasColumnType("integer"); + + b.Property("NetworkObservationMaxTotalSizeMb") + .HasColumnType("integer"); + + b.Property("NetworkObservationRetentionDays") + .HasColumnType("integer"); + b.Property("ReadConsoleInput") .HasColumnType("boolean"); diff --git a/src/Startup/GameServerContainer.cs b/src/Startup/GameServerContainer.cs index e2c4bc59ee..35985c7061 100644 --- a/src/Startup/GameServerContainer.cs +++ b/src/Startup/GameServerContainer.cs @@ -12,6 +12,7 @@ namespace MUnique.OpenMU.Startup; using MUnique.OpenMU.GameServer; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Analyzer.Archive; using MUnique.OpenMU.Network.PlugIns; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.PlugIns; @@ -36,6 +37,8 @@ public sealed class GameServerContainer : ServerContainerBase, IGameServerInstan private readonly IDictionary _gameServers; private readonly IEventPublisher _eventPublisher; + private readonly IPacketArchive? _packetArchive; + /// /// Initializes a new instance of the class. /// @@ -51,6 +54,7 @@ public sealed class GameServerContainer : ServerContainerBase, IGameServerInstan /// The plug in manager. /// The setup service. /// The change mediator. + /// The archive for the traffic of observed accounts. public GameServerContainer( ILoggerFactory loggerFactory, IList servers, @@ -63,7 +67,8 @@ public GameServerContainer( IIpAddressResolver ipResolver, PlugInManager plugInManager, SetupService setupService, - IConfigurationChangeMediator changeMediator) + IConfigurationChangeMediator changeMediator, + IPacketArchive? packetArchive = null) : base(setupService, loggerFactory.CreateLogger()) { this._loggerFactory = loggerFactory; @@ -80,6 +85,7 @@ public GameServerContainer( this._logger = this._loggerFactory.CreateLogger(); this._eventPublisher = new InMemoryEventPublisher(this._gameServers, this._friendServer, this._guildServer); + this._packetArchive = packetArchive; } /// @@ -166,7 +172,7 @@ protected override async Task StopInnerAsync(CancellationToken cancellationToken private void InitializeGameServer(GameServerDefinition gameServerDefinition) { using var loggerScope = this._logger.BeginScope("GameServer: {0}", gameServerDefinition.ServerID); - var gameServer = new GameServer(gameServerDefinition, this._guildServer, this._eventPublisher, this._loginServer, this._persistenceContextProvider, this._friendServer, this._loggerFactory, this._plugInManager, this._changeMediator); + var gameServer = new GameServer(gameServerDefinition, this._guildServer, this._eventPublisher, this._loginServer, this._persistenceContextProvider, this._friendServer, this._loggerFactory, this._plugInManager, this._changeMediator, this._packetArchive); gameServer.Context.ServerTimeZone = Program.ServerTimeZone; foreach (var endpoint in gameServerDefinition.Endpoints) { diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index b8cb976ce8..176919e3cd 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -20,6 +20,7 @@ namespace MUnique.OpenMU.Startup; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.FriendServer; using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameServer; using MUnique.OpenMU.GuildServer; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.LoginServer; @@ -296,7 +297,7 @@ private async Task CreateHostAsync(string[] args) .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton(CreatePacketCaptureService) .AddSingleton>(this.PlugInConfigurationsFactory) .AddTransient(provider => { @@ -314,6 +315,7 @@ private async Task CreateHostAsync(string[] args) .AddHostedService() .AddHostedService(provider => provider.GetService()!) .AddHostedService(provider => provider.GetService()!) + .AddNetworkObservation() .AddControllers().AddApplicationPart(typeof(ServerController).Assembly); var host = builder.Build(); @@ -538,6 +540,16 @@ private async Task PrepareRepositoryProvider return contextProvider; } + private static IPacketCaptureService CreatePacketCaptureService(IServiceProvider serviceProvider) + { + var serverProvider = serviceProvider.GetService() + ?? throw new InvalidOperationException($"{nameof(IServerProvider)} not registered."); + var bufferSize = _systemConfiguration?.NetworkAnalyzerLiveBufferSize ?? 0; + return new PacketCaptureService( + serverProvider, + bufferSize > 0 ? bufferSize : LiveCapturedConnection.DefaultMaximumPacketCount); + } + private async Task ReadSystemConfigurationAsync(IPersistenceContextProvider persistenceContextProvider) { using var context = persistenceContextProvider.CreateNewTypedContext(typeof(SystemConfiguration), false); diff --git a/tests/MUnique.OpenMU.Network.Tests/PacketArchiveTest.cs b/tests/MUnique.OpenMU.Network.Tests/PacketArchiveTest.cs new file mode 100644 index 0000000000..c731ef9f43 --- /dev/null +++ b/tests/MUnique.OpenMU.Network.Tests/PacketArchiveTest.cs @@ -0,0 +1,423 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Network.Tests; + +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Network.Analyzer.Archive; +using MUnique.OpenMU.Network.PlugIns; + +/// +/// Tests for the , which keeps the traffic of the observed accounts. +/// +[TestFixture] +public class PacketArchiveTest +{ + private static readonly byte[] LoginPacket = [0xC1, 0x06, 0xF1, 0x01, 0x01, 0x02]; + + private static readonly byte[] ResponsePacket = [0xC1, 0x05, 0xF1, 0x00, 0x01]; + + private string _archivePath = null!; + + /// + /// Creates the archive directory of the test. + /// + [SetUp] + public void SetUp() + { + this._archivePath = Path.Combine(Path.GetTempPath(), "openmu-archive-test-" + Guid.NewGuid().ToString("N")); + } + + /// + /// Removes the archive directory of the test. + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(this._archivePath)) + { + Directory.Delete(this._archivePath, true); + } + } + + /// + /// Tests if the archived packets are the same as the captured ones, and that the metadata + /// describes the finished session. + /// + /// The async task. + [Test] + public async Task ArchivedSessionContainsTheCapturedPacketsAsync() + { + var archive = this.CreateArchive(); + var writer = await archive.StartSessionAsync(CreateMetadata()).ConfigureAwait(false); + Assert.That(writer, Is.Not.Null); + + writer!.PacketCaptured(LoginPacket, false); + writer.PacketCaptured(ResponsePacket, true); + await writer.DisposeAsync().ConfigureAwait(false); + + var sessions = await archive.GetSessionsAsync().ConfigureAwait(false); + Assert.That(sessions, Has.Count.EqualTo(1)); + + var session = await ArchivedSession.LoadAsync(sessions[0], 0).ConfigureAwait(false); + Assert.That(session.PacketList, Has.Count.EqualTo(2)); + Assert.That(session.PacketList[0].PacketData, Is.EqualTo("C1 06 F1 01 01 02")); + Assert.That(session.PacketList[0].ToServer, Is.True, "A received packet goes to the server."); + Assert.That(session.PacketList[1].PacketData, Is.EqualTo("C1 05 F1 00 01")); + Assert.That(session.PacketList[1].ToServer, Is.False, "A sent packet goes to the client."); + + Assert.That(sessions[0].Metadata.PacketCount, Is.EqualTo(2)); + Assert.That(sessions[0].Metadata.EndTimestamp, Is.Not.Null); + Assert.That(sessions[0].Metadata.AccountName, Is.EqualTo("TestAccount")); + Assert.That(sessions[0].Metadata.ClientVersion, Is.EqualTo(new ClientVersion(6, 3, ClientLanguage.English))); + Assert.That(sessions[0].IsRunning, Is.False); + } + + /// + /// Tests if a session can be read while it's still being written - an admin should be able + /// to look at the traffic of a player which is still online. + /// + /// The async task. + [Test] + public async Task RunningSessionCanBeReadAsync() + { + var archive = this.CreateArchive(); + var writer = await archive.StartSessionAsync(CreateMetadata()).ConfigureAwait(false); + writer!.PacketCaptured(LoginPacket, false); + + var session = await this.WaitForPacketsAsync(archive, 1).ConfigureAwait(false); + Assert.That(session.PacketList, Has.Count.EqualTo(1)); + Assert.That(session.Info.IsRunning, Is.True, "The session is still being written."); + Assert.That(session.Info.Metadata.EndTimestamp, Is.Null); + + writer.PacketCaptured(ResponsePacket, true); + var updated = await this.WaitForPacketsAsync(archive, 2).ConfigureAwait(false); + Assert.That(updated.PacketList, Has.Count.EqualTo(2)); + + await writer.DisposeAsync().ConfigureAwait(false); + } + + /// + /// Tests if a session which grows over the configured size is continued in another file, + /// so that a single one stays small enough to be opened by the analyzer tool. + /// + /// The async task. + [Test] + public async Task BigSessionIsSplitIntoSeveralFilesAsync() + { + var archive = this.CreateArchive(); + var metadata = CreateMetadata(); + var directoryPath = Path.Combine(this._archivePath, "TestAccount", $"{metadata.StartTimestamp:yyyy-MM-dd_HH-mm-ss}_1"); + Directory.CreateDirectory(directoryPath); + + // One packet is about 30 bytes as text, so each one of them exceeds this maximum. + await using (var writer = new ArchivedSessionWriter(directoryPath, metadata, 10, new NullLogger())) + { + for (int i = 0; i < 5; i++) + { + writer.PacketCaptured(LoginPacket, false); + } + } + + var sessions = await archive.GetSessionsAsync().ConfigureAwait(false); + var session = await ArchivedSession.LoadAsync(sessions[0], 0).ConfigureAwait(false); + Assert.That(sessions[0].Metadata.Parts, Has.Count.EqualTo(5), "Each packet exceeds the maximum, so each one gets an own file."); + Assert.That(session.PacketList, Has.Count.EqualTo(5), "All packets are read, over all files."); + } + + /// + /// Tests if only the newest packets are loaded when a maximum is specified. + /// + /// The async task. + [Test] + public async Task OnlyTheNewestPacketsAreLoadedAsync() + { + var archive = this.CreateArchive(); + var writer = await archive.StartSessionAsync(CreateMetadata()).ConfigureAwait(false); + for (byte i = 0; i < 10; i++) + { + writer!.PacketCaptured([0xC1, 0x04, 0xF1, i], false); + } + + await writer!.DisposeAsync().ConfigureAwait(false); + + var sessions = await archive.GetSessionsAsync().ConfigureAwait(false); + var session = await ArchivedSession.LoadAsync(sessions[0], 3).ConfigureAwait(false); + Assert.That(session.PacketList, Has.Count.EqualTo(3)); + Assert.That(session.PacketList[2].PacketData, Is.EqualTo("C1 04 F1 09"), "The newest packet is the last one."); + } + + /// + /// Tests if the sessions of one account can be requested, so that the page doesn't have to + /// read the whole archive. + /// + /// The async task. + [Test] + public async Task SessionsCanBeFilteredByAccountAsync() + { + var archive = this.CreateArchive(); + await this.CreateFinishedSessionAsync(archive, "FirstAccount").ConfigureAwait(false); + await this.CreateFinishedSessionAsync(archive, "SecondAccount").ConfigureAwait(false); + + var sessions = await archive.GetSessionsAsync("SecondAccount").ConfigureAwait(false); + + Assert.That(sessions, Has.Count.EqualTo(1)); + Assert.That(sessions[0].Metadata.AccountName, Is.EqualTo("SecondAccount")); + } + + /// + /// Tests if a session can be deleted again. + /// + /// The async task. + [Test] + public async Task SessionCanBeDeletedAsync() + { + var archive = this.CreateArchive(); + var session = await this.CreateFinishedSessionAsync(archive, "TestAccount").ConfigureAwait(false); + + Assert.That(await archive.DeleteSessionAsync(session.Id).ConfigureAwait(false), Is.True); + Assert.That(await archive.GetSessionsAsync().ConfigureAwait(false), Is.Empty); + Assert.That(Directory.Exists(session.DirectoryPath), Is.False); + } + + /// + /// Tests if a running session is not deleted, because its file is still written. + /// + /// The async task. + [Test] + public async Task RunningSessionIsNotDeletedAsync() + { + var archive = this.CreateArchive(); + var writer = await archive.StartSessionAsync(CreateMetadata()).ConfigureAwait(false); + var sessions = await archive.GetSessionsAsync().ConfigureAwait(false); + + Assert.That(await archive.DeleteSessionAsync(sessions[0].Id).ConfigureAwait(false), Is.False); + Assert.That(await archive.GetSessionsAsync().ConfigureAwait(false), Has.Count.EqualTo(1)); + + await writer!.DisposeAsync().ConfigureAwait(false); + } + + /// + /// Tests if an identifier which points outside of the archive is refused - it comes from + /// the outside, over the route of a page. + /// + /// The async task. + [Test] + public async Task SessionOutsideOfTheArchiveIsNotFoundAsync() + { + var archive = this.CreateArchive(); + await this.CreateFinishedSessionAsync(archive, "TestAccount").ConfigureAwait(false); + + Assert.That(await archive.GetSessionAsync("../../etc").ConfigureAwait(false), Is.Null); + Assert.That(await archive.DeleteSessionAsync("../..").ConfigureAwait(false), Is.False); + } + + /// + /// Tests if the sessions which are older than the retention are removed. + /// + /// The async task. + [Test] + public async Task OldSessionsAreRemovedAsync() + { + var archive = this.CreateArchive(options => options.RetentionDays = 30); + var oldId = await this.WriteSessionOnDiskAsync("OldAccount", DateTime.UtcNow.AddDays(-31)).ConfigureAwait(false); + var recentId = await this.WriteSessionOnDiskAsync("RecentAccount", DateTime.UtcNow.AddDays(-1)).ConfigureAwait(false); + + await archive.ApplyHousekeepingAsync().ConfigureAwait(false); + + var sessions = await archive.GetSessionsAsync().ConfigureAwait(false); + Assert.That(sessions.Select(session => session.Id), Is.EqualTo(new[] { recentId })); + Assert.That(Directory.Exists(Path.Combine(this._archivePath, oldId)), Is.False); + } + + /// + /// Tests if the oldest sessions are removed when the archive gets too big. + /// + /// The async task. + [Test] + public async Task OldestSessionsAreRemovedWhenTheArchiveIsFullAsync() + { + var archive = this.CreateArchive(options => + { + options.RetentionDays = 0; + options.MaximumTotalSizeMb = 1; + }); + + // Together they are bigger than the maximum, so the older one has to go. + var oldestId = await this.WriteSessionOnDiskAsync("FirstAccount", DateTime.UtcNow.AddHours(-2), 600 * 1024).ConfigureAwait(false); + var newestId = await this.WriteSessionOnDiskAsync("SecondAccount", DateTime.UtcNow.AddHours(-1), 600 * 1024).ConfigureAwait(false); + + await archive.ApplyHousekeepingAsync().ConfigureAwait(false); + + var sessions = await archive.GetSessionsAsync().ConfigureAwait(false); + Assert.That(sessions.Select(session => session.Id), Is.EqualTo(new[] { newestId }), "The oldest session should have been removed."); + Assert.That(Directory.Exists(Path.Combine(this._archivePath, oldestId)), Is.False); + } + + /// + /// Tests if the housekeeping is applied when a session is finished, so that the archive + /// doesn't grow between the logins of the observed players. + /// + /// The async task. + [Test] + public async Task HousekeepingIsAppliedWhenASessionEndsAsync() + { + var archive = this.CreateArchive(options => options.RetentionDays = 30); + var oldId = await this.WriteSessionOnDiskAsync("OldAccount", DateTime.UtcNow.AddDays(-31)).ConfigureAwait(false); + + await this.CreateFinishedSessionAsync(archive, "TestAccount").ConfigureAwait(false); + + Assert.That(Directory.Exists(Path.Combine(this._archivePath, oldId)), Is.False); + } + + /// + /// Tests if an account name which can't be a directory name is still archived. + /// + /// The async task. + [Test] + public async Task AccountNameIsSanitizedAsync() + { + var archive = this.CreateArchive(); + var metadata = CreateMetadata(); + metadata.AccountName = "../etc/pass:wd"; + + var writer = await archive.StartSessionAsync(metadata).ConfigureAwait(false); + await writer!.DisposeAsync().ConfigureAwait(false); + + var sessions = await archive.GetSessionsAsync().ConfigureAwait(false); + Assert.That(sessions, Has.Count.EqualTo(1)); + Assert.That(sessions[0].DirectoryPath, Does.StartWith(this._archivePath)); + Assert.That(sessions[0].Metadata.AccountName, Is.EqualTo("../etc/pass:wd"), "The real name is kept in the metadata."); + } + + /// + /// Tests if a file which contains an incomplete line - which happens when the process died + /// while writing - is still readable. + /// + /// The async task. + [Test] + public async Task IncompleteLineIsSkippedAsync() + { + var archive = this.CreateArchive(); + var session = await this.CreateFinishedSessionAsync(archive, "TestAccount").ConfigureAwait(false); + var partPath = Path.Combine(session.DirectoryPath, session.Metadata.Parts[0]); + await File.AppendAllTextAsync(partPath, "1234567;True;6;C1 06 F1 01 01").ConfigureAwait(false); + + var loaded = await ArchivedSession.LoadAsync(session, 0).ConfigureAwait(false); + + Assert.That(loaded.PacketList, Has.Count.EqualTo(1), "Only the complete packet should be loaded."); + } + + /// + /// Tests if the metadata is written in a format which can be read again, so that the + /// sessions of a previous run of the server are still described. + /// + /// The async task. + [Test] + public async Task MetadataIsReadableAsJsonAsync() + { + var archive = this.CreateArchive(); + var session = await this.CreateFinishedSessionAsync(archive, "TestAccount").ConfigureAwait(false); + + var json = await File.ReadAllTextAsync(Path.Combine(session.DirectoryPath, ArchivedSessionWriter.MetadataFileName)).ConfigureAwait(false); + var metadata = JsonSerializer.Deserialize(json); + + Assert.That(metadata, Is.Not.Null); + Assert.That(metadata!.AccountName, Is.EqualTo("TestAccount")); + Assert.That(metadata.ServerType, Is.EqualTo(ServerType.GameServer)); + Assert.That(metadata.Parts, Has.Count.EqualTo(1)); + } + + private static ArchivedSessionMetadata CreateMetadata(string accountName = "TestAccount", DateTime? startTimestamp = null) + { + return new ArchivedSessionMetadata + { + AccountName = accountName, + ServerType = ServerType.GameServer, + ServerId = 1, + ServerDescription = "Test Server", + RemoteEndPoint = "127.0.0.1:1234", + ClientVersion = new ClientVersion(6, 3, ClientLanguage.English), + StartTimestamp = startTimestamp ?? DateTime.UtcNow, + }; + } + + private PacketArchive CreateArchive(Action? configure = null) + { + var options = new NetworkObservationOptions { ArchivePath = this._archivePath }; + configure?.Invoke(options); + return new PacketArchive(options, new NullLogger()); + } + + private async ValueTask CreateFinishedSessionAsync( + PacketArchive archive, + string accountName, + DateTime? startTimestamp = null, + int packetCount = 1) + { + var metadata = CreateMetadata(accountName, startTimestamp); + var writer = await archive.StartSessionAsync(metadata).ConfigureAwait(false); + for (int i = 0; i < packetCount; i++) + { + writer!.PacketCaptured(LoginPacket, false); + } + + await writer!.DisposeAsync().ConfigureAwait(false); + + var sessions = await archive.GetSessionsAsync(accountName).ConfigureAwait(false); + return sessions.First(session => session.Metadata.StartTimestamp == metadata.StartTimestamp); + } + + private async ValueTask WriteSessionOnDiskAsync(string accountName, DateTime startTimestamp, int approximateSize = 0) + { + var sessionDirectory = $"{startTimestamp:yyyy-MM-dd_HH-mm-ss}_1"; + var sessionId = $"{accountName}/{sessionDirectory}"; + var directoryPath = Path.Combine(this._archivePath, accountName, sessionDirectory); + Directory.CreateDirectory(directoryPath); + + var line = $"{TimeSpan.FromSeconds(1).Ticks};True;6;C1 06 F1 01 01 02;1"; + var content = new StringBuilder(startTimestamp.ToString("O")).AppendLine(); + do + { + content.AppendLine(line); + } + while (content.Length < approximateSize); + + await File.WriteAllTextAsync(Path.Combine(directoryPath, "part-000.mucap"), content.ToString()).ConfigureAwait(false); + + var metadata = CreateMetadata(accountName, startTimestamp); + metadata.EndTimestamp = startTimestamp.AddMinutes(1); + metadata.Parts.Add("part-000.mucap"); + await File.WriteAllTextAsync( + Path.Combine(directoryPath, ArchivedSessionWriter.MetadataFileName), + JsonSerializer.Serialize(metadata)).ConfigureAwait(false); + return sessionId; + } + + private async ValueTask WaitForPacketsAsync(PacketArchive archive, int packetCount) + { + // The packets are written by another task, so the file needs a moment to catch up. + var watch = Stopwatch.StartNew(); + ArchivedSession? session = null; + while (watch.Elapsed < TimeSpan.FromSeconds(10)) + { + var sessions = await archive.GetSessionsAsync().ConfigureAwait(false); + session = await ArchivedSession.LoadAsync(sessions[0], 0).ConfigureAwait(false); + if (session.PacketList.Count >= packetCount) + { + return session; + } + + await Task.Delay(50).ConfigureAwait(false); + } + + return session!; + } +} diff --git a/tests/MUnique.OpenMU.Tests/NetworkObservationTests.cs b/tests/MUnique.OpenMU.Tests/NetworkObservationTests.cs new file mode 100644 index 0000000000..b9a027a04e --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/NetworkObservationTests.cs @@ -0,0 +1,281 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Buffers; +using System.Diagnostics; +using System.IO; +using System.IO.Pipelines; +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameServer; +using MUnique.OpenMU.GameServer.RemoteView; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Analyzer; +using MUnique.OpenMU.Network.Analyzer.Archive; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.PlugIns; +using Nito.AsyncEx; + +/// +/// Tests for the archiving of the traffic of observed accounts. +/// +[TestFixture] +public class NetworkObservationTests +{ + private static readonly byte[] TestPacket = [0xC1, 0x06, 0xF1, 0x01, 0x01, 0x02]; + + private string _archivePath = null!; + + /// + /// Creates the archive directory of the test. + /// + [SetUp] + public void SetUp() + { + this._archivePath = Path.Combine(Path.GetTempPath(), "openmu-observation-test-" + Guid.NewGuid().ToString("N")); + } + + /// + /// Removes the archive directory of the test. + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(this._archivePath)) + { + Directory.Delete(this._archivePath, true); + } + } + + /// + /// Tests if the traffic of a player is archived when the observation of its account is + /// active. + /// + /// The async task. + [Test] + public async Task TrafficOfAnObservedAccountIsArchivedAsync() + { + var archive = this.CreateArchive(); + var handler = CreateHandler(archive); + var (player, connection) = CreateRemotePlayer(); + handler.Watch(player); + + await player.SetAccountAsync(CreateAccount(isObserved: true)).ConfigureAwait(false); + + Assert.That(connection.Sinks, Has.Count.EqualTo(1), "The archive should be a sink of the connection."); + var sessions = await archive.GetSessionsAsync().ConfigureAwait(false); + Assert.That(sessions, Has.Count.EqualTo(1)); + Assert.That(sessions[0].Metadata.AccountName, Is.EqualTo("ObservedAccount")); + Assert.That(sessions[0].IsRunning, Is.True); + + connection.Sinks[0].PacketCaptured(TestPacket, false); + var session = await this.WaitForPacketsAsync(archive, 1).ConfigureAwait(false); + Assert.That(session.PacketList[0].PacketData, Is.EqualTo("C1 06 F1 01 01 02")); + } + + /// + /// Tests if the traffic of a player is not archived when the observation of its account + /// is not active - which is the case for every account by default. + /// + /// The async task. + [Test] + public async Task TrafficOfAnUnobservedAccountIsNotArchivedAsync() + { + var archive = this.CreateArchive(); + var handler = CreateHandler(archive); + var (player, connection) = CreateRemotePlayer(); + handler.Watch(player); + + await player.SetAccountAsync(CreateAccount(isObserved: false)).ConfigureAwait(false); + + Assert.That(connection.Sinks, Is.Empty, "Nothing should be captured."); + Assert.That(await archive.GetSessionsAsync().ConfigureAwait(false), Is.Empty); + } + + /// + /// Tests if the archived session is finished when the player disconnects. + /// + /// The async task. + [Test] + public async Task SessionIsFinishedWhenThePlayerDisconnectsAsync() + { + var archive = this.CreateArchive(); + var handler = CreateHandler(archive); + var (player, connection) = CreateRemotePlayer(); + handler.Watch(player); + + // That's the state a player is in while it's at the login screen of the client. + await player.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await player.SetAccountAsync(CreateAccount(isObserved: true)).ConfigureAwait(false); + + await player.DisconnectAsync().ConfigureAwait(false); + + Assert.That(connection.Sinks, Is.Empty, "The archive should not be a sink anymore."); + var sessions = await archive.GetSessionsAsync().ConfigureAwait(false); + Assert.That(sessions, Has.Count.EqualTo(1)); + Assert.That(sessions[0].IsRunning, Is.False); + Assert.That(sessions[0].Metadata.EndTimestamp, Is.Not.Null); + } + + /// + /// Tests if the options of the observation are taken from the system configuration. + /// + [Test] + public void OptionsAreTakenFromTheSystemConfiguration() + { + var configuration = new SystemConfiguration + { + NetworkObservationArchivePath = "/tmp/openmu-captures", + NetworkObservationMaxSessionSizeMb = 5, + NetworkObservationMaxTotalSizeMb = 100, + NetworkObservationRetentionDays = 7, + }; + + var options = NetworkObservationExtensions.CreateOptions(configuration); + + Assert.That(options.ArchivePath, Is.EqualTo("/tmp/openmu-captures")); + Assert.That(options.MaximumSessionSizeMb, Is.EqualTo(5)); + Assert.That(options.MaximumTotalSizeMb, Is.EqualTo(100)); + Assert.That(options.RetentionDays, Is.EqualTo(7)); + } + + /// + /// Tests if the defaults are used for the values which are not configured - an existing + /// database has no values for them until they are saved once. + /// + [Test] + public void UnconfiguredOptionsFallBackToTheDefaults() + { + var configuration = new SystemConfiguration + { + NetworkObservationArchivePath = null, + NetworkObservationMaxSessionSizeMb = 0, + NetworkObservationMaxTotalSizeMb = 0, + NetworkObservationRetentionDays = 0, + }; + + var options = NetworkObservationExtensions.CreateOptions(configuration); + + Assert.That(options.ArchivePath, Is.EqualTo(NetworkObservationOptions.DefaultArchivePath)); + Assert.That(options.MaximumSessionSizeMb, Is.EqualTo(NetworkObservationOptions.DefaultMaximumSessionSizeMb)); + Assert.That(options.MaximumTotalSizeMb, Is.EqualTo(NetworkObservationOptions.DefaultMaximumTotalSizeMb)); + Assert.That(options.RetentionDays, Is.EqualTo(NetworkObservationOptions.DefaultRetentionDays)); + } + + private static NetworkObservationHandler CreateHandler(IPacketArchive archive) + { + return new NetworkObservationHandler(archive, 1, "Test Server", new NullLogger()); + } + + private static Account CreateAccount(bool isObserved) + { + return new Account + { + LoginName = isObserved ? "ObservedAccount" : "NormalAccount", + IsNetworkObservationActive = isObserved, + }; + } + + private static (RemotePlayer Player, TestConnection Connection) CreateRemotePlayer() + { + var connection = new TestConnection(); + var manager = new PlugInManager(null, new NullLoggerFactory(), null, null); + var gameContext = new Mock(); + gameContext.Setup(c => c.PersistenceContextProvider).Returns(new Mock().Object); + gameContext.Setup(c => c.Configuration).Returns(new GameConfiguration()); + gameContext.Setup(c => c.PlugInManager).Returns(manager); + gameContext.Setup(c => c.LoggerFactory).Returns(new NullLoggerFactory()); + return (new RemotePlayer(gameContext.Object, connection, default), connection); + } + + private PacketArchive CreateArchive() + { + var options = new NetworkObservationOptions { ArchivePath = this._archivePath }; + return new PacketArchive(options, new NullLogger()); + } + + private async ValueTask WaitForPacketsAsync(PacketArchive archive, int packetCount) + { + // The packets are written by another task, so the file needs a moment to catch up. + var watch = Stopwatch.StartNew(); + ArchivedSession? session = null; + while (watch.Elapsed < TimeSpan.FromSeconds(10)) + { + var sessions = await archive.GetSessionsAsync().ConfigureAwait(false); + session = await ArchivedSession.LoadAsync(sessions[0], 0).ConfigureAwait(false); + if (session.PacketList.Count >= packetCount) + { + return session; + } + + await Task.Delay(50).ConfigureAwait(false); + } + + return session!; + } + + /// + /// A connection which just keeps the registered capture sinks. + /// + private sealed class TestConnection : IConnection + { + private readonly MemoryStream _output = new(); + + public TestConnection() + { + this.Output = PipeWriter.Create(this._output, new StreamPipeWriterOptions(leaveOpen: true)); + } + + /// + /// Occurs when a packet got received. It's never raised by this test double. + /// + public event AsyncEventHandler>? PacketReceived + { + add { /* not raised */ } + remove { /* not raised */ } + } + + /// + /// Occurs when the client disconnected. It's never raised by this test double. + /// + public event AsyncEventHandler? Disconnected + { + add { /* not raised */ } + remove { /* not raised */ } + } + + public IList Sinks { get; } = new List(); + + public Guid Id { get; } = Guid.NewGuid(); + + public bool Connected => true; + + public EndPoint? EndPoint => null; + + public EndPoint? LocalEndPoint => null; + + public PipeWriter Output { get; } + + public AsyncLock OutputLock { get; } = new(); + + public void AddCaptureSink(IPacketCaptureSink sink) => this.Sinks.Add(sink); + + public void RemoveCaptureSink(IPacketCaptureSink sink) => this.Sinks.Remove(sink); + + public Task BeginReceiveAsync() => Task.CompletedTask; + + public ValueTask DisconnectAsync() => ValueTask.CompletedTask; + + public void Dispose() + { + this._output.Dispose(); + } + } +} From 8ca7c495fb9c51b4598fe21b854791a88fa4f205 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 10:15:49 +0000 Subject: [PATCH 2/5] Fix the duplicated documentation of AddNetworkObservation Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pb82LmoaUVdZtBtQs7xrtA --- src/GameServer/NetworkObservationExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GameServer/NetworkObservationExtensions.cs b/src/GameServer/NetworkObservationExtensions.cs index 3aa32015ea..d19ea7e6a7 100644 --- a/src/GameServer/NetworkObservationExtensions.cs +++ b/src/GameServer/NetworkObservationExtensions.cs @@ -21,7 +21,7 @@ public static class NetworkObservationExtensions /// configuration of the database. /// /// The service collection. - /// The service collection. + /// The given service collection, so that the calls can be chained. /// /// It belongs to the game server, not to the admin panel: an observed account is archived /// as soon as it plays, no matter whether an admin panel is running somewhere. From 0bc3365d40b397c265bb9238951144ae1b9a8bf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 10:44:09 +0000 Subject: [PATCH 3/5] Browse the archived sessions in the network analyzer page The last part of the analyzer page: the archive of the observed accounts is browsable next to the live connections, and the observation of an account can be switched on and off while its player is online. * The sidebar lists the archived sessions below the connections, grouped by account, with their date, packet count, duration and size. A session which is still being written is marked with a record dot. * Opening a session shows its packets in the same grid, analyzed with the client version which was recorded in its metadata. A running session is re-read while it's open, so the traffic of an observed player can be followed live - the file is the single source, so there is nothing to merge. * A session can be deleted after a confirmation, and downloaded as one capture file: the parts are concatenated behind a single header line, so the download opens in the analyzer tool as one session. * The download goes through a controller of the admin panel instead of a static file: an archived session contains the login packet of the player in plain text, so it must not be reachable without an authenticated user. It's logged like the other accesses to the archive. * The observation toggle in the header of a game server connection persists the account flag through the player and starts or ends the archived session right away, without a reconnect. The member order of the page was fixed on the way, which removes three StyleCop warnings it had before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pb82LmoaUVdZtBtQs7xrtA --- src/GameServer/GameServer.cs | 2 +- src/GameServer/NetworkObservationHandler.cs | 24 ++ src/GameServer/RemotePlayerConnectionInfo.cs | 40 ++- .../Analyzer/ICapturedConnectionInfo.cs | 18 ++ src/Network/Analyzer/IPacketCaptureService.cs | 9 + src/Network/Analyzer/PacketCaptureService.cs | 11 + .../API/NetworkArchiveController.cs | 109 +++++++ .../NetworkAnalyzer/ArchiveList.razor | 65 +++++ .../NetworkAnalyzer/ArchiveList.razor.cs | 111 ++++++++ .../NetworkAnalyzer/ArchiveList.razor.css | 5 + .../AdminPanel/Pages/NetworkAnalyzer.razor | 58 +++- .../AdminPanel/Pages/NetworkAnalyzer.razor.cs | 268 +++++++++++++++--- .../Properties/Resources.Designer.cs | 81 ++++++ src/Web/AdminPanel/Properties/Resources.resx | 27 ++ .../NetworkAnalyzer/ArchiveListTests.cs | 154 ++++++++++ .../NetworkAnalyzer/ArchiveTestHelper.cs | 78 +++++ .../NetworkAnalyzerPageTests.cs | 132 ++++++++- .../NetworkAnalyzer/TestCaptureService.cs | 17 ++ .../NetworkAnalyzer/TestConnectionInfo.cs | 10 + .../NetworkAnalyzer/TestModalService.cs | 54 ++++ 20 files changed, 1220 insertions(+), 53 deletions(-) create mode 100644 src/Web/AdminPanel/API/NetworkArchiveController.cs create mode 100644 src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor create mode 100644 src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor.cs create mode 100644 src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor.css create mode 100644 tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/ArchiveListTests.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/ArchiveTestHelper.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestModalService.cs diff --git a/src/GameServer/GameServer.cs b/src/GameServer/GameServer.cs index 853d56d43c..e2c7c65a81 100644 --- a/src/GameServer/GameServer.cs +++ b/src/GameServer/GameServer.cs @@ -439,7 +439,7 @@ public async ValueTask> GetConnectionsAsy return players .OfType() .Select(player => player.Connection is { } connection - ? new RemotePlayerConnectionInfo(player, connection, this.Id, this.Description) + ? new RemotePlayerConnectionInfo(player, connection, this.Id, this.Description, this._observationHandler) : null) .Where(info => info is not null) .Select(info => (ICapturedConnectionInfo)info!) diff --git a/src/GameServer/NetworkObservationHandler.cs b/src/GameServer/NetworkObservationHandler.cs index d8651e886f..3fa9d38eb6 100644 --- a/src/GameServer/NetworkObservationHandler.cs +++ b/src/GameServer/NetworkObservationHandler.cs @@ -66,6 +66,25 @@ public void Watch(Player player) player.PlayerDisconnected += this.OnPlayerDisconnectedAsync; } + /// + /// Applies a change of the observation to the running session of the player: it starts to + /// archive the traffic, or finishes the archived session. + /// + /// The player whose account has been (un)observed. + /// If set to true, the traffic is observed. + /// The async task. + public async ValueTask ApplyObservationAsync(Player player, bool isActive) + { + if (isActive) + { + await this.OnPlayerLoggedInAsync(player).ConfigureAwait(false); + } + else + { + await this.StopSessionAsync(player).ConfigureAwait(false); + } + } + private async ValueTask OnPlayerLoggedInAsync(Player player) { try @@ -123,6 +142,11 @@ private async ValueTask OnPlayerDisconnectedAsync(Player player) player.PlayerEnteredWorld -= this.OnPlayerEnteredWorldAsync; player.PlayerDisconnected -= this.OnPlayerDisconnectedAsync; + await this.StopSessionAsync(player).ConfigureAwait(false); + } + + private async ValueTask StopSessionAsync(Player player) + { if (!this._sessions.TryRemove(player, out var session)) { return; diff --git a/src/GameServer/RemotePlayerConnectionInfo.cs b/src/GameServer/RemotePlayerConnectionInfo.cs index f54b31f34e..fca090b423 100644 --- a/src/GameServer/RemotePlayerConnectionInfo.cs +++ b/src/GameServer/RemotePlayerConnectionInfo.cs @@ -19,6 +19,8 @@ internal sealed class RemotePlayerConnectionInfo : ICapturedConnectionInfo private readonly IConnection _connection; + private readonly NetworkObservationHandler? _observationHandler; + /// /// Initializes a new instance of the class. /// @@ -26,12 +28,20 @@ internal sealed class RemotePlayerConnectionInfo : ICapturedConnectionInfo /// The connection of the player. /// The identifier of the game server. /// The description of the game server. - public RemotePlayerConnectionInfo(RemotePlayer player, IConnection connection, int serverId, string serverDescription) + /// The handler which archives the traffic of the observed + /// accounts, if the observation is configured. + public RemotePlayerConnectionInfo( + RemotePlayer player, + IConnection connection, + int serverId, + string serverDescription, + NetworkObservationHandler? observationHandler = null) { this._player = player; this._connection = connection; this.ServerId = serverId; this.ServerDescription = serverDescription; + this._observationHandler = observationHandler; } /// @@ -67,12 +77,40 @@ public RemotePlayerConnectionInfo(RemotePlayer player, IConnection connection, i /// public string DisplayName => this.CharacterName ?? this.AccountName ?? this.RemoteEndPoint ?? this.Id.ToString(); + /// + public bool IsObserved => this._player.Account?.IsNetworkObservationActive is true; + /// public void AddCaptureSink(IPacketCaptureSink sink) => this._connection.AddCaptureSink(sink); /// public void RemoveCaptureSink(IPacketCaptureSink sink) => this._connection.RemoveCaptureSink(sink); + /// + public async ValueTask SetObservationAsync(bool isActive) + { + if (this._player.Account is not { } account) + { + return false; + } + + if (account.IsNetworkObservationActive != isActive) + { + account.IsNetworkObservationActive = isActive; + + // The flag belongs to the account, so it survives the session - the player owns + // the account object and its context, so it's saved through the player. + await this._player.SaveProgressAsync().ConfigureAwait(false); + } + + if (this._observationHandler is { } observationHandler) + { + await observationHandler.ApplyObservationAsync(this._player, isActive).ConfigureAwait(false); + } + + return true; + } + /// public ValueTask DisconnectAsync() => this._player.DisconnectAsync(); } diff --git a/src/Network/Analyzer/ICapturedConnectionInfo.cs b/src/Network/Analyzer/ICapturedConnectionInfo.cs index 8e95fa14ed..d54b0fb39e 100644 --- a/src/Network/Analyzer/ICapturedConnectionInfo.cs +++ b/src/Network/Analyzer/ICapturedConnectionInfo.cs @@ -72,6 +72,24 @@ public interface ICapturedConnectionInfo /// string DisplayName { get; } + /// + /// Gets a value indicating whether the traffic of the account of this connection is + /// observed, so that it's archived for each of its sessions. + /// + /// + /// The observation is an account setting, so only a connection which knows its account - + /// a game server connection - can be observed. + /// + bool IsObserved => false; + + /// + /// Sets whether the traffic of the account of this connection is observed. It's applied + /// to the running session as well, so it doesn't need a reconnect. + /// + /// If set to true, the traffic is observed. + /// , if it has been applied. + ValueTask SetObservationAsync(bool isActive) => ValueTask.FromResult(false); + /// /// Adds a sink which gets the data packets of this connection. /// diff --git a/src/Network/Analyzer/IPacketCaptureService.cs b/src/Network/Analyzer/IPacketCaptureService.cs index a02291ee1e..24e2b7a194 100644 --- a/src/Network/Analyzer/IPacketCaptureService.cs +++ b/src/Network/Analyzer/IPacketCaptureService.cs @@ -50,6 +50,15 @@ public interface IPacketCaptureService /// The identifier of the connection. void StopCapture(Guid connectionId); + /// + /// Sets whether the traffic of the account of the specified connection is observed, so + /// that it's archived for each of its sessions. + /// + /// The identifier of the connection. + /// If set to true, the traffic is observed. + /// , if it has been applied. + ValueTask SetObservationAsync(Guid connectionId, bool isActive); + /// /// Gets the currently running capture of the specified connection. /// diff --git a/src/Network/Analyzer/PacketCaptureService.cs b/src/Network/Analyzer/PacketCaptureService.cs index 63ebd08f53..76d960a4d7 100644 --- a/src/Network/Analyzer/PacketCaptureService.cs +++ b/src/Network/Analyzer/PacketCaptureService.cs @@ -107,6 +107,17 @@ public void StopCapture(Guid connectionId) } } + /// + public async ValueTask SetObservationAsync(Guid connectionId, bool isActive) + { + if (await this.FindConnectionAsync(connectionId).ConfigureAwait(false) is not { } connectionInfo) + { + return false; + } + + return await connectionInfo.SetObservationAsync(isActive).ConfigureAwait(false); + } + /// public ILiveCapturedConnection? GetRunningCapture(Guid connectionId) { diff --git a/src/Web/AdminPanel/API/NetworkArchiveController.cs b/src/Web/AdminPanel/API/NetworkArchiveController.cs new file mode 100644 index 0000000000..16575c0984 --- /dev/null +++ b/src/Web/AdminPanel/API/NetworkArchiveController.cs @@ -0,0 +1,109 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.API; + +using System.Globalization; +using System.IO; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Network.Analyzer.Archive; + +/// +/// Controller which offers the archived sessions of the observed accounts as a download. +/// +/// +/// The archived files are not served statically: they contain the traffic of a player in plain +/// text, including its login packet. Like every other controller of the admin panel, this one +/// requires an authenticated user. +/// +[Route("api/network-archive/")] +public class NetworkArchiveController : Controller +{ + private readonly IServiceProvider _serviceProvider; + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider, used to resolve the archive + /// optionally - it's only registered when the network observation is configured. + /// The logger. + public NetworkArchiveController(IServiceProvider serviceProvider, ILogger logger) + { + this._serviceProvider = serviceProvider; + this._logger = logger; + } + + /// + /// Downloads the specified archived session as one capture file, which can be opened by + /// the analyzer tool. + /// + /// The identifier of the session. + /// The async task. + [HttpGet("{**sessionId}")] + public async Task DownloadAsync(string sessionId) + { + if (this._serviceProvider.GetService(typeof(IPacketArchive)) is not IPacketArchive archive + || await archive.GetSessionAsync(sessionId).ConfigureAwait(false) is not { } session) + { + this.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + // Downloading the traffic of a player is as intrusive as observing it, so it leaves a + // trace as well. + this._logger.LogInformation("The archived session {SessionId} has been downloaded.", session.Id); + + this.Response.ContentType = "text/csv"; + this.Response.Headers.ContentDisposition = $"attachment; filename=\"{GetFileName(session)}\""; + + // The parts of the session are concatenated to one file: they only differ by the point + // in time at which the previous one got too big, and the timestamps of the packets are + // relative to the start of the session anyway. + await using var writer = new StreamWriter(this.Response.Body); + await writer.WriteLineAsync(session.Metadata.StartTimestamp.ToString("O", CultureInfo.InvariantCulture)).ConfigureAwait(false); + foreach (var part in session.Metadata.Parts) + { + await WritePartAsync(session, part, writer).ConfigureAwait(false); + } + } + + private static async Task WritePartAsync(ArchivedSessionInfo session, string part, StreamWriter writer) + { + var path = Path.Combine(session.DirectoryPath, Path.GetFileName(part)); + if (!System.IO.File.Exists(path)) + { + return; + } + + // The session may still be running, so the file is shared with its writer. + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream); + + // The first line of each part is the start timestamp, which is already written. + _ = await reader.ReadLineAsync().ConfigureAwait(false); + while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line) + { + await writer.WriteLineAsync(line).ConfigureAwait(false); + } + } + + private static string GetFileName(ArchivedSessionInfo session) + { + var accountName = new string(session.Metadata.AccountName + .Where(character => char.IsLetterOrDigit(character) || character is '-' or '_') + .ToArray()); + if (string.IsNullOrEmpty(accountName)) + { + accountName = "session"; + } + + return string.Create( + CultureInfo.InvariantCulture, + $"{accountName}_{session.Metadata.StartTimestamp:yyyy-MM-dd_HH-mm-ss}.mucap"); + } +} diff --git a/src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor b/src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor new file mode 100644 index 0000000000..40aea78abf --- /dev/null +++ b/src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor @@ -0,0 +1,65 @@ +@using MUnique.OpenMU.Network.Analyzer.Archive +@using MUnique.OpenMU.Web.AdminPanel.Properties + +
+
+ @Resources.ArchivedSessions + +
+ + @if (!this.AccountGroups.Any()) + { +

@Resources.NoArchivedSessions

+ } + else + { +
+ @foreach (var group in this.AccountGroups) + { +
+

+ +

+
+
+ @foreach (var session in group) + { + + } +
+
+
+ } +
+ } +
diff --git a/src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor.cs b/src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor.cs new file mode 100644 index 0000000000..ae1e625b30 --- /dev/null +++ b/src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor.cs @@ -0,0 +1,111 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Components.NetworkAnalyzer; + +using System.Globalization; +using Microsoft.AspNetCore.Components; +using MUnique.OpenMU.Network.Analyzer.Archive; + +/// +/// The list of the archived sessions of the observed accounts, grouped by their account. +/// +public partial class ArchiveList +{ + private readonly HashSet _collapsedGroups = new(); + + /// + /// Gets or sets the archived sessions which should be listed. + /// + [Parameter] + public IReadOnlyList Sessions { get; set; } = []; + + /// + /// Gets or sets the identifier of the currently opened session. + /// + [Parameter] + public string? SelectedSessionId { get; set; } + + /// + /// Gets or sets the route which downloads a session, to which its identifier is appended. + /// + [Parameter] + public string DownloadRoute { get; set; } = string.Empty; + + /// + /// Gets or sets the callback which is invoked when a session should be opened. + /// + [Parameter] + public EventCallback OnSelect { get; set; } + + /// + /// Gets or sets the callback which is invoked when a session should be deleted. + /// + [Parameter] + public EventCallback OnDelete { get; set; } + + /// + /// Gets or sets the callback which is invoked when the list should be refreshed. + /// + [Parameter] + public EventCallback OnRefresh { get; set; } + + private IEnumerable> AccountGroups => + this.Sessions + .GroupBy(session => session.Metadata.AccountName) + .OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the description of a session, which is shown below its date. + /// + /// The session. + /// The description of the session. + private static string GetDescription(ArchivedSessionInfo session) + { + return string.Create( + CultureInfo.InvariantCulture, + $"{session.Metadata.PacketCount} × {FormatDuration(session.Duration)} × {FormatSize(session.SizeInBytes)}"); + } + + /// + /// Escapes the identifier of a session, so that it can be used in the url of the download. + /// + /// The identifier of the session. + /// The escaped identifier. + /// + /// Only the segments are escaped: the separators between the account and the session stay + /// as they are, so that the route of the download still recognizes them. + /// + private static string EscapeSessionId(string sessionId) + { + return string.Join('/', sessionId.Split('/').Select(Uri.EscapeDataString)); + } + + private static string FormatDuration(TimeSpan duration) + { + return duration < TimeSpan.FromHours(1) + ? duration.ToString(@"mm\:ss", CultureInfo.InvariantCulture) + : duration.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture); + } + + private static string FormatSize(long sizeInBytes) + { + return sizeInBytes switch + { + < 1024 => string.Create(CultureInfo.InvariantCulture, $"{sizeInBytes} B"), + < 1024 * 1024 => string.Create(CultureInfo.InvariantCulture, $"{sizeInBytes / 1024.0:F1} KB"), + _ => string.Create(CultureInfo.InvariantCulture, $"{sizeInBytes / (1024.0 * 1024.0):F1} MB"), + }; + } + + private bool IsCollapsed(string accountName) => this._collapsedGroups.Contains(accountName); + + private void ToggleGroup(string accountName) + { + if (!this._collapsedGroups.Remove(accountName)) + { + this._collapsedGroups.Add(accountName); + } + } +} diff --git a/src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor.css b/src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor.css new file mode 100644 index 0000000000..5e8efe3365 --- /dev/null +++ b/src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor.css @@ -0,0 +1,5 @@ +.accordion { + /* The sessions scroll inside the sidebar, so the page doesn't grow with them. */ + max-height: 40vh; + overflow-y: auto; +} diff --git a/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor b/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor index 135a87b9b2..d6d33f7588 100644 --- a/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor +++ b/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor @@ -1,5 +1,6 @@ @page "/network-analyzer" @page "/network-analyzer/{ConnectionId:guid}" +@page "/network-analyzer/archive/{*SessionId}" @using MUnique.OpenMU.Network.Analyzer @using MUnique.OpenMU.Web.AdminPanel.Components.NetworkAnalyzer @@ -33,11 +34,21 @@ else OnSelect="this.OnConnectionSelectedAsync" OnDisconnect="this.OnDisconnectAsync" OnRefresh="this.RefreshConnectionsAsync"/> + + @if (this.IsArchiveAvailable) + { + + } } - +
- @if (this._capture is null) + @if (this._capture is null && this._selectedSession is null) {

@Resources.SelectAConnection

} @@ -46,9 +57,35 @@ else
- @this._capture.ConnectionInfo.DisplayName - @this._capture.ConnectionInfo.RemoteEndPoint - @this._capture.ConnectionInfo.ClientVersion + @if (this._capture is { } capture) + { + @capture.ConnectionInfo.DisplayName + @capture.ConnectionInfo.RemoteEndPoint + @capture.ConnectionInfo.ClientVersion + @if (capture.ConnectionInfo.AccountName is not null) + { + + } + } + else if (this._selectedSession is { } session) + { + @session.Metadata.AccountName + @if (session.Metadata.CharacterNames.Count > 0) + { + @string.Join(", ", session.Metadata.CharacterNames) + } + @session.Metadata.ClientVersion + @Resources.ArchivedSession + @if (session.IsRunning) + { + @Resources.SessionIsRunning + } + @session.Metadata.StartTimestamp.ToString("yyyy-MM-dd HH:mm:ss") UTC + }
@Resources.FollowNewPackets - + @if (this._capture is not null) + { + + }
@@ -75,7 +115,7 @@ else diff --git a/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor.cs b/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor.cs index 4d90093b01..726e4d9b58 100644 --- a/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor.cs +++ b/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor.cs @@ -7,12 +7,28 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; using System.Threading; using Microsoft.AspNetCore.Components; using MUnique.OpenMU.Network.Analyzer; +using MUnique.OpenMU.Network.Analyzer.Archive; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.Shared; +using MUnique.OpenMU.Web.Shared.Components.Modal; /// /// The page which shows the network traffic of the connections of our servers. /// public partial class NetworkAnalyzer : IAsyncDisposable { + /// + /// The route which downloads an archived session, with its identifier appended. + /// + internal const string ArchiveDownloadRoute = "api/network-archive/"; + + /// + /// The maximum number of packets which are loaded from an archived session. Only the + /// newest ones are shown when it contains more. + /// + private const int MaximumArchivedPacketCount = 5000; + /// /// The interval in which the list of the connections is refreshed. /// @@ -51,6 +67,12 @@ public partial class NetworkAnalyzer : IAsyncDisposable private IPacketCaptureService? _captureService; + private IPacketArchive? _archive; + + private IReadOnlyList _archivedSessions = []; + + private ArchivedSessionInfo? _selectedSession; + /// /// The direction of the packets which should be shown. /// @@ -78,6 +100,12 @@ private enum DirectionFilter [Parameter] public Guid? ConnectionId { get; set; } + /// + /// Gets or sets the identifier of the archived session which should be opened initially. + /// + [Parameter] + public string? SessionId { get; set; } + /// /// Gets or sets the service provider, used to resolve the capture service optionally: /// it's only registered in the all-in-one deployment, because it needs the servers in the @@ -92,30 +120,24 @@ private enum DirectionFilter [Inject] public PacketAnalyzerProvider AnalyzerProvider { get; set; } = null!; - private IPacketCaptureService? CaptureService => this._captureService; + /// + /// Gets or sets the modal service, used to confirm the deletion of an archived session. + /// + [Inject] + public IModalService ModalService { get; set; } = null!; - private string TableColClass => this._isSidebarCollapsed ? "col-12" : "col-10"; + private IPacketCaptureService? CaptureService => this._captureService; - private void UpdateFilteredPackets() - { - IEnumerable packets = this._packets; - packets = this._directionFilter switch - { - DirectionFilter.ToServer => packets.Where(packet => packet.ToServer), - DirectionFilter.ToClient => packets.Where(packet => !packet.ToServer), - _ => packets, - }; + private bool IsArchiveAvailable => this._archive is not null; - if (!string.IsNullOrWhiteSpace(this._packetFilter)) - { - var filter = this._packetFilter; - packets = packets.Where(packet => packet.PacketData.Contains(filter, StringComparison.OrdinalIgnoreCase) - || packet.DisplayCode.ToString("X2").Contains(filter, StringComparison.OrdinalIgnoreCase) - || this.GetMessageForFilter(packet).Contains(filter, StringComparison.OrdinalIgnoreCase)); - } + /// + /// Gets the client version which applies to the shown packets - either the one of the + /// captured connection, or the one which was recorded with the archived session. + /// + private ClientVersion CurrentClientVersion => + this._capture?.ConnectionInfo.ClientVersion ?? this._selectedSession?.Metadata.ClientVersion ?? default; - this._filteredPackets = packets.ToList(); - } + private string TableColClass => this._isSidebarCollapsed ? "col-12" : "col-10"; /// public async ValueTask DisposeAsync() @@ -130,48 +152,48 @@ protected override async Task OnInitializedAsync() { await base.OnInitializedAsync().ConfigureAwait(true); this._captureService = this.ServiceProvider.GetService(typeof(IPacketCaptureService)) as IPacketCaptureService; + this._archive = this.ServiceProvider.GetService(typeof(IPacketArchive)) as IPacketArchive; if (this._captureService is null) { return; } _ = await this.RefreshConnectionsAsync().ConfigureAwait(true); + _ = await this.RefreshArchiveAsync().ConfigureAwait(true); if (this.ConnectionId is { } connectionId && this._connections.FirstOrDefault(connection => connection.Id == connectionId) is { } preselected) { await this.OnConnectionSelectedAsync(preselected).ConfigureAwait(true); } + else if (!string.IsNullOrEmpty(this.SessionId) + && this._archivedSessions.FirstOrDefault(session => session.Id == this.SessionId) is { } preselectedSession) + { + await this.OnArchivedSessionSelectedAsync(preselectedSession).ConfigureAwait(true); + } _ = this.RefreshPeriodicallyAsync(); } - /// - /// Refreshes the list of the connections. - /// - /// , if the listed connections changed. - /// - /// The servers create the information about their connections on each request, so the - /// list is only replaced when it actually differs - otherwise the sidebar would be - /// rendered again and again. - /// - private async Task RefreshConnectionsAsync() + private static bool HasChanged(IReadOnlyList current, IReadOnlyList updated) { - if (this._captureService is not { } captureService) + if (current.Count != updated.Count) { - return false; + return true; } - var connections = await captureService.GetConnectionsAsync().ConfigureAwait(true); - if (!HasChanged(this._connections, connections)) + for (int i = 0; i < current.Count; i++) { - return false; + if (current[i].Id != updated[i].Id + || current[i].DisplayName != updated[i].DisplayName) + { + return true; + } } - this._connections = connections; - return true; + return false; } - private static bool HasChanged(IReadOnlyList current, IReadOnlyList updated) + private static bool HasChanged(IReadOnlyList current, IReadOnlyList updated) { if (current.Count != updated.Count) { @@ -181,7 +203,8 @@ private static bool HasChanged(IReadOnlyList current, I for (int i = 0; i < current.Count; i++) { if (current[i].Id != updated[i].Id - || current[i].DisplayName != updated[i].DisplayName) + || current[i].SizeInBytes != updated[i].SizeInBytes + || current[i].IsRunning != updated[i].IsRunning) { return true; } @@ -190,6 +213,53 @@ private static bool HasChanged(IReadOnlyList current, I return false; } + private void UpdateFilteredPackets() + { + IEnumerable packets = this._packets; + packets = this._directionFilter switch + { + DirectionFilter.ToServer => packets.Where(packet => packet.ToServer), + DirectionFilter.ToClient => packets.Where(packet => !packet.ToServer), + _ => packets, + }; + + if (!string.IsNullOrWhiteSpace(this._packetFilter)) + { + var filter = this._packetFilter; + packets = packets.Where(packet => packet.PacketData.Contains(filter, StringComparison.OrdinalIgnoreCase) + || packet.DisplayCode.ToString("X2").Contains(filter, StringComparison.OrdinalIgnoreCase) + || this.GetMessageForFilter(packet).Contains(filter, StringComparison.OrdinalIgnoreCase)); + } + + this._filteredPackets = packets.ToList(); + } + + /// + /// Refreshes the list of the connections. + /// + /// , if the listed connections changed. + /// + /// The servers create the information about their connections on each request, so the + /// list is only replaced when it actually differs - otherwise the sidebar would be + /// rendered again and again. + /// + private async Task RefreshConnectionsAsync() + { + if (this._captureService is not { } captureService) + { + return false; + } + + var connections = await captureService.GetConnectionsAsync().ConfigureAwait(true); + if (!HasChanged(this._connections, connections)) + { + return false; + } + + this._connections = connections; + return true; + } + private async Task OnConnectionSelectedAsync(ICapturedConnectionInfo connection) { if (this._captureService is not { } captureService || this._selectedConnection?.Id == connection.Id) @@ -198,6 +268,7 @@ private async Task OnConnectionSelectedAsync(ICapturedConnectionInfo connection) } this.StopCapture(); + this._selectedSession = null; this._selectedConnection = connection; this._capture = await captureService.StartCaptureAsync(connection.Id).ConfigureAwait(true); @@ -211,6 +282,116 @@ private async Task OnConnectionSelectedAsync(ICapturedConnectionInfo connection) this.UpdateFilteredPackets(); } + /// + /// Refreshes the list of the archived sessions. + /// + /// , if the listed sessions changed. + private async Task RefreshArchiveAsync() + { + if (this._archive is not { } archive) + { + return false; + } + + var sessions = await archive.GetSessionsAsync().ConfigureAwait(true); + if (!HasChanged(this._archivedSessions, sessions)) + { + return false; + } + + this._archivedSessions = sessions; + return true; + } + + /// + /// Opens an archived session, which stops a running capture of the page. + /// + /// The session which should be shown. + /// The async task. + private async Task OnArchivedSessionSelectedAsync(ArchivedSessionInfo session) + { + this.StopCapture(); + + this._selectedSession = session; + this._selectedPacket = null; + this._isFollowing = true; + this._analyzer = this.AnalyzerProvider.GetAnalyzer(PacketDefinitionSet.GameServer); + await this.LoadArchivedPacketsAsync().ConfigureAwait(true); + } + + /// + /// Deletes an archived session, after the user confirmed it. + /// + /// The session which should be deleted. + /// The async task. + private async Task OnDeleteArchivedSessionAsync(ArchivedSessionInfo session) + { + if (this._archive is not { } archive) + { + return; + } + + var isConfirmed = await this.ModalService + .ShowQuestionAsync(Resources.DeleteArchivedSession, string.Format(Resources.DeleteArchivedSessionQuestion, session.DisplayName)) + .ConfigureAwait(true); + if (!isConfirmed) + { + return; + } + + if (await archive.DeleteSessionAsync(session.Id).ConfigureAwait(true) + && this._selectedSession?.Id == session.Id) + { + this._selectedSession = null; + this._packets = []; + this._selectedPacket = null; + this.UpdateFilteredPackets(); + } + + _ = await this.RefreshArchiveAsync().ConfigureAwait(true); + } + + /// + /// Toggles the observation of the account of the selected connection. + /// + /// The async task. + private async Task ToggleObservationAsync() + { + if (this._captureService is not { } captureService || this._capture is not { } capture) + { + return; + } + + await captureService.SetObservationAsync(capture.ConnectionInfo.Id, !capture.ConnectionInfo.IsObserved).ConfigureAwait(true); + _ = await this.RefreshArchiveAsync().ConfigureAwait(true); + } + + /// + /// Loads the packets of the opened archived session. + /// + /// , if the shown packets changed. + private async Task LoadArchivedPacketsAsync() + { + if (this._selectedSession is not { } selectedSession || this._archive is not { } archive) + { + return false; + } + + // A running session grows while it's shown, so its file is read again - the newest + // packets are the interesting ones, and their number is capped anyway. + var session = await archive.GetSessionAsync(selectedSession.Id).ConfigureAwait(true) ?? selectedSession; + var loaded = await ArchivedSession.LoadAsync(session, MaximumArchivedPacketCount).ConfigureAwait(true); + if (loaded.PacketList.Count == this._packets.Count && this._packets.Count > 0) + { + return false; + } + + this._selectedSession = session; + this._packets = loaded.PacketList.ToList(); + this.UpdateFilteredPackets(); + return true; + } + private async Task OnDisconnectAsync(ICapturedConnectionInfo connection) { await connection.DisconnectAsync().ConfigureAwait(true); @@ -289,14 +470,14 @@ private void OnDirectionFilterChanged() private string GetMessageForFilter(Packet packet) { - if (this._analyzer is not { } analyzer || this._capture is not { } capture) + if (this._analyzer is not { } analyzer) { return string.Empty; } try { - return analyzer.ExtractShortInformation(packet, capture.ConnectionInfo.ClientVersion).Data; + return analyzer.ExtractShortInformation(packet, this.CurrentClientVersion).Data; } catch { @@ -327,9 +508,14 @@ await this.InvokeAsync(async () => if (refreshConnections) { hasChanged = await this.RefreshConnectionsAsync().ConfigureAwait(true); + hasChanged |= await this.RefreshArchiveAsync().ConfigureAwait(true); } hasChanged |= this.UpdatePackets(); + if (this._selectedSession is { IsRunning: true } && this._isFollowing) + { + hasChanged |= await this.LoadArchivedPacketsAsync().ConfigureAwait(true); + } // Rendering without a change would just make the grid flicker, which is // especially annoying while the user scrolls through the packets. diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index d7b0f94619..0a01da2123 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -2366,6 +2366,87 @@ public static string FollowNewPackets { } } + /// + /// Looks up a localized string similar to Archived sessions. + /// + public static string ArchivedSessions { + get { + return ResourceManager.GetString("ArchivedSessions", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No archived sessions.. + /// + public static string NoArchivedSessions { + get { + return ResourceManager.GetString("NoArchivedSessions", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Archive. + /// + public static string ArchivedSession { + get { + return ResourceManager.GetString("ArchivedSession", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Running. + /// + public static string SessionIsRunning { + get { + return ResourceManager.GetString("SessionIsRunning", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Observe. + /// + public static string ObserveAccount { + get { + return ResourceManager.GetString("ObserveAccount", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Archives the traffic of this account for each of its sessions, until it's turned off again.. + /// + public static string ObserveAccountHint { + get { + return ResourceManager.GetString("ObserveAccountHint", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete the archived session. + /// + public static string DeleteArchivedSession { + get { + return ResourceManager.GetString("DeleteArchivedSession", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You're about to delete the archived session '{0}'. Are you sure?. + /// + public static string DeleteArchivedSessionQuestion { + get { + return ResourceManager.GetString("DeleteArchivedSessionQuestion", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Download the archived session. + /// + public static string DownloadArchivedSession { + get { + return ResourceManager.GetString("DownloadArchivedSession", resourceCulture); + } + } + /// /// Looks up a localized string similar to Shows the newest packets and scrolls to them. It's turned off while you scroll up, so that the view doesn't move away.. /// diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index dc5ec73f8c..ccbf07cea3 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -891,4 +891,31 @@ Shows the newest packets and scrolls to them. It's turned off while you scroll up, so that the view doesn't move away. + + Archived sessions + + + No archived sessions. + + + Archive + + + Running + + + Observe + + + Archives the traffic of this account for each of its sessions, until it's turned off again. + + + Delete the archived session + + + You're about to delete the archived session '{0}'. Are you sure? + + + Download the archived session + \ No newline at end of file diff --git a/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/ArchiveListTests.cs b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/ArchiveListTests.cs new file mode 100644 index 0000000000..a0bebae741 --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/ArchiveListTests.cs @@ -0,0 +1,154 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests.NetworkAnalyzer; + +using Bunit; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Network.Analyzer.Archive; +using MUnique.OpenMU.Web.AdminPanel.Components.NetworkAnalyzer; + +/// +/// Tests for the . +/// +[TestFixture] +public class ArchiveListTests +{ + /// + /// Tests if a hint is shown when nothing is archived yet. + /// + [Test] + public void ShowsHintWhenThereAreNoSessions() + { + using var context = CreateContext(); + + var component = context.Render(parameters => parameters + .Add(list => list.Sessions, [])); + + Assert.That(component.Markup, Does.Contain("No archived sessions")); + } + + /// + /// Tests if the sessions are grouped by their account. + /// + [Test] + public void SessionsAreGroupedByAccount() + { + using var context = CreateContext(); + IReadOnlyList sessions = + [ + CreateSession("FirstAccount"), + CreateSession("SecondAccount"), + CreateSession("SecondAccount"), + ]; + + var component = context.Render(parameters => parameters + .Add(list => list.Sessions, sessions)); + + var groups = component.FindAll(".accordion-item"); + Assert.That(groups, Has.Count.EqualTo(2)); + Assert.That(groups[0].TextContent, Does.Contain("FirstAccount")); + Assert.That(groups[1].TextContent, Does.Contain("SecondAccount")); + Assert.That(component.FindAll(".list-group-item"), Has.Count.EqualTo(3)); + } + + /// + /// Tests if the download link points to the session, with a name which is escaped for the + /// url. + /// + [Test] + public void DownloadLinkPointsToTheSession() + { + using var context = CreateContext(); + var session = CreateSession("Account With Space"); + + var component = context.Render(parameters => parameters + .Add(list => list.Sessions, [session]) + .Add(list => list.DownloadRoute, "api/network-archive/")); + + var link = component.Find(".list-group-item a"); + Assert.That(link.GetAttribute("href"), Is.EqualTo("api/network-archive/Account%20With%20Space/2026-08-29_21-00-00_1")); + } + + /// + /// Tests if the selection of a session is reported. + /// + [Test] + public void SelectionIsReported() + { + using var context = CreateContext(); + var session = CreateSession("TestAccount"); + ArchivedSessionInfo? selected = null; + + var component = context.Render(parameters => parameters + .Add(list => list.Sessions, [session]) + .Add(list => list.OnSelect, info => selected = info)); + component.Find(".list-group-item").Click(); + + Assert.That(selected, Is.EqualTo(session)); + } + + /// + /// Tests if the deletion of a session is reported, without selecting it. + /// + [Test] + public void DeletionIsReportedWithoutSelectingTheSession() + { + using var context = CreateContext(); + var session = CreateSession("TestAccount"); + ArchivedSessionInfo? deleted = null; + ArchivedSessionInfo? selected = null; + + var component = context.Render(parameters => parameters + .Add(list => list.Sessions, [session]) + .Add(list => list.OnSelect, info => selected = info) + .Add(list => list.OnDelete, info => deleted = info)); + component.Find(".oi-trash").Click(); + + Assert.That(deleted, Is.EqualTo(session)); + Assert.That(selected, Is.Null, "Deleting a session should not open it."); + } + + /// + /// Tests if a running session is marked as such, so that an admin sees that the player is + /// still online. + /// + [Test] + public void RunningSessionIsMarked() + { + using var context = CreateContext(); + + var component = context.Render(parameters => parameters + .Add(list => list.Sessions, [CreateSession("TestAccount", isRunning: true)])); + + Assert.That(component.FindAll(".oi-media-record"), Has.Count.EqualTo(1)); + } + + private static BunitContext CreateContext() + { + var context = new BunitContext(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + return context; + } + + private static ArchivedSessionInfo CreateSession(string accountName, bool isRunning = false) + { + var metadata = new ArchivedSessionMetadata + { + AccountName = accountName, + ServerType = ServerType.GameServer, + ServerId = 1, + StartTimestamp = new DateTime(2026, 8, 29, 21, 0, 0, DateTimeKind.Utc), + PacketCount = 42, + }; + + var directory = $"{metadata.StartTimestamp:yyyy-MM-dd_HH-mm-ss}_{metadata.ServerId}"; + return new ArchivedSessionInfo( + $"{accountName}/{directory}", + $"/tmp/{accountName}/{directory}", + metadata, + 1234, + isRunning); + } +} diff --git a/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/ArchiveTestHelper.cs b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/ArchiveTestHelper.cs new file mode 100644 index 0000000000..13cf1cf685 --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/ArchiveTestHelper.cs @@ -0,0 +1,78 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests.NetworkAnalyzer; + +using System.IO; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Network.Analyzer.Archive; +using MUnique.OpenMU.Network.PlugIns; + +/// +/// Creates archives with sessions for the tests of the network analyzer page. +/// +public static class ArchiveTestHelper +{ + /// + /// Creates a path of an archive directory which doesn't exist yet. + /// + /// The path of the archive directory. + public static string CreateArchivePath() + { + return Path.Combine(Path.GetTempPath(), "openmu-page-archive-" + Guid.NewGuid().ToString("N")); + } + + /// + /// Creates an archive at the specified path. + /// + /// The path of the archive. + /// The created archive. + public static PacketArchive CreateArchive(string archivePath) + { + return new PacketArchive( + new NetworkObservationOptions { ArchivePath = archivePath }, + new NullLogger()); + } + + /// + /// Adds a finished session of the specified account to the archive. + /// + /// The archive. + /// The name of the account. + /// The packet which is archived. + /// The information about the created session. + public static async ValueTask AddSessionAsync(PacketArchive archive, string accountName, byte[] packet) + { + var metadata = new ArchivedSessionMetadata + { + AccountName = accountName, + ServerType = ServerType.GameServer, + ServerId = 1, + ServerDescription = "Test Server", + RemoteEndPoint = "127.0.0.1:1234", + ClientVersion = new ClientVersion(6, 3, ClientLanguage.English), + StartTimestamp = DateTime.UtcNow, + }; + + var writer = await archive.StartSessionAsync(metadata).ConfigureAwait(false); + writer!.PacketCaptured(packet, false); + await writer.DisposeAsync().ConfigureAwait(false); + + var sessions = await archive.GetSessionsAsync(accountName).ConfigureAwait(false); + return sessions.First(session => session.Metadata.StartTimestamp == metadata.StartTimestamp); + } + + /// + /// Removes the archive directory again. + /// + /// The path of the archive. + public static void DeleteArchive(string archivePath) + { + if (Directory.Exists(archivePath)) + { + Directory.Delete(archivePath, true); + } + } +} diff --git a/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/NetworkAnalyzerPageTests.cs b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/NetworkAnalyzerPageTests.cs index d776e6271c..d8452b9010 100644 --- a/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/NetworkAnalyzerPageTests.cs +++ b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/NetworkAnalyzerPageTests.cs @@ -7,8 +7,11 @@ namespace MUnique.OpenMU.Web.Tests.NetworkAnalyzer; using Bunit; using Microsoft.Extensions.DependencyInjection; using MUnique.OpenMU.Network.Analyzer; +using MUnique.OpenMU.Network.Analyzer.Archive; +using MUnique.OpenMU.Network.PlugIns; using MUnique.OpenMU.Web.AdminPanel.Components.NetworkAnalyzer; using MUnique.OpenMU.Web.AdminPanel.Pages; +using MUnique.OpenMU.Web.Shared.Components.Modal; using MUnique.OpenMU.Web.Shared.Services; /// @@ -17,6 +20,28 @@ namespace MUnique.OpenMU.Web.Tests.NetworkAnalyzer; [TestFixture] public class NetworkAnalyzerPageTests { + private static readonly byte[] TestPacket = [0xC1, 0x04, 0xF1, 0x01]; + + private string _archivePath = null!; + + /// + /// Creates the path of the archive of the test. + /// + [SetUp] + public void SetUp() + { + this._archivePath = ArchiveTestHelper.CreateArchivePath(); + } + + /// + /// Removes the archive of the test. + /// + [TearDown] + public void TearDown() + { + ArchiveTestHelper.DeleteArchive(this._archivePath); + } + /// /// Tests if a note is shown when the capture service is not registered, which is the case /// in the distributed deployment. @@ -243,17 +268,122 @@ public async Task UnchangedConnectionsDoNotRenderThePage() Assert.That(component.RenderCount, Is.EqualTo(renderCount), "The unchanged connections should not render the page."); } - private static BunitContext CreateContext(IPacketCaptureService? captureService = null) + /// + /// Tests if the archived sessions of the observed accounts are listed in the sidebar. + /// + /// The async task. + [Test] + public async Task ArchivedSessionsAreListedAsync() + { + var archive = ArchiveTestHelper.CreateArchive(this._archivePath); + await ArchiveTestHelper.AddSessionAsync(archive, "ObservedAccount", TestPacket).ConfigureAwait(false); + using var context = CreateContext(new TestCaptureService(), archive); + + var component = context.Render(); + + // The archive is read from the file system, so the list arrives with a later render. + component.WaitForState(() => component.Markup.Contains("ObservedAccount"), TimeSpan.FromSeconds(10)); + Assert.That(component.Markup, Does.Contain("Archived sessions")); + } + + /// + /// Tests if the packets of an archived session are shown when it's opened. + /// + /// The async task. + [Test] + public async Task ArchivedSessionShowsItsPacketsAsync() + { + var archive = ArchiveTestHelper.CreateArchive(this._archivePath); + await ArchiveTestHelper.AddSessionAsync(archive, "ObservedAccount", TestPacket).ConfigureAwait(false); + using var context = CreateContext(new TestCaptureService(), archive); + + var component = context.Render(); + component.WaitForElement(".archive-list .list-group-item", TimeSpan.FromSeconds(10)).Click(); + + // The packets of the session are read from its file, which takes a moment. + component.WaitForState( + () => component.FindComponents().Count > 0 && component.FindComponent().Instance.Packets.Count > 0, + TimeSpan.FromSeconds(10)); + var grid = component.FindComponent().Instance; + Assert.That(grid.Packets, Has.Count.EqualTo(1)); + Assert.That(grid.Packets[0].PacketData, Is.EqualTo("C1 04 F1 01")); + Assert.That(grid.ClientVersion, Is.EqualTo(new ClientVersion(6, 3, ClientLanguage.English)), "The version comes from the metadata of the session."); + } + + /// + /// Tests if an archived session is deleted after the user confirmed it. + /// + /// The async task. + [Test] + public async Task ArchivedSessionIsDeletedAfterConfirmationAsync() + { + var archive = ArchiveTestHelper.CreateArchive(this._archivePath); + var session = await ArchiveTestHelper.AddSessionAsync(archive, "ObservedAccount", TestPacket).ConfigureAwait(false); + var modalService = new TestModalService(); + using var context = CreateContext(new TestCaptureService(), archive, modalService); + + var component = context.Render(); + component.WaitForElement(".archive-list .oi-trash", TimeSpan.FromSeconds(10)).Click(); + + // The deletion is confirmed by the user and applied on the file system, so it takes a + // moment until the session is gone. + component.WaitForState(() => modalService.ShownDialogs.Count == 1, TimeSpan.FromSeconds(10)); + component.WaitForState(() => component.Markup.Contains("No archived sessions"), TimeSpan.FromSeconds(10)); + Assert.That(await archive.GetSessionAsync(session.Id).ConfigureAwait(false), Is.Null); + } + + /// + /// Tests if the observation of the account of the selected connection can be toggled. + /// + [Test] + public void ObservationOfAnAccountCanBeToggled() + { + var connection = new TestConnectionInfo { AccountName = "TestAccount", CharacterName = "TestCharacter" }; + var service = new TestCaptureService(connection); + using var context = CreateContext(service); + + var component = context.Render(); + component.Find(".list-group-item").Click(); + component.Find("button[title*='Archives the traffic']").Click(); + + Assert.That(service.ObservationChanges, Is.EqualTo(new[] { (connection.Id, true) })); + Assert.That(connection.IsObserved, Is.True); + } + + /// + /// Tests if the archive isn't shown when it's not configured - it's registered by the + /// game server, which isn't necessarily in the same process. + /// + [Test] + public void ArchiveIsNotShownWhenItIsNotAvailable() + { + using var context = CreateContext(new TestCaptureService()); + + var component = context.Render(); + + Assert.That(component.Markup, Does.Not.Contain("Archived sessions")); + } + + private static BunitContext CreateContext( + IPacketCaptureService? captureService = null, + IPacketArchive? archive = null, + IModalService? modalService = null) { var context = new BunitContext(); context.JSInterop.Mode = JSRuntimeMode.Loose; context.Services.AddSingleton(); context.Services.AddSingleton(); + context.Services.AddSingleton(modalService ?? new TestModalService()); if (captureService is not null) { context.Services.AddSingleton(captureService); } + if (archive is not null) + { + context.Services.AddSingleton(archive); + } + return context; } } diff --git a/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestCaptureService.cs b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestCaptureService.cs index e5a4511029..6467388592 100644 --- a/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestCaptureService.cs +++ b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestCaptureService.cs @@ -37,6 +37,11 @@ public TestCaptureService(params ICapturedConnectionInfo[] connections) /// public int RequestedConnectionsCount { get; private set; } + /// + /// Gets the observation changes which have been requested. + /// + public IList<(Guid ConnectionId, bool IsActive)> ObservationChanges { get; } = new List<(Guid, bool)>(); + /// public ValueTask> GetConnectionsAsync() { @@ -85,6 +90,18 @@ public void StopCapture(Guid connectionId) } } + /// + public async ValueTask SetObservationAsync(Guid connectionId, bool isActive) + { + this.ObservationChanges.Add((connectionId, isActive)); + if (await this.FindConnectionAsync(connectionId).ConfigureAwait(false) is not { } connectionInfo) + { + return false; + } + + return await connectionInfo.SetObservationAsync(isActive).ConfigureAwait(false); + } + /// public ILiveCapturedConnection? GetRunningCapture(Guid connectionId) { diff --git a/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestConnectionInfo.cs b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestConnectionInfo.cs index 7bc9dbbaa0..97b1ac8931 100644 --- a/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestConnectionInfo.cs +++ b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestConnectionInfo.cs @@ -68,12 +68,22 @@ public TestConnectionInfo(string serverDescription = "Test Server", int serverId /// public string DisplayName => this.CharacterName ?? this.AccountName ?? this.RemoteEndPoint ?? this.Id.ToString(); + /// + public bool IsObserved { get; private set; } + /// public void AddCaptureSink(IPacketCaptureSink sink) => this.Sinks.Add(sink); /// public void RemoveCaptureSink(IPacketCaptureSink sink) => this.Sinks.Remove(sink); + /// + public ValueTask SetObservationAsync(bool isActive) + { + this.IsObserved = isActive; + return ValueTask.FromResult(true); + } + /// public ValueTask DisconnectAsync() { diff --git a/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestModalService.cs b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestModalService.cs new file mode 100644 index 0000000000..54503fef31 --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/NetworkAnalyzer/TestModalService.cs @@ -0,0 +1,54 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests.NetworkAnalyzer; + +using Microsoft.AspNetCore.Components; +using MUnique.OpenMU.Web.Shared.Components.Modal; + +/// +/// A which answers every dialog without showing one. +/// +public sealed class TestModalService : IModalService +{ + private readonly bool _answer; + + /// + /// Initializes a new instance of the class. + /// + /// The answer which is given for each question. + public TestModalService(bool answer = true) + { + this._answer = answer; + } + + /// + /// Gets the titles of the dialogs which have been shown. + /// + public IList ShownDialogs { get; } = new List(); + + /// + public IModalReference Show(string title, ModalParameters? parameters = null, ModalOptions? options = null) + where TComponent : class, IComponent + { + return this.Show(typeof(TComponent), title, parameters, options); + } + + /// + public IModalReference Show(Type componentType, string title, ModalParameters? parameters = null, ModalOptions? options = null) + { + this.ShownDialogs.Add(title); + return new TestModalReference(ModalResult.Ok(this._answer)); + } + + private sealed class TestModalReference : IModalReference + { + public TestModalReference(ModalResult result) + { + this.Result = Task.FromResult(result); + } + + public Task Result { get; } + } +} From 64b578817ae52fbf1daf89a93aaf703d96c0de69 Mon Sep 17 00:00:00 2001 From: sven-n Date: Fri, 4 Sep 2026 21:11:25 +0200 Subject: [PATCH 4/5] regenerate resource designer files --- .../Properties/PlugInResources.Designer.cs | 318 ++++---- .../Properties/PlugInResources.Designer.cs | 688 +++++++++--------- .../Properties/Resources.Designer.cs | 170 +++-- 3 files changed, 584 insertions(+), 592 deletions(-) diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 68b82ab525..bc2b721160 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -284,7 +284,25 @@ public static string BlessJewelConsumeHandlerPlugIn_Name { return ResourceManager.GetString("BlessJewelConsumeHandlerPlugIn_Name", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Shows Land of Trials availability and its applicable entry fee.. + /// + public static string CastleSiegeGuardsmanTalkPlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeGuardsmanTalkPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege Guardsman. + /// + public static string CastleSiegeGuardsmanTalkPlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeGuardsmanTalkPlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Opens the operation interface for a Castle Siege gate lever.. /// @@ -293,7 +311,7 @@ public static string CastleSiegeLeverTalkPlugIn_Description { return ResourceManager.GetString("CastleSiegeLeverTalkPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Gate Lever. /// @@ -302,7 +320,7 @@ public static string CastleSiegeLeverTalkPlugIn_Name { return ResourceManager.GetString("CastleSiegeLeverTalkPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Opens a Castle Siege warfare machine for an authorized player.. /// @@ -311,7 +329,7 @@ public static string CastleSiegeMachineTalkPlugIn_Description { return ResourceManager.GetString("CastleSiegeMachineTalkPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Warfare Machine. /// @@ -320,25 +338,25 @@ public static string CastleSiegeMachineTalkPlugIn_Name { return ResourceManager.GetString("CastleSiegeMachineTalkPlugIn_Name", resourceCulture); } } - + /// - /// Looks up a localized string similar to Shows Land of Trials availability and its applicable entry fee.. + /// Looks up a localized string similar to Delivers queued Castle Siege participant rewards when a character enters the game.. /// - public static string CastleSiegeGuardsmanTalkPlugIn_Description { + public static string CastleSiegePendingRewardPlugIn_Description { get { - return ResourceManager.GetString("CastleSiegeGuardsmanTalkPlugIn_Description", resourceCulture); + return ResourceManager.GetString("CastleSiegePendingRewardPlugIn_Description", resourceCulture); } } - + /// - /// Looks up a localized string similar to Castle Siege Guardsman. + /// Looks up a localized string similar to Castle Siege Pending Reward Delivery. /// - public static string CastleSiegeGuardsmanTalkPlugIn_Name { + public static string CastleSiegePendingRewardPlugIn_Name { get { - return ResourceManager.GetString("CastleSiegeGuardsmanTalkPlugIn_Name", resourceCulture); + return ResourceManager.GetString("CastleSiegePendingRewardPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Drives the weekly Castle Siege state cycle.. /// @@ -347,7 +365,7 @@ public static string CastleSiegePlugIn_Description { return ResourceManager.GetString("CastleSiegePlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege. /// @@ -356,24 +374,6 @@ public static string CastleSiegePlugIn_Name { return ResourceManager.GetString("CastleSiegePlugIn_Name", resourceCulture); } } - - /// - /// Looks up a localized string similar to Delivers queued Castle Siege participant rewards when a character enters the game.. - /// - public static string CastleSiegePendingRewardPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegePendingRewardPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege Pending Reward Delivery. - /// - public static string CastleSiegePendingRewardPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegePendingRewardPlugIn_Name", resourceCulture); - } - } /// /// Looks up a localized string similar to Handles the chain lightning skill of the summoner class. Additionally to the attacked target, it will hit up to two additional targets.. @@ -1356,6 +1356,24 @@ public static string MiniGameStartConfiguration_EntranceOpenedMessage_Name { } } + /// + /// Looks up a localized string similar to Increases all monster base stats by a configurable percentage.. + /// + public static string MonsterAttributeScaler_Description { + get { + return ResourceManager.GetString("MonsterAttributeScaler_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Monster Attribute Scaler. + /// + public static string MonsterAttributeScaler_Name { + get { + return ResourceManager.GetString("MonsterAttributeScaler_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Handles the chat command '/move <target> <mapIdOrName?> <x?> <y?>'. Moves the character to the specified destination.. /// @@ -1735,47 +1753,47 @@ public static string PeriodicSaveProgressPlugInConfiguration_Interval_Name { } /// - /// Looks up a localized string similar to The text which prints as a golden message in the game when task starts.. + /// Looks up a localized string similar to The text which prints as a golden message in the game when task ends.. /// - public static string PeriodicTaskConfiguration_StartMessage_Description { + public static string PeriodicTaskConfiguration_EndMessage_Description { get { - return ResourceManager.GetString("PeriodicTaskConfiguration_StartMessage_Description", resourceCulture); + return ResourceManager.GetString("PeriodicTaskConfiguration_EndMessage_Description", resourceCulture); } } /// - /// Looks up a localized string similar to Start message. + /// Looks up a localized string similar to End message. /// - public static string PeriodicTaskConfiguration_StartMessage_Name { + public static string PeriodicTaskConfiguration_EndMessage_Name { get { - return ResourceManager.GetString("PeriodicTaskConfiguration_StartMessage_Name", resourceCulture); + return ResourceManager.GetString("PeriodicTaskConfiguration_EndMessage_Name", resourceCulture); } } - + /// - /// Looks up a localized string similar to The text which prints as a golden message in the game when task ends.. + /// Looks up a localized string similar to Pre-start message delay. /// - public static string PeriodicTaskConfiguration_EndMessage_Description { + public static string PeriodicTaskConfiguration_PreStartMessageDelay_Name { get { - return ResourceManager.GetString("PeriodicTaskConfiguration_EndMessage_Description", resourceCulture); + return ResourceManager.GetString("PeriodicTaskConfiguration_PreStartMessageDelay_Name", resourceCulture); } } /// - /// Looks up a localized string similar to End message. + /// Looks up a localized string similar to The text which prints as a golden message in the game when task starts.. /// - public static string PeriodicTaskConfiguration_EndMessage_Name { + public static string PeriodicTaskConfiguration_StartMessage_Description { get { - return ResourceManager.GetString("PeriodicTaskConfiguration_EndMessage_Name", resourceCulture); + return ResourceManager.GetString("PeriodicTaskConfiguration_StartMessage_Description", resourceCulture); } } /// - /// Looks up a localized string similar to Pre-start message delay. + /// Looks up a localized string similar to Start message. /// - public static string PeriodicTaskConfiguration_PreStartMessageDelay_Name { + public static string PeriodicTaskConfiguration_StartMessage_Name { get { - return ResourceManager.GetString("PeriodicTaskConfiguration_PreStartMessageDelay_Name", resourceCulture); + return ResourceManager.GetString("PeriodicTaskConfiguration_StartMessage_Name", resourceCulture); } } @@ -2357,6 +2375,15 @@ public static string ResetFeaturePlugIn_Description { } } + /// + /// Looks up a localized string similar to Reset Feature. + /// + public static string ResetFeaturePlugIn_Name { + get { + return ResourceManager.GetString("ResetFeaturePlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Handles the chat command '/resetinfo'. Shows the required costs and the granted points for the next reset.. /// @@ -2376,11 +2403,20 @@ public static string ResetInfoChatCommandPlugIn_Name { } /// - /// Looks up a localized string similar to Reset Feature. + /// Looks up a localized string similar to Handles the chat command '/resetstats'.. /// - public static string ResetFeaturePlugIn_Name { + public static string ResetStatsChatCommandPlugIn_Description { get { - return ResourceManager.GetString("ResetFeaturePlugIn_Name", resourceCulture); + return ResourceManager.GetString("ResetStatsChatCommandPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reset Stats chat command. + /// + public static string ResetStatsChatCommandPlugIn_Name { + get { + return ResourceManager.GetString("ResetStatsChatCommandPlugIn_Name", resourceCulture); } } @@ -2843,6 +2879,78 @@ public static string StartDevilSquareEventChatCommandPlugIn_Name { } } + /// + /// Looks up a localized string similar to Chat command enabled. + /// + public static string StatResetConfiguration_ChatCommandEnabled_Name { + get { + return ResourceManager.GetString("StatResetConfiguration_ChatCommandEnabled_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Log out. + /// + public static string StatResetConfiguration_LogOut_Name { + get { + return ResourceManager.GetString("StatResetConfiguration_LogOut_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Move home. + /// + public static string StatResetConfiguration_MoveHome_Name { + get { + return ResourceManager.GetString("StatResetConfiguration_MoveHome_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Required level. + /// + public static string StatResetConfiguration_RequiredLevel_Name { + get { + return ResourceManager.GetString("StatResetConfiguration_RequiredLevel_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Required money. + /// + public static string StatResetConfiguration_RequiredMoney_Name { + get { + return ResourceManager.GetString("StatResetConfiguration_RequiredMoney_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Required reset item. + /// + public static string StatResetConfiguration_RequiredResetItem_Name { + get { + return ResourceManager.GetString("StatResetConfiguration_RequiredResetItem_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Provides configuration for the stat reset feature.. + /// + public static string StatResetFeaturePlugIn_Description { + get { + return ResourceManager.GetString("StatResetFeaturePlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Stat Reset Feature. + /// + public static string StatResetFeaturePlugIn_Name { + get { + return ResourceManager.GetString("StatResetFeaturePlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Plugin which handles the summoning orb consumption.. /// @@ -3382,113 +3490,5 @@ public static string WeatherUpdatePlugIn_Name { return ResourceManager.GetString("WeatherUpdatePlugIn_Name", resourceCulture); } } - - /// - /// Looks up a localized string similar to Increases all monster base stats by a configurable percentage.. - /// - public static string MonsterAttributeScaler_Description { - get { - return ResourceManager.GetString("MonsterAttributeScaler_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Monster Attribute Scaler. - /// - public static string MonsterAttributeScaler_Name { - get { - return ResourceManager.GetString("MonsterAttributeScaler_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Provides configuration for the stat reset feature.. - /// - public static string StatResetFeaturePlugIn_Description { - get { - return ResourceManager.GetString("StatResetFeaturePlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Stat Reset Feature. - /// - public static string StatResetFeaturePlugIn_Name { - get { - return ResourceManager.GetString("StatResetFeaturePlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Handles the chat command '/resetstats'.. - /// - public static string ResetStatsChatCommandPlugIn_Description { - get { - return ResourceManager.GetString("ResetStatsChatCommandPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Reset Stats chat command. - /// - public static string ResetStatsChatCommandPlugIn_Name { - get { - return ResourceManager.GetString("ResetStatsChatCommandPlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Required level. - /// - public static string StatResetConfiguration_RequiredLevel_Name { - get { - return ResourceManager.GetString("StatResetConfiguration_RequiredLevel_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Required money. - /// - public static string StatResetConfiguration_RequiredMoney_Name { - get { - return ResourceManager.GetString("StatResetConfiguration_RequiredMoney_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Required reset item. - /// - public static string StatResetConfiguration_RequiredResetItem_Name { - get { - return ResourceManager.GetString("StatResetConfiguration_RequiredResetItem_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Chat command enabled. - /// - public static string StatResetConfiguration_ChatCommandEnabled_Name { - get { - return ResourceManager.GetString("StatResetConfiguration_ChatCommandEnabled_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Move home. - /// - public static string StatResetConfiguration_MoveHome_Name { - get { - return ResourceManager.GetString("StatResetConfiguration_MoveHome_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Log out. - /// - public static string StatResetConfiguration_LogOut_Name { - get { - return ResourceManager.GetString("StatResetConfiguration_LogOut_Name", resourceCulture); - } - } } } diff --git a/src/GameServer/Properties/PlugInResources.Designer.cs b/src/GameServer/Properties/PlugInResources.Designer.cs index d4c987b5b5..4b08ba16a5 100644 --- a/src/GameServer/Properties/PlugInResources.Designer.cs +++ b/src/GameServer/Properties/PlugInResources.Designer.cs @@ -617,7 +617,43 @@ public static string CancelGuildCreationHandlerPlugIn_Name { return ResourceManager.GetString("CancelGuildCreationHandlerPlugIn_Name", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Sends Castle Siege Crown capture progress to the game client.. + /// + public static string CastleSiegeCrownAccessStatePlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeCrownAccessStatePlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege Crown Access State View. + /// + public static string CastleSiegeCrownAccessStatePlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeCrownAccessStatePlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends the current Castle Siege Crown lock state to the game client.. + /// + public static string CastleSiegeCrownStatePlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeCrownStatePlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege Crown State View. + /// + public static string CastleSiegeCrownStatePlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeCrownStatePlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Handles Castle Siege defense-structure purchase requests.. /// @@ -626,7 +662,7 @@ public static string CastleSiegeDefenseBuyHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeDefenseBuyHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Defense Purchase Handler. /// @@ -635,7 +671,7 @@ public static string CastleSiegeDefenseBuyHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeDefenseBuyHandlerPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Handles Castle Siege defense-structure repair requests.. /// @@ -644,7 +680,7 @@ public static string CastleSiegeDefenseRepairHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeDefenseRepairHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Defense Repair Handler. /// @@ -653,7 +689,7 @@ public static string CastleSiegeDefenseRepairHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeDefenseRepairHandlerPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Handles Castle Siege defense-structure upgrade requests.. /// @@ -662,7 +698,7 @@ public static string CastleSiegeDefenseUpgradeHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeDefenseUpgradeHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Defense Upgrade Handler. /// @@ -671,7 +707,7 @@ public static string CastleSiegeDefenseUpgradeHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeDefenseUpgradeHandlerPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Handles requests for the Castle Siege gate list.. /// @@ -680,7 +716,7 @@ public static string CastleSiegeGateListHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeGateListHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Gate List Handler. /// @@ -689,7 +725,7 @@ public static string CastleSiegeGateListHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeGateListHandlerPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Handles Castle Siege gate open and close requests.. /// @@ -698,7 +734,7 @@ public static string CastleSiegeGateOperateHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeGateOperateHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Gate Operation Handler. /// @@ -707,7 +743,7 @@ public static string CastleSiegeGateOperateHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeGateOperateHandlerPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Routes Castle Siege packet subcodes.. /// @@ -716,7 +752,7 @@ public static string CastleSiegeGroupHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeGroupHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Packet Group Handler. /// @@ -725,7 +761,61 @@ public static string CastleSiegeGroupHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeGroupHandlerPlugIn_Name", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Handles requests for the selected Castle Siege guild list.. + /// + public static string CastleSiegeGuildListHandlerPlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeGuildListHandlerPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege Guild List Handler. + /// + public static string CastleSiegeGuildListHandlerPlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeGuildListHandlerPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends the selected Castle Siege guild list to the game client.. + /// + public static string CastleSiegeGuildListPlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeGuildListPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege Guild List View. + /// + public static string CastleSiegeGuildListPlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeGuildListPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Handles requests to enter the Castle Siege hunting zone.. + /// + public static string CastleSiegeHuntZoneEnterHandlerPlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeHuntZoneEnterHandlerPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege hunting-zone entry request handler. + /// + public static string CastleSiegeHuntZoneEnterHandlerPlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeHuntZoneEnterHandlerPlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Handles grouped Castle Siege hunting-zone packets.. /// @@ -734,7 +824,7 @@ public static string CastleSiegeHuntZoneGroupHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeHuntZoneGroupHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Hunting-Zone Packet Group. /// @@ -743,7 +833,79 @@ public static string CastleSiegeHuntZoneGroupHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeHuntZoneGroupHandlerPlugIn_Name", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Sends Castle Siege hunting-zone access information to the game client.. + /// + public static string CastleSiegeHuntZoneGuardInfoPlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeHuntZoneGuardInfoPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege hunting-zone guardsman view. + /// + public static string CastleSiegeHuntZoneGuardInfoPlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeHuntZoneGuardInfoPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends Castle Siege hunting-zone request results to the game client.. + /// + public static string CastleSiegeHuntZoneResultPlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeHuntZoneResultPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege hunting-zone result view. + /// + public static string CastleSiegeHuntZoneResultPlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeHuntZoneResultPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Handles requests to change public access to the Castle Siege hunting zone.. + /// + public static string CastleSiegeHuntZoneToggleHandlerPlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeHuntZoneToggleHandlerPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege hunting-zone access request handler. + /// + public static string CastleSiegeHuntZoneToggleHandlerPlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeHuntZoneToggleHandlerPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends a player's assigned Castle Siege side to the game client.. + /// + public static string CastleSiegeJoinSidePlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeJoinSidePlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege Join Side View. + /// + public static string CastleSiegeJoinSidePlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeJoinSidePlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Routes Castle Siege warfare-machine packet subcodes.. /// @@ -752,7 +914,7 @@ public static string CastleSiegeMachineGroupHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeMachineGroupHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Warfare-Machine Packet Group. /// @@ -761,7 +923,7 @@ public static string CastleSiegeMachineGroupHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeMachineGroupHandlerPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Sends Castle Siege warfare-machine interface packets to the game client.. /// @@ -770,7 +932,7 @@ public static string CastleSiegeMachineInterfacePlugIn_Description { return ResourceManager.GetString("CastleSiegeMachineInterfacePlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Warfare-Machine Interface View. /// @@ -779,7 +941,7 @@ public static string CastleSiegeMachineInterfacePlugIn_Name { return ResourceManager.GetString("CastleSiegeMachineInterfacePlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Sends Castle Siege warfare-machine impact-region notifications to the game client.. /// @@ -788,7 +950,7 @@ public static string CastleSiegeMachineRegionNotifyPlugIn_Description { return ResourceManager.GetString("CastleSiegeMachineRegionNotifyPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Warfare-Machine Region View. /// @@ -797,7 +959,7 @@ public static string CastleSiegeMachineRegionNotifyPlugIn_Name { return ResourceManager.GetString("CastleSiegeMachineRegionNotifyPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Handles Castle Siege warfare-machine fire requests.. /// @@ -806,7 +968,7 @@ public static string CastleSiegeMachineUseHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeMachineUseHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Warfare-Machine Fire Handler. /// @@ -815,7 +977,7 @@ public static string CastleSiegeMachineUseHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeMachineUseHandlerPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Sends Castle Siege warfare-machine fire results to the game client.. /// @@ -824,7 +986,7 @@ public static string CastleSiegeMachineUseResultPlugIn_Description { return ResourceManager.GetString("CastleSiegeMachineUseResultPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Warfare-Machine Use Result View. /// @@ -833,7 +995,7 @@ public static string CastleSiegeMachineUseResultPlugIn_Name { return ResourceManager.GetString("CastleSiegeMachineUseResultPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Handles Castle Siege Sign of Lord registration requests.. /// @@ -842,7 +1004,7 @@ public static string CastleSiegeMarkRegistrationHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeMarkRegistrationHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Mark Registration Handler. /// @@ -851,7 +1013,7 @@ public static string CastleSiegeMarkRegistrationHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeMarkRegistrationHandlerPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Sends Sign of Lord registration results to the game client.. /// @@ -860,7 +1022,7 @@ public static string CastleSiegeMarkRegistrationResultPlugIn_Description { return ResourceManager.GetString("CastleSiegeMarkRegistrationResultPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Mark Registration View. /// @@ -869,7 +1031,7 @@ public static string CastleSiegeMarkRegistrationResultPlugIn_Name { return ResourceManager.GetString("CastleSiegeMarkRegistrationResultPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Routes Castle Siege NPC-list packet subcodes.. /// @@ -878,7 +1040,7 @@ public static string CastleSiegeNpcGroupHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeNpcGroupHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege NPC Packet Group Handler. /// @@ -887,7 +1049,7 @@ public static string CastleSiegeNpcGroupHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeNpcGroupHandlerPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Sends Castle Siege defense-structure lists to the game client.. /// @@ -896,7 +1058,7 @@ public static string CastleSiegeNpcListPlugIn_Description { return ResourceManager.GetString("CastleSiegeNpcListPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege NPC List View. /// @@ -905,7 +1067,7 @@ public static string CastleSiegeNpcListPlugIn_Name { return ResourceManager.GetString("CastleSiegeNpcListPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Sends Castle Siege defense-structure operation results to the game client.. /// @@ -914,7 +1076,7 @@ public static string CastleSiegeNpcOperationResultPlugIn_Description { return ResourceManager.GetString("CastleSiegeNpcOperationResultPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege NPC Operation Result View. /// @@ -923,7 +1085,61 @@ public static string CastleSiegeNpcOperationResultPlugIn_Name { return ResourceManager.GetString("CastleSiegeNpcOperationResultPlugIn_Name", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Sends Castle Siege ownership changes to the game client.. + /// + public static string CastleSiegeOwnershipChangePlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeOwnershipChangePlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege Ownership Change View. + /// + public static string CastleSiegeOwnershipChangePlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeOwnershipChangePlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Handles requests for the current Castle Siege guild registrations.. + /// + public static string CastleSiegeRegisteredGuildListHandlerPlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeRegisteredGuildListHandlerPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege Registered Guild List Handler. + /// + public static string CastleSiegeRegisteredGuildListHandlerPlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeRegisteredGuildListHandlerPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends the current Castle Siege guild registrations to the game client.. + /// + public static string CastleSiegeRegisteredGuildListPlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeRegisteredGuildListPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege Registered Guild List View. + /// + public static string CastleSiegeRegisteredGuildListPlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeRegisteredGuildListPlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Handles Castle Siege guild registration requests.. /// @@ -932,7 +1148,7 @@ public static string CastleSiegeRegistrationHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeRegistrationHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Registration Handler. /// @@ -941,7 +1157,7 @@ public static string CastleSiegeRegistrationHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeRegistrationHandlerPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Sends Castle Siege registration results to the game client.. /// @@ -950,7 +1166,7 @@ public static string CastleSiegeRegistrationResultPlugIn_Description { return ResourceManager.GetString("CastleSiegeRegistrationResultPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Registration Result View. /// @@ -959,7 +1175,7 @@ public static string CastleSiegeRegistrationResultPlugIn_Name { return ResourceManager.GetString("CastleSiegeRegistrationResultPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Handles Castle Siege registration-state requests.. /// @@ -968,7 +1184,7 @@ public static string CastleSiegeRegistrationStateHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeRegistrationStateHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Registration State Handler. /// @@ -977,7 +1193,7 @@ public static string CastleSiegeRegistrationStateHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeRegistrationStateHandlerPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Sends Castle Siege registration state to the game client.. /// @@ -986,7 +1202,7 @@ public static string CastleSiegeRegistrationStatePlugIn_Description { return ResourceManager.GetString("CastleSiegeRegistrationStatePlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Registration State View. /// @@ -995,115 +1211,151 @@ public static string CastleSiegeRegistrationStatePlugIn_Name { return ResourceManager.GetString("CastleSiegeRegistrationStatePlugIn_Name", resourceCulture); } } - + /// - /// Looks up a localized string similar to Handles requests for the selected Castle Siege guild list.. + /// Looks up a localized string similar to Handles requests for the Castle Siege Guardian Statue list.. /// - public static string CastleSiegeGuildListHandlerPlugIn_Description { + public static string CastleSiegeStatueListHandlerPlugIn_Description { get { - return ResourceManager.GetString("CastleSiegeGuildListHandlerPlugIn_Description", resourceCulture); + return ResourceManager.GetString("CastleSiegeStatueListHandlerPlugIn_Description", resourceCulture); } } - + /// - /// Looks up a localized string similar to Castle Siege Guild List Handler. + /// Looks up a localized string similar to Castle Siege Guardian Statue List Handler. /// - public static string CastleSiegeGuildListHandlerPlugIn_Name { + public static string CastleSiegeStatueListHandlerPlugIn_Name { get { - return ResourceManager.GetString("CastleSiegeGuildListHandlerPlugIn_Name", resourceCulture); + return ResourceManager.GetString("CastleSiegeStatueListHandlerPlugIn_Name", resourceCulture); } } - + /// - /// Looks up a localized string similar to Handles requests for the current Castle Siege guild registrations.. + /// Looks up a localized string similar to Sends Castle Siege Crown switch occupancy information to the game client.. /// - public static string CastleSiegeRegisteredGuildListHandlerPlugIn_Description { + public static string CastleSiegeSwitchInfoPlugIn_Description { get { - return ResourceManager.GetString("CastleSiegeRegisteredGuildListHandlerPlugIn_Description", resourceCulture); + return ResourceManager.GetString("CastleSiegeSwitchInfoPlugIn_Description", resourceCulture); } } - + /// - /// Looks up a localized string similar to Castle Siege Registered Guild List Handler. + /// Looks up a localized string similar to Castle Siege Switch Information View. /// - public static string CastleSiegeRegisteredGuildListHandlerPlugIn_Name { + public static string CastleSiegeSwitchInfoPlugIn_Name { get { - return ResourceManager.GetString("CastleSiegeRegisteredGuildListHandlerPlugIn_Name", resourceCulture); + return ResourceManager.GetString("CastleSiegeSwitchInfoPlugIn_Name", resourceCulture); } } - + /// - /// Looks up a localized string similar to Sends the selected Castle Siege guild list to the game client.. + /// Looks up a localized string similar to Handles requests to change a Castle Siege tax rate.. /// - public static string CastleSiegeGuildListPlugIn_Description { + public static string CastleSiegeTaxChangeHandlerPlugIn_Description { get { - return ResourceManager.GetString("CastleSiegeGuildListPlugIn_Description", resourceCulture); + return ResourceManager.GetString("CastleSiegeTaxChangeHandlerPlugIn_Description", resourceCulture); } } - + /// - /// Looks up a localized string similar to Castle Siege Guild List View. + /// Looks up a localized string similar to Castle Siege tax change request handler. /// - public static string CastleSiegeGuildListPlugIn_Name { + public static string CastleSiegeTaxChangeHandlerPlugIn_Name { get { - return ResourceManager.GetString("CastleSiegeGuildListPlugIn_Name", resourceCulture); + return ResourceManager.GetString("CastleSiegeTaxChangeHandlerPlugIn_Name", resourceCulture); } } - + /// - /// Looks up a localized string similar to Sends the current Castle Siege guild registrations to the game client.. + /// Looks up a localized string similar to Sends Castle Siege tax changes and their results to the game client.. /// - public static string CastleSiegeRegisteredGuildListPlugIn_Description { + public static string CastleSiegeTaxChangeResultPlugIn_Description { get { - return ResourceManager.GetString("CastleSiegeRegisteredGuildListPlugIn_Description", resourceCulture); + return ResourceManager.GetString("CastleSiegeTaxChangeResultPlugIn_Description", resourceCulture); } } - + /// - /// Looks up a localized string similar to Castle Siege Registered Guild List View. + /// Looks up a localized string similar to Castle Siege tax change result view. /// - public static string CastleSiegeRegisteredGuildListPlugIn_Name { + public static string CastleSiegeTaxChangeResultPlugIn_Name { get { - return ResourceManager.GetString("CastleSiegeRegisteredGuildListPlugIn_Name", resourceCulture); + return ResourceManager.GetString("CastleSiegeTaxChangeResultPlugIn_Name", resourceCulture); } } - + /// - /// Looks up a localized string similar to Sends a player's assigned Castle Siege side to the game client.. + /// Looks up a localized string similar to Handles requests for the current Castle Siege tax and treasury information.. /// - public static string CastleSiegeJoinSidePlugIn_Description { + public static string CastleSiegeTaxInfoHandlerPlugIn_Description { get { - return ResourceManager.GetString("CastleSiegeJoinSidePlugIn_Description", resourceCulture); + return ResourceManager.GetString("CastleSiegeTaxInfoHandlerPlugIn_Description", resourceCulture); } } - + /// - /// Looks up a localized string similar to Castle Siege Join Side View. + /// Looks up a localized string similar to Castle Siege tax information request handler. /// - public static string CastleSiegeJoinSidePlugIn_Name { + public static string CastleSiegeTaxInfoHandlerPlugIn_Name { get { - return ResourceManager.GetString("CastleSiegeJoinSidePlugIn_Name", resourceCulture); + return ResourceManager.GetString("CastleSiegeTaxInfoHandlerPlugIn_Name", resourceCulture); } } - + /// - /// Looks up a localized string similar to Handles requests for the Castle Siege Guardian Statue list.. + /// Looks up a localized string similar to Sends the current Castle Siege tax and treasury information to the game client.. /// - public static string CastleSiegeStatueListHandlerPlugIn_Description { + public static string CastleSiegeTaxInfoPlugIn_Description { get { - return ResourceManager.GetString("CastleSiegeStatueListHandlerPlugIn_Description", resourceCulture); + return ResourceManager.GetString("CastleSiegeTaxInfoPlugIn_Description", resourceCulture); } } - + /// - /// Looks up a localized string similar to Castle Siege Guardian Statue List Handler. + /// Looks up a localized string similar to Castle Siege tax information view. /// - public static string CastleSiegeStatueListHandlerPlugIn_Name { + public static string CastleSiegeTaxInfoPlugIn_Name { get { - return ResourceManager.GetString("CastleSiegeStatueListHandlerPlugIn_Name", resourceCulture); + return ResourceManager.GetString("CastleSiegeTaxInfoPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Handles requests to withdraw money from the Castle Siege treasury.. + /// + public static string CastleSiegeTributeWithdrawHandlerPlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeTributeWithdrawHandlerPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege tribute withdrawal request handler. + /// + public static string CastleSiegeTributeWithdrawHandlerPlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeTributeWithdrawHandlerPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends Castle Siege treasury withdrawal results to the game client.. + /// + public static string CastleSiegeTributeWithdrawResultPlugIn_Description { + get { + return ResourceManager.GetString("CastleSiegeTributeWithdrawResultPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege tribute withdrawal result view. + /// + public static string CastleSiegeTributeWithdrawResultPlugIn_Name { + get { + return ResourceManager.GetString("CastleSiegeTributeWithdrawResultPlugIn_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Handles Castle Siege guild unregistration requests.. /// @@ -1112,7 +1364,7 @@ public static string CastleSiegeUnregisterHandlerPlugIn_Description { return ResourceManager.GetString("CastleSiegeUnregisterHandlerPlugIn_Description", resourceCulture); } } - + /// /// Looks up a localized string similar to Castle Siege Unregister Handler. /// @@ -6773,257 +7025,5 @@ public static string WhisperedChatMessageHandlerPlugIn_Name { return ResourceManager.GetString("WhisperedChatMessageHandlerPlugIn_Name", resourceCulture); } } - - /// - /// Looks up a localized string similar to Sends Castle Siege Crown capture progress to the game client.. - /// - public static string CastleSiegeCrownAccessStatePlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeCrownAccessStatePlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege Crown Access State View. - /// - public static string CastleSiegeCrownAccessStatePlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeCrownAccessStatePlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sends the current Castle Siege Crown lock state to the game client.. - /// - public static string CastleSiegeCrownStatePlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeCrownStatePlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege Crown State View. - /// - public static string CastleSiegeCrownStatePlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeCrownStatePlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sends Castle Siege ownership changes to the game client.. - /// - public static string CastleSiegeOwnershipChangePlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeOwnershipChangePlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege Ownership Change View. - /// - public static string CastleSiegeOwnershipChangePlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeOwnershipChangePlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sends Castle Siege Crown switch occupancy information to the game client.. - /// - public static string CastleSiegeSwitchInfoPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeSwitchInfoPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege Switch Information View. - /// - public static string CastleSiegeSwitchInfoPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeSwitchInfoPlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Handles requests to enter the Castle Siege hunting zone.. - /// - public static string CastleSiegeHuntZoneEnterHandlerPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeHuntZoneEnterHandlerPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege hunting-zone entry request handler. - /// - public static string CastleSiegeHuntZoneEnterHandlerPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeHuntZoneEnterHandlerPlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sends Castle Siege hunting-zone access information to the game client.. - /// - public static string CastleSiegeHuntZoneGuardInfoPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeHuntZoneGuardInfoPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege hunting-zone guardsman view. - /// - public static string CastleSiegeHuntZoneGuardInfoPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeHuntZoneGuardInfoPlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sends Castle Siege hunting-zone request results to the game client.. - /// - public static string CastleSiegeHuntZoneResultPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeHuntZoneResultPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege hunting-zone result view. - /// - public static string CastleSiegeHuntZoneResultPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeHuntZoneResultPlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Handles requests to change public access to the Castle Siege hunting zone.. - /// - public static string CastleSiegeHuntZoneToggleHandlerPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeHuntZoneToggleHandlerPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege hunting-zone access request handler. - /// - public static string CastleSiegeHuntZoneToggleHandlerPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeHuntZoneToggleHandlerPlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Handles requests to change a Castle Siege tax rate.. - /// - public static string CastleSiegeTaxChangeHandlerPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeTaxChangeHandlerPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege tax change request handler. - /// - public static string CastleSiegeTaxChangeHandlerPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeTaxChangeHandlerPlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sends Castle Siege tax changes and their results to the game client.. - /// - public static string CastleSiegeTaxChangeResultPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeTaxChangeResultPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege tax change result view. - /// - public static string CastleSiegeTaxChangeResultPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeTaxChangeResultPlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Handles requests for the current Castle Siege tax and treasury information.. - /// - public static string CastleSiegeTaxInfoHandlerPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeTaxInfoHandlerPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege tax information request handler. - /// - public static string CastleSiegeTaxInfoHandlerPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeTaxInfoHandlerPlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sends the current Castle Siege tax and treasury information to the game client.. - /// - public static string CastleSiegeTaxInfoPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeTaxInfoPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege tax information view. - /// - public static string CastleSiegeTaxInfoPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeTaxInfoPlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Handles requests to withdraw money from the Castle Siege treasury.. - /// - public static string CastleSiegeTributeWithdrawHandlerPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeTributeWithdrawHandlerPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege tribute withdrawal request handler. - /// - public static string CastleSiegeTributeWithdrawHandlerPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeTributeWithdrawHandlerPlugIn_Name", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Sends Castle Siege treasury withdrawal results to the game client.. - /// - public static string CastleSiegeTributeWithdrawResultPlugIn_Description { - get { - return ResourceManager.GetString("CastleSiegeTributeWithdrawResultPlugIn_Description", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Castle Siege tribute withdrawal result view. - /// - public static string CastleSiegeTributeWithdrawResultPlugIn_Name { - get { - return ResourceManager.GetString("CastleSiegeTributeWithdrawResultPlugIn_Name", resourceCulture); - } - } } } diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index a2b613ca2c..98c041d0ec 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -303,6 +303,24 @@ public static string ApplySelectedUpdates { } } + /// + /// Looks up a localized string similar to Archive. + /// + public static string ArchivedSession { + get { + return ResourceManager.GetString("ArchivedSession", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Archived sessions. + /// + public static string ArchivedSessions { + get { + return ResourceManager.GetString("ArchivedSessions", resourceCulture); + } + } + /// /// Looks up a localized string similar to Authenticator code. /// @@ -717,6 +735,24 @@ public static string DeleteApiKeyQuestion { } } + /// + /// Looks up a localized string similar to Delete the archived session. + /// + public static string DeleteArchivedSession { + get { + return ResourceManager.GetString("DeleteArchivedSession", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You're about to delete the archived session '{0}'. Are you sure?. + /// + public static string DeleteArchivedSessionQuestion { + get { + return ResourceManager.GetString("DeleteArchivedSessionQuestion", resourceCulture); + } + } + /// /// Looks up a localized string similar to Couldn't delete '{0}', probably because it's referenced by another object. For details, see log. /// @@ -801,6 +837,15 @@ public static string Disconnect { } } + /// + /// Looks up a localized string similar to Download the archived session. + /// + public static string DownloadArchivedSession { + get { + return ResourceManager.GetString("DownloadArchivedSession", resourceCulture); + } + } + /// /// Looks up a localized string similar to Download as JSON. /// @@ -1530,6 +1575,15 @@ public static string NoApiKeys { } } + /// + /// Looks up a localized string similar to No archived sessions.. + /// + public static string NoArchivedSessions { + get { + return ResourceManager.GetString("NoArchivedSessions", resourceCulture); + } + } + /// /// Looks up a localized string similar to No changes have been saved.. /// @@ -1620,6 +1674,24 @@ public static string NotCreated { } } + /// + /// Looks up a localized string similar to Observe. + /// + public static string ObserveAccount { + get { + return ResourceManager.GetString("ObserveAccount", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Archives the traffic of this account for each of its sessions, until it's turned off again.. + /// + public static string ObserveAccountHint { + get { + return ResourceManager.GetString("ObserveAccountHint", resourceCulture); + } + } + /// /// Looks up a localized string similar to OK. /// @@ -2187,6 +2259,15 @@ public static string ServerWithPortAlreadyExists { } } + /// + /// Looks up a localized string similar to Running. + /// + public static string SessionIsRunning { + get { + return ResourceManager.GetString("SessionIsRunning", resourceCulture); + } + } + /// /// Looks up a localized string similar to Setup. /// @@ -2663,94 +2744,5 @@ public static string YesCreateTestAccounts { return ResourceManager.GetString("YesCreateTestAccounts", resourceCulture); } } - /// - /// Archived sessions. - /// - public static string ArchivedSessions { - get { - return ResourceManager.GetString("ArchivedSessions", resourceCulture); - } - } - - /// - /// No archived sessions.. - /// - public static string NoArchivedSessions { - get { - return ResourceManager.GetString("NoArchivedSessions", resourceCulture); - } - } - - /// - /// Archive. - /// - public static string ArchivedSession { - get { - return ResourceManager.GetString("ArchivedSession", resourceCulture); - } - } - - /// - /// Running. - /// - public static string SessionIsRunning { - get { - return ResourceManager.GetString("SessionIsRunning", resourceCulture); - } - } - - /// - /// Observe. - /// - public static string ObserveAccount { - get { - return ResourceManager.GetString("ObserveAccount", resourceCulture); - } - } - - /// - /// Archives the traffic of this account for each of its sessions, until it's turned off again.. - /// - public static string ObserveAccountHint { - get { - return ResourceManager.GetString("ObserveAccountHint", resourceCulture); - } - } - - /// - /// Delete the archived session. - /// - public static string DeleteArchivedSession { - get { - return ResourceManager.GetString("DeleteArchivedSession", resourceCulture); - } - } - - /// - /// You're about to delete the archived session '{0}'. Are you sure?. - /// - public static string DeleteArchivedSessionQuestion { - get { - return ResourceManager.GetString("DeleteArchivedSessionQuestion", resourceCulture); - } - } - - /// - /// Download the archived session. - /// - public static string DownloadArchivedSession { - get { - return ResourceManager.GetString("DownloadArchivedSession", resourceCulture); - } - } - - /// - /// Shows the newest packets and scrolls to them. It's turned off while you scroll up, so that the view doesn't move away.. - /// - public static string FollowNewPacketsHint { - get { - return ResourceManager.GetString("FollowNewPacketsHint", resourceCulture); - } - } } } From 56da4006a5b452aa5239125506df60146f1d75a4 Mon Sep 17 00:00:00 2001 From: sven-n Date: Fri, 4 Sep 2026 21:27:25 +0200 Subject: [PATCH 5/5] fix merge error --- .../AdminPanel/Pages/NetworkAnalyzer.razor.cs | 64 +++---------------- 1 file changed, 10 insertions(+), 54 deletions(-) diff --git a/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor.cs b/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor.cs index 25a05c24bb..bca7e2bba6 100644 --- a/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor.cs +++ b/src/Web/AdminPanel/Pages/NetworkAnalyzer.razor.cs @@ -167,6 +167,7 @@ private enum DirectionFilter private string TableColClass => this._isSidebarCollapsed ? "col-12" : "col-10"; + /// public async ValueTask DisposeAsync() { @@ -188,16 +189,22 @@ protected override async Task OnInitializedAsync() _ = await this.RefreshConnectionsAsync().ConfigureAwait(true); _ = await this.RefreshArchiveAsync().ConfigureAwait(true); - if (this.ConnectionId is { } connectionId - && this._connections.FirstOrDefault(connection => connection.Id == connectionId) is { } preselected) + if (await this.FindPreselectedConnectionAsync().ConfigureAwait(true) is { } preselected) { await this.OnConnectionSelectedAsync(preselected).ConfigureAwait(true); + this._isSidebarCollapsed = true; } else if (!string.IsNullOrEmpty(this.SessionId) && this._archivedSessions.FirstOrDefault(session => session.Id == this.SessionId) is { } preselectedSession) { await this.OnArchivedSessionSelectedAsync(preselectedSession).ConfigureAwait(true); } + else + { + // A link may be opened when the player is already gone, e.g. because it was + // rendered in a list which isn't up to date anymore. + this._isPreselectedConnectionMissing = this.ConnectionId is not null || this.PlayerName is not null; + } _ = this.RefreshPeriodicallyAsync(); } @@ -262,39 +269,6 @@ private void UpdateFilteredPackets() this._filteredPackets = packets.ToList(); } -/// - public async ValueTask DisposeAsync() - { - await this._disposeCts.CancelAsync().ConfigureAwait(false); - this.StopCapture(); - this._disposeCts.Dispose(); - } - - /// - protected override async Task OnInitializedAsync() - { - await base.OnInitializedAsync().ConfigureAwait(true); - this._captureService = this.ServiceProvider.GetService(typeof(IPacketCaptureService)) as IPacketCaptureService; - if (this._captureService is null) - { - return; - } - - _ = await this.RefreshConnectionsAsync().ConfigureAwait(true); - if (await this.FindPreselectedConnectionAsync().ConfigureAwait(true) is { } preselected) - { - await this.OnConnectionSelectedAsync(preselected).ConfigureAwait(true); - this._isSidebarCollapsed = true; - } - else - { - // A link may be opened when the player is already gone, e.g. because it was - // rendered in a list which isn't up to date anymore. - this._isPreselectedConnectionMissing = this.ConnectionId is not null || this.PlayerName is not null; - } - - _ = this.RefreshPeriodicallyAsync(); - } /// /// Refreshes the list of the connections. /// @@ -321,25 +295,6 @@ private async Task RefreshConnectionsAsync() return true; } -private static bool HasChanged(IReadOnlyList current, IReadOnlyList updated) - { - if (current.Count != updated.Count) - { - return true; - } - - for (int i = 0; i < current.Count; i++) - { - if (current[i].Id != updated[i].Id - || current[i].DisplayName != updated[i].DisplayName) - { - return true; - } - } - - return false; - } - /// /// Finds the connection which should be selected initially, if the page was opened for a /// specific connection or player. @@ -364,6 +319,7 @@ private static bool HasChanged(IReadOnlyList current, I return null; } + private async Task OnConnectionSelectedAsync(ICapturedConnectionInfo connection) { if (this._captureService is not { } captureService || this._selectedConnection?.Id == connection.Id)