From 9e66c72c32850532c76a4ba19b6633275c90c693 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:34:38 +0000 Subject: [PATCH 1/8] feat(game-logic): publish events for direct messages and the next castle siege The game publishes blocked logins, received letters and characters which entered the game, and the castle siege events tell when the next battle takes place. /discord notify turns direct messages in Discord on or off. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ --- .../AccountLinking/AccountLinkService.cs | 44 ++++++++++++ .../AccountNotificationKeywords.cs | 67 +++++++++++++++++++ .../CastleSiege/CastleSiegeSchedule.cs | 18 +++++ src/GameLogic/PlayerActions/LoginAction.cs | 16 ++++- .../Messenger/LetterSendAction.cs | 7 +- .../Arguments/DiscordChatCommandArgs.cs | 16 ++++- .../ChatCommands/DiscordChatCommandPlugIn.cs | 13 ++++ .../GameEvents/GameEventPublisherPlugIn.cs | 8 ++- .../Properties/PlayerMessage.Designer.cs | 9 +++ .../Properties/PlayerMessage.de.resx | 3 + src/GameLogic/Properties/PlayerMessage.resx | 3 + .../Properties/PlugInResources.Designer.cs | 2 +- .../Properties/PlugInResources.de.resx | 2 +- src/GameLogic/Properties/PlugInResources.resx | 2 +- src/GameServer/GameServerContext.cs | 3 + src/Interfaces/AccountLoginBlockedEvent.cs | 14 ++++ .../CastleSiegeStateChangedEvent.cs | 4 +- src/Interfaces/GameEvent.cs | 3 + src/Interfaces/LetterReceivedEvent.cs | 16 +++++ src/Interfaces/PlayerEnteredGameEvent.cs | 15 +++++ .../CastleSiegeNextBattleTest.cs | 55 +++++++++++++++ .../DiscordChatCommandPlugInTest.cs | 19 ++++++ .../GameEventPublisherPlugInTest.cs | 4 ++ 23 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 src/GameLogic/AccountLinking/AccountNotificationKeywords.cs create mode 100644 src/Interfaces/AccountLoginBlockedEvent.cs create mode 100644 src/Interfaces/LetterReceivedEvent.cs create mode 100644 src/Interfaces/PlayerEnteredGameEvent.cs create mode 100644 tests/MUnique.OpenMU.Tests/CastleSiegeNextBattleTest.cs diff --git a/src/GameLogic/AccountLinking/AccountLinkService.cs b/src/GameLogic/AccountLinking/AccountLinkService.cs index 94466ec355..736ee953da 100644 --- a/src/GameLogic/AccountLinking/AccountLinkService.cs +++ b/src/GameLogic/AccountLinking/AccountLinkService.cs @@ -212,6 +212,50 @@ public async ValueTask SelectCharacterAsync(string pro return CharacterSelectionResult.Selected; } + /// + /// Turns a type of notifications on or off for a linked account. + /// + /// The identifier of the account. + /// The name of the external service. + /// The type of notifications. + /// The new state; or null, to toggle the current state. + /// The types of notifications which are on now; or null, if the account isn't linked. + public ValueTask SetNotificationAsync(Guid accountId, string provider, AccountNotificationTypes type, bool? enabled) + { + return this.SetNotificationAsync(context => context.GetAccountExternalLinkAsync(accountId, provider), type, enabled); + } + + /// + /// Turns a type of notifications on or off for a linked user. + /// + /// The name of the external service. + /// The identifier of the user in the external service. + /// The type of notifications. + /// The new state; or null, to toggle the current state. + /// The types of notifications which are on now; or null, if the user isn't linked. + public ValueTask SetNotificationByUserAsync(string provider, string externalUserId, AccountNotificationTypes type, bool? enabled) + { + return this.SetNotificationAsync(context => context.GetAccountExternalLinkByUserAsync(provider, externalUserId), type, enabled); + } + + private async ValueTask SetNotificationAsync(Func> getLink, AccountNotificationTypes type, bool? enabled) + { + using var context = this._createContext(); + if (await getLink(context).ConfigureAwait(false) is not { ExternalUserId: not null } link) + { + return null; + } + + if (type != AccountNotificationTypes.None) + { + var isEnabled = enabled ?? !link.Notifications.HasFlag(type); + link.Notifications = isEnabled ? link.Notifications | type : link.Notifications & ~type; + await context.SaveChangesAsync().ConfigureAwait(false); + } + + return link.Notifications; + } + /// /// Normalizes an entered code, so that it doesn't matter if it's entered in lower case or with separators. /// diff --git a/src/GameLogic/AccountLinking/AccountNotificationKeywords.cs b/src/GameLogic/AccountLinking/AccountNotificationKeywords.cs new file mode 100644 index 0000000000..204c0bc00b --- /dev/null +++ b/src/GameLogic/AccountLinking/AccountNotificationKeywords.cs @@ -0,0 +1,67 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.AccountLinking; + +using MUnique.OpenMU.DataModel.Entities; + +/// +/// The keywords of the , which are used in commands, e.g. /discord notify letter. +/// +public static class AccountNotificationKeywords +{ + /// + /// The keyword of . + /// + public const string LoginAttempt = "login"; + + /// + /// The keyword of . + /// + public const string LetterReceived = "letter"; + + /// + /// The keyword of . + /// + public const string FriendOnline = "friend"; + + /// + /// Gets the type of a keyword. + /// + /// The keyword. + /// The type; or , if the keyword is unknown. + public static AccountNotificationTypes Parse(string? keyword) => keyword?.ToLowerInvariant() switch + { + LoginAttempt => AccountNotificationTypes.LoginAttempt, + LetterReceived => AccountNotificationTypes.LetterReceived, + FriendOnline => AccountNotificationTypes.FriendOnline, + _ => AccountNotificationTypes.None, + }; + + /// + /// Formats the types as keywords, e.g. login, letter. + /// + /// The types. + /// The keywords; or -, if there are none. + public static string Format(AccountNotificationTypes types) + { + var keywords = new List(); + if (types.HasFlag(AccountNotificationTypes.LoginAttempt)) + { + keywords.Add(LoginAttempt); + } + + if (types.HasFlag(AccountNotificationTypes.LetterReceived)) + { + keywords.Add(LetterReceived); + } + + if (types.HasFlag(AccountNotificationTypes.FriendOnline)) + { + keywords.Add(FriendOnline); + } + + return keywords.Count > 0 ? string.Join(", ", keywords) : "-"; + } +} diff --git a/src/GameLogic/CastleSiege/CastleSiegeSchedule.cs b/src/GameLogic/CastleSiege/CastleSiegeSchedule.cs index b8b1c4a5b4..8eb28a4358 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeSchedule.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeSchedule.cs @@ -139,6 +139,24 @@ public CastleSiegeStatePeriod CreateEarlyStartPeriod(CastleSiegeState state, Dat /// /// The current state. /// The next configured state. + /// + /// Gets the next period of a state which starts now or later, e.g. the next battle. + /// + /// The state. + /// The current time, in UTC. + /// The next period; or null, if the schedule doesn't contain the state. + public CastleSiegeStatePeriod? GetNextPeriod(CastleSiegeState state, DateTime utcNow) + { + if (!this.ContainsState(state)) + { + return null; + } + + utcNow = EnsureUtc(utcNow); + var startUtc = this.GetPreviousOrCurrentStateStart(state, utcNow); + return this.CreatePeriod(state, startUtc < utcNow ? startUtc + WeekDuration : startUtc); + } + public CastleSiegeState GetNextState(CastleSiegeState state) { var index = this.GetStateIndex(state); diff --git a/src/GameLogic/PlayerActions/LoginAction.cs b/src/GameLogic/PlayerActions/LoginAction.cs index aff5d3aa21..824a882a4a 100644 --- a/src/GameLogic/PlayerActions/LoginAction.cs +++ b/src/GameLogic/PlayerActions/LoginAction.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions; using System.Threading; using MUnique.OpenMU.GameLogic.Views.Login; +using MUnique.OpenMU.Interfaces; /// /// Action to log in a player to the game. @@ -134,7 +135,7 @@ private async ValueTask ValidateAccountStateAsync(Player player, AccountSt { context.Allowed = false; await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.AccountAlreadyConnected)).ConfigureAwait(false); - await gameServerContext.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext.Id, username).ConfigureAwait(false); + await NotifyAlreadyLoggedInAsync(gameServerContext, username).ConfigureAwait(false); return (false, null); } @@ -168,7 +169,7 @@ private async ValueTask ValidateAccountStateAsync(Player player, AccountSt { context.Allowed = false; await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.AccountAlreadyConnected)).ConfigureAwait(false); - await gameServerContext.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext.Id, username).ConfigureAwait(false); + await NotifyAlreadyLoggedInAsync(gameServerContext, username).ConfigureAwait(false); return (false, null); } @@ -211,6 +212,15 @@ private async ValueTask ValidateAccountStateAsync(Player player, AccountSt } } + /// + /// Notifies the game servers and external systems, that somebody tried to log into an account which is already logged in. + /// + private static async ValueTask NotifyAlreadyLoggedInAsync(IGameServerContext gameServerContext, string username) + { + await gameServerContext.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext.Id, username).ConfigureAwait(false); + await gameServerContext.EventPublisher.GameEventAsync(new AccountLoginBlockedEvent(gameServerContext.Id, DateTime.UtcNow, username)).ConfigureAwait(false); + } + private async ValueTask HandleAlreadyConnectedAsync(Player player, string username) { var result = player.LoginResultOverride ?? LoginResult.AccountAlreadyConnected; @@ -218,7 +228,7 @@ private async ValueTask HandleAlreadyConnectedAsync(Player player, string userna await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(result)).ConfigureAwait(false); if (player.GameContext is IGameServerContext gameServerContext) { - await gameServerContext.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext.Id, username).ConfigureAwait(false); + await NotifyAlreadyLoggedInAsync(gameServerContext, username).ConfigureAwait(false); } } diff --git a/src/GameLogic/PlayerActions/Messenger/LetterSendAction.cs b/src/GameLogic/PlayerActions/Messenger/LetterSendAction.cs index 0b932f0880..964cf009ab 100644 --- a/src/GameLogic/PlayerActions/Messenger/LetterSendAction.cs +++ b/src/GameLogic/PlayerActions/Messenger/LetterSendAction.cs @@ -68,9 +68,12 @@ public async ValueTask SendLetterAsync(Player player, string receiver, string me } // Try to forward it to the player, if he is online - if ((player.GameContext as IGameServerContext)?.FriendServer is { } friendServer) + if (player.GameContext is IGameServerContext gameServerContext) { - await friendServer.ForwardLetterAsync(letter).ConfigureAwait(false); + await gameServerContext.FriendServer.ForwardLetterAsync(letter).ConfigureAwait(false); + + // E.g. the Discord bot can notify the receiver. + await gameServerContext.EventPublisher.GameEventAsync(new LetterReceivedEvent(gameServerContext.Id, DateTime.UtcNow, letter.ReceiverName ?? receiver, letter.SenderName ?? player.Name, letter.Subject ?? string.Empty)).ConfigureAwait(false); } } diff --git a/src/GameLogic/PlugIns/ChatCommands/Arguments/DiscordChatCommandArgs.cs b/src/GameLogic/PlugIns/ChatCommands/Arguments/DiscordChatCommandArgs.cs index dd4131d767..d3f894c8b2 100644 --- a/src/GameLogic/PlugIns/ChatCommands/Arguments/DiscordChatCommandArgs.cs +++ b/src/GameLogic/PlugIns/ChatCommands/Arguments/DiscordChatCommandArgs.cs @@ -4,6 +4,8 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands.Arguments; +using MUnique.OpenMU.GameLogic.AccountLinking; + /// /// Arguments for the discord chat command. /// @@ -19,10 +21,22 @@ public class DiscordChatCommandArgs : ArgumentsBase /// public const string UnlinkAction = "unlink"; + /// + /// The action which turns direct messages in Discord on or off. + /// + public const string NotifyAction = "notify"; + /// /// Gets or sets the action. Without action, the state of the link is shown. /// [Argument("action", false)] - [ValidValues(LinkAction, UnlinkAction)] + [ValidValues(LinkAction, UnlinkAction, NotifyAction)] public string? Action { get; set; } + + /// + /// Gets or sets the type of the direct messages, which is turned on or off by the . + /// + [Argument("type", false)] + [ValidValues(AccountNotificationKeywords.LoginAttempt, AccountNotificationKeywords.LetterReceived, AccountNotificationKeywords.FriendOnline)] + public string? Type { get; set; } } diff --git a/src/GameLogic/PlugIns/ChatCommands/DiscordChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/DiscordChatCommandPlugIn.cs index 0623d46302..84cdd49152 100644 --- a/src/GameLogic/PlugIns/ChatCommands/DiscordChatCommandPlugIn.cs +++ b/src/GameLogic/PlugIns/ChatCommands/DiscordChatCommandPlugIn.cs @@ -15,6 +15,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; /// A chat command plugin which links the account of the player to a Discord user. /// /discord link creates a one-time code, which the player enters in Discord with /link. /// /discord unlink removes the link, and /discord shows it. +/// /discord notify letter turns the direct messages about letters on or off; login and friend are the other types. /// [Guid("6694D50F-49B1-481D-8F65-B0D7DCCA77C3")] [PlugIn] @@ -56,6 +57,18 @@ await gameServerContext.EventPublisher.GameEventAsync( } await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.DiscordUnlinked)).ConfigureAwait(false); + break; + case DiscordChatCommandArgs.NotifyAction: + var type = AccountNotificationKeywords.Parse(arguments.Type); + if (await service.SetNotificationAsync(accountId, AccountLinkService.DiscordProvider, type, null).ConfigureAwait(false) is { } notifications) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.DiscordNotifications), AccountNotificationKeywords.Format(notifications)).ConfigureAwait(false); + } + else + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.DiscordNotLinked)).ConfigureAwait(false); + } + break; default: var link = await service.GetLinkAsync(accountId, AccountLinkService.DiscordProvider).ConfigureAwait(false); diff --git a/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs b/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs index be12ca8a83..8eb09ecf9e 100644 --- a/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs +++ b/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs @@ -139,13 +139,17 @@ public ValueTask CastleSiegeStateChangedAsync(IGameContext gameContext, CastleSi return ValueTask.CompletedTask; } + var now = DateTime.UtcNow; + var nextBattle = castleSiege.Schedule.GetNextPeriod(CastleSiegeState.Start, now); return context.EventPublisher.GameEventAsync(new CastleSiegeStateChangedEvent( context.Id, - DateTime.UtcNow, + now, previousState.ToString(), castleSiege.CurrentState.ToString(), castleSiege.StateEndTimeUtc, - castleSiege.SiegeData?.OwnerGuildId)); + castleSiege.SiegeData?.OwnerGuildId, + nextBattle?.StartUtc, + nextBattle?.EndUtc)); } /// diff --git a/src/GameLogic/Properties/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs index dd1d2b720b..400d4ac651 100644 --- a/src/GameLogic/Properties/PlayerMessage.Designer.cs +++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs @@ -265,6 +265,15 @@ public static string DiscordLinked { } } + /// + /// Looks up a localized string similar to Direct messages in Discord about: {0}. Turn them on or off with /discord notify login, letter or friend.. + /// + public static string DiscordNotifications { + get { + return ResourceManager.GetString("DiscordNotifications", resourceCulture); + } + } + /// /// Looks up a localized string similar to Your account isn't linked to Discord. Use /discord link to get a code for linking.. /// diff --git a/src/GameLogic/Properties/PlayerMessage.de.resx b/src/GameLogic/Properties/PlayerMessage.de.resx index d62f889b29..b977b7495e 100644 --- a/src/GameLogic/Properties/PlayerMessage.de.resx +++ b/src/GameLogic/Properties/PlayerMessage.de.resx @@ -931,4 +931,7 @@ Inventarerweiterungen: {18} Der Chat deiner Allianz wird nach Discord gespiegelt. Mitglieder, die ihren Discord-Benutzer verknüpft haben, können ihn dort lesen und schreiben. + + Direktnachrichten in Discord über: {0}. Mit /discord notify login, letter oder friend schaltest du sie ein oder aus. + diff --git a/src/GameLogic/Properties/PlayerMessage.resx b/src/GameLogic/Properties/PlayerMessage.resx index b5c79d95b2..ba5ff213fe 100644 --- a/src/GameLogic/Properties/PlayerMessage.resx +++ b/src/GameLogic/Properties/PlayerMessage.resx @@ -943,4 +943,7 @@ Inventory Extensions: {18} The chat of your alliance is mirrored to Discord. Members who linked their Discord user can read and write it there. + + Direct messages in Discord about: {0}. Turn them on or off with /discord notify login, letter or friend. + diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index a995dc3561..9ba8e7d6d3 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -646,7 +646,7 @@ public static string DisconnectChatCommandPlugIn_Name { } /// - /// Looks up a localized string similar to Links the account to a Discord user: '/discord link' creates a code which is entered in Discord, '/discord unlink' removes the link.. + /// Looks up a localized string similar to Links the account to a Discord user: '/discord link' creates a code which is entered in Discord, '/discord unlink' removes the link, '/discord notify letter' turns direct messages about letters on or off (also 'login' and 'friend').. /// public static string DiscordChatCommandPlugIn_Description { get { diff --git a/src/GameLogic/Properties/PlugInResources.de.resx b/src/GameLogic/Properties/PlugInResources.de.resx index a7c91ca652..8457aa741f 100644 --- a/src/GameLogic/Properties/PlugInResources.de.resx +++ b/src/GameLogic/Properties/PlugInResources.de.resx @@ -1771,7 +1771,7 @@ Befehl Discord - Verknüpft das Konto mit einem Discord-Benutzer: '/discord link' erstellt einen Code, der in Discord eingegeben wird, '/discord unlink' entfernt die Verknüpfung. + Verknüpft das Konto mit einem Discord-Benutzer: '/discord link' erstellt einen Code, der in Discord eingegeben wird, '/discord unlink' entfernt die Verknüpfung, '/discord notify letter' schaltet Direktnachrichten über Briefe ein oder aus (auch 'login' und 'friend'). Befehl Weltchat diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 24b82c7b98..3a5a8b8bbd 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1614,7 +1614,7 @@ Discord command - Links the account to a Discord user: '/discord link' creates a code which is entered in Discord, '/discord unlink' removes the link. + Links the account to a Discord user: '/discord link' creates a code which is entered in Discord, '/discord unlink' removes the link, '/discord notify letter' turns direct messages about letters on or off (also 'login' and 'friend'). World chat command diff --git a/src/GameServer/GameServerContext.cs b/src/GameServer/GameServerContext.cs index a0104279d3..ddd01f9b75 100644 --- a/src/GameServer/GameServerContext.cs +++ b/src/GameServer/GameServerContext.cs @@ -265,6 +265,9 @@ private async ValueTask PlayerEnteredWorldAsync(Player player) } await this.EventPublisher.PlayerEnteredGameAsync(this.Id, selectedCharacter.Id, selectedCharacter.Name).ConfigureAwait(false); + + // E.g. the Discord bot can notify the friends of the player. + await this.EventPublisher.GameEventAsync(new PlayerEnteredGameEvent(this.Id, DateTime.UtcNow, selectedCharacter.Id, selectedCharacter.Name)).ConfigureAwait(false); } catch (Exception ex) { diff --git a/src/Interfaces/AccountLoginBlockedEvent.cs b/src/Interfaces/AccountLoginBlockedEvent.cs new file mode 100644 index 0000000000..1a0d21c5b1 --- /dev/null +++ b/src/Interfaces/AccountLoginBlockedEvent.cs @@ -0,0 +1,14 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Interfaces; + +/// +/// Somebody tried to log into an account with the correct password, while the account was already logged in. +/// +/// The identifier of the game server. +/// The timestamp, in UTC. +/// The login name of the account. +public sealed record AccountLoginBlockedEvent(byte ServerId, DateTime TimestampUtc, string LoginName) + : GameEvent(ServerId, TimestampUtc); diff --git a/src/Interfaces/CastleSiegeStateChangedEvent.cs b/src/Interfaces/CastleSiegeStateChangedEvent.cs index ab22d21782..fa28bf888d 100644 --- a/src/Interfaces/CastleSiegeStateChangedEvent.cs +++ b/src/Interfaces/CastleSiegeStateChangedEvent.cs @@ -16,5 +16,7 @@ namespace MUnique.OpenMU.Interfaces; /// The persistent identifier of the guild which owns the castle, if any. After the battle, it's the winner. /// The name can be retrieved with . /// -public sealed record CastleSiegeStateChangedEvent(byte ServerId, DateTime TimestampUtc, string PreviousState, string State, DateTime StateEndsAtUtc, Guid? OwnerGuildId) +/// The time, in UTC, when the next battle starts, if it's known. +/// The time, in UTC, when the next battle ends, if it's known. +public sealed record CastleSiegeStateChangedEvent(byte ServerId, DateTime TimestampUtc, string PreviousState, string State, DateTime StateEndsAtUtc, Guid? OwnerGuildId, DateTime? NextBattleStartUtc = null, DateTime? NextBattleEndUtc = null) : GameEvent(ServerId, TimestampUtc); diff --git a/src/Interfaces/GameEvent.cs b/src/Interfaces/GameEvent.cs index 92b78e195a..3569adda75 100644 --- a/src/Interfaces/GameEvent.cs +++ b/src/Interfaces/GameEvent.cs @@ -30,4 +30,7 @@ namespace MUnique.OpenMU.Interfaces; [JsonDerivedType(typeof(CharacterLevelMilestoneEvent), nameof(CharacterLevelMilestoneEvent))] [JsonDerivedType(typeof(AccountUnlinkedEvent), nameof(AccountUnlinkedEvent))] [JsonDerivedType(typeof(ChatMessageEvent), nameof(ChatMessageEvent))] +[JsonDerivedType(typeof(AccountLoginBlockedEvent), nameof(AccountLoginBlockedEvent))] +[JsonDerivedType(typeof(LetterReceivedEvent), nameof(LetterReceivedEvent))] +[JsonDerivedType(typeof(PlayerEnteredGameEvent), nameof(PlayerEnteredGameEvent))] public abstract record GameEvent(byte ServerId, DateTime TimestampUtc); diff --git a/src/Interfaces/LetterReceivedEvent.cs b/src/Interfaces/LetterReceivedEvent.cs new file mode 100644 index 0000000000..b13a700404 --- /dev/null +++ b/src/Interfaces/LetterReceivedEvent.cs @@ -0,0 +1,16 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Interfaces; + +/// +/// A character received a letter. +/// +/// The identifier of the game server. +/// The timestamp, in UTC. +/// The name of the character which received the letter. +/// The name of the character which sent the letter. +/// The subject of the letter. +public sealed record LetterReceivedEvent(byte ServerId, DateTime TimestampUtc, string ReceiverName, string SenderName, string Subject) + : GameEvent(ServerId, TimestampUtc); diff --git a/src/Interfaces/PlayerEnteredGameEvent.cs b/src/Interfaces/PlayerEnteredGameEvent.cs new file mode 100644 index 0000000000..1692fcb1a7 --- /dev/null +++ b/src/Interfaces/PlayerEnteredGameEvent.cs @@ -0,0 +1,15 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Interfaces; + +/// +/// A character entered the game. +/// +/// The identifier of the game server. +/// The timestamp, in UTC. +/// The identifier of the character. +/// The name of the character. +public sealed record PlayerEnteredGameEvent(byte ServerId, DateTime TimestampUtc, Guid CharacterId, string CharacterName) + : GameEvent(ServerId, TimestampUtc); diff --git a/tests/MUnique.OpenMU.Tests/CastleSiegeNextBattleTest.cs b/tests/MUnique.OpenMU.Tests/CastleSiegeNextBattleTest.cs new file mode 100644 index 0000000000..9132e971ed --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/CastleSiegeNextBattleTest.cs @@ -0,0 +1,55 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.CastleSiege; + +/// +/// Tests for , which tells when the next battle takes place. +/// +[TestFixture] +public class CastleSiegeNextBattleTest +{ + private static readonly CastleSiegeSchedule Schedule = new(new[] + { + new CastleSiegeStateScheduleEntry { State = CastleSiegeState.RegisterGuild, DayOfWeek = DayOfWeek.Monday, Hour = 0 }, + new CastleSiegeStateScheduleEntry { State = CastleSiegeState.Start, DayOfWeek = DayOfWeek.Saturday, Hour = 20 }, + new CastleSiegeStateScheduleEntry { State = CastleSiegeState.End, DayOfWeek = DayOfWeek.Saturday, Hour = 22 }, + }); + + /// + /// Tests that the battle of this week is the next one before it starts. + /// + [Test] + public void BattleOfThisWeekIsNext() + { + // Wednesday, 2026-10-07 + var period = Schedule.GetNextPeriod(CastleSiegeState.Start, new DateTime(2026, 10, 7, 12, 0, 0, DateTimeKind.Utc)); + + Assert.That(period?.StartUtc, Is.EqualTo(new DateTime(2026, 10, 10, 20, 0, 0, DateTimeKind.Utc))); + Assert.That(period?.EndUtc, Is.EqualTo(new DateTime(2026, 10, 10, 22, 0, 0, DateTimeKind.Utc))); + } + + /// + /// Tests that the battle of the next week is the next one, when the battle of this week already started. + /// + [Test] + public void BattleOfNextWeekIsNextAfterTheStart() + { + var period = Schedule.GetNextPeriod(CastleSiegeState.Start, new DateTime(2026, 10, 10, 21, 0, 0, DateTimeKind.Utc)); + + Assert.That(period?.StartUtc, Is.EqualTo(new DateTime(2026, 10, 17, 20, 0, 0, DateTimeKind.Utc))); + } + + /// + /// Tests that there is no next period of a state which isn't scheduled. + /// + [Test] + public void UnscheduledStateHasNoPeriod() + { + Assert.That(Schedule.GetNextPeriod(CastleSiegeState.Notify, DateTime.UtcNow), Is.Null); + } +} diff --git a/tests/MUnique.OpenMU.Tests/DiscordChatCommandPlugInTest.cs b/tests/MUnique.OpenMU.Tests/DiscordChatCommandPlugInTest.cs index 837d954ef4..f12f5f5224 100644 --- a/tests/MUnique.OpenMU.Tests/DiscordChatCommandPlugInTest.cs +++ b/tests/MUnique.OpenMU.Tests/DiscordChatCommandPlugInTest.cs @@ -37,6 +37,25 @@ public async Task LinkCreatesCodeAndUnlinkRemovesItAsync() Assert.That(await this.GetLinkAsync(player).ConfigureAwait(false), Is.Null); } + /// + /// Tests that /discord notify toggles the direct messages of a linked account. + /// + [Test] + public async Task NotifyTogglesDirectMessagesAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var plugIn = new DiscordChatCommandPlugIn(); + var service = new AccountLinkService(() => player.GameContext.PersistenceContextProvider.CreateNewPlayerContext(player.GameContext.Configuration)); + var code = await service.CreateCodeAsync(player.Account!.GetId(), AccountLinkService.DiscordProvider, null).ConfigureAwait(false); + await service.LinkAsync(AccountLinkService.DiscordProvider, code, "42", "hero").ConfigureAwait(false); + + await plugIn.HandleCommandAsync(player, "/discord notify letter").ConfigureAwait(false); + await plugIn.HandleCommandAsync(player, "/discord notify friend").ConfigureAwait(false); + await plugIn.HandleCommandAsync(player, "/discord notify letter").ConfigureAwait(false); + + Assert.That((await this.GetLinkAsync(player).ConfigureAwait(false))?.Notifications, Is.EqualTo(DataModel.Entities.AccountNotificationTypes.FriendOnline)); + } + private async Task GetLinkAsync(Player player) { using var context = player.GameContext.PersistenceContextProvider.CreateNewPlayerContext(player.GameContext.Configuration); diff --git a/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs b/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs index fed81453a7..6b82919f07 100644 --- a/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs +++ b/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs @@ -54,6 +54,10 @@ public class GameEventPublisherPlugInTest new CharacterLevelMilestoneEvent(1, DateTime.UtcNow, "Hero", "Blade Knight", 400, false), new AccountUnlinkedEvent(1, DateTime.UtcNow, "discord", "42"), new ChatMessageEvent(1, DateTime.UtcNow, GameChatChannel.Alliance, 7, "Hero", "Hello"), + new AccountLoginBlockedEvent(1, DateTime.UtcNow, "hero"), + new LetterReceivedEvent(1, DateTime.UtcNow, "Hero", "Elf", "Hello"), + new PlayerEnteredGameEvent(1, DateTime.UtcNow, Guid.NewGuid(), "Hero"), + new CastleSiegeStateChangedEvent(1, DateTime.UtcNow, "Notify", "Ready", DateTime.UtcNow.AddHours(1), null, DateTime.UtcNow.AddHours(1), DateTime.UtcNow.AddHours(3)), }; /// From 05634b04e658c9448356825e6e8d94c14beb17b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:34:38 +0000 Subject: [PATCH 2/8] feat(persistence): store the opted-in notifications of account links Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ --- src/DataModel/Entities/AccountExternalLink.cs | 6 + .../Entities/AccountNotificationTypes.cs | 32 + .../AccountContextModelBuilder.cs | 2 +- .../AccountExternalLinkEntityType.cs | 10 +- .../AccountExternalLinkEntityType.cs | 10 +- .../EntityDataContextModelBuilder.cs | 2 +- .../AccountExternalLinkEntityType.cs | 10 +- .../TradeContext/TradeContextModelBuilder.cs | 2 +- ...082348_AddAccountNotifications.Designer.cs | 6407 +++++++++++++++++ .../20261009082348_AddAccountNotifications.cs | 31 + .../EntityDataContextModelSnapshot.cs | 3 + .../Model/BasicModelConverter.Generated.cs | 1 + .../EntityFramework/PlayerContext.cs | 28 + src/Persistence/IPlayerContext.cs | 16 + .../InMemory/PlayerInMemoryContext.cs | 18 + 15 files changed, 6572 insertions(+), 6 deletions(-) create mode 100644 src/DataModel/Entities/AccountNotificationTypes.cs create mode 100644 src/Persistence/EntityFramework/Migrations/20261009082348_AddAccountNotifications.Designer.cs create mode 100644 src/Persistence/EntityFramework/Migrations/20261009082348_AddAccountNotifications.cs diff --git a/src/DataModel/Entities/AccountExternalLink.cs b/src/DataModel/Entities/AccountExternalLink.cs index 945f979d3c..c9d72f333a 100644 --- a/src/DataModel/Entities/AccountExternalLink.cs +++ b/src/DataModel/Entities/AccountExternalLink.cs @@ -55,6 +55,12 @@ public class AccountExternalLink /// public DateTime? LinkedAt { get; set; } + /// + /// Gets or sets the types of notifications which the user receives in the external service. + /// They are opt-in, so there are none by default. + /// + public AccountNotificationTypes Notifications { get; set; } + /// /// Gets or sets the hash of the pending one-time code; null, if there is none. /// diff --git a/src/DataModel/Entities/AccountNotificationTypes.cs b/src/DataModel/Entities/AccountNotificationTypes.cs new file mode 100644 index 0000000000..1e87995aba --- /dev/null +++ b/src/DataModel/Entities/AccountNotificationTypes.cs @@ -0,0 +1,32 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.DataModel.Entities; + +/// +/// The types of notifications which a player receives in an external service, e.g. as direct message in Discord. +/// +[Flags] +public enum AccountNotificationTypes +{ + /// + /// No notifications. + /// + None = 0, + + /// + /// Somebody tried to log into the account while it was logged in. + /// + LoginAttempt = 1, + + /// + /// A character of the account received a letter. + /// + LetterReceived = 2, + + /// + /// A friend of a character of the account entered the game. + /// + FriendOnline = 4, +} diff --git a/src/Persistence/EntityFramework/CompiledModels/AccountContext/AccountContextModelBuilder.cs b/src/Persistence/EntityFramework/CompiledModels/AccountContext/AccountContextModelBuilder.cs index ae619eb8d2..a81d819c24 100644 --- a/src/Persistence/EntityFramework/CompiledModels/AccountContext/AccountContextModelBuilder.cs +++ b/src/Persistence/EntityFramework/CompiledModels/AccountContext/AccountContextModelBuilder.cs @@ -12,7 +12,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.CompiledModels.ForAccountCo public partial class AccountContextModel { private AccountContextModel() - : base(skipDetectChanges: false, modelId: new Guid("f913b9cf-15b0-42c6-9d5c-25fd4e7c82e0"), entityTypeCount: 47) + : base(skipDetectChanges: false, modelId: new Guid("8fd63d79-0fa0-4211-a3c3-539b60dfb6ee"), entityTypeCount: 47) { } diff --git a/src/Persistence/EntityFramework/CompiledModels/AccountContext/AccountExternalLinkEntityType.cs b/src/Persistence/EntityFramework/CompiledModels/AccountContext/AccountExternalLinkEntityType.cs index 5966cac863..0bd94dcee3 100644 --- a/src/Persistence/EntityFramework/CompiledModels/AccountContext/AccountExternalLinkEntityType.cs +++ b/src/Persistence/EntityFramework/CompiledModels/AccountContext/AccountExternalLinkEntityType.cs @@ -22,7 +22,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas "MUnique.OpenMU.Persistence.EntityFramework.Model.AccountExternalLink", typeof(MUnique.OpenMU.Persistence.EntityFramework.Model.AccountExternalLink), baseEntityType, - propertyCount: 9, + propertyCount: 10, foreignKeyCount: 1, unnamedIndexCount: 3, keyCount: 1); @@ -94,6 +94,14 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas nullable: true); linkedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + var notifications = runtimeEntityType.AddProperty( + "Notifications", + typeof(MUnique.OpenMU.DataModel.Entities.AccountNotificationTypes), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.AccountExternalLink).GetProperty("Notifications", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.AccountExternalLink).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + notifications.SetSentinelFromProviderValue(0); + notifications.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + var provider = runtimeEntityType.AddProperty( "Provider", typeof(string), diff --git a/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/AccountExternalLinkEntityType.cs b/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/AccountExternalLinkEntityType.cs index b210dc2ebb..173c2e9eba 100644 --- a/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/AccountExternalLinkEntityType.cs +++ b/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/AccountExternalLinkEntityType.cs @@ -22,7 +22,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas "MUnique.OpenMU.Persistence.EntityFramework.Model.AccountExternalLink", typeof(MUnique.OpenMU.Persistence.EntityFramework.Model.AccountExternalLink), baseEntityType, - propertyCount: 9, + propertyCount: 10, foreignKeyCount: 1, unnamedIndexCount: 3, keyCount: 1); @@ -94,6 +94,14 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas nullable: true); linkedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + var notifications = runtimeEntityType.AddProperty( + "Notifications", + typeof(MUnique.OpenMU.DataModel.Entities.AccountNotificationTypes), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.AccountExternalLink).GetProperty("Notifications", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.AccountExternalLink).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + notifications.SetSentinelFromProviderValue(0); + notifications.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + var provider = runtimeEntityType.AddProperty( "Provider", typeof(string), diff --git a/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/EntityDataContextModelBuilder.cs b/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/EntityDataContextModelBuilder.cs index d772479549..d6040b2c20 100644 --- a/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/EntityDataContextModelBuilder.cs +++ b/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/EntityDataContextModelBuilder.cs @@ -12,7 +12,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.CompiledModels.ForEntityDat public partial class EntityDataContextModel { private EntityDataContextModel() - : base(skipDetectChanges: false, modelId: new Guid("df6cc1b4-f3a3-4a2d-8622-8880116d35c5"), entityTypeCount: 121) + : base(skipDetectChanges: false, modelId: new Guid("4c12378e-de30-4820-87ce-ce4582bd71be"), entityTypeCount: 121) { } diff --git a/src/Persistence/EntityFramework/CompiledModels/TradeContext/AccountExternalLinkEntityType.cs b/src/Persistence/EntityFramework/CompiledModels/TradeContext/AccountExternalLinkEntityType.cs index 636c7fe828..d0b1a8d1e9 100644 --- a/src/Persistence/EntityFramework/CompiledModels/TradeContext/AccountExternalLinkEntityType.cs +++ b/src/Persistence/EntityFramework/CompiledModels/TradeContext/AccountExternalLinkEntityType.cs @@ -22,7 +22,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas "MUnique.OpenMU.Persistence.EntityFramework.Model.AccountExternalLink", typeof(MUnique.OpenMU.Persistence.EntityFramework.Model.AccountExternalLink), baseEntityType, - propertyCount: 9, + propertyCount: 10, foreignKeyCount: 1, unnamedIndexCount: 3, keyCount: 1); @@ -94,6 +94,14 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas nullable: true); linkedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + var notifications = runtimeEntityType.AddProperty( + "Notifications", + typeof(MUnique.OpenMU.DataModel.Entities.AccountNotificationTypes), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.AccountExternalLink).GetProperty("Notifications", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.AccountExternalLink).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + notifications.SetSentinelFromProviderValue(0); + notifications.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + var provider = runtimeEntityType.AddProperty( "Provider", typeof(string), diff --git a/src/Persistence/EntityFramework/CompiledModels/TradeContext/TradeContextModelBuilder.cs b/src/Persistence/EntityFramework/CompiledModels/TradeContext/TradeContextModelBuilder.cs index 7ed225596c..a2efa7e537 100644 --- a/src/Persistence/EntityFramework/CompiledModels/TradeContext/TradeContextModelBuilder.cs +++ b/src/Persistence/EntityFramework/CompiledModels/TradeContext/TradeContextModelBuilder.cs @@ -12,7 +12,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.CompiledModels.ForTradeCont public partial class TradeContextModel { private TradeContextModel() - : base(skipDetectChanges: false, modelId: new Guid("798dc6a8-72f4-47a8-86f6-9c4ca24ea833"), entityTypeCount: 46) + : base(skipDetectChanges: false, modelId: new Guid("687a81b2-0e6e-4a59-b2de-a51011e32073"), entityTypeCount: 46) { } diff --git a/src/Persistence/EntityFramework/Migrations/20261009082348_AddAccountNotifications.Designer.cs b/src/Persistence/EntityFramework/Migrations/20261009082348_AddAccountNotifications.Designer.cs new file mode 100644 index 0000000000..b0a427c450 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20261009082348_AddAccountNotifications.Designer.cs @@ -0,0 +1,6407 @@ +// +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("20261009082348_AddAccountNotifications")] + partial class AddAccountNotifications + { + /// + 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("GoblinPoints") + .HasColumnType("integer"); + + 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.Property("WCoinC") + .HasColumnType("integer"); + + b.Property("WCoinP") + .HasColumnType("integer"); + + 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.AccountExternalLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CharacterName") + .HasColumnType("text"); + + b.Property("CodeExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CodeHash") + .HasColumnType("text"); + + b.Property("ExternalUserId") + .HasColumnType("text"); + + b.Property("ExternalUserName") + .HasColumnType("text"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Notifications") + .HasColumnType("integer"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CodeHash"); + + b.HasIndex("AccountId", "Provider") + .IsUnique(); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("AccountExternalLink", "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.CashShopCoinGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("integer"); + + b.Property("AppliedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CoinType") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GrantedBy") + .HasColumnType("text"); + + b.Property("Reason") + .HasColumnType("text"); + + b.Property("Reference") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Reference") + .IsUnique(); + + b.HasIndex("AccountId", "AppliedAt"); + + b.ToTable("CashShopCoinGrant", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CashShopConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BannerSaleZone") + .HasColumnType("smallint"); + + b.Property("BannerYear") + .HasColumnType("smallint"); + + b.Property("BannerYearId") + .HasColumnType("smallint"); + + b.Property("ScriptSaleZone") + .HasColumnType("smallint"); + + b.Property("ScriptYear") + .HasColumnType("smallint"); + + b.Property("ScriptYearId") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("CashShopConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CashShopPackage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CashShopConfigurationId") + .HasColumnType("uuid"); + + b.Property("CoinType") + .HasColumnType("integer"); + + b.Property("IsBundle") + .HasColumnType("boolean"); + + b.Property("IsForSale") + .HasColumnType("boolean"); + + b.Property("IsGiftable") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PackageSequence") + .HasColumnType("integer"); + + b.Property("Price") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CashShopConfigurationId"); + + b.ToTable("CashShopPackage", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CashShopProduct", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CashShopPackageId") + .HasColumnType("uuid"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemLevel") + .HasColumnType("smallint"); + + b.Property("Price") + .HasColumnType("integer"); + + b.Property("PriceSequence") + .HasColumnType("integer"); + + b.Property("ProductSequence") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CashShopPackageId"); + + b.HasIndex("ItemDefinitionId"); + + b.ToTable("CashShopProduct", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CashShopStorageItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GiftMessage") + .HasColumnType("text"); + + b.Property("GiftSenderName") + .HasColumnType("text"); + + b.Property("IsGift") + .HasColumnType("boolean"); + + b.Property("PriceSequence") + .HasColumnType("integer"); + + b.Property("ProductSequence") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("CashShopStorageItem", "data"); + }); + + 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("Key") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + 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("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("CashShopConfigurationId") + .HasColumnType("uuid"); + + 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("CashShopConfigurationId") + .IsUnique(); + + 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.GensAbuse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("KillCount") + .HasColumnType("integer"); + + b.Property("KillerId") + .HasColumnType("uuid"); + + b.Property("LastKillAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VictimId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("VictimId"); + + b.HasIndex("KillerId", "VictimId") + .IsUnique(); + + b.ToTable("GensAbuse", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GensMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("Contribution") + .HasColumnType("integer"); + + b.Property("Gens") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Rank") + .HasColumnType("smallint"); + + b.Property("RankingPosition") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId") + .IsUnique(); + + b.ToTable("GensMember", "data"); + }); + + 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.GuildChatBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BoundBy") + .HasColumnType("text"); + + b.Property("ExternalChannelId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExternalServerId") + .IsRequired() + .HasColumnType("text"); + + b.Property("GuildId") + .HasColumnType("uuid"); + + b.Property("IsHosted") + .HasColumnType("boolean"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Provider", "ExternalChannelId") + .IsUnique(); + + b.HasIndex("GuildId", "Scope", "Provider") + .IsUnique(); + + b.ToTable("GuildChatBinding", "data"); + }); + + 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("IsDroppable") + .HasColumnType("boolean"); + + b.Property("IsPersonalStoreSellable") + .HasColumnType("boolean"); + + b.Property("IsQuestItem") + .HasColumnType("boolean"); + + b.Property("IsRepairable") + .HasColumnType("boolean"); + + b.Property("IsSellableToNpc") + .HasColumnType("boolean"); + + b.Property("IsStorable") + .HasColumnType("boolean"); + + b.Property("IsTradable") + .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("MasterSkillDefinitionId") + .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("MasterSkillDefinitionId"); + + 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.AccountExternalLink", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.CashShopCoinGrant", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CashShopPackage", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CashShopConfiguration", null) + .WithMany("RawPackages") + .HasForeignKey("CashShopConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CashShopProduct", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CashShopPackage", null) + .WithMany("RawProducts") + .HasForeignKey("CashShopPackageId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId"); + + b.Navigation("RawItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CashShopStorageItem", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.CashShopConfiguration", "RawCashShopConfiguration") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "CashShopConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + 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("RawCashShopConfiguration"); + + 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.GensAbuse", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany() + .HasForeignKey("KillerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany() + .HasForeignKey("VictimId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GensMember", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.GuildChatBinding", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", null) + .WithMany() + .HasForeignKey("GuildId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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.MasterSkillDefinition", null) + .WithMany("RawPassivePowerUps") + .HasForeignKey("MasterSkillDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + 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.CashShopConfiguration", b => + { + b.Navigation("RawPackages"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CashShopPackage", b => + { + b.Navigation("RawProducts"); + }); + + 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"); + + b.Navigation("RawPassivePowerUps"); + }); + + 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/20261009082348_AddAccountNotifications.cs b/src/Persistence/EntityFramework/Migrations/20261009082348_AddAccountNotifications.cs new file mode 100644 index 0000000000..8fdab71a07 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20261009082348_AddAccountNotifications.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations +{ + /// + public partial class AddAccountNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Notifications", + schema: "data", + table: "AccountExternalLink", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Notifications", + schema: "data", + table: "AccountExternalLink"); + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs b/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs index 9bd6c29498..03087a09bc 100644 --- a/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs +++ b/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs @@ -145,6 +145,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LinkedAt") .HasColumnType("timestamp with time zone"); + b.Property("Notifications") + .HasColumnType("integer"); + b.Property("Provider") .IsRequired() .HasColumnType("text"); diff --git a/src/Persistence/EntityFramework/Model/BasicModelConverter.Generated.cs b/src/Persistence/EntityFramework/Model/BasicModelConverter.Generated.cs index 9e3f23b57b..4ed7bab3eb 100644 --- a/src/Persistence/EntityFramework/Model/BasicModelConverter.Generated.cs +++ b/src/Persistence/EntityFramework/Model/BasicModelConverter.Generated.cs @@ -223,6 +223,7 @@ private BasicModel.AccountExternalLink Convert(AccountExternalLink source) target.ExternalUserName = source.ExternalUserName; target.CharacterName = source.CharacterName; target.LinkedAt = source.LinkedAt; + target.Notifications = source.Notifications; target.CodeHash = source.CodeHash; target.CodeExpiresAt = source.CodeExpiresAt; return target; diff --git a/src/Persistence/EntityFramework/PlayerContext.cs b/src/Persistence/EntityFramework/PlayerContext.cs index 32bd700fe8..860385f11e 100644 --- a/src/Persistence/EntityFramework/PlayerContext.cs +++ b/src/Persistence/EntityFramework/PlayerContext.cs @@ -247,6 +247,34 @@ public async ValueTask CanSaveLetterAsync(Interfaces.LetterHeader letterHe } } + /// + public async ValueTask GetAccountIdByLoginNameAsync(string loginName, CancellationToken cancellationToken = default) + { + using var l = await this.LockAsync(cancellationToken).ConfigureAwait(false); + using (this.RepositoryProvider.ContextStack.UseContext(this)) + { + return await this.Context.Set() + .Where(account => account.LoginName == loginName) + .Select(account => (Guid?)account.Id) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + } + } + + /// + public async ValueTask GetCharacterStatusAsync(string characterName, CancellationToken cancellationToken = default) + { + using var l = await this.LockAsync(cancellationToken).ConfigureAwait(false); + using (this.RepositoryProvider.ContextStack.UseContext(this)) + { + return await this.Context.Set() + .Where(character => character.Name == characterName) + .Select(character => (DataModel.Entities.CharacterStatus?)character.CharacterStatus) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + } + } + /// public async ValueTask GetAccountChatBanUntilAsync( Guid accountId, diff --git a/src/Persistence/IPlayerContext.cs b/src/Persistence/IPlayerContext.cs index 4784db5b0a..604e4c4cf1 100644 --- a/src/Persistence/IPlayerContext.cs +++ b/src/Persistence/IPlayerContext.cs @@ -161,6 +161,22 @@ ValueTask> GetLatestCashShopCoinGrantsAsync( string reference, CancellationToken cancellationToken = default); + /// + /// Gets the identifier of an account by its login name, without loading the account. + /// + /// The login name. + /// The cancellation token. + /// The identifier of the account; Otherwise, null, if the account doesn't exist. + ValueTask GetAccountIdByLoginNameAsync(string loginName, CancellationToken cancellationToken = default); + + /// + /// Gets the status of a character, e.g. if it's a game master, without loading the account. + /// + /// The name of the character. + /// The cancellation token. + /// The status; Otherwise, null, if the character doesn't exist. + ValueTask GetCharacterStatusAsync(string characterName, CancellationToken cancellationToken = default); + /// /// Gets the time until which the chat of an account is banned, without loading the account. /// diff --git a/src/Persistence/InMemory/PlayerInMemoryContext.cs b/src/Persistence/InMemory/PlayerInMemoryContext.cs index da86804336..4f9ed75b6d 100644 --- a/src/Persistence/InMemory/PlayerInMemoryContext.cs +++ b/src/Persistence/InMemory/PlayerInMemoryContext.cs @@ -156,6 +156,24 @@ public async ValueTask CanSaveLetterAsync(Interfaces.LetterHeader letterHe return grants.FirstOrDefault(grant => grant.Reference == reference); } + /// + public async ValueTask GetAccountIdByLoginNameAsync(string loginName, CancellationToken cancellationToken = default) + { + var accounts = await this.Provider.GetRepository() + .GetAllAsync(cancellationToken) + .ConfigureAwait(false); + return accounts.FirstOrDefault(account => account.LoginName == loginName)?.Id; + } + + /// + public async ValueTask GetCharacterStatusAsync(string characterName, CancellationToken cancellationToken = default) + { + var characters = await this.Provider.GetRepository() + .GetAllAsync(cancellationToken) + .ConfigureAwait(false); + return characters.FirstOrDefault(character => character.Name == characterName)?.CharacterStatus; + } + /// public async ValueTask GetAccountChatBanUntilAsync( Guid accountId, From 18ab252414bc1cf2020369a3b709fb0363911669 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:34:38 +0000 Subject: [PATCH 3/8] feat(discord): add game master commands, direct messages and scheduled events /announce, /kick and /ban are allowed for users with the GM role and a linked game master character, and every use is posted to the staff alerts channel. Linked users opt in to direct messages with /notify. The bot can create a scheduled event for the next castle siege. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ --- src/Discord/DiscordAccountCommands.cs | 44 ++++ src/Discord/DiscordBot.Extras.cs | 188 ++++++++++++++ src/Discord/DiscordBot.cs | 31 ++- src/Discord/DiscordBotSettings.cs | 6 + src/Discord/DiscordChannelCategory.cs | 5 + src/Discord/DiscordDirectMessages.cs | 133 ++++++++++ src/Discord/DiscordGameMasterAnswer.cs | 12 + src/Discord/DiscordGameMasterCommands.cs | 159 ++++++++++++ .../DiscordServiceCollectionExtensions.cs | 15 ++ src/Discord/Properties/Resources.Designer.cs | 234 ++++++++++++++++++ src/Discord/Properties/Resources.de.resx | 78 ++++++ src/Discord/Properties/Resources.resx | 78 ++++++ src/Discord/Provisioning/DefaultLayout.json | 2 +- .../Provisioning/DiscordServerLayout.cs | 5 + .../Discord/DiscordDirectMessagesTest.cs | 131 ++++++++++ .../Discord/DiscordGameMasterCommandsTest.cs | 114 +++++++++ 16 files changed, 1232 insertions(+), 3 deletions(-) create mode 100644 src/Discord/DiscordBot.Extras.cs create mode 100644 src/Discord/DiscordDirectMessages.cs create mode 100644 src/Discord/DiscordGameMasterAnswer.cs create mode 100644 src/Discord/DiscordGameMasterCommands.cs create mode 100644 tests/MUnique.OpenMU.Tests/Discord/DiscordDirectMessagesTest.cs create mode 100644 tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs diff --git a/src/Discord/DiscordAccountCommands.cs b/src/Discord/DiscordAccountCommands.cs index 839b62caa4..8f6105ecd2 100644 --- a/src/Discord/DiscordAccountCommands.cs +++ b/src/Discord/DiscordAccountCommands.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.Discord; using System.Globalization; using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.Discord.Properties; using MUnique.OpenMU.GameLogic.AccountLinking; @@ -78,6 +79,49 @@ public async ValueTask ExecuteAsync(string commandName, st } } + /// + /// Turns a type of direct messages on or off. + /// + /// The identifier of the Discord user. + /// The keyword of the type, see . + /// A value indicating whether the direct messages are turned on. + /// The answer. + public async ValueTask SetNotificationAsync(ulong userId, string? typeKeyword, bool enabled) + { + try + { + var notifications = await this._linkService.SetNotificationByUserAsync(Provider, userId.ToString(CultureInfo.InvariantCulture), AccountNotificationKeywords.Parse(typeKeyword), enabled).ConfigureAwait(false); + if (notifications is not { } types) + { + return this.Answer(nameof(Resources.Link_NotLinked)).Embed; + } + + var names = new List(); + if (types.HasFlag(AccountNotificationTypes.LoginAttempt)) + { + names.Add(DiscordCommands.GetText(this._culture, nameof(Resources.Notify_Type_Login))); + } + + if (types.HasFlag(AccountNotificationTypes.LetterReceived)) + { + names.Add(DiscordCommands.GetText(this._culture, nameof(Resources.Notify_Type_Letter))); + } + + if (types.HasFlag(AccountNotificationTypes.FriendOnline)) + { + names.Add(DiscordCommands.GetText(this._culture, nameof(Resources.Notify_Type_Friend))); + } + + var list = names.Count > 0 ? string.Join(", ", names) : DiscordCommands.GetText(this._culture, nameof(Resources.Notify_None)); + return this.Answer(nameof(Resources.Notify_Changed), [], list).Embed; + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error when changing the direct messages of the Discord user {userId}.", userId); + return this.Answer(nameof(Resources.Command_Failed)).Embed; + } + } + private async ValueTask LinkAsync(string code, ulong userId, string userName) { var userIdText = userId.ToString(CultureInfo.InvariantCulture); diff --git a/src/Discord/DiscordBot.Extras.cs b/src/Discord/DiscordBot.Extras.cs new file mode 100644 index 0000000000..d45aa659c6 --- /dev/null +++ b/src/Discord/DiscordBot.Extras.cs @@ -0,0 +1,188 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Threading; +using global::Discord; +using global::Discord.WebSocket; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Discord.Properties; +using MUnique.OpenMU.Discord.Provisioning; +using MUnique.OpenMU.GameLogic.AccountLinking; +using MUnique.OpenMU.Interfaces; + +/// +/// The part of the with the commands for game masters, the direct messages +/// and the scheduled events. +/// +public sealed partial class DiscordBot +{ + /// + /// The name of the command which turns direct messages on or off. + /// + private const string NotifyCommandName = "notify"; + + private const string NotifyTypeOptionName = "type"; + + private const string NotifyEnabledOptionName = "enabled"; + + private IEnumerable CreateExtraCommands() + { + foreach (var definition in DiscordGameMasterCommands.Definitions) + { + yield return new SlashCommandBuilder() + .WithName(definition.Name) + .WithDescription(this._commands.Text(definition.DescriptionKey)) + .WithContextTypes(InteractionContextType.Guild) + .AddOption(definition.OptionName, ApplicationCommandOptionType.String, this._commands.Text(definition.OptionDescriptionKey!), isRequired: true) + .Build(); + } + + yield return new SlashCommandBuilder() + .WithName(NotifyCommandName) + .WithDescription(this._commands.Text(nameof(Resources.Command_Notify_Description))) + .AddOption(new SlashCommandOptionBuilder() + .WithName(NotifyTypeOptionName) + .WithDescription(this._commands.Text(nameof(Resources.Command_Notify_TypeOption))) + .WithType(ApplicationCommandOptionType.String) + .WithRequired(true) + .AddChoice(this._commands.Text(nameof(Resources.Notify_Type_Login)), AccountNotificationKeywords.LoginAttempt) + .AddChoice(this._commands.Text(nameof(Resources.Notify_Type_Letter)), AccountNotificationKeywords.LetterReceived) + .AddChoice(this._commands.Text(nameof(Resources.Notify_Type_Friend)), AccountNotificationKeywords.FriendOnline)) + .AddOption(NotifyEnabledOptionName, ApplicationCommandOptionType.Boolean, this._commands.Text(nameof(Resources.Command_Notify_EnabledOption)), isRequired: true) + .Build(); + } + + private async Task ExecuteGameMasterCommandAsync(SocketSlashCommand command) + { + try + { + await command.DeferAsync(ephemeral: true).ConfigureAwait(false); + var argument = command.Data.Options.FirstOrDefault()?.Value as string ?? string.Empty; + var answer = await this._gameMasterCommands.ExecuteAsync(command.Data.Name, argument, command.User.Id, command.User.Username, this.HasGameMasterRole(command)).ConfigureAwait(false); + if (answer.StaffAlert is { } alert && this._routing.GetChannelId(DiscordChannelCategory.StaffAlerts) is { } channelId) + { + await this.SendAsync(channelId, [alert], CancellationToken.None).ConfigureAwait(false); + } + + await command.FollowupAsync(embed: ToDiscordEmbed(answer.Embed), ephemeral: true, allowedMentions: AllowedMentions.None).ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error when answering the Discord command {command}.", command.Data.Name); + } + } + + /// + /// Determines whether the user has the GM role of the layout on the Discord server of the game server. + /// + private bool HasGameMasterRole(SocketSlashCommand command) + { + var roleName = this._layout.Roles.FirstOrDefault(role => role.Key == DiscordServerLayout.GameMasterRoleKey)?.Name; + return this._client is { } client + && command.User is SocketGuildUser user + && user.Guild.Id == this.GetRoutingGuild(client)?.Id + && user.Roles.Any(role => string.Equals(role.Name, roleName, StringComparison.OrdinalIgnoreCase)); + } + + private async Task ExecuteNotifyCommandAsync(SocketSlashCommand command) + { + try + { + await command.DeferAsync(ephemeral: true).ConfigureAwait(false); + var type = command.Data.Options.FirstOrDefault(option => option.Name == NotifyTypeOptionName)?.Value as string; + var enabled = command.Data.Options.FirstOrDefault(option => option.Name == NotifyEnabledOptionName)?.Value is true; + var answer = await this._accountCommands.SetNotificationAsync(command.User.Id, type, enabled).ConfigureAwait(false); + await command.FollowupAsync(embed: ToDiscordEmbed(answer), ephemeral: true, allowedMentions: AllowedMentions.None).ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error when answering the Discord command {command}.", command.Data.Name); + } + } + + /// + /// Sends the direct messages for a game event in the background, so that the game doesn't wait for the database or Discord. + /// + private void SendDirectMessages(GameEvent gameEvent) + { + _ = Task.Run(async () => + { + try + { + foreach (var (userId, embed) in await this._directMessages.GetMessagesAsync(gameEvent).ConfigureAwait(false)) + { + await this._ready.Task.WaitAsync(CancellationToken.None).ConfigureAwait(false); + if (this._client is { } client + && await client.Rest.GetUserAsync(userId).ConfigureAwait(false) is { } user) + { + var channel = await user.CreateDMChannelAsync().ConfigureAwait(false); + await channel.SendMessageAsync(embed: ToDiscordEmbed(embed), allowedMentions: AllowedMentions.None).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + // E.g. the user doesn't accept direct messages. + this._logger.LogWarning(ex, "A direct message for the game event {gameEvent} couldn't be sent.", gameEvent.GetType().Name); + } + }); + } + + /// + /// Creates or updates the scheduled event of the next castle siege on the Discord server of the game server. + /// + private void UpdateCastleSiegeEvent(CastleSiegeStateChangedEvent stateChanged) + { + if (!this._settings.CreateScheduledEvents + || stateChanged.NextBattleStartUtc is not { } startUtc + || startUtc <= DateTime.UtcNow.AddMinutes(1)) + { + return; + } + + var endUtc = stateChanged.NextBattleEndUtc is { } end && end > startUtc ? end : startUtc.AddHours(2); + _ = Task.Run(async () => + { + try + { + await this._ready.Task.WaitAsync(CancellationToken.None).ConfigureAwait(false); + if (this._client is not { } client || this.GetRoutingGuild(client) is not { } guild) + { + return; + } + + var name = this._commands.Text(nameof(Resources.Event_CastleSiege_Name)); + var start = new DateTimeOffset(DateTime.SpecifyKind(startUtc, DateTimeKind.Utc)); + var endTime = new DateTimeOffset(DateTime.SpecifyKind(endUtc, DateTimeKind.Utc)); + var events = await guild.GetEventsAsync().ConfigureAwait(false); + var existing = events.FirstOrDefault(e => e.Name == name && e.Type == GuildScheduledEventType.External && e.Status == GuildScheduledEventStatus.Scheduled); + if (existing is null) + { + await guild.CreateEventAsync( + name, + start, + GuildScheduledEventType.External, + GuildScheduledEventPrivacyLevel.Private, + this._commands.Text(nameof(Resources.Event_CastleSiege_Description)), + endTime, + location: this._commands.Text(nameof(Resources.Event_Location))).ConfigureAwait(false); + } + else if (existing.StartTime != start || existing.EndTime != endTime) + { + await existing.ModifyAsync(properties => + { + properties.StartTime = start; + properties.EndTime = endTime; + }).ConfigureAwait(false); + } + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "The scheduled event of the castle siege couldn't be updated. Has the bot the permission to create events?"); + } + }); + } +} diff --git a/src/Discord/DiscordBot.cs b/src/Discord/DiscordBot.cs index 507775be10..1465d79857 100644 --- a/src/Discord/DiscordBot.cs +++ b/src/Discord/DiscordBot.cs @@ -46,6 +46,8 @@ public sealed partial class DiscordBot : IManageableServer, IDiscordMessenger, I private readonly DiscordServerProvisioner _provisioner; private readonly DiscordChatBridge _chatBridge; private readonly DiscordChatCommands _chatCommands; + private readonly DiscordGameMasterCommands _gameMasterCommands; + private readonly DiscordDirectMessages _directMessages; private readonly ILogger _logger; private readonly SemaphoreSlim _lifecycleLock = new(1, 1); @@ -69,8 +71,10 @@ public sealed partial class DiscordBot : IManageableServer, IDiscordMessenger, I /// The provisioner, which sets up the Discord server. /// The chat bridge between the game and Discord. /// The commands of the chat bridge. + /// The commands for game masters. + /// The direct messages to linked users. /// The logger. - public DiscordBot(DiscordBotSettings settings, DiscordCommands commands, DiscordAccountCommands accountCommands, DiscordStatusFormatter statusFormatter, IDiscordGameDataProvider data, DiscordServerLayout layout, DiscordServerProvisioner provisioner, DiscordChatBridge chatBridge, DiscordChatCommands chatCommands, ILogger logger) + public DiscordBot(DiscordBotSettings settings, DiscordCommands commands, DiscordAccountCommands accountCommands, DiscordStatusFormatter statusFormatter, IDiscordGameDataProvider data, DiscordServerLayout layout, DiscordServerProvisioner provisioner, DiscordChatBridge chatBridge, DiscordChatCommands chatCommands, DiscordGameMasterCommands gameMasterCommands, DiscordDirectMessages directMessages, ILogger logger) { this._settings = settings; this._commands = commands; @@ -81,6 +85,8 @@ public DiscordBot(DiscordBotSettings settings, DiscordCommands commands, Discord this._provisioner = provisioner; this._chatBridge = chatBridge; this._chatCommands = chatCommands; + this._gameMasterCommands = gameMasterCommands; + this._directMessages = directMessages; this._logger = logger; this._routing = DiscordChannelRouting.Create(settings, layout, new Dictionary()); } @@ -199,6 +205,7 @@ public async Task SendAsync(ulong channelId, IReadOnlyList embeds, /// /// /// Chat messages are mirrored to the bound channels. + /// Linked users get direct messages, if they turned them on, and the next castle siege becomes a scheduled event. /// When an account is unlinked in the game, the role of linked players is removed from its Discord user. /// public async ValueTask OnGameEventAsync(GameEvent gameEvent) @@ -207,6 +214,14 @@ public async ValueTask OnGameEventAsync(GameEvent gameEvent) { await this.OnChatMessageAsync(chatMessage).ConfigureAwait(false); } + else if (gameEvent is AccountLoginBlockedEvent or LetterReceivedEvent or PlayerEnteredGameEvent) + { + this.SendDirectMessages(gameEvent); + } + else if (gameEvent is CastleSiegeStateChangedEvent castleSiegeStateChanged) + { + this.UpdateCastleSiegeEvent(castleSiegeStateChanged); + } else if (gameEvent is AccountUnlinkedEvent { Provider: AccountLinkService.DiscordProvider } unlinked && ulong.TryParse(unlinked.ExternalUserId, NumberStyles.None, CultureInfo.InvariantCulture, out var userId)) { @@ -308,7 +323,7 @@ private async Task RegisterCommandsAsync(DiscordSocketClient client) } return (ApplicationCommandProperties)builder.Build(); - }).Append(this.CreateAdministrationCommand()).Concat(this.CreateChatCommands()).ToArray(); + }).Append(this.CreateAdministrationCommand()).Concat(this.CreateChatCommands()).Concat(this.CreateExtraCommands()).ToArray(); if (this._settings.GuildId is { } guildId && client.GetGuild(guildId) is { } guild) { @@ -354,6 +369,18 @@ private async Task OnSlashCommandExecutedAsync(SocketSlashCommand command) return; } + if (DiscordGameMasterCommands.IsGameMasterCommand(command.Data.Name)) + { + await this.ExecuteGameMasterCommandAsync(command).ConfigureAwait(false); + return; + } + + if (command.Data.Name == NotifyCommandName) + { + await this.ExecuteNotifyCommandAsync(command).ConfigureAwait(false); + return; + } + try { // The data might come from the database, which can take longer than Discord waits for an answer. diff --git a/src/Discord/DiscordBotSettings.cs b/src/Discord/DiscordBotSettings.cs index ed256ea71e..7690a4f6bf 100644 --- a/src/Discord/DiscordBotSettings.cs +++ b/src/Discord/DiscordBotSettings.cs @@ -48,6 +48,12 @@ public class DiscordBotSettings /// public DiscordChatBridgeSettings ChatBridge { get; set; } = new(); + /// + /// Gets or sets a value indicating whether the bot creates a scheduled event on the Discord server for the next castle siege. + /// It requires the permission to create events. + /// + public bool CreateScheduledEvents { get; set; } + /// /// Gets or sets the interval in which the status message and the presence of the bot are updated. /// diff --git a/src/Discord/DiscordChannelCategory.cs b/src/Discord/DiscordChannelCategory.cs index 8e021a4356..de2e737127 100644 --- a/src/Discord/DiscordChannelCategory.cs +++ b/src/Discord/DiscordChannelCategory.cs @@ -28,4 +28,9 @@ public enum DiscordChannelCategory /// Notices of game masters. /// Notices, + + /// + /// Alerts for the staff, e.g. the commands which game masters used in Discord. + /// + StaffAlerts, } diff --git a/src/Discord/DiscordDirectMessages.cs b/src/Discord/DiscordDirectMessages.cs new file mode 100644 index 0000000000..29498d9594 --- /dev/null +++ b/src/Discord/DiscordDirectMessages.cs @@ -0,0 +1,133 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Globalization; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Discord.Properties; +using MUnique.OpenMU.GameLogic.AccountLinking; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; + +/// +/// Determines the direct messages which the bot sends to linked users because of game events, if they turned them on. +/// +public sealed class DiscordDirectMessages +{ + /// + /// The color of the direct messages (blue). + /// + private const int Color = DiscordCommands.AnswerColor; + + private readonly AccountLinkService _linkService; + private readonly IFriendServer _friendServer; + private readonly Func _createContext; + private readonly Func _createFriendContext; + private readonly Func _getServerName; + private readonly CultureInfo _culture; + + /// + /// Initializes a new instance of the class. + /// + /// The service of the account links. + /// The friend server. + /// The function which creates a player persistence context. + /// The function which creates a friend persistence context. + /// The function which gets the name of a game server. + /// The culture of the messages. + public DiscordDirectMessages(AccountLinkService linkService, IFriendServer friendServer, Func createContext, Func createFriendContext, Func getServerName, CultureInfo culture) + { + this._linkService = linkService; + this._friendServer = friendServer; + this._createContext = createContext; + this._createFriendContext = createFriendContext; + this._getServerName = getServerName; + this._culture = culture; + } + + /// + /// Gets the direct messages for a game event. + /// + /// The game event. + /// The identifiers of the Discord users and their messages. + public async ValueTask> GetMessagesAsync(GameEvent gameEvent) + { + switch (gameEvent) + { + case AccountLoginBlockedEvent loginBlocked: + { + using var context = this._createContext(); + var accountId = await context.GetAccountIdByLoginNameAsync(loginBlocked.LoginName).ConfigureAwait(false); + return await this.CreateAsync(accountId, AccountNotificationTypes.LoginAttempt, nameof(Resources.Dm_LoginAttempt), this._getServerName(loginBlocked.ServerId) ?? loginBlocked.ServerId.ToString(this._culture)).ConfigureAwait(false); + } + + case LetterReceivedEvent letter: + { + using var context = this._createContext(); + var accountId = await context.GetAccountIdByCharacterNameAsync(letter.ReceiverName).ConfigureAwait(false); + return await this.CreateAsync(accountId, AccountNotificationTypes.LetterReceived, nameof(Resources.Dm_LetterReceived), DiscordMessageFormatter.Escape(letter.ReceiverName), DiscordMessageFormatter.Escape(letter.SenderName), DiscordMessageFormatter.Escape(letter.Subject)).ConfigureAwait(false); + } + + case PlayerEnteredGameEvent entered: + return await this.GetFriendOnlineMessagesAsync(entered).ConfigureAwait(false); + default: + return []; + } + } + + private async ValueTask> GetFriendOnlineMessagesAsync(PlayerEnteredGameEvent entered) + { + // Invisible players don't want their friends to know that they are online. + if (await this._friendServer.GetOnlineServerIdAsync(entered.CharacterName).ConfigureAwait(false) is null) + { + return []; + } + + IEnumerable friendNames; + using (var friendContext = this._createFriendContext()) + { + friendNames = (await friendContext.GetFriendNamesAsync(entered.CharacterId).ConfigureAwait(false)).ToList(); + } + + var accountIds = new HashSet(); + using (var context = this._createContext()) + { + foreach (var friendName in friendNames) + { + if (await context.GetAccountIdByCharacterNameAsync(friendName).ConfigureAwait(false) is { } accountId) + { + accountIds.Add(accountId); + } + } + } + + var messages = new List<(ulong UserId, DiscordEmbed Embed)>(); + foreach (var accountId in accountIds) + { + messages.AddRange(await this.CreateAsync(accountId, AccountNotificationTypes.FriendOnline, nameof(Resources.Dm_FriendOnline), DiscordMessageFormatter.Escape(entered.CharacterName)).ConfigureAwait(false)); + } + + return messages; + } + + private async ValueTask> CreateAsync(Guid? accountId, AccountNotificationTypes type, string resourceKey, params object[] args) + { + if (accountId is not { } id + || await this._linkService.GetLinkAsync(id, AccountLinkService.DiscordProvider).ConfigureAwait(false) is not { ExternalUserId: { } externalUserId } link + || !link.Notifications.HasFlag(type) + || !ulong.TryParse(externalUserId, NumberStyles.None, CultureInfo.InvariantCulture, out var userId)) + { + return []; + } + + var embed = new DiscordEmbed( + DiscordCommands.GetText(this._culture, nameof(Resources.Link_Title)), + DiscordCommands.GetText(this._culture, resourceKey, args), + Color, + DateTime.UtcNow, + null); + return [(userId, embed)]; + } +} diff --git a/src/Discord/DiscordGameMasterAnswer.cs b/src/Discord/DiscordGameMasterAnswer.cs new file mode 100644 index 0000000000..bba48e1d6e --- /dev/null +++ b/src/Discord/DiscordGameMasterAnswer.cs @@ -0,0 +1,12 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +/// +/// The answer of a game master command. +/// +/// The answer for the game master. +/// The alert for the staff about the use of the command; or null, if the command wasn't executed. +public sealed record DiscordGameMasterAnswer(DiscordEmbed Embed, DiscordEmbed? StaffAlert); diff --git a/src/Discord/DiscordGameMasterCommands.cs b/src/Discord/DiscordGameMasterCommands.cs new file mode 100644 index 0000000000..2cd6b3e81d --- /dev/null +++ b/src/Discord/DiscordGameMasterCommands.cs @@ -0,0 +1,159 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Globalization; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Discord.Properties; +using MUnique.OpenMU.GameLogic.AccountLinking; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; + +/// +/// The slash commands for game masters, which change something in the game. +/// +/// +/// They are allowed for Discord users who have the GM role of the layout, and who are linked to an account +/// whose selected character is a game master. Every use is reported to the staff. +/// +public sealed class DiscordGameMasterCommands +{ + /// + /// The color of the alerts for the staff (orange). + /// + private const int StaffAlertColor = 0xE67E22; + + private readonly IServerProvider _serverProvider; + private readonly AccountLinkService _linkService; + private readonly Func _createContext; + private readonly CultureInfo _culture; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The provider of the game servers. + /// The service of the account links. + /// The function which creates a persistence context. + /// The culture of the answers. + /// The logger. + public DiscordGameMasterCommands(IServerProvider serverProvider, AccountLinkService linkService, Func createContext, CultureInfo culture, ILogger logger) + { + this._serverProvider = serverProvider; + this._linkService = linkService; + this._createContext = createContext; + this._culture = culture; + this._logger = logger; + } + + /// + /// Gets the definitions of the commands. + /// + public static IReadOnlyList Definitions { get; } = + [ + new("announce", nameof(Resources.Command_Announce_Description), "text", nameof(Resources.Command_Announce_TextOption)), + new("kick", nameof(Resources.Command_Kick_Description), "character", nameof(Resources.Command_Who_CharacterOption)), + new("ban", nameof(Resources.Command_Ban_Description), "character", nameof(Resources.Command_Who_CharacterOption)), + ]; + + /// + /// Determines whether the command is one of these commands. + /// + /// The name of the command. + /// true, if the command is one of these commands. + public static bool IsGameMasterCommand(string commandName) => Definitions.Any(definition => definition.Name == commandName); + + /// + /// Executes a command, if the user is allowed to. + /// + /// The name of the command. + /// The value of the option of the command. + /// The identifier of the Discord user. + /// The name of the Discord user. + /// A value indicating whether the user has the GM role. + /// The answer. + public async ValueTask ExecuteAsync(string commandName, string argument, ulong userId, string userName, bool hasGameMasterRole) + { + if (!hasGameMasterRole || await this.GetGameMasterCharacterAsync(userId).ConfigureAwait(false) is not { } characterName) + { + return new(this.Answer(nameof(Resources.Gm_NotAllowed)), null); + } + + argument = argument.Trim(); + var gameServers = this._serverProvider.Servers.OfType().ToList(); + string resultKey; + switch (commandName) + { + case "announce": + foreach (var gameServer in gameServers) + { + await gameServer.SendGlobalMessageAsync(argument, MessageType.GoldenCenter).ConfigureAwait(false); + } + + resultKey = nameof(Resources.Gm_Announced); + break; + case "kick": + resultKey = await AnyAsync(gameServers, server => server.DisconnectPlayerAsync(argument)).ConfigureAwait(false) + ? nameof(Resources.Gm_Kicked) + : nameof(Resources.Gm_NotOnline); + break; + default: + resultKey = await AnyAsync(gameServers, server => server.BanPlayerAsync(argument)).ConfigureAwait(false) + ? nameof(Resources.Gm_Banned) + : nameof(Resources.Gm_NotOnline); + break; + } + + this._logger.LogInformation("The Discord user {user} ({userId}) as {character} used the command {command} {argument}.", userName, userId, characterName, commandName, argument); + var alert = new DiscordEmbed( + DiscordCommands.GetText(this._culture, nameof(Resources.StaffAlert_Title)), + DiscordCommands.GetText(this._culture, nameof(Resources.StaffAlert_Text), DiscordMessageFormatter.Escape(userName), DiscordMessageFormatter.Escape(characterName), commandName, DiscordMessageFormatter.Escape(argument)), + StaffAlertColor, + DateTime.UtcNow, + null); + return new(this.Answer(resultKey, DiscordMessageFormatter.Escape(argument)), alert); + } + + private static async ValueTask AnyAsync(IEnumerable gameServers, Func> action) + { + foreach (var gameServer in gameServers) + { + if (await action(gameServer).ConfigureAwait(false)) + { + return true; + } + } + + return false; + } + + /// + /// Gets the name of the selected character of the linked account, if it's a game master. + /// + private async ValueTask GetGameMasterCharacterAsync(ulong userId) + { + if (await this._linkService.GetLinkByUserAsync(AccountLinkService.DiscordProvider, userId.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false) is not { ExternalUserId: not null, CharacterName: { } characterName } link) + { + return null; + } + + using var context = this._createContext(); + return await context.GetAccountIdByCharacterNameAsync(characterName).ConfigureAwait(false) == link.AccountId + && await context.GetCharacterStatusAsync(characterName).ConfigureAwait(false) == CharacterStatus.GameMaster + ? characterName + : null; + } + + private DiscordEmbed Answer(string resourceKey, params object[] args) + { + return new DiscordEmbed( + DiscordCommands.GetText(this._culture, nameof(Resources.Gm_Title)), + DiscordCommands.GetText(this._culture, resourceKey, args), + DiscordCommands.AnswerColor, + DateTime.UtcNow, + null); + } +} diff --git a/src/Discord/DiscordServiceCollectionExtensions.cs b/src/Discord/DiscordServiceCollectionExtensions.cs index 4e8c99b899..6f2e068103 100644 --- a/src/Discord/DiscordServiceCollectionExtensions.cs +++ b/src/Discord/DiscordServiceCollectionExtensions.cs @@ -62,6 +62,19 @@ public static IServiceCollection AddDiscord(this IServiceCollection services, Di () => provider.GetService(), provider.GetRequiredService>())) .AddSingleton(new DiscordChatCommands(culture)) + .AddSingleton(provider => new DiscordGameMasterCommands( + provider.GetRequiredService(), + new AccountLinkService(() => provider.GetRequiredService().CreateNewPlayerContext(new GameConfiguration())), + () => provider.GetRequiredService().CreateNewPlayerContext(new GameConfiguration()), + culture, + provider.GetRequiredService>())) + .AddSingleton(provider => new DiscordDirectMessages( + new AccountLinkService(() => provider.GetRequiredService().CreateNewPlayerContext(new GameConfiguration())), + provider.GetRequiredService(), + () => provider.GetRequiredService().CreateNewPlayerContext(new GameConfiguration()), + () => provider.GetRequiredService().CreateNewFriendServerContext(), + serverId => provider.GetRequiredService().Servers.OfType().FirstOrDefault(server => server.Id == serverId)?.Description, + culture)) .AddSingleton(new DiscordStatusFormatter(culture)) .AddSingleton(settings.Bot.LayoutFile is { Length: > 0 } layoutFile ? DiscordServerLayout.Load(layoutFile) : DiscordServerLayout.LoadDefault()) .AddSingleton(provider => new DiscordServerProvisioner(provider.GetRequiredService>())) @@ -75,6 +88,8 @@ public static IServiceCollection AddDiscord(this IServiceCollection services, Di provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), provider.GetRequiredService>())) .AddHostedService(provider => provider.GetRequiredService()) .AddSingleton(provider => provider.GetRequiredService()); diff --git a/src/Discord/Properties/Resources.Designer.cs b/src/Discord/Properties/Resources.Designer.cs index 09c683f785..009dae5b34 100644 --- a/src/Discord/Properties/Resources.Designer.cs +++ b/src/Discord/Properties/Resources.Designer.cs @@ -411,6 +411,33 @@ public static string Chat_TooFast { } } + /// + /// Looks up a localized string similar to Sends a notice to all players in the game. For game masters.. + /// + public static string Command_Announce_Description { + get { + return ResourceManager.GetString("Command_Announce_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The text of the notice.. + /// + public static string Command_Announce_TextOption { + get { + return ResourceManager.GetString("Command_Announce_TextOption", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Bans the account of a character, which is online. For game masters.. + /// + public static string Command_Ban_Description { + get { + return ResourceManager.GetString("Command_Ban_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to Selects the character of your account, as which you appear.. /// @@ -528,6 +555,15 @@ public static string Command_GuildChat_Unbind_Description { } } + /// + /// Looks up a localized string similar to Disconnects a character from the game. For game masters.. + /// + public static string Command_Kick_Description { + get { + return ResourceManager.GetString("Command_Kick_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to The code which you got in the game with /discord link.. /// @@ -546,6 +582,33 @@ public static string Command_Link_Description { } } + /// + /// Looks up a localized string similar to Turns direct messages about your game account on or off.. + /// + public static string Command_Notify_Description { + get { + return ResourceManager.GetString("Command_Notify_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to On or off.. + /// + public static string Command_Notify_EnabledOption { + get { + return ResourceManager.GetString("Command_Notify_EnabledOption", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The kind of direct messages.. + /// + public static string Command_Notify_TypeOption { + get { + return ResourceManager.GetString("Command_Notify_TypeOption", resourceCulture); + } + } + /// /// Looks up a localized string similar to Shows how many players are online.. /// @@ -627,6 +690,60 @@ public static string Command_Who_Description { } } + /// + /// Looks up a localized string similar to Your friend {0} is online.. + /// + public static string Dm_FriendOnline { + get { + return ResourceManager.GetString("Dm_FriendOnline", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} received a letter from {1}: {2}. + /// + public static string Dm_LetterReceived { + get { + return ResourceManager.GetString("Dm_LetterReceived", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Somebody tried to log into your account on {0}, while you were logged in. If it wasn't you, change your password.. + /// + public static string Dm_LoginAttempt { + get { + return ResourceManager.GetString("Dm_LoginAttempt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The battle for the castle in the game. Guilds register for it in the game.. + /// + public static string Event_CastleSiege_Description { + get { + return ResourceManager.GetString("Event_CastleSiege_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Castle Siege. + /// + public static string Event_CastleSiege_Name { + get { + return ResourceManager.GetString("Event_CastleSiege_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to In the game. + /// + public static string Event_Location { + get { + return ResourceManager.GetString("Event_Location", resourceCulture); + } + } + /// /// Looks up a localized string similar to There are no events in the next 24 hours.. /// @@ -654,6 +771,60 @@ public static string GlobalNotice_Title { } } + /// + /// Looks up a localized string similar to The notice was sent: {0}. + /// + public static string Gm_Announced { + get { + return ResourceManager.GetString("Gm_Announced", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The account of {0} was banned.. + /// + public static string Gm_Banned { + get { + return ResourceManager.GetString("Gm_Banned", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} was disconnected.. + /// + public static string Gm_Kicked { + get { + return ResourceManager.GetString("Gm_Kicked", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Only game masters can use this command. You need the role GM, and a game master character of your linked account selected with /character.. + /// + public static string Gm_NotAllowed { + get { + return ResourceManager.GetString("Gm_NotAllowed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} isn't online.. + /// + public static string Gm_NotOnline { + get { + return ResourceManager.GetString("Gm_NotOnline", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Game master. + /// + public static string Gm_Title { + get { + return ResourceManager.GetString("Gm_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Guild master: {0}. /// @@ -843,6 +1014,51 @@ public static string MiniGameWon_Description { } } + /// + /// Looks up a localized string similar to You get direct messages about: {0}. + /// + public static string Notify_Changed { + get { + return ResourceManager.GetString("Notify_Changed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to nothing. + /// + public static string Notify_None { + get { + return ResourceManager.GetString("Notify_None", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Friends online. + /// + public static string Notify_Type_Friend { + get { + return ResourceManager.GetString("Notify_Type_Friend", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Letters. + /// + public static string Notify_Type_Letter { + get { + return ResourceManager.GetString("Notify_Type_Letter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Login attempts. + /// + public static string Notify_Type_Login { + get { + return ResourceManager.GetString("Notify_Type_Login", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0}: **{1}** / {2}. /// @@ -969,6 +1185,24 @@ public static string Setup_Title { } } + /// + /// Looks up a localized string similar to {0} ({1}) used /{2} {3}. + /// + public static string StaffAlert_Text { + get { + return ResourceManager.GetString("StaffAlert_Text", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Game master command. + /// + public static string StaffAlert_Title { + get { + return ResourceManager.GetString("StaffAlert_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to No game server is running.. /// diff --git a/src/Discord/Properties/Resources.de.resx b/src/Discord/Properties/Resources.de.resx index 5777eff5df..f823b3c136 100644 --- a/src/Discord/Properties/Resources.de.resx +++ b/src/Discord/Properties/Resources.de.resx @@ -215,6 +215,15 @@ Du sendest zu schnell Nachrichten. Bitte warte einen Moment. + + Sendet eine Nachricht an alle Spieler im Spiel. Für Spielleiter. + + + Der Text der Nachricht. + + + Sperrt das Konto eines Charakters, der online ist. Für Spielleiter. + Wählt den Charakter deines Kontos, als der du erscheinst. @@ -254,12 +263,24 @@ Entfernt die Verbindung dieses Kanals mit dem Chat des Spiels. + + Trennt einen Charakter vom Spiel. Für Spielleiter. + Der Code, den du im Spiel mit /discord link erhalten hast. Verknüpft deinen Discord-Benutzer mit deinem Spielkonto. + + Schaltet Direktnachrichten über dein Spielkonto ein oder aus. + + + Ein oder aus. + + + Die Art der Direktnachrichten. + Zeigt, wie viele Spieler online sind. @@ -287,6 +308,24 @@ Zeigt Informationen über einen Charakter. + + Dein Freund {0} ist online. + + + {0} hat einen Brief von {1} erhalten: {2} + + + Jemand hat versucht, sich auf {0} in dein Konto einzuloggen, während du eingeloggt warst. Wenn du es nicht warst, ändere dein Passwort. + + + Der Kampf um die Burg im Spiel. Gilden melden sich im Spiel dafür an. + + + Burgbelagerung + + + Im Spiel + In den nächsten 24 Stunden gibt es keine Ereignisse. @@ -296,6 +335,24 @@ Ankündigung + + Die Nachricht wurde gesendet: {0} + + + Das Konto von {0} wurde gesperrt. + + + {0} wurde getrennt. + + + Nur Spielleiter können diesen Befehl verwenden. Du brauchst die Rolle GM und einen mit /character gewählten Spielleiter-Charakter deines verknüpften Kontos. + + + {0} ist nicht online. + + + Spielleiter + Gildenmeister: {0} @@ -359,6 +416,21 @@ **{0}** hat {1} gewonnen! + + Du erhältst Direktnachrichten über: {0} + + + nichts + + + Freunde online + + + Briefe + + + Anmeldeversuche + {0}: **{1}** / {2} @@ -401,6 +473,12 @@ Einrichtung des Discord-Servers + + {0} ({1}) verwendete /{2} {3} + + + Spielleiter-Befehl + Es läuft kein Spielserver. diff --git a/src/Discord/Properties/Resources.resx b/src/Discord/Properties/Resources.resx index c5f6c3533c..9e539818da 100644 --- a/src/Discord/Properties/Resources.resx +++ b/src/Discord/Properties/Resources.resx @@ -215,6 +215,15 @@ You're sending messages too fast. Please wait a moment. + + Sends a notice to all players in the game. For game masters. + + + The text of the notice. + + + Bans the account of a character, which is online. For game masters. + Selects the character of your account, as which you appear. @@ -254,12 +263,24 @@ Removes the binding of this channel to the chat of the game. + + Disconnects a character from the game. For game masters. + The code which you got in the game with /discord link. Links your Discord user to your game account. + + Turns direct messages about your game account on or off. + + + On or off. + + + The kind of direct messages. + Shows how many players are online. @@ -287,6 +308,24 @@ Shows information about a character. + + Your friend {0} is online. + + + {0} received a letter from {1}: {2} + + + Somebody tried to log into your account on {0}, while you were logged in. If it wasn't you, change your password. + + + The battle for the castle in the game. Guilds register for it in the game. + + + Castle Siege + + + In the game + There are no events in the next 24 hours. @@ -296,6 +335,24 @@ Notice + + The notice was sent: {0} + + + The account of {0} was banned. + + + {0} was disconnected. + + + Only game masters can use this command. You need the role GM, and a game master character of your linked account selected with /character. + + + {0} isn't online. + + + Game master + Guild master: {0} @@ -359,6 +416,21 @@ **{0}** won {1}! + + You get direct messages about: {0} + + + nothing + + + Friends online + + + Letters + + + Login attempts + {0}: **{1}** / {2} @@ -401,6 +473,12 @@ Setup of the Discord server + + {0} ({1}) used /{2} {3} + + + Game master command + No game server is running. diff --git a/src/Discord/Provisioning/DefaultLayout.json b/src/Discord/Provisioning/DefaultLayout.json index 47db6bef63..b511d5f58a 100644 --- a/src/Discord/Provisioning/DefaultLayout.json +++ b/src/Discord/Provisioning/DefaultLayout.json @@ -35,7 +35,7 @@ "name": "Staff", "visibleTo": [ "gm" ], "channels": [ - { "key": "staff-alerts", "name": "staff-alerts", "isReadOnly": true }, + { "key": "staff-alerts", "name": "staff-alerts", "isReadOnly": true, "notifications": [ "StaffAlerts" ] }, { "key": "gm-commands", "name": "gm-commands" } ] } diff --git a/src/Discord/Provisioning/DiscordServerLayout.cs b/src/Discord/Provisioning/DiscordServerLayout.cs index a7776dcf2d..a07f01d92e 100644 --- a/src/Discord/Provisioning/DiscordServerLayout.cs +++ b/src/Discord/Provisioning/DiscordServerLayout.cs @@ -20,6 +20,11 @@ public sealed class DiscordServerLayout /// public const string LinkedRoleKey = "linked"; + /// + /// The key of the role of the game masters, which can use the commands for game masters. + /// + public const string GameMasterRoleKey = "gm"; + /// /// The key of the category in which the bot creates the channels for the chats of guilds. /// diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordDirectMessagesTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordDirectMessagesTest.cs new file mode 100644 index 0000000000..a6d4d0f6e0 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordDirectMessagesTest.cs @@ -0,0 +1,131 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.Globalization; +using Moq; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Discord; +using MUnique.OpenMU.GameLogic.AccountLinking; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Persistence.InMemory; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordDirectMessagesTest +{ + private const ulong HeroUser = 42; + + private InMemoryPersistenceContextProvider _persistence = null!; + private AccountLinkService _linkService = null!; + private Mock _friendServer = null!; + private DiscordDirectMessages _directMessages = null!; + private Guid _elfId; + + /// + /// Sets up the linked account of Hero, who is a friend of Elf. + /// + [SetUp] + public async Task SetUpAsync() + { + this._persistence = new InMemoryPersistenceContextProvider(); + this._linkService = new AccountLinkService(this.CreateContext); + var heroAccountId = await this.CreateAccountAsync("hero", "Hero").ConfigureAwait(false); + await this.CreateAccountAsync("elf", "Elf").ConfigureAwait(false); + using (var context = this.CreateContext()) + { + this._elfId = (await context.GetAccountByCharacterNameAsync("Elf").ConfigureAwait(false))!.Characters.Single().GetId(); + } + + using (var friendContext = this._persistence.CreateNewFriendServerContext()) + { + await friendContext.CreateNewFriendAsync("Elf", "Hero").ConfigureAwait(false); + await friendContext.SaveChangesAsync().ConfigureAwait(false); + } + + var code = await this._linkService.CreateCodeAsync(heroAccountId, AccountLinkService.DiscordProvider, "Hero").ConfigureAwait(false); + await this._linkService.LinkAsync(AccountLinkService.DiscordProvider, code, HeroUser.ToString(CultureInfo.InvariantCulture), "hero").ConfigureAwait(false); + + this._friendServer = new Mock(); + this._friendServer.Setup(s => s.GetOnlineServerIdAsync("Elf")).ReturnsAsync((byte)0); + this._directMessages = new DiscordDirectMessages( + this._linkService, + this._friendServer.Object, + this.CreateContext, + this._persistence.CreateNewFriendServerContext, + _ => "Server 1", + CultureInfo.GetCultureInfo("en")); + } + + /// + /// Tests that there are no direct messages, until the user turns them on. + /// + [Test] + public async Task DirectMessagesAreOptInAsync() + { + var letter = new LetterReceivedEvent(0, DateTime.UtcNow, "Hero", "Elf", "Hi"); + Assert.That(await this._directMessages.GetMessagesAsync(letter).ConfigureAwait(false), Is.Empty); + + await this.TurnOnAsync(AccountNotificationTypes.LetterReceived).ConfigureAwait(false); + var messages = await this._directMessages.GetMessagesAsync(letter).ConfigureAwait(false); + + Assert.That(messages, Has.Count.EqualTo(1)); + Assert.That(messages[0].UserId, Is.EqualTo(HeroUser)); + Assert.That(messages[0].Embed.Description, Is.EqualTo("Hero received a letter from Elf: Hi")); + } + + /// + /// Tests the direct message about a blocked login. + /// + [Test] + public async Task LoginAttemptIsReportedAsync() + { + await this.TurnOnAsync(AccountNotificationTypes.LoginAttempt).ConfigureAwait(false); + + var messages = await this._directMessages.GetMessagesAsync(new AccountLoginBlockedEvent(0, DateTime.UtcNow, "hero")).ConfigureAwait(false); + + Assert.That(messages.Single().Embed.Description, Does.StartWith("Somebody tried to log into your account on Server 1")); + } + + /// + /// Tests that the friends of a character get a direct message when it enters the game, unless it's invisible. + /// + [Test] + public async Task FriendsAreNotifiedUnlessInvisibleAsync() + { + await this.TurnOnAsync(AccountNotificationTypes.FriendOnline).ConfigureAwait(false); + var entered = new PlayerEnteredGameEvent(0, DateTime.UtcNow, this._elfId, "Elf"); + + var messages = await this._directMessages.GetMessagesAsync(entered).ConfigureAwait(false); + this._friendServer.Setup(s => s.GetOnlineServerIdAsync("Elf")).ReturnsAsync((byte?)null); + var invisibleMessages = await this._directMessages.GetMessagesAsync(entered).ConfigureAwait(false); + + Assert.That(messages.Single().Embed.Description, Is.EqualTo("Your friend Elf is online.")); + Assert.That(invisibleMessages, Is.Empty); + } + + private Task TurnOnAsync(AccountNotificationTypes type) + { + return this._linkService.SetNotificationByUserAsync(AccountLinkService.DiscordProvider, HeroUser.ToString(CultureInfo.InvariantCulture), type, true).AsTask(); + } + + private IPlayerContext CreateContext() => this._persistence.CreateNewPlayerContext(new GameConfiguration()); + + private async Task CreateAccountAsync(string loginName, string characterName) + { + using var context = this.CreateContext(); + var account = context.CreateNew(); + account.LoginName = loginName; + var character = context.CreateNew(); + character.Name = characterName; + account.Characters.Add(character); + await context.SaveChangesAsync().ConfigureAwait(false); + return account.GetId(); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs new file mode 100644 index 0000000000..319f73bb7b --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs @@ -0,0 +1,114 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.Globalization; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Discord; +using MUnique.OpenMU.GameLogic.AccountLinking; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Persistence.InMemory; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordGameMasterCommandsTest +{ + private const ulong GameMasterUser = 42; + private const ulong PlayerUser = 43; + + private InMemoryPersistenceContextProvider _persistence = null!; + private Mock _server1 = null!; + private Mock _server2 = null!; + private DiscordGameMasterCommands _commands = null!; + + /// + /// Sets up a linked game master and a linked player, and two game servers. + /// + [SetUp] + public async Task SetUpAsync() + { + this._persistence = new InMemoryPersistenceContextProvider(); + var linkService = new AccountLinkService(this.CreateContext); + await this.CreateLinkedAccountAsync(linkService, "admin", "Admin", CharacterStatus.GameMaster, GameMasterUser).ConfigureAwait(false); + await this.CreateLinkedAccountAsync(linkService, "hero", "Hero", CharacterStatus.Normal, PlayerUser).ConfigureAwait(false); + + this._server1 = new Mock(); + this._server2 = new Mock(); + var serverProvider = new Mock(); + serverProvider.SetupGet(p => p.Servers).Returns(new List { this._server1.Object, this._server2.Object }); + this._commands = new DiscordGameMasterCommands(serverProvider.Object, linkService, this.CreateContext, CultureInfo.GetCultureInfo("en"), NullLogger.Instance); + } + + /// + /// Tests that a command needs the GM role and a game master character. + /// + [Test] + public async Task OnlyGameMastersCanUseCommandsAsync() + { + var withoutRole = await this._commands.ExecuteAsync("announce", "Hello", GameMasterUser, "admin", false).ConfigureAwait(false); + var withoutGameMaster = await this._commands.ExecuteAsync("announce", "Hello", PlayerUser, "hero", true).ConfigureAwait(false); + + Assert.That(withoutRole.StaffAlert, Is.Null); + Assert.That(withoutGameMaster.StaffAlert, Is.Null); + Assert.That(withoutGameMaster.Embed.Description, Does.StartWith("Only game masters can use this command.")); + this._server1.Verify(s => s.SendGlobalMessageAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Tests that a notice is sent to all game servers, and that the staff is alerted. + /// + [Test] + public async Task AnnounceSendsToAllGameServersAsync() + { + var answer = await this._commands.ExecuteAsync("announce", "Hello", GameMasterUser, "admin", true).ConfigureAwait(false); + + this._server1.Verify(s => s.SendGlobalMessageAsync("Hello", MessageType.GoldenCenter), Times.Once); + this._server2.Verify(s => s.SendGlobalMessageAsync("Hello", MessageType.GoldenCenter), Times.Once); + Assert.That(answer.StaffAlert?.Description, Is.EqualTo("admin (Admin) used /announce Hello")); + } + + /// + /// Tests that a character is disconnected from the game server on which it's online. + /// + [Test] + public async Task KickDisconnectsFromTheGameServerOfTheCharacterAsync() + { + this._server2.Setup(s => s.DisconnectPlayerAsync("Hero")).ReturnsAsync(true); + + var answer = await this._commands.ExecuteAsync("kick", "Hero", GameMasterUser, "admin", true).ConfigureAwait(false); + var offlineAnswer = await this._commands.ExecuteAsync("ban", "Nobody", GameMasterUser, "admin", true).ConfigureAwait(false); + + Assert.That(answer.Embed.Description, Is.EqualTo("Hero was disconnected.")); + Assert.That(offlineAnswer.Embed.Description, Is.EqualTo("Nobody isn't online.")); + Assert.That(offlineAnswer.StaffAlert, Is.Not.Null); + } + + private IPlayerContext CreateContext() => this._persistence.CreateNewPlayerContext(new GameConfiguration()); + + private async Task CreateLinkedAccountAsync(AccountLinkService linkService, string loginName, string characterName, CharacterStatus status, ulong userId) + { + Guid accountId; + using (var context = this.CreateContext()) + { + var account = context.CreateNew(); + account.LoginName = loginName; + var character = context.CreateNew(); + character.Name = characterName; + character.CharacterStatus = status; + account.Characters.Add(character); + await context.SaveChangesAsync().ConfigureAwait(false); + accountId = account.GetId(); + } + + var code = await linkService.CreateCodeAsync(accountId, AccountLinkService.DiscordProvider, characterName).ConfigureAwait(false); + await linkService.LinkAsync(AccountLinkService.DiscordProvider, code, userId.ToString(CultureInfo.InvariantCulture), loginName).ConfigureAwait(false); + } +} From 0e666855161f9d2f73ca0c05546369890c106ed9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:34:39 +0000 Subject: [PATCH 4/8] docs(discord): document the commands for game masters, direct messages and scheduled events Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ --- docs-website/docs/server-features/discord.md | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs-website/docs/server-features/discord.md b/docs-website/docs/server-features/discord.md index fc5479c71a..358c042702 100644 --- a/docs-website/docs/server-features/discord.md +++ b/docs-website/docs/server-features/discord.md @@ -25,6 +25,7 @@ combined: | `CastleSiege` | Guild registration, the start and the end of the battle, and the new owner of the castle | | `WorldNews` | Boss kills, excellent and ancient items which monsters dropped, characters which reach a level milestone | | `Notices` | Global notices which game masters send with the `!` chat prefix | +| `StaffAlerts` | The commands which game masters used in Discord, see [Commands for game masters](#commands-for-game-masters) | Each category is posted into its own channel. A category without channel isn't posted. @@ -103,6 +104,8 @@ In the distributed deployment, the bot runs in its own container, see | `/character ` | Selects the character of the account, as which the player appears | | `/say ` | Sends a message to the chat of the game which is bound to the channel, see [Chat bridge](#chat-bridge) | | `/guildchat bind`, `create`, `unbind` | Binds the chat of a guild or alliance to a channel, see [Chat bridge](#chat-bridge) | +| `/notify ` | Turns [direct messages](#direct-messages) on or off | +| `/announce `, `/kick `, `/ban ` | [Commands for game masters](#commands-for-game-masters) | The commands only show information; they don't change anything in the game. Additionally, players can [link their account](#linking-accounts) with @@ -267,6 +270,45 @@ Without the intent, players use `/say `. Discord only approves the intent for verified bots, which are in more than 100 Discord servers, with a reason. +## Commands for game masters + +Game masters can use these commands in Discord: + +| Command | Effect | +|---|---| +| `/announce ` | Sends the text as golden notice to all players of all game servers. | +| `/kick ` | Disconnects the character from the game. | +| `/ban ` | Bans the account of the character, which has to be online, and disconnects it. | + +They need the role *GM* of the [layout](#setting-up-your-discord-server) on your +Discord server, **and** a [linked account](#linking-accounts) whose selected +character (`/character`) is a game master. So a Discord role alone isn't +enough. Every use is logged and posted into `#staff-alerts`, or the channel of +the `StaffAlerts` category. + +## Direct messages + +Linked players can get direct messages from the bot. They are off by default, +and each player turns them on with `/notify` in Discord, or with +`/discord notify ` in the game, which turns a type on or off: + +| Type | Direct message | +|---|---| +| `login` | Somebody tried to log into the account with the correct password, while it was logged in. | +| `letter` | A character of the account received a letter. | +| `friend` | A friend entered the game. Friends who are invisible in the game don't cause a message. | + +The bot can only send direct messages to users who share a Discord server with +it, and who allow direct messages from its members. + +## Scheduled events + +With `Bot__CreateScheduledEvents` set to `true`, the bot creates a *scheduled +event* on your Discord server for the next castle siege, so that players can +mark it as interesting and get reminded by Discord. It's updated when the castle +siege changes its state, which requires the *Game Event Publisher* plugin with +castle siege events. The bot needs the permission to create events. + ## Settings All settings are part of the `Discord` section of the configuration, so they can @@ -284,6 +326,7 @@ the parts are separated by two underscores. | `Bot__StatusChannelId` | the layout | The identifier of the channel with the status message. | | `Bot__Channels__` | the layout | The identifier of the channel per category, into which the bot posts the notifications. | | `Bot__LayoutFile` | the default | The path of a JSON file with your own [layout](#your-own-layout). | +| `Bot__CreateScheduledEvents` | `false` | Creates a [scheduled event](#scheduled-events) for the next castle siege. | | `Bot__StatusUpdateInterval` | `00:01:00` | How often the status message and the presence are updated. | | `Bot__ChatBridge__BindingMode` | `Both` | Where the chats of guilds can be bound: `Both`, `HostedOnly` (only channels on your Discord server) or `GuildOwnedOnly` (only on Discord servers of guilds). | | `Bot__ChatBridge__AllowedDiscordServerIds__0`, `__1`, … | all | The Discord servers of guilds, on which chats can be bound. | From 207cf573b2ece663808b22407e0b0d4dbbb0b9ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:54:31 +0000 Subject: [PATCH 5/8] fix(discord): query the friends at once, and make announcements robust - The friends who get a direct message when a character enters the game are determined with two queries instead of several per friend, and with none when the character has no friends. - /announce is limited to 200 characters, a failing game server doesn't stop the announcement on the others, and the answer tells on how many game servers it was shown. A failing game server doesn't stop /kick and /ban either. - The remarks explain that the GM role is only a filter, and that the game master status of the character is the actual check. - A failed command gets an answer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ --- src/Discord/DiscordBot.Extras.cs | 2 + src/Discord/DiscordDirectMessages.cs | 45 ++++++++------- src/Discord/DiscordGameMasterCommands.cs | 56 ++++++++++++++++--- src/Discord/Properties/Resources.Designer.cs | 18 ++++++ src/Discord/Properties/Resources.de.resx | 6 ++ src/Discord/Properties/Resources.resx | 6 ++ src/Interfaces/AccountLoginBlockedEvent.cs | 3 + .../Discord/DiscordGameMasterCommandsTest.cs | 27 +++++++++ 8 files changed, 135 insertions(+), 28 deletions(-) diff --git a/src/Discord/DiscordBot.Extras.cs b/src/Discord/DiscordBot.Extras.cs index d45aa659c6..c97beea85f 100644 --- a/src/Discord/DiscordBot.Extras.cs +++ b/src/Discord/DiscordBot.Extras.cs @@ -72,6 +72,7 @@ private async Task ExecuteGameMasterCommandAsync(SocketSlashCommand command) catch (Exception ex) { this._logger.LogError(ex, "Error when answering the Discord command {command}.", command.Data.Name); + await this.AnswerFailureAsync(command, true).ConfigureAwait(false); } } @@ -100,6 +101,7 @@ private async Task ExecuteNotifyCommandAsync(SocketSlashCommand command) catch (Exception ex) { this._logger.LogError(ex, "Error when answering the Discord command {command}.", command.Data.Name); + await this.AnswerFailureAsync(command, true).ConfigureAwait(false); } } diff --git a/src/Discord/DiscordDirectMessages.cs b/src/Discord/DiscordDirectMessages.cs index 29498d9594..b76517452d 100644 --- a/src/Discord/DiscordDirectMessages.cs +++ b/src/Discord/DiscordDirectMessages.cs @@ -85,49 +85,54 @@ public DiscordDirectMessages(AccountLinkService linkService, IFriendServer frien return []; } - IEnumerable friendNames; + IReadOnlyList friendNames; using (var friendContext = this._createFriendContext()) { friendNames = (await friendContext.GetFriendNamesAsync(entered.CharacterId).ConfigureAwait(false)).ToList(); } - var accountIds = new HashSet(); - using (var context = this._createContext()) + if (friendNames.Count == 0) { - foreach (var friendName in friendNames) - { - if (await context.GetAccountIdByCharacterNameAsync(friendName).ConfigureAwait(false) is { } accountId) - { - accountIds.Add(accountId); - } - } - } - - var messages = new List<(ulong UserId, DiscordEmbed Embed)>(); - foreach (var accountId in accountIds) - { - messages.AddRange(await this.CreateAsync(accountId, AccountNotificationTypes.FriendOnline, nameof(Resources.Dm_FriendOnline), DiscordMessageFormatter.Escape(entered.CharacterName)).ConfigureAwait(false)); + return []; } - return messages; + // Two queries for all friends, because this runs whenever a character enters the game. + using var context = this._createContext(); + var accountIds = (await context.GetAccountIdsByCharacterNamesAsync(friendNames).ConfigureAwait(false)).Values.Distinct().ToList(); + var links = await context.GetAccountExternalLinksAsync(AccountLinkService.DiscordProvider, accountIds).ConfigureAwait(false); + return links + .Where(link => link.Notifications.HasFlag(AccountNotificationTypes.FriendOnline)) + .Select(link => this.Create(link, nameof(Resources.Dm_FriendOnline), DiscordMessageFormatter.Escape(entered.CharacterName))) + .OfType<(ulong UserId, DiscordEmbed Embed)>() + .ToList(); } private async ValueTask> CreateAsync(Guid? accountId, AccountNotificationTypes type, string resourceKey, params object[] args) { if (accountId is not { } id - || await this._linkService.GetLinkAsync(id, AccountLinkService.DiscordProvider).ConfigureAwait(false) is not { ExternalUserId: { } externalUserId } link + || await this._linkService.GetLinkAsync(id, AccountLinkService.DiscordProvider).ConfigureAwait(false) is not { } link || !link.Notifications.HasFlag(type) - || !ulong.TryParse(externalUserId, NumberStyles.None, CultureInfo.InvariantCulture, out var userId)) + || this.Create(link, resourceKey, args) is not { } message) { return []; } + return [message]; + } + + private (ulong UserId, DiscordEmbed Embed)? Create(AccountExternalLink link, string resourceKey, params object[] args) + { + if (!ulong.TryParse(link.ExternalUserId, NumberStyles.None, CultureInfo.InvariantCulture, out var userId)) + { + return null; + } + var embed = new DiscordEmbed( DiscordCommands.GetText(this._culture, nameof(Resources.Link_Title)), DiscordCommands.GetText(this._culture, resourceKey, args), Color, DateTime.UtcNow, null); - return [(userId, embed)]; + return (userId, embed); } } diff --git a/src/Discord/DiscordGameMasterCommands.cs b/src/Discord/DiscordGameMasterCommands.cs index 2cd6b3e81d..bf13fdd885 100644 --- a/src/Discord/DiscordGameMasterCommands.cs +++ b/src/Discord/DiscordGameMasterCommands.cs @@ -18,6 +18,8 @@ namespace MUnique.OpenMU.Discord; /// /// They are allowed for Discord users who have the GM role of the layout, and who are linked to an account /// whose selected character is a game master. Every use is reported to the staff. +/// The role is matched by its name, so it's only a convenience filter, e.g. to hide the commands from others: +/// The game master status of the character is the actual check, because only the game can grant it. /// public sealed class DiscordGameMasterCommands { @@ -26,6 +28,11 @@ public sealed class DiscordGameMasterCommands /// private const int StaffAlertColor = 0xE67E22; + /// + /// The maximum length of an announcement. Longer texts would be shown as several golden messages. + /// + private const int MaximumAnnouncementLength = 200; + private readonly IServerProvider _serverProvider; private readonly AccountLinkService _linkService; private readonly Func _createContext; @@ -83,25 +90,37 @@ public async ValueTask ExecuteAsync(string commandName, } argument = argument.Trim(); + if (commandName == "announce" && argument.Length > MaximumAnnouncementLength) + { + return new(this.Answer(nameof(Resources.Gm_AnnouncementTooLong), MaximumAnnouncementLength), null); + } + var gameServers = this._serverProvider.Servers.OfType().ToList(); string resultKey; + object[] resultArgs = [DiscordMessageFormatter.Escape(argument)]; switch (commandName) { case "announce": + // A failing game server doesn't stop the others, and the game master learns about it. + var announced = 0; foreach (var gameServer in gameServers) { - await gameServer.SendGlobalMessageAsync(argument, MessageType.GoldenCenter).ConfigureAwait(false); + if (await this.TryAsync(gameServer, server => server.SendGlobalMessageAsync(argument, MessageType.GoldenCenter)).ConfigureAwait(false)) + { + announced++; + } } - resultKey = nameof(Resources.Gm_Announced); + resultKey = announced == gameServers.Count ? nameof(Resources.Gm_Announced) : nameof(Resources.Gm_AnnouncedPartially); + resultArgs = [DiscordMessageFormatter.Escape(argument), announced, gameServers.Count]; break; case "kick": - resultKey = await AnyAsync(gameServers, server => server.DisconnectPlayerAsync(argument)).ConfigureAwait(false) + resultKey = await this.AnyAsync(gameServers, server => server.DisconnectPlayerAsync(argument)).ConfigureAwait(false) ? nameof(Resources.Gm_Kicked) : nameof(Resources.Gm_NotOnline); break; default: - resultKey = await AnyAsync(gameServers, server => server.BanPlayerAsync(argument)).ConfigureAwait(false) + resultKey = await this.AnyAsync(gameServers, server => server.BanPlayerAsync(argument)).ConfigureAwait(false) ? nameof(Resources.Gm_Banned) : nameof(Resources.Gm_NotOnline); break; @@ -114,22 +133,43 @@ public async ValueTask ExecuteAsync(string commandName, StaffAlertColor, DateTime.UtcNow, null); - return new(this.Answer(resultKey, DiscordMessageFormatter.Escape(argument)), alert); + return new(this.Answer(resultKey, resultArgs), alert); } - private static async ValueTask AnyAsync(IEnumerable gameServers, Func> action) + private async ValueTask AnyAsync(IEnumerable gameServers, Func> action) { foreach (var gameServer in gameServers) { - if (await action(gameServer).ConfigureAwait(false)) + try { - return true; + if (await action(gameServer).ConfigureAwait(false)) + { + return true; + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error when executing a game master command on game server {server}.", gameServer.Id); } } return false; } + private async ValueTask TryAsync(IGameServer gameServer, Func action) + { + try + { + await action(gameServer).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error when executing a game master command on game server {server}.", gameServer.Id); + return false; + } + } + /// /// Gets the name of the selected character of the linked account, if it's a game master. /// diff --git a/src/Discord/Properties/Resources.Designer.cs b/src/Discord/Properties/Resources.Designer.cs index cd1c23f437..d19e79282e 100644 --- a/src/Discord/Properties/Resources.Designer.cs +++ b/src/Discord/Properties/Resources.Designer.cs @@ -798,6 +798,24 @@ public static string Gm_Announced { } } + /// + /// Looks up a localized string similar to The notice was sent to {1} of {2} game servers: {0}. + /// + public static string Gm_AnnouncedPartially { + get { + return ResourceManager.GetString("Gm_AnnouncedPartially", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The notice can have at most {0} characters.. + /// + public static string Gm_AnnouncementTooLong { + get { + return ResourceManager.GetString("Gm_AnnouncementTooLong", resourceCulture); + } + } + /// /// Looks up a localized string similar to The account of {0} was banned.. /// diff --git a/src/Discord/Properties/Resources.de.resx b/src/Discord/Properties/Resources.de.resx index b6c40bbcc9..0341501ade 100644 --- a/src/Discord/Properties/Resources.de.resx +++ b/src/Discord/Properties/Resources.de.resx @@ -344,6 +344,12 @@ Die Nachricht wurde gesendet: {0} + + Die Nachricht wurde an {1} von {2} Spielservern gesendet: {0} + + + Die Nachricht darf höchstens {0} Zeichen haben. + Das Konto von {0} wurde gesperrt. diff --git a/src/Discord/Properties/Resources.resx b/src/Discord/Properties/Resources.resx index cb012cfd79..69254d474b 100644 --- a/src/Discord/Properties/Resources.resx +++ b/src/Discord/Properties/Resources.resx @@ -344,6 +344,12 @@ The notice was sent: {0} + + The notice was sent to {1} of {2} game servers: {0} + + + The notice can have at most {0} characters. + The account of {0} was banned. diff --git a/src/Interfaces/AccountLoginBlockedEvent.cs b/src/Interfaces/AccountLoginBlockedEvent.cs index 1a0d21c5b1..3aa771fb2e 100644 --- a/src/Interfaces/AccountLoginBlockedEvent.cs +++ b/src/Interfaces/AccountLoginBlockedEvent.cs @@ -7,6 +7,9 @@ namespace MUnique.OpenMU.Interfaces; /// /// Somebody tried to log into an account with the correct password, while the account was already logged in. /// +/// +/// Logins with a wrong password don't cause it; they don't reach the point where the account is known to be logged in. +/// /// The identifier of the game server. /// The timestamp, in UTC. /// The login name of the account. diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs index 319f73bb7b..bc86ac8d00 100644 --- a/tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs @@ -75,6 +75,33 @@ public async Task AnnounceSendsToAllGameServersAsync() Assert.That(answer.StaffAlert?.Description, Is.EqualTo("admin (Admin) used /announce Hello")); } + /// + /// Tests that a failing game server doesn't stop the announcement on the others, and that the game master learns about it. + /// + [Test] + public async Task FailingGameServerDoesNotStopAnnouncementAsync() + { + this._server1.Setup(s => s.SendGlobalMessageAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("Test")); + + var answer = await this._commands.ExecuteAsync("announce", "Hello", GameMasterUser, "admin", true).ConfigureAwait(false); + + this._server2.Verify(s => s.SendGlobalMessageAsync("Hello", MessageType.GoldenCenter), Times.Once); + Assert.That(answer.Embed.Description, Is.EqualTo("The notice was sent to 1 of 2 game servers: Hello")); + } + + /// + /// Tests that a too long announcement is rejected. + /// + [Test] + public async Task TooLongAnnouncementIsRejectedAsync() + { + var answer = await this._commands.ExecuteAsync("announce", new string('x', 201), GameMasterUser, "admin", true).ConfigureAwait(false); + + Assert.That(answer.StaffAlert, Is.Null); + Assert.That(answer.Embed.Description, Is.EqualTo("The notice can have at most 200 characters.")); + this._server1.Verify(s => s.SendGlobalMessageAsync(It.IsAny(), It.IsAny()), Times.Never); + } + /// /// Tests that a character is disconnected from the game server on which it's online. /// From d33585945378be1c5f3c2005a63d39048ae32bb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 21:38:22 +0000 Subject: [PATCH 6/8] feat(discord): post the announcements of game masters into Discord, too /announce sent the notice only into the game. It's posted into the channel of the notices now, too, like a notice which a game master sends in the game, as long as at least one game server got it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01USFZ9xErMB1EwkpfdUHzoZ --- docs-website/docs/server-features/discord.md | 2 +- src/Discord/DiscordBot.Extras.cs | 5 +++++ src/Discord/DiscordGameMasterAnswer.cs | 3 ++- src/Discord/DiscordGameMasterCommands.cs | 14 +++++++++++- src/Discord/Properties/Resources.Designer.cs | 2 +- src/Discord/Properties/Resources.de.resx | 2 +- src/Discord/Properties/Resources.resx | 2 +- .../Discord/DiscordGameMasterCommandsTest.cs | 22 ++++++++++++++++++- 8 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docs-website/docs/server-features/discord.md b/docs-website/docs/server-features/discord.md index 0b00a94d68..faa7a4da22 100644 --- a/docs-website/docs/server-features/discord.md +++ b/docs-website/docs/server-features/discord.md @@ -282,7 +282,7 @@ Game masters can use these commands in Discord: | Command | Effect | |---|---| -| `/announce ` | Sends the text as golden notice to all players of all game servers. | +| `/announce ` | Sends the text as golden notice to all players of all game servers, and posts it into the channel of the `Notices` category, e.g. `#announcements`. | | `/kick ` | Disconnects the character from the game. | | `/ban ` | Bans the account of the character, which has to be online, and disconnects it. | diff --git a/src/Discord/DiscordBot.Extras.cs b/src/Discord/DiscordBot.Extras.cs index c97beea85f..bf72c21633 100644 --- a/src/Discord/DiscordBot.Extras.cs +++ b/src/Discord/DiscordBot.Extras.cs @@ -67,6 +67,11 @@ private async Task ExecuteGameMasterCommandAsync(SocketSlashCommand command) await this.SendAsync(channelId, [alert], CancellationToken.None).ConfigureAwait(false); } + if (answer.Announcement is { } announcement && this._routing.GetChannelId(DiscordChannelCategory.Notices) is { } noticesChannelId) + { + await this.SendAsync(noticesChannelId, [announcement], CancellationToken.None).ConfigureAwait(false); + } + await command.FollowupAsync(embed: ToDiscordEmbed(answer.Embed), ephemeral: true, allowedMentions: AllowedMentions.None).ConfigureAwait(false); } catch (Exception ex) diff --git a/src/Discord/DiscordGameMasterAnswer.cs b/src/Discord/DiscordGameMasterAnswer.cs index bba48e1d6e..bd59f1b5b4 100644 --- a/src/Discord/DiscordGameMasterAnswer.cs +++ b/src/Discord/DiscordGameMasterAnswer.cs @@ -9,4 +9,5 @@ namespace MUnique.OpenMU.Discord; /// /// The answer for the game master. /// The alert for the staff about the use of the command; or null, if the command wasn't executed. -public sealed record DiscordGameMasterAnswer(DiscordEmbed Embed, DiscordEmbed? StaffAlert); +/// The announcement for the channel of the notices, which /announce sent to the game; or null. +public sealed record DiscordGameMasterAnswer(DiscordEmbed Embed, DiscordEmbed? StaffAlert, DiscordEmbed? Announcement = null); diff --git a/src/Discord/DiscordGameMasterCommands.cs b/src/Discord/DiscordGameMasterCommands.cs index bf13fdd885..637c5d3cdc 100644 --- a/src/Discord/DiscordGameMasterCommands.cs +++ b/src/Discord/DiscordGameMasterCommands.cs @@ -98,6 +98,7 @@ public async ValueTask ExecuteAsync(string commandName, var gameServers = this._serverProvider.Servers.OfType().ToList(); string resultKey; object[] resultArgs = [DiscordMessageFormatter.Escape(argument)]; + DiscordEmbed? announcement = null; switch (commandName) { case "announce": @@ -113,6 +114,17 @@ public async ValueTask ExecuteAsync(string commandName, resultKey = announced == gameServers.Count ? nameof(Resources.Gm_Announced) : nameof(Resources.Gm_AnnouncedPartially); resultArgs = [DiscordMessageFormatter.Escape(argument), announced, gameServers.Count]; + if (announced > 0) + { + // Posted like a notice which a game master sends in the game, but without a game server in the footer. + announcement = new DiscordEmbed( + DiscordCommands.GetText(this._culture, nameof(Resources.GlobalNotice_Title)), + DiscordMessageFormatter.Escape(argument), + DiscordMessageFormatter.NoticeColor, + DateTime.UtcNow, + null); + } + break; case "kick": resultKey = await this.AnyAsync(gameServers, server => server.DisconnectPlayerAsync(argument)).ConfigureAwait(false) @@ -133,7 +145,7 @@ public async ValueTask ExecuteAsync(string commandName, StaffAlertColor, DateTime.UtcNow, null); - return new(this.Answer(resultKey, resultArgs), alert); + return new(this.Answer(resultKey, resultArgs), alert, announcement); } private async ValueTask AnyAsync(IEnumerable gameServers, Func> action) diff --git a/src/Discord/Properties/Resources.Designer.cs b/src/Discord/Properties/Resources.Designer.cs index d19e79282e..dfff232f21 100644 --- a/src/Discord/Properties/Resources.Designer.cs +++ b/src/Discord/Properties/Resources.Designer.cs @@ -430,7 +430,7 @@ public static string Chat_TooFast { } /// - /// Looks up a localized string similar to Sends a notice to all players in the game. For game masters.. + /// Looks up a localized string similar to Sends a notice to all players in the game and into the announcements channel. For game masters.. /// public static string Command_Announce_Description { get { diff --git a/src/Discord/Properties/Resources.de.resx b/src/Discord/Properties/Resources.de.resx index 0341501ade..e6a8168eff 100644 --- a/src/Discord/Properties/Resources.de.resx +++ b/src/Discord/Properties/Resources.de.resx @@ -222,7 +222,7 @@ Du sendest zu schnell Nachrichten. Bitte warte einen Moment. - Sendet eine Nachricht an alle Spieler im Spiel. Für Spielleiter. + Sendet eine Nachricht an alle Spieler im Spiel und in den Ankündigungskanal. Für Spielleiter. Der Text der Nachricht. diff --git a/src/Discord/Properties/Resources.resx b/src/Discord/Properties/Resources.resx index 69254d474b..a48fb49f3d 100644 --- a/src/Discord/Properties/Resources.resx +++ b/src/Discord/Properties/Resources.resx @@ -222,7 +222,7 @@ You're sending messages too fast. Please wait a moment. - Sends a notice to all players in the game. For game masters. + Sends a notice to all players in the game and into the announcements channel. For game masters. The text of the notice. diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs index bc86ac8d00..883f7eeca7 100644 --- a/tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordGameMasterCommandsTest.cs @@ -57,13 +57,15 @@ public async Task OnlyGameMastersCanUseCommandsAsync() var withoutGameMaster = await this._commands.ExecuteAsync("announce", "Hello", PlayerUser, "hero", true).ConfigureAwait(false); Assert.That(withoutRole.StaffAlert, Is.Null); + Assert.That(withoutRole.Announcement, Is.Null); Assert.That(withoutGameMaster.StaffAlert, Is.Null); + Assert.That(withoutGameMaster.Announcement, Is.Null); Assert.That(withoutGameMaster.Embed.Description, Does.StartWith("Only game masters can use this command.")); this._server1.Verify(s => s.SendGlobalMessageAsync(It.IsAny(), It.IsAny()), Times.Never); } /// - /// Tests that a notice is sent to all game servers, and that the staff is alerted. + /// Tests that a notice is sent to all game servers and announced in Discord, and that the staff is alerted. /// [Test] public async Task AnnounceSendsToAllGameServersAsync() @@ -73,6 +75,24 @@ public async Task AnnounceSendsToAllGameServersAsync() this._server1.Verify(s => s.SendGlobalMessageAsync("Hello", MessageType.GoldenCenter), Times.Once); this._server2.Verify(s => s.SendGlobalMessageAsync("Hello", MessageType.GoldenCenter), Times.Once); Assert.That(answer.StaffAlert?.Description, Is.EqualTo("admin (Admin) used /announce Hello")); + Assert.That(answer.Announcement?.Title, Is.EqualTo("Notice")); + Assert.That(answer.Announcement?.Description, Is.EqualTo("Hello")); + Assert.That(answer.Announcement?.Footer, Is.Null); + } + + /// + /// Tests that nothing is announced in Discord, when no game server got the notice. + /// + [Test] + public async Task NothingIsAnnouncedWhenNoGameServerGotTheNoticeAsync() + { + this._server1.Setup(s => s.SendGlobalMessageAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("Test")); + this._server2.Setup(s => s.SendGlobalMessageAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("Test")); + + var answer = await this._commands.ExecuteAsync("announce", "Hello", GameMasterUser, "admin", true).ConfigureAwait(false); + + Assert.That(answer.Announcement, Is.Null); + Assert.That(answer.Embed.Description, Is.EqualTo("The notice was sent to 0 of 2 game servers: Hello")); } /// From ca5566b5608bdfa423da28c304d53bdd8919344a Mon Sep 17 00:00:00 2001 From: sven-n Date: Thu, 22 Oct 2026 19:33:54 +0200 Subject: [PATCH 7/8] style(discord): wrap direct message request arguments --- src/Discord/DiscordBot.Extras.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Discord/DiscordBot.Extras.cs b/src/Discord/DiscordBot.Extras.cs index e71cb146d2..d06f7abbf4 100644 --- a/src/Discord/DiscordBot.Extras.cs +++ b/src/Discord/DiscordBot.Extras.cs @@ -126,7 +126,10 @@ private void SendDirectMessages(GameEvent gameEvent) && await client.Rest.GetUserAsync(userId, options: new RequestOptions { CancelToken = cancellationToken }).ConfigureAwait(false) is { } user) { var channel = await user.CreateDMChannelAsync(new RequestOptions { CancelToken = cancellationToken }).ConfigureAwait(false); - await channel.SendMessageAsync(embed: ToDiscordEmbed(embed), allowedMentions: AllowedMentions.None, options: new RequestOptions { CancelToken = cancellationToken }).ConfigureAwait(false); + await channel.SendMessageAsync( + embed: ToDiscordEmbed(embed), + allowedMentions: AllowedMentions.None, + options: new RequestOptions { CancelToken = cancellationToken }).ConfigureAwait(false); } } } From c6a0fdee728a3700f6d67125114bd6667bb8d0f0 Mon Sep 17 00:00:00 2001 From: sven-n Date: Thu, 22 Oct 2026 19:31:14 +0000 Subject: [PATCH 8/8] test(logging): wait for advanced editor to render --- .../Pages/LoggingConfigurationPageTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/MUnique.OpenMU.Web.Tests/Pages/LoggingConfigurationPageTests.cs b/tests/MUnique.OpenMU.Web.Tests/Pages/LoggingConfigurationPageTests.cs index f328b64340..a73891d8c9 100644 --- a/tests/MUnique.OpenMU.Web.Tests/Pages/LoggingConfigurationPageTests.cs +++ b/tests/MUnique.OpenMU.Web.Tests/Pages/LoggingConfigurationPageTests.cs @@ -115,6 +115,7 @@ public void DoesNotSaveInvalidJson() cut.WaitForState(() => cut.FindAll("button.nav-link").Count == 2); cut.FindAll("button.nav-link")[1].Click(); + cut.WaitForElement("textarea"); cut.Find("textarea").Change("{ \"MinimumLevel\": "); cut.FindAll("button").First(b => b.TextContent.Contains(Resources.Save)).Click();