diff --git a/.gitignore b/.gitignore index 15b839f1a..dd969b780 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 dd22a8bdb..4081bdbc8 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 b373ce175..3028e16cf 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 9a543c4c2..f23742efe 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 82f8b816f..7dfd43b6d 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 699ad0dd4..4e856da2e 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 763644b55..10a442a07 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 8dea16293..e23289670 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 e8902f86f..bba41dc7c 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, IAsyncDisposable, IGa private readonly ICollection _listeners = new List(); + private readonly NetworkObservationHandler? _observationHandler; + private ServerState _serverState; /// @@ -49,6 +52,8 @@ public sealed class GameServer : IGameServer, IDisposable, IAsyncDisposable, IGa /// 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)); @@ -489,6 +498,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 000000000..d19ea7e6a --- /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 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. + /// + 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 000000000..d8651e886 --- /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 000000000..156388f55 --- /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 000000000..b04e5e5f4 --- /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 000000000..43cc92434 --- /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 000000000..43cabb0a0 --- /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 000000000..922402ae8 --- /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 000000000..57d066187 --- /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 000000000..df95af76f --- /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 000000000..d67698a79 --- /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 000000000..019e03a65 --- /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 2004f716a..517184d7a 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 315d31660..65f682b8d 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.Persistence.Initialization; @@ -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; } /// @@ -174,7 +180,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 c2dfb9207..7990ffd9a 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; @@ -311,7 +312,7 @@ private async Task CreateHostAsync(string[] args) .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton(CreatePacketCaptureService) .AddSingleton>(this.PlugInConfigurationsFactory) .AddTransient(provider => { @@ -329,6 +330,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(); @@ -553,6 +555,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 000000000..c731ef9f4 --- /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 000000000..b9a027a04 --- /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(); + } + } +}