diff --git a/docs-website/docs/server-features/discord.md b/docs-website/docs/server-features/discord.md index a3f3bb8c30..5aa4c08d21 100644 --- a/docs-website/docs/server-features/discord.md +++ b/docs-website/docs/server-features/discord.md @@ -76,7 +76,8 @@ webhook. 1. Open the [Discord developer portal](https://discord.com/developers/applications), create a *New Application* and give it the name of your server. 2. Under *Bot*, click *Reset Token* and copy the token. Treat it like a password. - The bot doesn't need any of the *Privileged Gateway Intents*. + The bot doesn't need any of the *Privileged Gateway Intents*, unless the + [chat bridge](#writing-from-discord) should read the normal messages. 3. Under *OAuth2 → URL Generator*, select the scopes `bot` and `applications.commands`, and the permissions *View Channels*, *Send Messages*, *Embed Links* and *Read Message History*. For [`/openmu setup`](#setting-up-your-discord-server), @@ -101,6 +102,8 @@ In the distributed deployment, the bot runs in its own container, see | `/link ` | Links the Discord user to the game account, see [Linking accounts](#linking-accounts) | | `/unlink` | Removes the link to the game account | | `/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) | The commands only show information; they don't change anything in the game. Additionally, players can [link their account](#linking-accounts) with @@ -132,8 +135,8 @@ Linked users get the role *Linked Player* of the [layout](#setting-up-your-discord-server) on your Discord server, so you can, for example, open channels only for players. The bot needs the *Manage Roles* permission for it, and its own role has to be above *Linked Player* in the role -list. When a link is removed, also in the admin panel of the all-in-one -deployment, the role is removed again. +list. When a link is removed, also in the admin panel, the role is removed +again. The answers to these commands are only visible to the player who used them. The codes are stored as hash in the database, and every code can only be used @@ -207,6 +210,69 @@ server with the same channels, roles and permissions, but without messages and members. After inviting the bot to it, `/openmu setup` adopts everything and reports that nothing was missing. +## Chat bridge + +The bot can mirror the chats of the game to Discord channels, in both directions: + +* the **world chat**: players write into it in the game with `/world `, + and all players of all game servers read it. It's mirrored to `#world-chat` of + the [layout](#setting-up-your-discord-server). +* the chat of a **guild** (`@` in the game) or an **alliance** (`@@`), when its + guild master binds it to a channel. + +The chat of the players is private, so nothing is mirrored by default: enable +*Publish guild, alliance and world chat* in the configuration of the *Game Event +Publisher* plugin. Players who enter the game are told when the chat of their +guild or alliance is mirrored. + +### Binding the chat of a guild + +The guild master links the Discord user to the account (see +[Linking accounts](#linking-accounts)) and selects the guild master character +with `/character`. Then there are two ways, which you can restrict with +`Discord__Bot__ChatBridge__BindingMode`: + +* **On your Discord server:** `/guildchat create` creates a channel like + `#guild-legends` in the category *Guilds* of the layout. Only the bot and the + members of the guild who linked their Discord user can see it. The bot keeps + this up to date when users link themselves and every 10 status updates. It + needs the *Manage Channels* permission for it. +* **On the Discord server of the guild:** the guild master invites the bot to + the Discord server of the guild, and uses `/guildchat bind` in the channel + which should be bound. This requires the permission to manage the channel. With + `Discord__Bot__ChatBridge__AllowedDiscordServerIds`, you can allow only some Discord + servers. + +The option `scope` of these commands chooses the chat of the alliance instead, +which only the master of an alliance can bind. `/guildchat unbind` in the channel +removes the binding; the guild master and users who manage the channel can use it. +The binding is also removed when the guild is disbanded, the channel is deleted +or the bot is removed from the Discord server. + +### Writing from Discord + +Only Discord users who are linked to an account can write into the game, as +their selected character, which has to be a member of the guild or alliance. The +chat ban of the account applies, and a user can send 10 messages per minute +(`Discord__Bot__ChatBridge__MaximumMessagesPerMinute`). The messages go through the +plugins of the plugin point *External chat message received*, e.g. a filter, +which can stop them; the plugins of *Chat message received* only get the +messages of players in the game. + +In the game, the messages appear with the character name and the prefix `@`, +e.g. `@Hero`, so a Discord user can't pretend to be a character in the game. +Mentions, markdown and line breaks are removed, custom emojis become `:name:`, +and long messages are cut (`Discord__Bot__ChatBridge__MaximumMessageLength`). + +To read the normal messages in the channels, the bot needs the privileged +*Message Content* intent: enable it for the bot in the Discord developer portal +under *Bot → Privileged Gateway Intents*, and set +`Discord__Bot__ChatBridge__ReadMessages` to `true`. When a message can't be sent to the +game, the bot reacts with ❌ (permission *Add Reactions*), and `/say` tells why. +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. + ## Settings All settings are part of the `Discord` section of the configuration, so they can @@ -225,6 +291,11 @@ the parts are separated by two underscores. | `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__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. | +| `Bot__ChatBridge__ReadMessages` | `false` | Reads the normal messages in the bound channels. Requires the *Message Content* intent. | +| `Bot__ChatBridge__MaximumMessageLength` | `100` | The maximum length of a message from Discord to the game. | +| `Bot__ChatBridge__MaximumMessagesPerMinute` | `10` | The maximum number of messages per minute of a Discord user to the game. | ## Good to know @@ -249,3 +320,9 @@ the parts are separated by two underscores. Private staff channels are skipped if their required roles or permissions cannot be set. Fix the reported permissions and run setup again. Public channels with staff channel names are not used automatically for notifications. + +Commands are registered globally so guild-owned Discord servers can use `/link`, +`/guildchat` and `/say`. The main configured Discord server also gets +guild-scoped registration for immediate availability. Binding and unbinding as a +guild master require that the selected character still belongs to the linked +account. diff --git a/src/Dapr/AdminPanel.Host/Program.cs b/src/Dapr/AdminPanel.Host/Program.cs index 2b9100ce1c..f97d3ff7f0 100644 --- a/src/Dapr/AdminPanel.Host/Program.cs +++ b/src/Dapr/AdminPanel.Host/Program.cs @@ -27,6 +27,9 @@ .AddManageableServerRegistry() .AddTransient>(s => s.GetRequiredService().Servers.OfType().ToDictionary(server => server.Id)) .AddSingleton() + + // E.g. to notify the Discord bot when an administrator removes the link of an account. + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/Dapr/GameServer.Host/EventPublisher.cs b/src/Dapr/Common/EventPublisher.cs similarity index 86% rename from src/Dapr/GameServer.Host/EventPublisher.cs rename to src/Dapr/Common/EventPublisher.cs index 0cf801ab12..f56e605317 100644 --- a/src/Dapr/GameServer.Host/EventPublisher.cs +++ b/src/Dapr/Common/EventPublisher.cs @@ -2,7 +2,7 @@ // Licensed under the MIT License. See LICENSE file in the project root for full license information. // -namespace MUnique.OpenMU.GameServer.Host; +namespace MUnique.OpenMU.Dapr.Common; using global::Dapr.Client; using Microsoft.Extensions.Logging; @@ -17,14 +17,14 @@ public class EventPublisher : IEventPublisher { private const string PubSubName = "pubsub"; private readonly DaprClient _daprClient; - private readonly ILogger _logger; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The dapr client. /// The logger. - public EventPublisher(DaprClient daprClient, ILogger logger) + public EventPublisher(DaprClient daprClient, ILogger logger) { this._daprClient = daprClient; this._logger = logger; @@ -98,6 +98,23 @@ await this._daprClient } } + /// + public async ValueTask WorldChatMessageAsync(string sender, string message) + { + try + { + await this._daprClient + .PublishEventAsync( + PubSubName, + nameof(IGameServer.WorldChatMessageAsync), + new WorldChatMessageArguments(sender, message)).ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogError(ex, "Unexpected error when publishing a world chat message"); + } + } + /// /// Notifies that a client tried to log into an already logged-in account. /// The connected player can be notified about that. diff --git a/src/Dapr/Common/GameServerClient.cs b/src/Dapr/Common/GameServerClient.cs index cc8de82690..0cfb219b6b 100644 --- a/src/Dapr/Common/GameServerClient.cs +++ b/src/Dapr/Common/GameServerClient.cs @@ -64,6 +64,10 @@ public ValueTask GuildPlayerKickedAsync(string playerName) public ValueTask AllianceChatMessageAsync(uint guildId, string sender, string message) => this.PublishAsync(nameof(this.AllianceChatMessageAsync), new GuildMessageArguments(guildId, sender, message)); + /// + public ValueTask WorldChatMessageAsync(string sender, string message) + => this.PublishAsync(nameof(this.WorldChatMessageAsync), new WorldChatMessageArguments(sender, message)); + /// public ValueTask SendGlobalMessageAsync(string message, MessageType messageType) => this.PublishCommandAsync(nameof(this.SendGlobalMessageAsync), new MessageArguments(this.Id, message, messageType)); diff --git a/src/Dapr/Discord.Host/Program.cs b/src/Dapr/Discord.Host/Program.cs index 5f7330d2e1..10fd89a447 100644 --- a/src/Dapr/Discord.Host/Program.cs +++ b/src/Dapr/Discord.Host/Program.cs @@ -22,6 +22,7 @@ .AddManageableServerRegistry() .AddSingleton() .AddSingleton() + .AddSingleton() // The game servers of the distributed deployment run in UTC, too. .AddDiscord(settings, () => TimeZoneInfo.Utc); diff --git a/src/Dapr/GameServer.Host/GameServerController.cs b/src/Dapr/GameServer.Host/GameServerController.cs index 4331137f38..e57c4cba4c 100644 --- a/src/Dapr/GameServer.Host/GameServerController.cs +++ b/src/Dapr/GameServer.Host/GameServerController.cs @@ -77,6 +77,17 @@ public ValueTask AllianceChatMessageAsync([FromBody] GuildMessageArguments data) return this._gameServer.AllianceChatMessageAsync(data.GuildId, data.Sender, data.Message); } + /// + /// Sends a message of the world chat to all connected players. + /// + /// The message arguments. + [HttpPost(nameof(IGameServer.WorldChatMessageAsync))] + [Topic("pubsub", nameof(IGameServer.WorldChatMessageAsync))] + public ValueTask WorldChatMessageAsync([FromBody] WorldChatMessageArguments data) + { + return this._gameServer.WorldChatMessageAsync(data.Sender, data.Message); + } + /// /// Notifies the game server that a guild got deleted. /// diff --git a/src/Dapr/ServerClients/WorldChatMessageArguments.cs b/src/Dapr/ServerClients/WorldChatMessageArguments.cs new file mode 100644 index 0000000000..258428a8db --- /dev/null +++ b/src/Dapr/ServerClients/WorldChatMessageArguments.cs @@ -0,0 +1,12 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.ServerClients; + +/// +/// Arguments for a message of the world chat. +/// +/// The sender. +/// The message. +public record WorldChatMessageArguments(string Sender, string Message); diff --git a/src/DataModel/Entities/GuildChatBinding.cs b/src/DataModel/Entities/GuildChatBinding.cs new file mode 100644 index 0000000000..9010f91903 --- /dev/null +++ b/src/DataModel/Entities/GuildChatBinding.cs @@ -0,0 +1,64 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.DataModel.Entities; + +/// +/// A binding of the chat of a guild or alliance to a channel of an external service, e.g. Discord, +/// so that the messages are mirrored in both directions. +/// +/// +/// It's not part of the aggregate, because it's managed by the external service. +/// That's why it refers to its guild by identifier. +/// +[AggregateRoot] +public class GuildChatBinding +{ + /// + /// Gets or sets the identifier. + /// + public Guid Id { get; set; } + + /// + /// Gets or sets the identifier of the guild. For an alliance, it's the master guild of the alliance. + /// + public Guid GuildId { get; set; } + + /// + /// Gets or sets the scope, which tells if the chat of the guild or of its alliance is bound. + /// + public GuildChatScope Scope { get; set; } + + /// + /// Gets or sets the name of the external service, e.g. discord. + /// + public string Provider { get; set; } = string.Empty; + + /// + /// Gets or sets the identifier of the server of the external service, e.g. of the Discord server. + /// + public string ExternalServerId { get; set; } = string.Empty; + + /// + /// Gets or sets the identifier of the channel of the external service. + /// + public string ExternalChannelId { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the channel is hosted by the server owner, + /// i.e. it's in the Discord server of the game server and was created for the guild. + /// Otherwise, it's in a Discord server of the guild. + /// + public bool IsHosted { get; set; } + + /// + /// Gets or sets the name of the character which bound the chat. + /// + public string? BoundBy { get; set; } + + /// + /// Gets or sets the timestamp at which the chat was bound. + /// + public DateTime BoundAt { get; set; } +} diff --git a/src/DataModel/Entities/GuildChatScope.cs b/src/DataModel/Entities/GuildChatScope.cs new file mode 100644 index 0000000000..510d280907 --- /dev/null +++ b/src/DataModel/Entities/GuildChatScope.cs @@ -0,0 +1,21 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.DataModel.Entities; + +/// +/// The chat of a guild which is bound to a channel of an external service. +/// +public enum GuildChatScope +{ + /// + /// The chat of the guild. + /// + Guild, + + /// + /// The chat of the alliance whose master is the guild. + /// + Alliance, +} diff --git a/src/Discord/ChatBridge/DiscordChatBindResult.cs b/src/Discord/ChatBridge/DiscordChatBindResult.cs new file mode 100644 index 0000000000..a508157b36 --- /dev/null +++ b/src/Discord/ChatBridge/DiscordChatBindResult.cs @@ -0,0 +1,51 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +/// +/// The result of binding a chat of a guild or alliance to a Discord channel, or of removing the binding. +/// +public enum DiscordChatBindResult +{ + /// + /// The binding was created, changed or removed. + /// + Success, + + /// + /// The binding mode of the settings doesn't allow it. + /// + ModeNotAllowed, + + /// + /// The Discord server isn't allowed by the settings. + /// + ServerNotAllowed, + + /// + /// The Discord user isn't linked to a game account. + /// + NotLinked, + + /// + /// The character of the Discord user isn't the guild master. + /// + NotGuildMaster, + + /// + /// The guild of the Discord user isn't the master of an alliance. + /// + NotAllianceMaster, + + /// + /// The channel is bound to the chat of another guild or alliance. + /// + ChannelInUse, + + /// + /// The channel isn't bound. + /// + NotBound, +} diff --git a/src/Discord/ChatBridge/DiscordChatBridge.cs b/src/Discord/ChatBridge/DiscordChatBridge.cs new file mode 100644 index 0000000000..edaf52d750 --- /dev/null +++ b/src/Discord/ChatBridge/DiscordChatBridge.cs @@ -0,0 +1,377 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +using System.ComponentModel; +using System.Globalization; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.AccountLinking; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.PlugIns; + +/// +/// The chat bridge between the game and Discord: It mirrors the guild, alliance and world chat +/// to Discord channels and back, and manages the bindings of the chats of guilds to Discord channels. +/// +/// +/// Only Discord users who are linked to a game account can write into the game, as the selected character +/// of their account. They have to be a member of the guild or alliance, and the chat ban of the account applies. +/// The messages go through the s, e.g. filters, instead of the +/// s, because there is no player who sends them. +/// Only the guild master can bind the chat of a guild, and the master of an alliance the chat of the alliance. +/// +public sealed class DiscordChatBridge +{ + private const string Provider = AccountLinkService.DiscordProvider; + + private readonly DiscordChatBridgeSettings _settings; + private readonly GuildChatBindingStore _store; + private readonly AccountLinkService _linkService; + private readonly IGuildServer _guildServer; + private readonly Func _createPlayerContext; + private readonly Func _createGuildContext; + private readonly Func _getEventPublisher; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly DiscordFloodGuard _floodGuard; + private readonly PlugInManager? _plugInManager; + + /// + /// Initializes a new instance of the class. + /// + /// The settings. + /// The store of the bindings. + /// The service of the account links. + /// The guild server. + /// The function which creates a player persistence context. + /// The function which creates a guild persistence context. + /// The function which gets the event publisher, through which the messages are sent to the game servers. + /// The logger. + /// The plugin manager, for the s. + /// The time provider. + public DiscordChatBridge( + DiscordChatBridgeSettings settings, + GuildChatBindingStore store, + AccountLinkService linkService, + IGuildServer guildServer, + Func createPlayerContext, + Func createGuildContext, + Func getEventPublisher, + ILogger logger, + PlugInManager? plugInManager = null, + TimeProvider? timeProvider = null) + { + this._settings = settings; + this._store = store; + this._linkService = linkService; + this._guildServer = guildServer; + this._createPlayerContext = createPlayerContext; + this._createGuildContext = createGuildContext; + this._getEventPublisher = getEventPublisher; + this._logger = logger; + this._plugInManager = plugInManager; + this._timeProvider = timeProvider ?? TimeProvider.System; + this._floodGuard = new DiscordFloodGuard(settings.MaximumMessagesPerMinute, this._timeProvider); + } + + /// + /// Gets the store of the bindings. + /// + public GuildChatBindingStore Store => this._store; + + /// + /// Gets the Discord channel and the text of a chat message of the game. + /// + /// The chat message. + /// The identifier of the channel of the world chat, if there is one. + /// The channel and the text; or null, if the chat isn't bound. + public async ValueTask<(ulong ChannelId, string Text)?> GetDiscordMessageAsync(ChatMessageEvent chatMessage, ulong? worldChatChannelId) + { + ulong? channelId = chatMessage.Channel switch + { + GameChatChannel.World => worldChatChannelId, + GameChatChannel.Guild => GetChannelId(await this._guildServer.GetPersistentGuildIdAsync(chatMessage.GuildId).ConfigureAwait(false), GuildChatScope.Guild), + GameChatChannel.Alliance => GetChannelId(await this._guildServer.GetPersistentAllianceMasterGuildIdAsync(chatMessage.GuildId).ConfigureAwait(false), GuildChatScope.Alliance), + _ => null, + }; + + return channelId is { } id ? (id, DiscordChatText.ToDiscord(chatMessage.Sender, chatMessage.Message)) : null; + + ulong? GetChannelId(Guid? guildId, GuildChatScope scope) + { + return guildId is { } g && this._store.Get(g, scope) is { } binding + && ulong.TryParse(binding.ExternalChannelId, NumberStyles.None, CultureInfo.InvariantCulture, out var bindingChannelId) + ? bindingChannelId + : null; + } + } + + /// + /// Sends a message of a Discord user to the chat of the game, which is bound to the channel. + /// + /// The identifier of the channel. + /// The identifier of the channel of the world chat, if there is one. + /// The identifier of the Discord user. + /// The text of the message. + /// The result, the name of the character as which the user wrote, and the message as it was sent to the game. + public async ValueTask<(DiscordChatPostResult Result, string? CharacterName, string? Message)> PostToGameAsync(ulong channelId, ulong? worldChatChannelId, ulong userId, string text) + { + var binding = this._store.GetByChannel(channelId); + if (binding is null && channelId != worldChatChannelId) + { + return (DiscordChatPostResult.NotBridged, null, null); + } + + // Checked first, so that spamming doesn't cause queries to the database. + if (!this._floodGuard.TryRegister(userId)) + { + return (DiscordChatPostResult.TooFast, null, null); + } + + if (await this._linkService.GetLinkByUserAsync(Provider, GuildChatBindingStore.ToText(userId)).ConfigureAwait(false) is not { ExternalUserId: not null } link) + { + return (DiscordChatPostResult.NotLinked, null, null); + } + + if (link.CharacterName is not { } characterName) + { + return (DiscordChatPostResult.NoCharacter, null, null); + } + + using (var context = this._createPlayerContext()) + { + // The character might have been deleted or moved to another account in the meantime. + if (await context.GetAccountIdByCharacterNameAsync(characterName).ConfigureAwait(false) != link.AccountId) + { + return (DiscordChatPostResult.NoCharacter, null, null); + } + + if (await context.GetAccountChatBanUntilAsync(link.AccountId).ConfigureAwait(false) is { } chatBanUntil + && chatBanUntil > this._timeProvider.GetUtcNow().UtcDateTime) + { + return (DiscordChatPostResult.ChatBanned, characterName, null); + } + } + + if (binding is not null && !await this.IsMemberAsync(characterName, binding).ConfigureAwait(false)) + { + return (DiscordChatPostResult.NotMember, characterName, null); + } + + var message = DiscordChatText.ToGame(text, this._settings.MaximumMessageLength); + if (message.Length == 0) + { + return (DiscordChatPostResult.Empty, characterName, null); + } + + var channel = binding is null ? GameChatChannel.World : binding.Scope == GuildChatScope.Alliance ? GameChatChannel.Alliance : GameChatChannel.Guild; + var eventArgs = new CancelEventArgs(); + this._plugInManager?.GetPlugInPoint()?.ExternalChatMessageReceived(characterName, message, channel, eventArgs); + if (eventArgs.Cancel) + { + return (DiscordChatPostResult.Filtered, characterName, null); + } + + if (this._getEventPublisher() is not { } publisher) + { + this._logger.LogWarning("A chat message from Discord couldn't be sent to the game, because the event publisher isn't available."); + return (DiscordChatPostResult.NotBridged, characterName, null); + } + + var sender = DiscordChatText.ToGameSender(characterName); + if (binding is null) + { + await publisher.WorldChatMessageAsync(sender, message).ConfigureAwait(false); + } + else if (await this._guildServer.GetGuildIdAsync(binding.GuildId).ConfigureAwait(false) is var guildId and not 0) + { + // Without non-persistent identifier, no member of the guild is online. + if (binding.Scope == GuildChatScope.Alliance) + { + await publisher.AllianceMessageAsync(guildId, sender, message).ConfigureAwait(false); + } + else + { + await publisher.GuildMessageAsync(guildId, sender, message).ConfigureAwait(false); + } + } + + return (DiscordChatPostResult.Sent, characterName, message); + } + + /// + /// Gets the guild or alliance whose chat the Discord user can bind, because its character is the guild master. + /// + /// The identifier of the Discord user. + /// The scope. + /// The result, and the guild or alliance, if the user can bind its chat. + public async ValueTask<(DiscordChatBindResult Result, GuildChatTarget? Target)> GetBindableTargetAsync(ulong userId, GuildChatScope scope) + { + if (await this._linkService.GetLinkByUserAsync(Provider, GuildChatBindingStore.ToText(userId)).ConfigureAwait(false) is not { ExternalUserId: not null } link) + { + return (DiscordChatBindResult.NotLinked, null); + } + + if (link.CharacterName is not { } characterName) + { + return (DiscordChatBindResult.NotGuildMaster, null); + } + + // A saved name can outlive a character transfer or be reused after deletion. + using (var playerContext = this._createPlayerContext()) + { + if (await playerContext.GetAccountIdByCharacterNameAsync(characterName).ConfigureAwait(false) != link.AccountId) + { + return (DiscordChatBindResult.NotGuildMaster, null); + } + } + + using var guildContext = this._createGuildContext(); + if (await guildContext.GetGuildMembershipAsync(characterName).ConfigureAwait(false) is not { Position: GuildPosition.GuildMaster } membership) + { + return (DiscordChatBindResult.NotGuildMaster, null); + } + + if (scope == GuildChatScope.Alliance + && ((membership.AllianceMasterGuildId is { } master && master != membership.GuildId) + || (await guildContext.GetAlliancesAsync(membership.GuildId).ConfigureAwait(false)).Count == 0)) + { + return (DiscordChatBindResult.NotAllianceMaster, null); + } + + var guildName = await this._guildServer.GetPersistentGuildNameAsync(membership.GuildId).ConfigureAwait(false) ?? characterName; + return (DiscordChatBindResult.Success, new GuildChatTarget(membership.GuildId, guildName, characterName)); + } + + /// + /// Binds the chat of the guild or alliance of the Discord user to a channel. + /// + /// The identifier of the Discord user, whose character has to be the guild master. + /// The scope. + /// The identifier of the Discord server. + /// The identifier of the channel. + /// A value indicating whether the channel is on the Discord server of the game server. + /// The result, and the bound guild or alliance. + public async ValueTask<(DiscordChatBindResult Result, GuildChatTarget? Target)> BindAsync(ulong userId, GuildChatScope scope, ulong serverId, ulong channelId, bool isHosted) + { + if (this.CheckAllowed(serverId, isHosted) is { } notAllowed) + { + return (notAllowed, null); + } + + var (result, target) = await this.GetBindableTargetAsync(userId, scope).ConfigureAwait(false); + if (target is null) + { + return (result, null); + } + + if (this._store.GetByChannel(channelId) is { } existing + && (existing.GuildId != target.GuildId || existing.Scope != scope)) + { + return (DiscordChatBindResult.ChannelInUse, null); + } + + await this._store.SetAsync(target.GuildId, scope, serverId, channelId, isHosted, target.CharacterName, this._timeProvider.GetUtcNow().UtcDateTime).ConfigureAwait(false); + this._logger.LogInformation("The {scope} chat of {guild} was bound to the Discord channel {channelId} by {character}.", scope, target.GuildName, channelId, target.CharacterName); + return (DiscordChatBindResult.Success, target); + } + + /// + /// Checks whether the binding mode and the allowed Discord servers allow a binding. + /// + /// The identifier of the Discord server. + /// A value indicating whether the channel is on the Discord server of the game server. + /// The result, if it's not allowed; otherwise, null. + public DiscordChatBindResult? CheckAllowed(ulong serverId, bool isHosted) + { + if (this._settings.BindingMode == (isHosted ? DiscordGuildChatBindingMode.GuildOwnedOnly : DiscordGuildChatBindingMode.HostedOnly)) + { + return DiscordChatBindResult.ModeNotAllowed; + } + + if (!isHosted && this._settings.AllowedDiscordServerIds is { Count: > 0 } allowed && !allowed.Contains(serverId)) + { + return DiscordChatBindResult.ServerNotAllowed; + } + + return null; + } + + /// + /// Removes the binding of a channel. It's allowed for the guild master, and for users who manage the channel. + /// + /// The identifier of the channel. + /// The identifier of the Discord user. + /// A value indicating whether the user has the permission to manage the channel. + /// The result. + public async ValueTask UnbindAsync(ulong channelId, ulong userId, bool userManagesChannel) + { + if (this._store.GetByChannel(channelId) is not { } binding) + { + return DiscordChatBindResult.NotBound; + } + + if (!userManagesChannel) + { + var (result, target) = await this.GetBindableTargetAsync(userId, binding.Scope).ConfigureAwait(false); + if (target?.GuildId != binding.GuildId) + { + return result == DiscordChatBindResult.Success ? DiscordChatBindResult.NotGuildMaster : result; + } + } + + await this._store.RemoveAsync(channelId, null).ConfigureAwait(false); + this._logger.LogInformation("The binding of the Discord channel {channelId} was removed by the user {userId}.", channelId, userId); + return DiscordChatBindResult.Success; + } + + /// + /// Gets the Discord users who are linked to a member of the guild or alliance of a binding. + /// They can see the hosted channel of the binding. + /// + /// The binding. + /// The identifiers of the Discord users. + public async ValueTask> GetLinkedMembersAsync(GuildChatBinding binding) + { + var guildIds = new List { binding.GuildId }; + var memberNames = new List(); + using (var guildContext = this._createGuildContext()) + { + if (binding.Scope == GuildChatScope.Alliance) + { + guildIds.AddRange((await guildContext.GetAlliancesAsync(binding.GuildId).ConfigureAwait(false)).Select(guild => guild.GetId())); + } + + foreach (var guildId in guildIds.Distinct()) + { + memberNames.AddRange((await guildContext.GetMemberNamesAsync(guildId).ConfigureAwait(false)).Values); + } + } + + // Two queries for all members, instead of two per member. + using var context = this._createPlayerContext(); + var accountIds = (await context.GetAccountIdsByCharacterNamesAsync(memberNames.Distinct().ToList()).ConfigureAwait(false)).Values.Distinct().ToList(); + var links = await context.GetAccountExternalLinksAsync(Provider, accountIds).ConfigureAwait(false); + return links + .Select(link => ulong.TryParse(link.ExternalUserId, NumberStyles.None, CultureInfo.InvariantCulture, out var userId) ? userId : (ulong?)null) + .OfType() + .ToHashSet(); + } + + private async ValueTask IsMemberAsync(string characterName, GuildChatBinding binding) + { + using var guildContext = this._createGuildContext(); + if (await guildContext.GetGuildMembershipAsync(characterName).ConfigureAwait(false) is not { } membership) + { + return false; + } + + return membership.GuildId == binding.GuildId + || (binding.Scope == GuildChatScope.Alliance && membership.AllianceMasterGuildId == binding.GuildId); + } +} diff --git a/src/Discord/ChatBridge/DiscordChatBridgeSettings.cs b/src/Discord/ChatBridge/DiscordChatBridgeSettings.cs new file mode 100644 index 0000000000..e2271a9ddb --- /dev/null +++ b/src/Discord/ChatBridge/DiscordChatBridgeSettings.cs @@ -0,0 +1,42 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +/// +/// The settings of the chat bridge between the game and Discord. +/// +/// +/// The game servers only publish the chat messages, if it's enabled in the configuration of the game event publisher plugin. +/// +public class DiscordChatBridgeSettings +{ + /// + /// Gets or sets where the chats of guilds can be bound to. + /// + public DiscordGuildChatBindingMode BindingMode { get; set; } + + /// + /// Gets or sets the identifiers of the Discord servers of guilds, on which chats can be bound. + /// If it's empty, all Discord servers are allowed. + /// + public List AllowedDiscordServerIds { get; set; } = new(); + + /// + /// Gets or sets a value indicating whether the bot reads the messages in the bound channels. + /// It requires the privileged Message Content intent, which has to be enabled for the bot in the Discord developer portal. + /// Without it, the messages are sent with the command /say. + /// + public bool ReadMessages { get; set; } + + /// + /// Gets or sets the maximum length of a message from Discord to the game. Longer messages are cut. + /// + public int MaximumMessageLength { get; set; } = 100; + + /// + /// Gets or sets the maximum number of messages per minute, which a Discord user can send to the game. + /// + public int MaximumMessagesPerMinute { get; set; } = 10; +} diff --git a/src/Discord/ChatBridge/DiscordChatCommands.cs b/src/Discord/ChatBridge/DiscordChatCommands.cs new file mode 100644 index 0000000000..6270218e12 --- /dev/null +++ b/src/Discord/ChatBridge/DiscordChatCommands.cs @@ -0,0 +1,161 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +using System.Globalization; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Discord.Properties; + +/// +/// The slash commands of the chat bridge, and their answers. +/// +public sealed class DiscordChatCommands +{ + /// + /// The name of the command which sends a message to the chat of the game. + /// + public const string SayCommandName = "say"; + + /// + /// The name of the option of the message of . + /// + public const string MessageOptionName = "message"; + + /// + /// The name of the command which manages the bindings of the chats of guilds. + /// + public const string GuildChatCommandName = "guildchat"; + + /// + /// The name of the sub command which binds the current channel. + /// + public const string BindCommandName = "bind"; + + /// + /// The name of the sub command which creates a channel on the Discord server of the game server. + /// + public const string CreateCommandName = "create"; + + /// + /// The name of the sub command which removes the binding of the current channel. + /// + public const string UnbindCommandName = "unbind"; + + /// + /// The name of the option of the scope. + /// + public const string ScopeOptionName = "scope"; + + /// + /// The value of the option of the scope for the alliance chat. + /// + public const string AllianceScope = "alliance"; + + /// + /// The value of the option of the scope for the guild chat. + /// + public const string GuildScope = "guild"; + + private readonly CultureInfo _culture; + + /// + /// Initializes a new instance of the class. + /// + /// The culture of the answers. + public DiscordChatCommands(CultureInfo culture) + { + this._culture = culture; + } + + /// + /// Gets the definition of the command which sends a message to the chat of the game. + /// + public static DiscordCommandDefinition SayDefinition { get; } = new(SayCommandName, nameof(Resources.Command_Say_Description), MessageOptionName, nameof(Resources.Command_Say_MessageOption)); + + /// + /// Gets the culture of the answers. + /// + public CultureInfo Culture => this._culture; + + /// + /// Parses the value of the scope option. + /// + /// The value. + /// The scope. + public static GuildChatScope ParseScope(string? value) => value == AllianceScope ? GuildChatScope.Alliance : GuildChatScope.Guild; + + /// + /// Gets the text of a resource. + /// + /// The resource key. + /// The text. + public string Text(string resourceKey) => DiscordCommands.GetText(this._culture, resourceKey); + + /// + /// Creates the answer to a message which couldn't be sent to the game. + /// + /// The result. + /// The name of the character as which the user wrote. + /// The answer. + public DiscordEmbed CreatePostAnswer(DiscordChatPostResult result, string? characterName) + { + var key = result switch + { + DiscordChatPostResult.NotBridged => nameof(Resources.Chat_NotBridged), + DiscordChatPostResult.NotLinked => nameof(Resources.Link_NotLinked), + DiscordChatPostResult.NoCharacter => nameof(Resources.Chat_NoCharacter), + DiscordChatPostResult.NotMember => nameof(Resources.Chat_NotMember), + DiscordChatPostResult.ChatBanned => nameof(Resources.Chat_ChatBanned), + DiscordChatPostResult.TooFast => nameof(Resources.Chat_TooFast), + DiscordChatPostResult.Empty => nameof(Resources.Chat_Empty), + DiscordChatPostResult.Filtered => nameof(Resources.Chat_Filtered), + _ => nameof(Resources.Chat_Sent), + }; + + return this.Answer(nameof(Resources.Chat_Title), key, DiscordMessageFormatter.Escape(characterName ?? "-")); + } + + /// + /// Creates the answer to a binding or the removal of a binding. + /// + /// The result. + /// The resource key of the answer on success. + /// The bound guild or alliance. + /// The mention of the channel. + /// The answer. + public DiscordEmbed CreateBindAnswer(DiscordChatBindResult result, string successKey, GuildChatTarget? target, string? channelMention) + { + var key = result switch + { + DiscordChatBindResult.Success => successKey, + DiscordChatBindResult.ModeNotAllowed => nameof(Resources.Bind_ModeNotAllowed), + DiscordChatBindResult.ServerNotAllowed => nameof(Resources.Bind_ServerNotAllowed), + DiscordChatBindResult.NotLinked => nameof(Resources.Link_NotLinked), + DiscordChatBindResult.NotGuildMaster => nameof(Resources.Bind_NotGuildMaster), + DiscordChatBindResult.NotAllianceMaster => nameof(Resources.Bind_NotAllianceMaster), + DiscordChatBindResult.ChannelInUse => nameof(Resources.Bind_ChannelInUse), + _ => nameof(Resources.Bind_NotBound), + }; + + return this.Answer(nameof(Resources.Bind_Title), key, DiscordMessageFormatter.Escape(target?.GuildName ?? "-"), channelMention ?? "-"); + } + + /// + /// Creates an answer with a text. + /// + /// The resource key of the title. + /// The resource key of the text. + /// The format arguments. + /// The answer. + public DiscordEmbed Answer(string titleKey, string resourceKey, params object[] args) + { + return new DiscordEmbed( + DiscordCommands.GetText(this._culture, titleKey), + DiscordCommands.GetText(this._culture, resourceKey, args), + DiscordCommands.AnswerColor, + DateTime.UtcNow, + null); + } +} diff --git a/src/Discord/ChatBridge/DiscordChatPostResult.cs b/src/Discord/ChatBridge/DiscordChatPostResult.cs new file mode 100644 index 0000000000..af93c44878 --- /dev/null +++ b/src/Discord/ChatBridge/DiscordChatPostResult.cs @@ -0,0 +1,56 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +/// +/// The result of sending a message from Discord to the game. +/// +public enum DiscordChatPostResult +{ + /// + /// The message was sent. + /// + Sent, + + /// + /// The channel isn't bound to a chat of the game. + /// + NotBridged, + + /// + /// The Discord user isn't linked to a game account. + /// + NotLinked, + + /// + /// The Discord user has no character of its account selected. + /// + NoCharacter, + + /// + /// The character isn't a member of the guild or alliance of the channel. + /// + NotMember, + + /// + /// The chat of the account is banned. + /// + ChatBanned, + + /// + /// The user sent too many messages in a short time. + /// + TooFast, + + /// + /// A plugin of the server, e.g. a filter, didn't let the message through. + /// + Filtered, + + /// + /// The message is empty, e.g. after removing what the game can't show. + /// + Empty, +} diff --git a/src/Discord/ChatBridge/DiscordChatQueue.cs b/src/Discord/ChatBridge/DiscordChatQueue.cs new file mode 100644 index 0000000000..e27e30e3e9 --- /dev/null +++ b/src/Discord/ChatBridge/DiscordChatQueue.cs @@ -0,0 +1,116 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +using System.Text; +using System.Threading; +using System.Threading.Channels; +using Microsoft.Extensions.Logging; + +/// +/// Sends the chat messages of a channel in the background. When messages come in faster than Discord +/// accepts them, they are combined into one Discord message. +/// +public sealed class DiscordChatQueue : IAsyncDisposable +{ + /// + /// The maximum length of a Discord message. + /// + internal const int MaximumMessageLength = 2000; + + private readonly ulong _channelId; + private readonly IDiscordChatMessenger _messenger; + private readonly ILogger _logger; + private readonly Channel _queue; + private readonly CancellationTokenSource _cancellation = new(); + private Task? _worker; + + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the channel. + /// The messenger. + /// The logger. + /// The maximum number of queued messages. When there are more, the oldest ones are dropped. + public DiscordChatQueue(ulong channelId, IDiscordChatMessenger messenger, ILogger logger, int capacity) + { + this._channelId = channelId; + this._messenger = messenger; + this._logger = logger; + this._queue = Channel.CreateBounded(new BoundedChannelOptions(Math.Max(capacity, 1)) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + }); + } + + /// + /// Adds a line of text to the queue. + /// + /// The line. + public void Enqueue(string line) + { + if (this._worker is null) + { + lock (this._queue) + { + this._worker ??= Task.Run(this.RunAsync); + } + } + + this._queue.Writer.TryWrite(line.Length > MaximumMessageLength ? line[..MaximumMessageLength] : line); + } + + /// + public async ValueTask DisposeAsync() + { + this._queue.Writer.TryComplete(); + await this._cancellation.CancelAsync().ConfigureAwait(false); + if (this._worker is { } worker) + { + await worker.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + + this._cancellation.Dispose(); + } + + private async Task RunAsync() + { + var reader = this._queue.Reader; + var cancellationToken = this._cancellation.Token; + string? pending = null; + try + { + while (pending is not null || await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + var text = new StringBuilder(); + while ((pending ?? (reader.TryRead(out var line) ? line : null)) is { } next) + { + if (text.Length > 0 && text.Length + 1 + next.Length > MaximumMessageLength) + { + pending = next; + break; + } + + pending = null; + text.Append(text.Length > 0 ? "\n" : string.Empty).Append(next); + } + + try + { + await this._messenger.SendTextAsync(this._channelId, text.ToString(), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this._logger.LogError(ex, "Unexpected error when sending chat messages to the Discord channel {channelId}; they are dropped.", this._channelId); + } + } + } + catch (OperationCanceledException) + { + // shutting down + } + } +} diff --git a/src/Discord/ChatBridge/DiscordChatText.cs b/src/Discord/ChatBridge/DiscordChatText.cs new file mode 100644 index 0000000000..661d4f78ee --- /dev/null +++ b/src/Discord/ChatBridge/DiscordChatText.cs @@ -0,0 +1,84 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +using System.Text; +using System.Text.RegularExpressions; + +/// +/// Converts the texts of chat messages between the game and Discord. +/// +public static partial class DiscordChatText +{ + /// + /// The prefix of the names of Discord users in the game. Character names can't contain it, + /// so Discord users can't pretend to be a character in the game. + /// + public const char DiscordSenderPrefix = '@'; + + /// + /// The maximum length of a sender name in the game. + /// + private const int MaximumSenderLength = 10; + + /// + /// Formats a message of the game for Discord. + /// + /// The sender. + /// The message. + /// The text for Discord. + public static string ToDiscord(string sender, string message) + { + return $"**{DiscordMessageFormatter.Escape(sender)}**: {DiscordMessageFormatter.Escape(message)}"; + } + + /// + /// Gets the name of a Discord user in the game, which is the name of its character with a prefix. + /// + /// The name of the character. + /// The name in the game. + public static string ToGameSender(string characterName) + { + var sender = DiscordSenderPrefix + characterName; + return sender.Length > MaximumSenderLength ? sender[..MaximumSenderLength] : sender; + } + + /// + /// Converts a Discord message to a message for the game: Mentions are removed, custom emojis are + /// replaced by their name, markdown and line breaks are removed, and the length is limited. + /// + /// The text of the Discord message. + /// The maximum length. + /// The message for the game. + public static string ToGame(string text, int maximumLength) + { + text = CustomEmojiRegex().Replace(text, ":$1:"); + text = MentionRegex().Replace(text, string.Empty); + var result = new StringBuilder(text.Length); + foreach (var character in text) + { + if (char.IsControl(character) || char.IsWhiteSpace(character)) + { + if (result.Length > 0 && result[^1] != ' ') + { + result.Append(' '); + } + } + else if (character is not ('*' or '_' or '~' or '`' or '|' or '\\')) + { + result.Append(character); + } + } + + var message = result.ToString().Trim(); + return message.Length > maximumLength ? message[..maximumLength].TrimEnd() : message; + } + + [GeneratedRegex(@"")] + private static partial Regex CustomEmojiRegex(); + + [GeneratedRegex(@"<(@[!&]?|#)\d+>|@(everyone|here)")] + private static partial Regex MentionRegex(); +} diff --git a/src/Discord/ChatBridge/DiscordFloodGuard.cs b/src/Discord/ChatBridge/DiscordFloodGuard.cs new file mode 100644 index 0000000000..4afe78ffde --- /dev/null +++ b/src/Discord/ChatBridge/DiscordFloodGuard.cs @@ -0,0 +1,104 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +using System.Collections.Concurrent; +using System.Threading; + +/// +/// Limits the number of messages which a Discord user can send to the game per minute. +/// +public sealed class DiscordFloodGuard +{ + /// + /// The number of registrations after which the inactive users are removed. + /// + private const int SweepInterval = 100; + + private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + + private readonly int _maximumMessages; + private readonly TimeProvider _timeProvider; + private readonly ConcurrentDictionary> _messageTimes = new(); + private int _registrationCount; + + /// + /// Initializes a new instance of the class. + /// + /// The maximum number of messages per minute. + /// The time provider. + public DiscordFloodGuard(int maximumMessages, TimeProvider timeProvider) + { + this._maximumMessages = Math.Max(maximumMessages, 1); + this._timeProvider = timeProvider; + } + + /// + /// Gets the number of users which are tracked, for tests. + /// + internal int TrackedUserCount => this._messageTimes.Count; + + /// + /// Registers a message of the user, if the user didn't send too many messages. + /// + /// The identifier of the user. + /// true, if the message is allowed; otherwise, false. + public bool TryRegister(ulong userId) + { + var now = this._timeProvider.GetUtcNow(); + if (Interlocked.Increment(ref this._registrationCount) % SweepInterval == 0) + { + this.RemoveInactiveUsers(now); + } + + while (true) + { + var times = this._messageTimes.GetOrAdd(userId, _ => new Queue()); + lock (times) + { + // The queue might have been removed by RemoveInactiveUsers in the meantime. + if (!this._messageTimes.TryGetValue(userId, out var current) || current != times) + { + continue; + } + + RemoveExpired(times, now); + if (times.Count >= this._maximumMessages) + { + return false; + } + + times.Enqueue(now); + return true; + } + } + } + + private static void RemoveExpired(Queue times, DateTimeOffset now) + { + while (times.TryPeek(out var oldest) && now - oldest >= Window) + { + times.Dequeue(); + } + } + + /// + /// Removes the users who didn't send a message within the window, so that the dictionary doesn't grow forever. + /// + private void RemoveInactiveUsers(DateTimeOffset now) + { + foreach (var (userId, times) in this._messageTimes) + { + lock (times) + { + RemoveExpired(times, now); + if (times.Count == 0) + { + this._messageTimes.TryRemove(new KeyValuePair>(userId, times)); + } + } + } + } +} diff --git a/src/Discord/ChatBridge/DiscordGuildChatBindingMode.cs b/src/Discord/ChatBridge/DiscordGuildChatBindingMode.cs new file mode 100644 index 0000000000..62a9e3951b --- /dev/null +++ b/src/Discord/ChatBridge/DiscordGuildChatBindingMode.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +/// +/// Defines where the chats of guilds can be bound to. +/// +public enum DiscordGuildChatBindingMode +{ + /// + /// The chats can be bound to channels on the Discord server of the game server, and to channels on Discord servers of the guilds. + /// + Both, + + /// + /// The chats can only be bound to channels on the Discord server of the game server, which the bot creates for the guilds. + /// + HostedOnly, + + /// + /// The chats can only be bound to channels on Discord servers of the guilds. + /// + GuildOwnedOnly, +} diff --git a/src/Discord/ChatBridge/GuildChatBindingStore.cs b/src/Discord/ChatBridge/GuildChatBindingStore.cs new file mode 100644 index 0000000000..b8dd1b725d --- /dev/null +++ b/src/Discord/ChatBridge/GuildChatBindingStore.cs @@ -0,0 +1,154 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +using System.Globalization; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.AccountLinking; +using MUnique.OpenMU.Persistence; + +/// +/// Stores the bindings of the chats of guilds to Discord channels, and keeps them in memory, +/// so that a chat message doesn't need a query to the database. +/// +public sealed class GuildChatBindingStore +{ + private const string Provider = AccountLinkService.DiscordProvider; + + private readonly Func _createContext; + private IReadOnlyList _bindings = []; + + /// + /// Initializes a new instance of the class. + /// + /// The function which creates a persistence context. + public GuildChatBindingStore(Func createContext) + { + this._createContext = createContext; + } + + /// + /// Gets the bindings. + /// + public IReadOnlyList Bindings => this._bindings; + + /// + /// Loads the bindings from the database again, e.g. because the bindings of deleted guilds were deleted with them. + /// + /// The task. + public async ValueTask ReloadAsync() + { + using var context = this._createContext(); + var bindings = await context.GetAsync().ConfigureAwait(false); + this._bindings = bindings.Where(binding => binding.Provider == Provider).ToList(); + } + + /// + /// Gets the binding of a channel. + /// + /// The identifier of the channel. + /// The binding; or null, if the channel isn't bound. + public GuildChatBinding? GetByChannel(ulong channelId) + { + var id = ToText(channelId); + return this._bindings.FirstOrDefault(binding => binding.ExternalChannelId == id); + } + + /// + /// Gets the binding of the chat of a guild or alliance. + /// + /// The identifier of the guild; for an alliance, of its master guild. + /// The scope. + /// The binding; or null, if the chat isn't bound. + public GuildChatBinding? Get(Guid guildId, GuildChatScope scope) + { + return this._bindings.FirstOrDefault(binding => binding.GuildId == guildId && binding.Scope == scope); + } + + /// + /// Binds the chat of a guild or alliance to a channel. An existing binding of the chat is moved to the channel. + /// + /// The identifier of the guild; for an alliance, of its master guild. + /// The scope. + /// The identifier of the Discord server. + /// The identifier of the channel. + /// A value indicating whether the channel is on the Discord server of the game server. + /// The name of the character which binds the chat. + /// The timestamp. + /// The task. + public async ValueTask SetAsync(Guid guildId, GuildChatScope scope, ulong serverId, ulong channelId, bool isHosted, string boundBy, DateTime boundAt) + { + using (var context = this._createContext()) + { + var bindings = await context.GetAsync().ConfigureAwait(false); + var binding = bindings.FirstOrDefault(b => b.Provider == Provider && b.GuildId == guildId && b.Scope == scope); + if (binding is null) + { + binding = context.CreateNew(); + binding.GuildId = guildId; + binding.Scope = scope; + binding.Provider = Provider; + } + + binding.ExternalServerId = ToText(serverId); + binding.ExternalChannelId = ToText(channelId); + binding.IsHosted = isHosted; + binding.BoundBy = boundBy; + binding.BoundAt = boundAt; + await context.SaveChangesAsync().ConfigureAwait(false); + } + + await this.ReloadAsync().ConfigureAwait(false); + } + + /// + /// Removes the bindings of channels or of a Discord server. + /// + /// The identifier of the channel; or null, to remove the bindings of the Discord server. + /// The identifier of the Discord server; or null, to remove the binding of the channel. + /// The number of removed bindings. + public async ValueTask RemoveAsync(ulong? channelId, ulong? serverId) + { + if (channelId is null && serverId is null) + { + // That would remove all bindings. + throw new ArgumentException("A channel or a Discord server is required.", nameof(channelId)); + } + + var channelText = channelId is { } c ? ToText(c) : null; + var serverText = serverId is { } s ? ToText(s) : null; + if (!this._bindings.Any(Matches)) + { + return 0; + } + + int count; + using (var context = this._createContext()) + { + var bindings = (await context.GetAsync().ConfigureAwait(false)).Where(Matches).ToList(); + foreach (var binding in bindings) + { + await context.DeleteAsync(binding).ConfigureAwait(false); + } + + await context.SaveChangesAsync().ConfigureAwait(false); + count = bindings.Count; + } + + await this.ReloadAsync().ConfigureAwait(false); + return count; + + bool Matches(GuildChatBinding binding) => binding.Provider == Provider + && (channelText is null || binding.ExternalChannelId == channelText) + && (serverText is null || binding.ExternalServerId == serverText); + } + + /// + /// Converts an identifier of Discord to the text which is stored. + /// + /// The identifier. + /// The text. + internal static string ToText(ulong id) => id.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/Discord/ChatBridge/GuildChatTarget.cs b/src/Discord/ChatBridge/GuildChatTarget.cs new file mode 100644 index 0000000000..f1131a6165 --- /dev/null +++ b/src/Discord/ChatBridge/GuildChatTarget.cs @@ -0,0 +1,13 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +/// +/// The guild or alliance whose chat a guild master can bind. +/// +/// The identifier of the guild; for an alliance, of its master guild. +/// The name of the guild. +/// The name of the character of the guild master. +public sealed record GuildChatTarget(Guid GuildId, string GuildName, string CharacterName); diff --git a/src/Discord/ChatBridge/IDiscordChatMessenger.cs b/src/Discord/ChatBridge/IDiscordChatMessenger.cs new file mode 100644 index 0000000000..41808a22eb --- /dev/null +++ b/src/Discord/ChatBridge/IDiscordChatMessenger.cs @@ -0,0 +1,22 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord.ChatBridge; + +using System.Threading; + +/// +/// Sends chat messages as text to Discord channels. +/// +public interface IDiscordChatMessenger +{ + /// + /// Sends a text to the channel. Mentions in the text don't ping anybody. + /// + /// The identifier of the channel. + /// The text. + /// The cancellation token. + /// The task. + Task SendTextAsync(ulong channelId, string text, CancellationToken cancellationToken); +} diff --git a/src/Discord/DiscordBot.ChatBridge.cs b/src/Discord/DiscordBot.ChatBridge.cs new file mode 100644 index 0000000000..84b48f722a --- /dev/null +++ b/src/Discord/DiscordBot.ChatBridge.cs @@ -0,0 +1,420 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Discord; + +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using System.Threading; +using global::Discord; +using global::Discord.WebSocket; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Discord.ChatBridge; +using MUnique.OpenMU.Discord.Properties; +using MUnique.OpenMU.Discord.Provisioning; +using MUnique.OpenMU.Interfaces; + +/// +/// The part of the which runs the chat bridge between the game and Discord. +/// +public sealed partial class DiscordBot +{ + /// + /// The number of status updates after which the channels of the guilds are synchronized with the members of the guilds. + /// + private const int HostedChannelSyncInterval = 10; + + /// + /// The maximum number of chat messages per channel which wait to be sent. + /// + private const int MaximumQueuedChatMessages = 100; + + /// + /// The maximum number of permission overwrites of a Discord channel. + /// + private const int MaximumPermissionOverwrites = 500; + + private readonly ConcurrentDictionary _chatQueues = new(); + private int _maintenanceCount; + + /// + public async Task SendTextAsync(ulong channelId, string text, CancellationToken cancellationToken) + { + await this._ready.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + if (await this.GetMessageChannelAsync(channelId).ConfigureAwait(false) is not { } channel) + { + this._logger.LogWarning("The Discord channel {channelId} of the chat isn't available for the bot.", channelId); + return; + } + + await channel.SendMessageAsync(text, allowedMentions: AllowedMentions.None).ConfigureAwait(false); + } + + private static bool IsChatCommand(string commandName) => commandName is DiscordChatCommands.SayCommandName or DiscordChatCommands.GuildChatCommandName; + + /// + /// Gets the name of the channel of a guild, e.g. guild-legends. + /// + private static string GetHostedChannelName(GuildChatScope scope, string guildName) + { + var name = new StringBuilder(scope == GuildChatScope.Alliance ? "alliance-" : "guild-"); + foreach (var character in guildName.ToLowerInvariant()) + { + name.Append(char.IsLetterOrDigit(character) ? character : '-'); + } + + return name.ToString().TrimEnd('-'); + } + + private GatewayIntents GetGatewayIntents() + { + // Reading the messages requires the privileged intent for their content. + return this._settings.ChatBridge.ReadMessages + ? GatewayIntents.Guilds | GatewayIntents.GuildMessages | GatewayIntents.MessageContent + : GatewayIntents.Guilds; + } + + private void RegisterChatEvents(DiscordSocketClient client) + { + if (this._settings.ChatBridge.ReadMessages) + { + client.MessageReceived += this.OnMessageReceivedAsync; + } + + client.ChannelDestroyed += channel => this.RemoveBindingsAsync(channel.Id, null); + client.LeftGuild += guild => this.RemoveBindingsAsync(null, guild.Id); + } + + private IEnumerable CreateChatCommands() + { + var say = DiscordChatCommands.SayDefinition; + yield return new SlashCommandBuilder() + .WithName(say.Name) + .WithDescription(this._chatCommands.Text(say.DescriptionKey)) + .WithContextTypes(InteractionContextType.Guild) + .AddOption(say.OptionName, ApplicationCommandOptionType.String, this._chatCommands.Text(say.OptionDescriptionKey!), isRequired: true) + .Build(); + + SlashCommandOptionBuilder SubCommand(string name, string descriptionKey) => new SlashCommandOptionBuilder() + .WithName(name) + .WithDescription(this._chatCommands.Text(descriptionKey)) + .WithType(ApplicationCommandOptionType.SubCommand) + .AddOption(new SlashCommandOptionBuilder() + .WithName(DiscordChatCommands.ScopeOptionName) + .WithDescription(this._chatCommands.Text(nameof(Resources.Command_GuildChat_ScopeOption))) + .WithType(ApplicationCommandOptionType.String) + .WithRequired(false) + .AddChoice(this._chatCommands.Text(nameof(Resources.Command_GuildChat_ScopeGuild)), DiscordChatCommands.GuildScope) + .AddChoice(this._chatCommands.Text(nameof(Resources.Command_GuildChat_ScopeAlliance)), DiscordChatCommands.AllianceScope)); + + yield return new SlashCommandBuilder() + .WithName(DiscordChatCommands.GuildChatCommandName) + .WithDescription(this._chatCommands.Text(nameof(Resources.Command_GuildChat_Description))) + .WithContextTypes(InteractionContextType.Guild) + .AddOption(SubCommand(DiscordChatCommands.BindCommandName, nameof(Resources.Command_GuildChat_Bind_Description))) + .AddOption(SubCommand(DiscordChatCommands.CreateCommandName, nameof(Resources.Command_GuildChat_Create_Description))) + .AddOption(SubCommand(DiscordChatCommands.UnbindCommandName, nameof(Resources.Command_GuildChat_Unbind_Description))) + .Build(); + } + + private async Task ExecuteChatCommandAsync(SocketSlashCommand command) + { + try + { + await command.DeferAsync(ephemeral: true).ConfigureAwait(false); + var answer = command.Data.Name == DiscordChatCommands.SayCommandName + ? await this.SayAsync(command).ConfigureAwait(false) + : await this.ExecuteGuildChatCommandAsync(command).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); + await this.AnswerFailureAsync(command, true).ConfigureAwait(false); + } + } + + private async Task SayAsync(SocketSlashCommand command) + { + var text = command.Data.Options.FirstOrDefault()?.Value as string ?? string.Empty; + var channelId = command.ChannelId ?? 0; + var (result, characterName, sentMessage) = await this._chatBridge.PostToGameAsync(channelId, this._routing.WorldChatChannelId, command.User.Id, text).ConfigureAwait(false); + if (result == DiscordChatPostResult.Sent) + { + // The command itself isn't visible to the others in the channel, so the bot shows the message, exactly as the game got it. + this.EnqueueChatMessage(channelId, DiscordChatText.ToDiscord(characterName!, sentMessage!)); + } + + return this._chatCommands.CreatePostAnswer(result, characterName); + } + + private async Task ExecuteGuildChatCommandAsync(SocketSlashCommand command) + { + var subCommand = command.Data.Options.First(); + var scope = DiscordChatCommands.ParseScope(subCommand.Options.FirstOrDefault(option => option.Name == DiscordChatCommands.ScopeOptionName)?.Value as string); + if (this._client is not { } client + || command.GuildId is not { } guildId + || command.Channel is not IGuildChannel channel + || command.User is not SocketGuildUser user) + { + return this._chatCommands.Answer(nameof(Resources.Bind_Title), nameof(Resources.Bind_NotInChannel)); + } + + var managesChannel = user.GetPermissions(channel).ManageChannel; + var isHostedServer = this.GetRoutingGuild(client)?.Id == guildId; + switch (subCommand.Name) + { + case DiscordChatCommands.BindCommandName when isHostedServer: + return this._chatCommands.Answer(nameof(Resources.Bind_Title), nameof(Resources.Bind_UseCreate)); + case DiscordChatCommands.BindCommandName when !managesChannel: + return this._chatCommands.Answer(nameof(Resources.Bind_Title), nameof(Resources.Bind_ManageChannelRequired)); + case DiscordChatCommands.BindCommandName: + var (bindResult, target) = await this._chatBridge.BindAsync(user.Id, scope, guildId, channel.Id, false).ConfigureAwait(false); + return this._chatCommands.CreateBindAnswer( + bindResult, + scope == GuildChatScope.Alliance ? nameof(Resources.Bind_AllianceBound) : nameof(Resources.Bind_GuildBound), + target, + MentionUtils.MentionChannel(channel.Id)); + case DiscordChatCommands.CreateCommandName: + return await this.CreateHostedChannelAsync(client, user.Id, scope).ConfigureAwait(false); + default: + var unbindResult = await this._chatBridge.UnbindAsync(channel.Id, user.Id, managesChannel).ConfigureAwait(false); + return this._chatCommands.CreateBindAnswer(unbindResult, nameof(Resources.Bind_Unbound), null, null); + } + } + + /// + /// Creates the channel of a guild on the Discord server of the game server, and binds the chat of the guild to it. + /// If it already exists, it's bound again. + /// + private async Task CreateHostedChannelAsync(DiscordSocketClient client, ulong userId, GuildChatScope scope) + { + if (this.GetRoutingGuild(client) is not { } guild) + { + return this._chatCommands.Answer(nameof(Resources.Bind_Title), nameof(Resources.Bind_HostedServerUnavailable)); + } + + if (this._chatBridge.CheckAllowed(guild.Id, true) is { } notAllowed) + { + return this._chatCommands.CreateBindAnswer(notAllowed, string.Empty, null, null); + } + + var (result, target) = await this._chatBridge.GetBindableTargetAsync(userId, scope).ConfigureAwait(false); + if (target is null) + { + return this._chatCommands.CreateBindAnswer(result, string.Empty, null, null); + } + + IGuildChannel channel; + if (this._chatBridge.Store.Get(target.GuildId, scope) is { IsHosted: true } existing + && ulong.TryParse(existing.ExternalChannelId, NumberStyles.None, CultureInfo.InvariantCulture, out var existingId) + && guild.GetTextChannel(existingId) is { } existingChannel) + { + channel = existingChannel; + } + else + { + var categoryName = this._layout.Categories.FirstOrDefault(c => c.Key == DiscordServerLayout.GuildsCategoryKey)?.Name; + var category = guild.CategoryChannels.FirstOrDefault(c => string.Equals(c.Name, categoryName, StringComparison.OrdinalIgnoreCase)); + channel = await guild.CreateTextChannelAsync(GetHostedChannelName(scope, target.GuildName), properties => + { + properties.CategoryId = category?.Id; + properties.PermissionOverwrites = new[] + { + new Overwrite(guild.EveryoneRole.Id, PermissionTarget.Role, new OverwritePermissions(viewChannel: PermValue.Deny)), + new Overwrite(client.CurrentUser.Id, PermissionTarget.User, new OverwritePermissions(viewChannel: PermValue.Allow, sendMessages: PermValue.Allow)), + }; + }).ConfigureAwait(false); + } + + var (bindResult, boundTarget) = await this._chatBridge.BindAsync(userId, scope, guild.Id, channel.Id, true).ConfigureAwait(false); + var allMembersFit = true; + if (bindResult == DiscordChatBindResult.Success && this._chatBridge.Store.GetByChannel(channel.Id) is { } binding) + { + allMembersFit = await this.SyncHostedChannelAsync(client, channel, binding).ConfigureAwait(false); + } + + var answer = this._chatCommands.CreateBindAnswer(bindResult, nameof(Resources.Bind_Created), boundTarget, MentionUtils.MentionChannel(channel.Id)); + return allMembersFit + ? answer + : answer with { Description = answer.Description + "\n" + DiscordCommands.GetText(this._chatCommands.Culture, nameof(Resources.Bind_TooManyMembers), MaximumPermissionOverwrites) }; + } + + private async ValueTask OnChatMessageAsync(ChatMessageEvent chatMessage) + { + try + { + if (await this._chatBridge.GetDiscordMessageAsync(chatMessage, this._routing.WorldChatChannelId).ConfigureAwait(false) is { } message) + { + this.EnqueueChatMessage(message.ChannelId, message.Text); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error when mirroring a chat message of the game to Discord."); + } + } + + private void EnqueueChatMessage(ulong channelId, string text) + { + this._chatQueues + .GetOrAdd(channelId, id => new DiscordChatQueue(id, this, this._logger, MaximumQueuedChatMessages)) + .Enqueue(text); + } + + private async ValueTask StopChatQueuesAsync() + { + foreach (var queue in this._chatQueues.Values) + { + await queue.DisposeAsync().ConfigureAwait(false); + } + + this._chatQueues.Clear(); + } + + private Task OnMessageReceivedAsync(SocketMessage message) + { + if (message.Author.IsBot + || message.Author.IsWebhook + || message is not SocketUserMessage userMessage + || message.Channel is not SocketTextChannel channel + || (this._chatBridge.Store.GetByChannel(channel.Id) is null && channel.Id != this._routing.WorldChatChannelId)) + { + return Task.CompletedTask; + } + + // The gateway shouldn't wait for the database. + _ = Task.Run(async () => + { + try + { + var (result, _, _) = await this._chatBridge.PostToGameAsync(channel.Id, this._routing.WorldChatChannelId, message.Author.Id, message.Content).ConfigureAwait(false); + if (result != DiscordChatPostResult.Sent) + { + // The user should notice that the message didn't reach the game. /say tells why. + await userMessage.AddReactionAsync(new Emoji("❌")).ConfigureAwait(false); + } + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "A Discord message couldn't be sent to the chat of the game."); + } + }); + + return Task.CompletedTask; + } + + private async Task RemoveBindingsAsync(ulong? channelId, ulong? serverId) + { + try + { + if (await this._chatBridge.Store.RemoveAsync(channelId, serverId).ConfigureAwait(false) is var count and > 0) + { + this._logger.LogInformation("Removed {count} binding(s) of guild chats, because the Discord channel {channelId} or server {serverId} isn't available anymore.", count, channelId, serverId); + } + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error when removing the bindings of guild chats."); + } + } + + private async ValueTask ReloadChatBindingsAsync() + { + try + { + await this._chatBridge.Store.ReloadAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this._logger.LogError(ex, "The bindings of guild chats couldn't be loaded."); + } + } + + /// + /// Loads the bindings again from time to time, because the bindings of deleted guilds are deleted with them, + /// and synchronizes the channels of the guilds with their members. + /// + private async ValueTask MaintainChatBridgeAsync() + { + // The store knows the changes of the bot. A binding of a deleted guild fails harmlessly until the next reload. + if (++this._maintenanceCount % HostedChannelSyncInterval == 0) + { + await this.ReloadChatBindingsAsync().ConfigureAwait(false); + await this.SyncHostedChannelsAsync().ConfigureAwait(false); + } + } + + private async Task SyncHostedChannelsAsync() + { + if (this._client is not { } client || this.GetRoutingGuild(client) is not { } guild) + { + return; + } + + var serverId = guild.Id.ToString(CultureInfo.InvariantCulture); + foreach (var binding in this._chatBridge.Store.Bindings.Where(b => b.IsHosted && b.ExternalServerId == serverId)) + { + if (ulong.TryParse(binding.ExternalChannelId, NumberStyles.None, CultureInfo.InvariantCulture, out var channelId) + && guild.GetTextChannel(channelId) is { } channel) + { + await this.SyncHostedChannelAsync(client, channel, binding).ConfigureAwait(false); + } + } + } + + /// + /// Allows the linked members of the guild or alliance to see the channel, and removes the others. + /// The bot doesn't use roles for it, because it can't see the members of roles without the privileged intent for the members. + /// Discord allows overwrites per channel, so a very large alliance doesn't fit completely. + /// + /// true, if all linked members can see the channel; false, if some don't fit. + private async Task SyncHostedChannelAsync(DiscordSocketClient client, IGuildChannel channel, GuildChatBinding binding) + { + try + { + var linkedMembers = await this._chatBridge.GetLinkedMembersAsync(binding).ConfigureAwait(false); + var currentMembers = channel.PermissionOverwrites + .Where(overwrite => overwrite.TargetType == PermissionTarget.User && overwrite.TargetId != client.CurrentUser.Id) + .Select(overwrite => overwrite.TargetId) + .ToHashSet(); + + // The removals first, so that they free overwrites for the additions. + foreach (var userId in currentMembers.Except(linkedMembers)) + { + if (await client.Rest.GetUserAsync(userId).ConfigureAwait(false) is { } user) + { + await channel.RemovePermissionOverwriteAsync(user).ConfigureAwait(false); + currentMembers.Remove(userId); + } + } + + // E.g. the overwrites of @everyone and of the bot. + var otherOverwrites = channel.PermissionOverwrites.Count(overwrite => overwrite.TargetType != PermissionTarget.User || overwrite.TargetId == client.CurrentUser.Id); + var freeOverwrites = MaximumPermissionOverwrites - otherOverwrites - currentMembers.Count; + var missingMembers = linkedMembers.Except(currentMembers).Order().ToList(); + foreach (var userId in missingMembers.Take(Math.Max(freeOverwrites, 0))) + { + if (await client.Rest.GetGuildUserAsync(channel.GuildId, userId).ConfigureAwait(false) is { } user) + { + await channel.AddPermissionOverwriteAsync(user, new OverwritePermissions(viewChannel: PermValue.Allow, sendMessages: PermValue.Allow, readMessageHistory: PermValue.Allow)).ConfigureAwait(false); + } + } + + if (missingMembers.Count > freeOverwrites) + { + this._logger.LogWarning("The Discord channel {channel} can't be opened for {count} linked members, because Discord allows only {maximum} permission overwrites per channel.", channel.Name, missingMembers.Count - freeOverwrites, MaximumPermissionOverwrites); + return false; + } + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "The members of the Discord channel {channel} couldn't be synchronized. Has the bot the permission to manage it?", channel.Name); + } + + return true; + } +} diff --git a/src/Discord/DiscordBot.cs b/src/Discord/DiscordBot.cs index 0decaefccf..643c5698e7 100644 --- a/src/Discord/DiscordBot.cs +++ b/src/Discord/DiscordBot.cs @@ -11,6 +11,7 @@ namespace MUnique.OpenMU.Discord; using global::Discord; using global::Discord.WebSocket; using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Discord.ChatBridge; using MUnique.OpenMU.Discord.Properties; using MUnique.OpenMU.Discord.Provisioning; using MUnique.OpenMU.GameLogic.AccountLinking; @@ -22,9 +23,11 @@ namespace MUnique.OpenMU.Discord; /// /// /// It only needs the intent, and no privileged one, -/// because it reacts on slash commands instead of reading messages. +/// because it reacts on slash commands instead of reading messages. Only when the chat bridge reads +/// the messages (), it needs the privileged +/// intent. /// -public sealed class DiscordBot : IManageableServer, IDiscordMessenger, IGameEventListener, IAsyncDisposable +public sealed partial class DiscordBot : IManageableServer, IDiscordMessenger, IDiscordChatMessenger, IGameEventListener, IAsyncDisposable { /// /// The name of the command which administrates the integration. @@ -43,6 +46,8 @@ public sealed class DiscordBot : IManageableServer, IDiscordMessenger, IGameEven private readonly IDiscordGameDataProvider _data; private readonly DiscordServerLayout _layout; private readonly DiscordServerProvisioner _provisioner; + private readonly DiscordChatBridge _chatBridge; + private readonly DiscordChatCommands _chatCommands; private readonly ILogger _logger; private readonly SemaphoreSlim _lifecycleLock = new(1, 1); @@ -64,8 +69,10 @@ public sealed class DiscordBot : IManageableServer, IDiscordMessenger, IGameEven /// The provider of the data of the game. /// The layout of the Discord server. /// The provisioner, which sets up the Discord server. + /// The chat bridge between the game and Discord. + /// The commands of the chat bridge. /// The logger. - public DiscordBot(DiscordBotSettings settings, DiscordCommands commands, DiscordAccountCommands accountCommands, DiscordStatusFormatter statusFormatter, IDiscordGameDataProvider data, DiscordServerLayout layout, DiscordServerProvisioner provisioner, ILogger logger) + public DiscordBot(DiscordBotSettings settings, DiscordCommands commands, DiscordAccountCommands accountCommands, DiscordStatusFormatter statusFormatter, IDiscordGameDataProvider data, DiscordServerLayout layout, DiscordServerProvisioner provisioner, DiscordChatBridge chatBridge, DiscordChatCommands chatCommands, ILogger logger) { this._settings = settings; this._commands = commands; @@ -74,6 +81,8 @@ public DiscordBot(DiscordBotSettings settings, DiscordCommands commands, Discord this._data = data; this._layout = layout; this._provisioner = provisioner; + this._chatBridge = chatBridge; + this._chatCommands = chatCommands; this._logger = logger; this._routing = DiscordChannelRouting.Create(settings, layout, new Dictionary()); } @@ -133,10 +142,11 @@ public async ValueTask StartAsync() } this._ready = new(TaskCreationOptions.RunContinuationsAsynchronously); - var client = new DiscordSocketClient(new DiscordSocketConfig { GatewayIntents = GatewayIntents.Guilds }); + var client = new DiscordSocketClient(new DiscordSocketConfig { GatewayIntents = this.GetGatewayIntents() }); client.Log += this.OnLogAsync; client.Ready += () => this.OnReadyAsync(client); client.SlashCommandExecuted += this.OnSlashCommandExecutedAsync; + this.RegisterChatEvents(client); this._client = client; await client.LoginAsync(TokenType.Bot, this._settings.Token).ConfigureAwait(false); @@ -195,11 +205,16 @@ public async Task SendAsync(ulong channelId, IReadOnlyList embeds, /// /// + /// Chat messages are mirrored to the bound channels. /// 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) { - if (gameEvent is AccountUnlinkedEvent { Provider: AccountLinkService.DiscordProvider } unlinked + if (gameEvent is ChatMessageEvent chatMessage) + { + await this.OnChatMessageAsync(chatMessage).ConfigureAwait(false); + } + else if (gameEvent is AccountUnlinkedEvent { Provider: AccountLinkService.DiscordProvider } unlinked && ulong.TryParse(unlinked.ExternalUserId, NumberStyles.None, CultureInfo.InvariantCulture, out var userId)) { await this.UpdateLinkedRoleAsync(new DiscordRoleChange(userId, false)).ConfigureAwait(false); @@ -259,6 +274,7 @@ private async ValueTask StopClientAsync() await client.DisposeAsync().ConfigureAwait(false); } + await this.StopChatQueuesAsync().ConfigureAwait(false); this._statusMessageId = null; this.ServerState = ServerState.Stopped; } @@ -275,6 +291,7 @@ private async Task OnReadyAsync(DiscordSocketClient client) } this.UpdateRouting(client); + await this.ReloadChatBindingsAsync().ConfigureAwait(false); this.ServerState = ServerState.Started; this._ready.TrySetResult(); @@ -298,21 +315,15 @@ private async Task RegisterCommandsAsync(DiscordSocketClient client) } return (ApplicationCommandProperties)builder.Build(); - }).Append(this.CreateAdministrationCommand()).ToArray(); + }).Append(this.CreateAdministrationCommand()).Concat(this.CreateChatCommands()).ToArray(); + // Guild-owned chat channels need /link, /guildchat and /say outside the routing guild, too. + await ((IDiscordClient)client).BulkOverwriteGlobalApplicationCommand(commands).ConfigureAwait(false); if (this._settings.GuildId is { } guildId && client.GetGuild(guildId) is { } guild) { + // Keep immediate availability on the main Discord server. await guild.BulkOverwriteApplicationCommandAsync(commands).ConfigureAwait(false); } - else - { - if (this._settings.GuildId is { } missingGuildId) - { - this._logger.LogWarning("The bot isn't on the configured Discord server {guildId}, so the slash commands are registered globally, which can take up to an hour. Is the identifier correct, and was the bot invited?", missingGuildId); - } - - await ((IDiscordClient)client).BulkOverwriteGlobalApplicationCommand(commands).ConfigureAwait(false); - } } private ApplicationCommandProperties CreateAdministrationCommand() @@ -343,6 +354,12 @@ private async Task OnSlashCommandExecutedAsync(SocketSlashCommand command) return; } + if (IsChatCommand(command.Data.Name)) + { + await this.ExecuteChatCommandAsync(command).ConfigureAwait(false); + return; + } + try { // The data might come from the database, which can take longer than Discord waits for an answer. @@ -371,6 +388,12 @@ private async Task ExecuteAccountCommandAsync(SocketSlashCommand command) await this.UpdateLinkedRoleAsync(roleChange).ConfigureAwait(false); } + if (answer.RoleChanges.Count > 0) + { + // The user might get or lose access to channels of guilds. + await this.SyncHostedChannelsAsync().ConfigureAwait(false); + } + await command.FollowupAsync(embed: ToDiscordEmbed(answer.Embed), ephemeral: true, allowedMentions: AllowedMentions.None).ConfigureAwait(false); } catch (Exception ex) @@ -480,6 +503,7 @@ private async Task RunStatusLoopAsync(CancellationToken cancellationToken) do { await this.UpdateStatusAsync().ConfigureAwait(false); + await this.MaintainChatBridgeAsync().ConfigureAwait(false); } while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)); } diff --git a/src/Discord/DiscordBotSettings.cs b/src/Discord/DiscordBotSettings.cs index 8845273964..ed256ea71e 100644 --- a/src/Discord/DiscordBotSettings.cs +++ b/src/Discord/DiscordBotSettings.cs @@ -4,6 +4,8 @@ namespace MUnique.OpenMU.Discord; +using MUnique.OpenMU.Discord.ChatBridge; + /// /// The settings of the Discord bot. /// @@ -41,6 +43,11 @@ public class DiscordBotSettings /// public string? LayoutFile { get; set; } + /// + /// Gets or sets the settings of the chat bridge between the game and Discord. + /// + public DiscordChatBridgeSettings ChatBridge { get; set; } = new(); + /// /// Gets or sets the interval in which the status message and the presence of the bot are updated. /// diff --git a/src/Discord/DiscordChannelRouting.cs b/src/Discord/DiscordChannelRouting.cs index 2a2bb71c4c..1aaab6c308 100644 --- a/src/Discord/DiscordChannelRouting.cs +++ b/src/Discord/DiscordChannelRouting.cs @@ -14,10 +14,11 @@ public sealed class DiscordChannelRouting { private readonly IReadOnlyDictionary _notificationChannels; - private DiscordChannelRouting(IReadOnlyDictionary notificationChannels, ulong? statusChannelId) + private DiscordChannelRouting(IReadOnlyDictionary notificationChannels, ulong? statusChannelId, ulong? worldChatChannelId) { this._notificationChannels = notificationChannels; this.StatusChannelId = statusChannelId; + this.WorldChatChannelId = worldChatChannelId; } /// @@ -25,6 +26,11 @@ private DiscordChannelRouting(IReadOnlyDictionary /// public ulong? StatusChannelId { get; } + /// + /// Gets the identifier of the channel which is bound to the world chat of the game. + /// + public ulong? WorldChatChannelId { get; } + /// /// Creates the routing. /// @@ -36,6 +42,7 @@ public static DiscordChannelRouting Create(DiscordBotSettings settings, DiscordS { var notificationChannels = new Dictionary(); ulong? statusChannelId = null; + ulong? worldChatChannelId = null; foreach (var channel in layout.Channels) { if (!layoutChannelIds.TryGetValue(channel.Key, out var channelId)) @@ -52,6 +59,11 @@ public static DiscordChannelRouting Create(DiscordBotSettings settings, DiscordS { statusChannelId ??= channelId; } + + if (channel.IsWorldChat) + { + worldChatChannelId ??= channelId; + } } foreach (var (category, channelId) in settings.Channels) @@ -59,7 +71,7 @@ public static DiscordChannelRouting Create(DiscordBotSettings settings, DiscordS notificationChannels[category] = channelId; } - return new DiscordChannelRouting(notificationChannels, settings.StatusChannelId ?? statusChannelId); + return new DiscordChannelRouting(notificationChannels, settings.StatusChannelId ?? statusChannelId, worldChatChannelId); } /// diff --git a/src/Discord/DiscordServiceCollectionExtensions.cs b/src/Discord/DiscordServiceCollectionExtensions.cs index 7e3313e727..094a7a9408 100644 --- a/src/Discord/DiscordServiceCollectionExtensions.cs +++ b/src/Discord/DiscordServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.Discord; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.Discord.ChatBridge; using MUnique.OpenMU.Discord.Provisioning; using MUnique.OpenMU.GameLogic.AccountLinking; using MUnique.OpenMU.Interfaces; @@ -49,6 +50,19 @@ public static IServiceCollection AddDiscord(this IServiceCollection services, Di new AccountLinkService(() => provider.GetRequiredService().CreateNewPlayerContext(new GameConfiguration())), culture, provider.GetRequiredService>())) + + // The event publisher of the all-in-one deployment depends on the bot, so it's resolved when it's needed. + .AddSingleton(provider => new DiscordChatBridge( + settings.Bot.ChatBridge, + new GuildChatBindingStore(() => provider.GetRequiredService().CreateNewPlayerContext(new GameConfiguration())), + new AccountLinkService(() => provider.GetRequiredService().CreateNewPlayerContext(new GameConfiguration())), + provider.GetRequiredService(), + () => provider.GetRequiredService().CreateNewPlayerContext(new GameConfiguration()), + () => provider.GetRequiredService().CreateNewGuildContext(), + () => provider.GetService(), + provider.GetRequiredService>(), + provider.GetService())) + .AddSingleton(new DiscordChatCommands(culture)) .AddSingleton(new DiscordStatusFormatter(culture)) .AddSingleton(settings.Bot.LayoutFile is { Length: > 0 } layoutFile ? DiscordServerLayout.Load(layoutFile) : DiscordServerLayout.LoadDefault()) .AddSingleton(provider => new DiscordServerProvisioner(provider.GetRequiredService>())) @@ -60,6 +74,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 321c9aee9e..5ccc1e8b72 100644 --- a/src/Discord/Properties/Resources.Designer.cs +++ b/src/Discord/Properties/Resources.Designer.cs @@ -60,6 +60,150 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to This channel is bound to the chat of the alliance of {0} now. The messages are mirrored in both directions.. + /// + public static string Bind_AllianceBound { + get { + return ResourceManager.GetString("Bind_AllianceBound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This channel is already bound to the chat of another guild or alliance.. + /// + public static string Bind_ChannelInUse { + get { + return ResourceManager.GetString("Bind_ChannelInUse", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {1} is the channel for the chat of {0}. Members who linked their Discord user can see it.. + /// + public static string Bind_Created { + get { + return ResourceManager.GetString("Bind_Created", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This channel is bound to the chat of the guild {0} now. The messages are mirrored in both directions.. + /// + public static string Bind_GuildBound { + get { + return ResourceManager.GetString("Bind_GuildBound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Discord server of the game isn't available to the bot.. + /// + public static string Bind_HostedServerUnavailable { + get { + return ResourceManager.GetString("Bind_HostedServerUnavailable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need the permission to manage this channel.. + /// + public static string Bind_ManageChannelRequired { + get { + return ResourceManager.GetString("Bind_ManageChannelRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The server owner doesn't allow this kind of binding.. + /// + public static string Bind_ModeNotAllowed { + get { + return ResourceManager.GetString("Bind_ModeNotAllowed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Only the master of an alliance can bind the chat of the alliance.. + /// + public static string Bind_NotAllianceMaster { + get { + return ResourceManager.GetString("Bind_NotAllianceMaster", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This channel isn't bound to a chat of the game.. + /// + public static string Bind_NotBound { + get { + return ResourceManager.GetString("Bind_NotBound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Only the guild master can do this. Select your guild master character with /character.. + /// + public static string Bind_NotGuildMaster { + get { + return ResourceManager.GetString("Bind_NotGuildMaster", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use this command in a text channel of a Discord server.. + /// + public static string Bind_NotInChannel { + get { + return ResourceManager.GetString("Bind_NotInChannel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The server owner doesn't allow bindings on this Discord server.. + /// + public static string Bind_ServerNotAllowed { + get { + return ResourceManager.GetString("Bind_ServerNotAllowed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Guild chat. + /// + public static string Bind_Title { + get { + return ResourceManager.GetString("Bind_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Discord allows only {0} members per channel, so not all linked members can see the channel.. + /// + public static string Bind_TooManyMembers { + get { + return ResourceManager.GetString("Bind_TooManyMembers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The binding of this channel was removed.. + /// + public static string Bind_Unbound { + get { + return ResourceManager.GetString("Bind_Unbound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to On the Discord server of the game, use /guildchat create instead.. + /// + public static string Bind_UseCreate { + get { + return ResourceManager.GetString("Bind_UseCreate", resourceCulture); + } + } + /// /// Looks up a localized string similar to **{0}** defeated **{1}** in {2}!. /// @@ -204,6 +348,87 @@ public static string Character_Selected { } } + /// + /// Looks up a localized string similar to Your chat is banned.. + /// + public static string Chat_ChatBanned { + get { + return ResourceManager.GetString("Chat_ChatBanned", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The message is empty.. + /// + public static string Chat_Empty { + get { + return ResourceManager.GetString("Chat_Empty", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The message isn't allowed in the game.. + /// + public static string Chat_Filtered { + get { + return ResourceManager.GetString("Chat_Filtered", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Select a character of your account with /character first.. + /// + public static string Chat_NoCharacter { + get { + return ResourceManager.GetString("Chat_NoCharacter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This channel isn't bound to a chat of the game.. + /// + public static string Chat_NotBridged { + get { + return ResourceManager.GetString("Chat_NotBridged", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Your character {0} isn't a member of the guild or alliance of this channel. Select another character with /character.. + /// + public static string Chat_NotMember { + get { + return ResourceManager.GetString("Chat_NotMember", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The message was sent as {0}.. + /// + public static string Chat_Sent { + get { + return ResourceManager.GetString("Chat_Sent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Chat. + /// + public static string Chat_Title { + get { + return ResourceManager.GetString("Chat_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You're sending messages too fast. Please wait a moment.. + /// + public static string Chat_TooFast { + get { + return ResourceManager.GetString("Chat_TooFast", resourceCulture); + } + } + /// /// Looks up a localized string similar to Selects the character of your account, as which you appear.. /// @@ -258,6 +483,69 @@ public static string Command_Guild_GuildOption { } } + /// + /// Looks up a localized string similar to Binds this channel to the chat of your guild or alliance.. + /// + public static string Command_GuildChat_Bind_Description { + get { + return ResourceManager.GetString("Command_GuildChat_Bind_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Creates a channel for the chat of your guild or alliance on the Discord server of the game.. + /// + public static string Command_GuildChat_Create_Description { + get { + return ResourceManager.GetString("Command_GuildChat_Create_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Binds the chat of your guild or alliance to a Discord channel.. + /// + public static string Command_GuildChat_Description { + get { + return ResourceManager.GetString("Command_GuildChat_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Alliance. + /// + public static string Command_GuildChat_ScopeAlliance { + get { + return ResourceManager.GetString("Command_GuildChat_ScopeAlliance", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Guild. + /// + public static string Command_GuildChat_ScopeGuild { + get { + return ResourceManager.GetString("Command_GuildChat_ScopeGuild", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The chat of the guild or of the alliance.. + /// + public static string Command_GuildChat_ScopeOption { + get { + return ResourceManager.GetString("Command_GuildChat_ScopeOption", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Removes the binding of this channel to the chat of the game.. + /// + public static string Command_GuildChat_Unbind_Description { + get { + return ResourceManager.GetString("Command_GuildChat_Unbind_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to The code which you got in the game with /discord link.. /// @@ -303,6 +591,24 @@ public static string Command_Rank_Description { } } + /// + /// Looks up a localized string similar to Sends a message from this channel to the chat of the game.. + /// + public static string Command_Say_Description { + get { + return ResourceManager.GetString("Command_Say_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The message.. + /// + public static string Command_Say_MessageOption { + get { + return ResourceManager.GetString("Command_Say_MessageOption", resourceCulture); + } + } + /// /// Looks up a localized string similar to Creates the missing channels and roles of this Discord server.. /// diff --git a/src/Discord/Properties/Resources.de.resx b/src/Discord/Properties/Resources.de.resx index 0d880239f7..0c60795c4f 100644 --- a/src/Discord/Properties/Resources.de.resx +++ b/src/Discord/Properties/Resources.de.resx @@ -98,6 +98,54 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Dieser Kanal ist jetzt mit dem Chat der Allianz von {0} verbunden. Die Nachrichten werden in beide Richtungen gespiegelt. + + + Dieser Kanal ist bereits mit dem Chat einer anderen Gilde oder Allianz verbunden. + + + {1} ist der Kanal für den Chat von {0}. Mitglieder, die ihren Discord-Benutzer verknüpft haben, können ihn sehen. + + + Dieser Kanal ist jetzt mit dem Chat der Gilde {0} verbunden. Die Nachrichten werden in beide Richtungen gespiegelt. + + + Der Discord-Server des Spiels ist für den Bot nicht verfügbar. + + + Du brauchst die Berechtigung, diesen Kanal zu verwalten. + + + Der Serverbetreiber erlaubt diese Art der Verbindung nicht. + + + Nur der Meister einer Allianz kann den Chat der Allianz verbinden. + + + Dieser Kanal ist nicht mit einem Chat des Spiels verbunden. + + + Nur der Gildenmeister kann das. Wähle deinen Gildenmeister-Charakter mit /character. + + + Verwende diesen Befehl in einem Textkanal eines Discord-Servers. + + + Der Serverbetreiber erlaubt keine Verbindungen auf diesem Discord-Server. + + + Gildenchat + + + Discord erlaubt nur {0} Mitglieder pro Kanal, daher können nicht alle verknüpften Mitglieder den Kanal sehen. + + + Die Verbindung dieses Kanals wurde entfernt. + + + Auf dem Discord-Server des Spiels verwende stattdessen /guildchat create. + **{0}** hat **{1}** in {2} besiegt! @@ -146,6 +194,33 @@ Du erscheinst jetzt als **{0}**. + + Dein Chat ist gesperrt. + + + Die Nachricht ist leer. + + + Die Nachricht ist im Spiel nicht erlaubt. + + + Wähle zuerst mit /character einen Charakter deines Kontos. + + + Dieser Kanal ist nicht mit einem Chat des Spiels verbunden. + + + Dein Charakter {0} ist kein Mitglied der Gilde oder Allianz dieses Kanals. Wähle mit /character einen anderen Charakter. + + + Die Nachricht wurde als {0} gesendet. + + + Chat + + + Du sendest zu schnell Nachrichten. Bitte warte einen Moment. + Wählt den Charakter deines Kontos, als der du erscheinst. @@ -164,6 +239,27 @@ Der Name der Gilde. + + Verbindet diesen Kanal mit dem Chat deiner Gilde oder Allianz. + + + Erstellt einen Kanal für den Chat deiner Gilde oder Allianz auf dem Discord-Server des Spiels. + + + Verbindet den Chat deiner Gilde oder Allianz mit einem Discord-Kanal. + + + Allianz + + + Gilde + + + Der Chat der Gilde oder der Allianz. + + + Entfernt die Verbindung dieses Kanals mit dem Chat des Spiels. + Der Code, den du im Spiel mit /discord link erhalten hast. @@ -179,6 +275,12 @@ Zeigt die besten Charaktere. + + Sendet eine Nachricht aus diesem Kanal in den Chat des Spiels. + + + Die Nachricht. + Erstellt die fehlenden Kanäle und Rollen dieses Discord-Servers. diff --git a/src/Discord/Properties/Resources.resx b/src/Discord/Properties/Resources.resx index 5e47ce4174..d52f7803ac 100644 --- a/src/Discord/Properties/Resources.resx +++ b/src/Discord/Properties/Resources.resx @@ -98,6 +98,54 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + This channel is bound to the chat of the alliance of {0} now. The messages are mirrored in both directions. + + + This channel is already bound to the chat of another guild or alliance. + + + {1} is the channel for the chat of {0}. Members who linked their Discord user can see it. + + + This channel is bound to the chat of the guild {0} now. The messages are mirrored in both directions. + + + The Discord server of the game isn't available to the bot. + + + You need the permission to manage this channel. + + + The server owner doesn't allow this kind of binding. + + + Only the master of an alliance can bind the chat of the alliance. + + + This channel isn't bound to a chat of the game. + + + Only the guild master can do this. Select your guild master character with /character. + + + Use this command in a text channel of a Discord server. + + + The server owner doesn't allow bindings on this Discord server. + + + Guild chat + + + Discord allows only {0} members per channel, so not all linked members can see the channel. + + + The binding of this channel was removed. + + + On the Discord server of the game, use /guildchat create instead. + **{0}** defeated **{1}** in {2}! @@ -146,6 +194,33 @@ You appear as **{0}** now. + + Your chat is banned. + + + The message is empty. + + + The message isn't allowed in the game. + + + Select a character of your account with /character first. + + + This channel isn't bound to a chat of the game. + + + Your character {0} isn't a member of the guild or alliance of this channel. Select another character with /character. + + + The message was sent as {0}. + + + Chat + + + You're sending messages too fast. Please wait a moment. + Selects the character of your account, as which you appear. @@ -164,6 +239,27 @@ The name of the guild. + + Binds this channel to the chat of your guild or alliance. + + + Creates a channel for the chat of your guild or alliance on the Discord server of the game. + + + Binds the chat of your guild or alliance to a Discord channel. + + + Alliance + + + Guild + + + The chat of the guild or of the alliance. + + + Removes the binding of this channel to the chat of the game. + The code which you got in the game with /discord link. @@ -179,6 +275,12 @@ Shows the best characters. + + Sends a message from this channel to the chat of the game. + + + The message. + Creates the missing channels and roles of this Discord server. diff --git a/src/Discord/Provisioning/DefaultLayout.json b/src/Discord/Provisioning/DefaultLayout.json index 2d337d77d5..47db6bef63 100644 --- a/src/Discord/Provisioning/DefaultLayout.json +++ b/src/Discord/Provisioning/DefaultLayout.json @@ -20,10 +20,16 @@ "name": "Community", "channels": [ { "key": "world-news", "name": "world-news", "topic": "Boss kills, rare drops and level milestones.", "isReadOnly": true, "notifications": [ "WorldNews" ] }, - { "key": "world-chat", "name": "world-chat" }, + { "key": "world-chat", "name": "world-chat", "topic": "The world chat of the game.", "isWorldChat": true }, { "key": "general", "name": "general" } ] }, + { + // The channels for the chats of guilds are created here with /guildchat create. + "key": "guilds", + "name": "Guilds", + "channels": [] + }, { "key": "staff", "name": "Staff", diff --git a/src/Discord/Provisioning/DiscordChannelLayout.cs b/src/Discord/Provisioning/DiscordChannelLayout.cs index e650bd11ae..fc2eb0d317 100644 --- a/src/Discord/Provisioning/DiscordChannelLayout.cs +++ b/src/Discord/Provisioning/DiscordChannelLayout.cs @@ -44,4 +44,9 @@ public sealed class DiscordChannelLayout /// Gets or sets a value indicating whether the bot shows the status of the game servers in the channel. /// public bool ShowsStatus { get; set; } + + /// + /// Gets or sets a value indicating whether the channel is bound to the world chat of the game. + /// + public bool IsWorldChat { get; set; } } diff --git a/src/Discord/Provisioning/DiscordServerLayout.cs b/src/Discord/Provisioning/DiscordServerLayout.cs index f13a316d38..703c3fde05 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 category in which the bot creates the channels for the chats of guilds. + /// + public const string GuildsCategoryKey = "guilds"; + private const string DefaultLayoutResourceName = "MUnique.OpenMU.Discord.Provisioning.DefaultLayout.json"; private static readonly JsonSerializerOptions SerializerOptions = new() diff --git a/src/GameLogic/PlugIns/ChatCommands/WorldChatChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/WorldChatChatCommandPlugIn.cs new file mode 100644 index 0000000000..232c8e35b5 --- /dev/null +++ b/src/GameLogic/PlugIns/ChatCommands/WorldChatChatCommandPlugIn.cs @@ -0,0 +1,59 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +using System.ComponentModel; +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.PlayerActions.Chat; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.PlugIns; + +/// +/// A chat command plugin which sends a message to the world chat, which all players of all game servers can read. +/// The normal chat only reaches the players nearby. +/// +[Guid("9529E0ED-9C80-4C00-9328-40D2E147B118")] +[PlugIn] +[Display(Name = nameof(PlugInResources.WorldChatChatCommandPlugIn_Name), Description = nameof(PlugInResources.WorldChatChatCommandPlugIn_Description), ResourceType = typeof(PlugInResources))] +[ChatCommandHelp(CommandKey, CharacterStatus.Normal)] +public class WorldChatChatCommandPlugIn : IChatCommandPlugIn +{ + private const string CommandKey = "/world"; + + /// + public string Key => CommandKey; + + /// + public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.Normal; + + /// + public async ValueTask HandleCommandAsync(Player player, string command) + { + var message = command.Length > CommandKey.Length ? command[CommandKey.Length..].Trim() : string.Empty; + if (string.IsNullOrWhiteSpace(message) + || player.SelectedCharacter is not { } character + || player.GameContext is not IGameServerContext context) + { + return; + } + + var remainingChatBan = (player.Account?.ChatBanUntil ?? default) - DateTime.UtcNow; + if (remainingChatBan > TimeSpan.Zero) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.ChatBanMinutesRemaining), (int)Math.Ceiling(remainingChatBan.TotalMinutes)).ConfigureAwait(false); + return; + } + + var eventArgs = new CancelEventArgs(); + player.GameContext.PlugInManager.GetPlugInPoint()?.ChatMessageReceived(player, message, eventArgs); + if (eventArgs.Cancel) + { + return; + } + + await context.EventPublisher.WorldChatMessageAsync(character.Name, message).ConfigureAwait(false); + await player.NotifyChatMessageSentAsync(message, ChatMessageType.World).ConfigureAwait(false); + } +} diff --git a/src/GameLogic/PlugIns/GameEvents/GameEventPublisherConfiguration.cs b/src/GameLogic/PlugIns/GameEvents/GameEventPublisherConfiguration.cs index 8e4f649546..03d89f175e 100644 --- a/src/GameLogic/PlugIns/GameEvents/GameEventPublisherConfiguration.cs +++ b/src/GameLogic/PlugIns/GameEvents/GameEventPublisherConfiguration.cs @@ -78,4 +78,14 @@ public class GameEventPublisherConfiguration /// [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.GameEventPublisherConfiguration_MasterLevelMilestones_Name))] public IList MasterLevelMilestones { get; set; } = new List(); + + /// + /// Gets or sets a value indicating whether the messages of the guild, alliance and world chat are published, + /// e.g. for the chat bridge of Discord. + /// + /// + /// It's off by default, because the chat of the players is private, unless the server owner decides otherwise. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.GameEventPublisherConfiguration_PublishChatMessages_Name))] + public bool PublishChatMessages { get; set; } } diff --git a/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs b/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs index 865aeb10f3..be12ca8a83 100644 --- a/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs +++ b/src/GameLogic/PlugIns/GameEvents/GameEventPublisherPlugIn.cs @@ -182,14 +182,38 @@ public ValueTask MonsterItemDroppedAsync(AttackableNpcBase monster, Player kille /// public ValueTask ChatMessageSentAsync(Player sender, string message, ChatMessageType messageType, Player? receiver) { - if (messageType != ChatMessageType.GlobalNotification - || this.Configuration?.PublishGlobalNotices is not true - || sender.GameContext is not IGameServerContext context) + if (sender.GameContext is not IGameServerContext context) { return ValueTask.CompletedTask; } - return context.EventPublisher.GameEventAsync(new GlobalNoticeEvent(context.Id, DateTime.UtcNow, sender.Name, message.TrimStart('!'))); + if (messageType == ChatMessageType.GlobalNotification) + { + return this.Configuration?.PublishGlobalNotices is true + ? context.EventPublisher.GameEventAsync(new GlobalNoticeEvent(context.Id, DateTime.UtcNow, sender.Name, message.TrimStart('!'))) + : ValueTask.CompletedTask; + } + + GameChatChannel? channel = messageType switch + { + ChatMessageType.Guild => GameChatChannel.Guild, + ChatMessageType.Alliance => GameChatChannel.Alliance, + ChatMessageType.World => GameChatChannel.World, + _ => null, + }; + if (channel is null || this.Configuration?.PublishChatMessages is not true) + { + return ValueTask.CompletedTask; + } + + // The guild and alliance messages still contain their prefix, @ or @@. + return context.EventPublisher.GameEventAsync(new ChatMessageEvent( + context.Id, + DateTime.UtcNow, + channel.Value, + sender.GuildStatus?.GuildId ?? 0, + sender.Name, + channel == GameChatChannel.World ? message : message.TrimStart('@'))); } /// diff --git a/src/GameLogic/PlugIns/GuildChatBridgeNoticePlugIn.cs b/src/GameLogic/PlugIns/GuildChatBridgeNoticePlugIn.cs new file mode 100644 index 0000000000..d1c3f2ea40 --- /dev/null +++ b/src/GameLogic/PlugIns/GuildChatBridgeNoticePlugIn.cs @@ -0,0 +1,60 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.PlugIns; + +/// +/// Tells players when they enter the game, if the chat of their guild or alliance is mirrored +/// to a channel of an external service like Discord. So nobody's chat is mirrored without knowing it. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.GuildChatBridgeNoticePlugIn_Name), Description = nameof(PlugInResources.GuildChatBridgeNoticePlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("7FE145F7-54D4-4295-A43A-E4A812EF5F29")] +public class GuildChatBridgeNoticePlugIn : IPlayerStateChangedPlugIn +{ + /// + public async ValueTask PlayerStateChangedAsync(Player player, State previousState, State currentState) + { + if (previousState != PlayerState.CharacterSelection + || currentState != PlayerState.EnteredWorld + || player.SelectedCharacter is not { } character) + { + return; + } + + try + { + // The guild status of the player isn't known yet, so the membership is loaded from the database. + using var guildContext = player.GameContext.PersistenceContextProvider.CreateNewGuildContext(); + if (await guildContext.GetGuildMembershipAsync(character.Name).ConfigureAwait(false) is not { } membership) + { + return; + } + + using var context = player.GameContext.PersistenceContextProvider.CreateNewPlayerContext(player.GameContext.Configuration); + var bindings = await context.GetAsync().ConfigureAwait(false); + var allianceId = membership.AllianceMasterGuildId ?? membership.GuildId; + foreach (var binding in bindings) + { + if (binding is { Scope: GuildChatScope.Guild } && binding.GuildId == membership.GuildId) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.GuildChatMirrored)).ConfigureAwait(false); + } + else if (binding is { Scope: GuildChatScope.Alliance } && binding.GuildId == allianceId) + { + await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.AllianceChatMirrored)).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + player.Logger.LogWarning(ex, "The bindings of the guild chat couldn't be checked."); + } + } +} diff --git a/src/GameLogic/PlugIns/IChatMessageReceivedPlugIn.cs b/src/GameLogic/PlugIns/IChatMessageReceivedPlugIn.cs index 089ce1503f..05a052e2c8 100644 --- a/src/GameLogic/PlugIns/IChatMessageReceivedPlugIn.cs +++ b/src/GameLogic/PlugIns/IChatMessageReceivedPlugIn.cs @@ -12,6 +12,10 @@ namespace MUnique.OpenMU.GameLogic.PlugIns; /// A plugin which is called when a chat message got received. /// It allows to cancel the forwarding of the message to the receivers. /// +/// +/// Messages which arrive from outside of the game, e.g. from Discord, have no sending player; +/// they go through instead. +/// [Guid("026BF30C-6602-444E-862C-FAE19308E5EA")] [PlugInPoint("Chat message received", "Plugins which will be executed when a chat message arrives.")] public interface IChatMessageReceivedPlugIn diff --git a/src/GameLogic/PlugIns/IExternalChatMessageReceivedPlugIn.cs b/src/GameLogic/PlugIns/IExternalChatMessageReceivedPlugIn.cs new file mode 100644 index 0000000000..f53408d11d --- /dev/null +++ b/src/GameLogic/PlugIns/IExternalChatMessageReceivedPlugIn.cs @@ -0,0 +1,32 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.ComponentModel; +using System.Runtime.InteropServices; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin interface which is called when a chat message arrives from outside of the game, e.g. from Discord, +/// before it's sent into the game. It's the counterpart of , for senders +/// which aren't players, so that e.g. a filter can cover all chats. +/// +[Guid("CC5DC7AF-9896-40DB-8BBA-1FAA935521AB")] +[PlugInPoint("External chat message received", "Plugins which will be executed when a chat message arrives from outside of the game, e.g. from Discord.")] +public interface IExternalChatMessageReceivedPlugIn +{ + /// + /// Is called when a chat message arrives from outside of the game. + /// + /// The name of the sender, e.g. the name of the character of a linked Discord user. + /// The message. + /// The chat of the game into which the message is sent. + /// + /// The instance containing the event data. + /// It allows to cancel the forwarding of the message into the game. + /// + void ExternalChatMessageReceived(string senderName, string message, GameChatChannel channel, CancelEventArgs cancelEventArgs); +} diff --git a/src/GameLogic/Properties/PlayerMessage.Designer.cs b/src/GameLogic/Properties/PlayerMessage.Designer.cs index 0d8bd6be44..b3473a6d36 100644 --- a/src/GameLogic/Properties/PlayerMessage.Designer.cs +++ b/src/GameLogic/Properties/PlayerMessage.Designer.cs @@ -114,6 +114,15 @@ public static string AddingMultiplePointsWhileMiniGameNotAllowed { } } + /// + /// Looks up a localized string similar to The chat of your alliance is mirrored to Discord. Members who linked their Discord user can read and write it there.. + /// + public static string AllianceChatMirrored { + get { + return ResourceManager.GetString("AllianceChatMirrored", resourceCulture); + } + } + /// /// Looks up a localized string similar to You are already in a duel.. /// @@ -715,6 +724,15 @@ public static string GoblinPointsForPlayTime { } } + /// + /// Looks up a localized string similar to The chat of your guild is mirrored to Discord. Members who linked their Discord user can read and write it there.. + /// + public static string GuildChatMirrored { + get { + return ResourceManager.GetString("GuildChatMirrored", resourceCulture); + } + } + /// /// Looks up a localized string similar to You need at least level {0} to enter the Fortress of Imperial Guardian.. /// @@ -2776,6 +2794,15 @@ public static string UsingThisItemNotImplemented { } } + /// + /// Looks up a localized string similar to [World] {0}: {1}. + /// + public static string WorldChatMessage { + get { + return ResourceManager.GetString("WorldChatMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Wrong Security Code.. /// diff --git a/src/GameLogic/Properties/PlayerMessage.de.resx b/src/GameLogic/Properties/PlayerMessage.de.resx index 10b4a9f5c3..1416822272 100644 --- a/src/GameLogic/Properties/PlayerMessage.de.resx +++ b/src/GameLogic/Properties/PlayerMessage.de.resx @@ -1000,4 +1000,13 @@ Inventarerweiterungen: {18} Dein Konto ist nicht mehr mit Discord verknüpft. + + [Welt] {0}: {1} + + + Der Chat deiner Gilde wird nach Discord gespiegelt. Mitglieder, die ihren Discord-Benutzer verknüpft haben, können ihn dort lesen und schreiben. + + + Der Chat deiner Allianz wird nach Discord gespiegelt. Mitglieder, die ihren Discord-Benutzer verknüpft haben, können ihn dort lesen und schreiben. + diff --git a/src/GameLogic/Properties/PlayerMessage.resx b/src/GameLogic/Properties/PlayerMessage.resx index 35d42ec97e..2b12e6d7cf 100644 --- a/src/GameLogic/Properties/PlayerMessage.resx +++ b/src/GameLogic/Properties/PlayerMessage.resx @@ -1111,4 +1111,13 @@ Inventory Extensions: {18} Your account isn't linked to Discord anymore. + + [World] {0}: {1} + + + The chat of your guild is mirrored to Discord. Members who linked their Discord user can read and write it there. + + + The chat of your alliance is mirrored to Discord. Members who linked their Discord user can read and write it there. + diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 1a050ec62a..9f5052229e 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -951,6 +951,15 @@ public static string GameEventPublisherConfiguration_PublishCastleSiegeEvents_Na } } + /// + /// Looks up a localized string similar to Publish guild, alliance and world chat. + /// + public static string GameEventPublisherConfiguration_PublishChatMessages_Name { + get { + return ResourceManager.GetString("GameEventPublisherConfiguration_PublishChatMessages_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Publish excellent item drops. /// @@ -1302,6 +1311,24 @@ public static string GuildBattleSoccerChatCommandPlugIn_Name { } } + /// + /// Looks up a localized string similar to Tells players when they enter the game, if the chat of their guild or alliance is mirrored to Discord.. + /// + public static string GuildChatBridgeNoticePlugIn_Description { + get { + return ResourceManager.GetString("GuildChatBridgeNoticePlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Notice about the chat bridge. + /// + public static string GuildChatBridgeNoticePlugIn_Name { + get { + return ResourceManager.GetString("GuildChatBridgeNoticePlugIn_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to Handles the chat command '/guilddisconnect <guild>'. Disconnect the guild members.. /// @@ -4246,6 +4273,24 @@ public static string WeatherUpdatePlugIn_Name { return ResourceManager.GetString("WeatherUpdatePlugIn_Name", resourceCulture); } } + + /// + /// Looks up a localized string similar to Sends a message to the world chat, which all players of all game servers can read, e.g. '/world Hello!'. It can be bridged to Discord.. + /// + public static string WorldChatChatCommandPlugIn_Description { + get { + return ResourceManager.GetString("WorldChatChatCommandPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to World chat command. + /// + public static string WorldChatChatCommandPlugIn_Name { + get { + return ResourceManager.GetString("WorldChatChatCommandPlugIn_Name", resourceCulture); + } + } /// /// Looks up a localized string similar to Consumes Life Stones after they are successfully placed during Castle Siege. diff --git a/src/GameLogic/Properties/PlugInResources.de.resx b/src/GameLogic/Properties/PlugInResources.de.resx index 26044dacf3..0c1f73120c 100644 --- a/src/GameLogic/Properties/PlugInResources.de.resx +++ b/src/GameLogic/Properties/PlugInResources.de.resx @@ -1881,4 +1881,19 @@ Verknüpft das Konto mit einem Discord-Benutzer: '/discord link' erstellt einen Code, der in Discord eingegeben wird, '/discord unlink' entfernt die Verknüpfung. + + Befehl Weltchat + + + Sendet eine Nachricht in den Weltchat, den alle Spieler aller Spielserver lesen, z.B. '/world Hallo!'. Er kann mit Discord verbunden werden. + + + Gilden-, Allianz- und Weltchat veröffentlichen + + + Hinweis auf die Chat-Brücke + + + Teilt Spielern beim Betreten des Spiels mit, ob der Chat ihrer Gilde oder Allianz nach Discord gespiegelt wird. + diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index 86cc70f435..bcfdad26a5 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1688,4 +1688,19 @@ Links the account to a Discord user: '/discord link' creates a code which is entered in Discord, '/discord unlink' removes the link. + + World chat command + + + Sends a message to the world chat, which all players of all game servers can read, e.g. '/world Hello!'. It can be bridged to Discord. + + + Publish guild, alliance and world chat + + + Notice about the chat bridge + + + Tells players when they enter the game, if the chat of their guild or alliance is mirrored to Discord. + diff --git a/src/GameLogic/Views/ChatMessageType.cs b/src/GameLogic/Views/ChatMessageType.cs index c957786861..51921285ee 100644 --- a/src/GameLogic/Views/ChatMessageType.cs +++ b/src/GameLogic/Views/ChatMessageType.cs @@ -48,4 +48,9 @@ public enum ChatMessageType /// A command message. /// Command, + + /// + /// A message of the world chat, which all players of all game servers can read. + /// + World, } \ No newline at end of file diff --git a/src/GameServer/GameServer.cs b/src/GameServer/GameServer.cs index 28042260c8..4f0e26e2f7 100644 --- a/src/GameServer/GameServer.cs +++ b/src/GameServer/GameServer.cs @@ -245,6 +245,12 @@ public async ValueTask AllianceChatMessageAsync(uint guildId, string sender, str await this._gameContext.ForEachAlliancePlayerAsync(guildId, player => player.InvokeViewPlugInAsync(p => p.ChatMessageAsync(messageSend, sender, ChatMessageType.Alliance)).AsTask()).ConfigureAwait(false); } + /// + public async ValueTask WorldChatMessageAsync(string sender, string message) + { + await this._gameContext.ForEachPlayerAsync(player => player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.WorldChatMessage), sender, message).AsTask()).ConfigureAwait(false); + } + /// public async ValueTask LetterReceivedAsync(LetterHeader letter) { diff --git a/src/Interfaces/ChatMessageEvent.cs b/src/Interfaces/ChatMessageEvent.cs new file mode 100644 index 0000000000..b1381d2f27 --- /dev/null +++ b/src/Interfaces/ChatMessageEvent.cs @@ -0,0 +1,17 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Interfaces; + +/// +/// A player sent a message to the guild, alliance or world chat. +/// +/// The identifier of the game server. +/// The timestamp, in UTC. +/// The chat. +/// The (non-persistent) identifier of the guild of the sender, for the guild and alliance chat. +/// The name of the character which sent the message. +/// The message, without the prefix of the chat. +public sealed record ChatMessageEvent(byte ServerId, DateTime TimestampUtc, GameChatChannel Channel, uint GuildId, string Sender, string Message) + : GameEvent(ServerId, TimestampUtc); diff --git a/src/Interfaces/GameChatChannel.cs b/src/Interfaces/GameChatChannel.cs new file mode 100644 index 0000000000..6e7e933dc0 --- /dev/null +++ b/src/Interfaces/GameChatChannel.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Interfaces; + +/// +/// The chats of the game which reach more than the players nearby. +/// +public enum GameChatChannel +{ + /// + /// The chat of a guild. + /// + Guild, + + /// + /// The chat of an alliance of guilds. + /// + Alliance, + + /// + /// The world chat, which all players of all game servers can read. + /// + World, +} diff --git a/src/Interfaces/GameEvent.cs b/src/Interfaces/GameEvent.cs index 3bfbf6333d..92b78e195a 100644 --- a/src/Interfaces/GameEvent.cs +++ b/src/Interfaces/GameEvent.cs @@ -29,4 +29,5 @@ namespace MUnique.OpenMU.Interfaces; [JsonDerivedType(typeof(BossKilledEvent), nameof(BossKilledEvent))] [JsonDerivedType(typeof(CharacterLevelMilestoneEvent), nameof(CharacterLevelMilestoneEvent))] [JsonDerivedType(typeof(AccountUnlinkedEvent), nameof(AccountUnlinkedEvent))] +[JsonDerivedType(typeof(ChatMessageEvent), nameof(ChatMessageEvent))] public abstract record GameEvent(byte ServerId, DateTime TimestampUtc); diff --git a/src/Interfaces/IEventPublisher.cs b/src/Interfaces/IEventPublisher.cs index ea2c13166c..bc5dd796ed 100644 --- a/src/Interfaces/IEventPublisher.cs +++ b/src/Interfaces/IEventPublisher.cs @@ -43,6 +43,13 @@ public interface IEventPublisher /// The message. ValueTask AllianceMessageAsync(uint guildId, string sender, string message); + /// + /// Notifies the game servers that a message was sent to the world chat, which all players of all game servers can read. + /// + /// The sender. + /// The message. + ValueTask WorldChatMessageAsync(string sender, string message); + /// /// Notifies that a client tried to log into an already logged-in account. /// The connected player can be notified about that. diff --git a/src/Interfaces/IGameServer.cs b/src/Interfaces/IGameServer.cs index f442296cd2..0015e7508c 100644 --- a/src/Interfaces/IGameServer.cs +++ b/src/Interfaces/IGameServer.cs @@ -94,6 +94,13 @@ public interface IGameServer : IManageableServer, IFriendSystemSubscriber /// The message. ValueTask AllianceChatMessageAsync(uint guildId, string sender, string message); + /// + /// Sends a message of the world chat to all connected players. + /// + /// The sender, e.g. the character name. + /// The message. + ValueTask WorldChatMessageAsync(string sender, string message); + /// /// Sends a global message to all connected players with the specified message type. /// diff --git a/src/Persistence/BasicModel/GuildChatBinding.Generated.cs b/src/Persistence/BasicModel/GuildChatBinding.Generated.cs new file mode 100644 index 0000000000..094662b155 --- /dev/null +++ b/src/Persistence/BasicModel/GuildChatBinding.Generated.cs @@ -0,0 +1,46 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +//------------------------------------------------------------------------------ +// +// This source code was auto-generated by a roslyn code generator. +// +//------------------------------------------------------------------------------ + +// ReSharper disable All + +namespace MUnique.OpenMU.Persistence.BasicModel; + +using MUnique.OpenMU.Persistence.Json; + +/// +/// A plain implementation of . +/// +public partial class GuildChatBinding : MUnique.OpenMU.DataModel.Entities.GuildChatBinding, IIdentifiable, IConvertibleTo +{ + + + + + /// + public override bool Equals(object obj) + { + var baseObject = obj as IIdentifiable; + if (baseObject != null) + { + return baseObject.Id == this.Id; + } + + return base.Equals(obj); + } + + /// + public override int GetHashCode() + { + return this.Id.GetHashCode(); + } + + /// + public GuildChatBinding Convert() => this; +} diff --git a/src/Persistence/EntityFramework/CharacterAccountRow.cs b/src/Persistence/EntityFramework/CharacterAccountRow.cs new file mode 100644 index 0000000000..21526a7900 --- /dev/null +++ b/src/Persistence/EntityFramework/CharacterAccountRow.cs @@ -0,0 +1,12 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework; + +/// +/// A character name with the identifier of its account. +/// +/// The name of the character. +/// The identifier of the account. +internal sealed record CharacterAccountRow(string Name, Guid AccountId); diff --git a/src/Persistence/EntityFramework/CompiledModels/AccountContext/AccountContextModelBuilder.cs b/src/Persistence/EntityFramework/CompiledModels/AccountContext/AccountContextModelBuilder.cs index 7c92bfb748..d63116b099 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("e53209e9-b4b9-474d-9c80-88038379d7dc"), entityTypeCount: 47) + : base(skipDetectChanges: false, modelId: new Guid("0f82ffdc-4633-4fdb-b68c-5f1a7436f138"), entityTypeCount: 48) { } @@ -41,6 +41,7 @@ partial void Initialize() var gameServerConfigurationGameMapDefinition = GameServerConfigurationGameMapDefinitionEntityType.Create(this); var gensAbuse = GensAbuseEntityType.Create(this); var gensMember = GensMemberEntityType.Create(this); + var guildChatBinding = GuildChatBindingEntityType.Create(this); var item = ItemEntityType.Create(this); var itemAppearance = ItemAppearanceEntityType.Create(this); var itemAppearanceItemOptionType = ItemAppearanceItemOptionTypeEntityType.Create(this); @@ -120,6 +121,7 @@ partial void Initialize() GameServerConfigurationGameMapDefinitionEntityType.CreateAnnotations(gameServerConfigurationGameMapDefinition); GensAbuseEntityType.CreateAnnotations(gensAbuse); GensMemberEntityType.CreateAnnotations(gensMember); + GuildChatBindingEntityType.CreateAnnotations(guildChatBinding); ItemEntityType.CreateAnnotations(item); ItemAppearanceEntityType.CreateAnnotations(itemAppearance); ItemAppearanceItemOptionTypeEntityType.CreateAnnotations(itemAppearanceItemOptionType); diff --git a/src/Persistence/EntityFramework/CompiledModels/AccountContext/GuildChatBindingEntityType.cs b/src/Persistence/EntityFramework/CompiledModels/AccountContext/GuildChatBindingEntityType.cs new file mode 100644 index 0000000000..5c0a966b8c --- /dev/null +++ b/src/Persistence/EntityFramework/CompiledModels/AccountContext/GuildChatBindingEntityType.cs @@ -0,0 +1,129 @@ +// +using System; +using System.Reflection; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Persistence.EntityFramework.Model; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.CompiledModels.ForAccountContext +{ + [EntityFrameworkInternal] + public partial class GuildChatBindingEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "MUnique.OpenMU.Persistence.EntityFramework.Model.GuildChatBinding", + typeof(MUnique.OpenMU.Persistence.EntityFramework.Model.GuildChatBinding), + baseEntityType, + propertyCount: 9, + unnamedIndexCount: 2, + keyCount: 1); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(Guid), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + valueGenerated: ValueGenerated.OnAdd, + afterSaveBehavior: PropertySaveBehavior.Throw, + valueGeneratorFactory: new MUnique.OpenMU.Persistence.EntityFramework.GuidV7ValueGeneratorFactory().Create, + sentinel: new Guid("00000000-0000-0000-0000-000000000000")); + id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var boundAt = runtimeEntityType.AddProperty( + "BoundAt", + typeof(DateTime), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("BoundAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + boundAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var boundBy = runtimeEntityType.AddProperty( + "BoundBy", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("BoundBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + boundBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var externalChannelId = runtimeEntityType.AddProperty( + "ExternalChannelId", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("ExternalChannelId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + externalChannelId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var externalServerId = runtimeEntityType.AddProperty( + "ExternalServerId", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("ExternalServerId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + externalServerId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var guildId = runtimeEntityType.AddProperty( + "GuildId", + typeof(Guid), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("GuildId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new Guid("00000000-0000-0000-0000-000000000000")); + guildId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var isHosted = runtimeEntityType.AddProperty( + "IsHosted", + typeof(bool), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("IsHosted", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: false); + isHosted.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var provider = runtimeEntityType.AddProperty( + "Provider", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("Provider", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + provider.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var scope = runtimeEntityType.AddProperty( + "Scope", + typeof(MUnique.OpenMU.DataModel.Entities.GuildChatScope), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("Scope", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + scope.SetSentinelFromProviderValue(0); + scope.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + + var index = runtimeEntityType.AddIndex( + new[] { provider, externalChannelId }, + unique: true); + + var index0 = runtimeEntityType.AddIndex( + new[] { guildId, scope, provider }, + unique: true); + + return runtimeEntityType; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", "data"); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "GuildChatBinding"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/EntityDataContextModelBuilder.cs b/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/EntityDataContextModelBuilder.cs index 4c907747be..159f190093 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("b9d48a9a-c807-40a2-9b41-a5bfa0fe2167"), entityTypeCount: 122) + : base(skipDetectChanges: false, modelId: new Guid("e2795425-f3fb-4ca3-af47-e5d48685e5dc"), entityTypeCount: 123) { } @@ -74,6 +74,7 @@ partial void Initialize() var gensAbuse = GensAbuseEntityType.Create(this); var gensMember = GensMemberEntityType.Create(this); var guild = GuildEntityType.Create(this); + var guildChatBinding = GuildChatBindingEntityType.Create(this); var guildMember = GuildMemberEntityType.Create(this); var increasableItemOption = IncreasableItemOptionEntityType.Create(this); var item = ItemEntityType.Create(this); @@ -245,6 +246,7 @@ partial void Initialize() GensMemberEntityType.CreateForeignKey1(gensMember, character); GuildEntityType.CreateForeignKey1(guild, guild); GuildEntityType.CreateForeignKey2(guild, guild); + GuildChatBindingEntityType.CreateForeignKey1(guildChatBinding, guild); GuildMemberEntityType.CreateForeignKey1(guildMember, guild); GuildMemberEntityType.CreateForeignKey2(guildMember, character); IncreasableItemOptionEntityType.CreateForeignKey1(increasableItemOption, itemOptionDefinition); @@ -438,6 +440,7 @@ partial void Initialize() GensAbuseEntityType.CreateAnnotations(gensAbuse); GensMemberEntityType.CreateAnnotations(gensMember); GuildEntityType.CreateAnnotations(guild); + GuildChatBindingEntityType.CreateAnnotations(guildChatBinding); GuildMemberEntityType.CreateAnnotations(guildMember); IncreasableItemOptionEntityType.CreateAnnotations(increasableItemOption); ItemEntityType.CreateAnnotations(item); diff --git a/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/GuildChatBindingEntityType.cs b/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/GuildChatBindingEntityType.cs new file mode 100644 index 0000000000..8f49912c4e --- /dev/null +++ b/src/Persistence/EntityFramework/CompiledModels/EntityDataContext/GuildChatBindingEntityType.cs @@ -0,0 +1,142 @@ +// +using System; +using System.Reflection; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Persistence.EntityFramework.Model; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.CompiledModels.ForEntityDataContext +{ + [EntityFrameworkInternal] + public partial class GuildChatBindingEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "MUnique.OpenMU.Persistence.EntityFramework.Model.GuildChatBinding", + typeof(MUnique.OpenMU.Persistence.EntityFramework.Model.GuildChatBinding), + baseEntityType, + propertyCount: 9, + foreignKeyCount: 1, + unnamedIndexCount: 2, + keyCount: 1); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(Guid), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + valueGenerated: ValueGenerated.OnAdd, + afterSaveBehavior: PropertySaveBehavior.Throw, + valueGeneratorFactory: new MUnique.OpenMU.Persistence.EntityFramework.GuidV7ValueGeneratorFactory().Create, + sentinel: new Guid("00000000-0000-0000-0000-000000000000")); + id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var boundAt = runtimeEntityType.AddProperty( + "BoundAt", + typeof(DateTime), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("BoundAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + boundAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var boundBy = runtimeEntityType.AddProperty( + "BoundBy", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("BoundBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + boundBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var externalChannelId = runtimeEntityType.AddProperty( + "ExternalChannelId", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("ExternalChannelId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + externalChannelId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var externalServerId = runtimeEntityType.AddProperty( + "ExternalServerId", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("ExternalServerId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + externalServerId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var guildId = runtimeEntityType.AddProperty( + "GuildId", + typeof(Guid), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("GuildId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new Guid("00000000-0000-0000-0000-000000000000")); + guildId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var isHosted = runtimeEntityType.AddProperty( + "IsHosted", + typeof(bool), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("IsHosted", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: false); + isHosted.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var provider = runtimeEntityType.AddProperty( + "Provider", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("Provider", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + provider.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var scope = runtimeEntityType.AddProperty( + "Scope", + typeof(MUnique.OpenMU.DataModel.Entities.GuildChatScope), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("Scope", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + scope.SetSentinelFromProviderValue(0); + scope.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + + var index = runtimeEntityType.AddIndex( + new[] { provider, externalChannelId }, + unique: true); + + var index0 = runtimeEntityType.AddIndex( + new[] { guildId, scope, provider }, + unique: true); + + return runtimeEntityType; + } + + public static RuntimeForeignKey CreateForeignKey1(RuntimeEntityType declaringEntityType, RuntimeEntityType principalEntityType) + { + var runtimeForeignKey = declaringEntityType.AddForeignKey(new[] { declaringEntityType.FindProperty("GuildId") }, + principalEntityType.FindKey(new[] { principalEntityType.FindProperty("Id") }), + principalEntityType, + deleteBehavior: DeleteBehavior.Cascade, + required: true); + + return runtimeForeignKey; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", "data"); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "GuildChatBinding"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Persistence/EntityFramework/CompiledModels/TradeContext/GuildChatBindingEntityType.cs b/src/Persistence/EntityFramework/CompiledModels/TradeContext/GuildChatBindingEntityType.cs new file mode 100644 index 0000000000..a0fe958500 --- /dev/null +++ b/src/Persistence/EntityFramework/CompiledModels/TradeContext/GuildChatBindingEntityType.cs @@ -0,0 +1,129 @@ +// +using System; +using System.Reflection; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Persistence.EntityFramework.Model; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#pragma warning disable 219, 612, 618 +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.CompiledModels.ForTradeContext +{ + [EntityFrameworkInternal] + public partial class GuildChatBindingEntityType + { + public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null) + { + var runtimeEntityType = model.AddEntityType( + "MUnique.OpenMU.Persistence.EntityFramework.Model.GuildChatBinding", + typeof(MUnique.OpenMU.Persistence.EntityFramework.Model.GuildChatBinding), + baseEntityType, + propertyCount: 9, + unnamedIndexCount: 2, + keyCount: 1); + + var id = runtimeEntityType.AddProperty( + "Id", + typeof(Guid), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + valueGenerated: ValueGenerated.OnAdd, + afterSaveBehavior: PropertySaveBehavior.Throw, + valueGeneratorFactory: new MUnique.OpenMU.Persistence.EntityFramework.GuidV7ValueGeneratorFactory().Create, + sentinel: new Guid("00000000-0000-0000-0000-000000000000")); + id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var boundAt = runtimeEntityType.AddProperty( + "BoundAt", + typeof(DateTime), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("BoundAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + boundAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var boundBy = runtimeEntityType.AddProperty( + "BoundBy", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("BoundBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + nullable: true); + boundBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var externalChannelId = runtimeEntityType.AddProperty( + "ExternalChannelId", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("ExternalChannelId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + externalChannelId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var externalServerId = runtimeEntityType.AddProperty( + "ExternalServerId", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("ExternalServerId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + externalServerId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var guildId = runtimeEntityType.AddProperty( + "GuildId", + typeof(Guid), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("GuildId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: new Guid("00000000-0000-0000-0000-000000000000")); + guildId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var isHosted = runtimeEntityType.AddProperty( + "IsHosted", + typeof(bool), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("IsHosted", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly), + sentinel: false); + isHosted.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var provider = runtimeEntityType.AddProperty( + "Provider", + typeof(string), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("Provider", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + provider.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var scope = runtimeEntityType.AddProperty( + "Scope", + typeof(MUnique.OpenMU.DataModel.Entities.GuildChatScope), + propertyInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetProperty("Scope", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly), + fieldInfo: typeof(MUnique.OpenMU.DataModel.Entities.GuildChatBinding).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); + scope.SetSentinelFromProviderValue(0); + scope.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None); + + var key = runtimeEntityType.AddKey( + new[] { id }); + runtimeEntityType.SetPrimaryKey(key); + + var index = runtimeEntityType.AddIndex( + new[] { provider, externalChannelId }, + unique: true); + + var index0 = runtimeEntityType.AddIndex( + new[] { guildId, scope, provider }, + unique: true); + + return runtimeEntityType; + } + + public static void CreateAnnotations(RuntimeEntityType runtimeEntityType) + { + runtimeEntityType.AddAnnotation("Relational:FunctionName", null); + runtimeEntityType.AddAnnotation("Relational:Schema", "data"); + runtimeEntityType.AddAnnotation("Relational:SqlQuery", null); + runtimeEntityType.AddAnnotation("Relational:TableName", "GuildChatBinding"); + runtimeEntityType.AddAnnotation("Relational:ViewName", null); + runtimeEntityType.AddAnnotation("Relational:ViewSchema", null); + + Customize(runtimeEntityType); + } + + static partial void Customize(RuntimeEntityType runtimeEntityType); + } +} diff --git a/src/Persistence/EntityFramework/CompiledModels/TradeContext/TradeContextModelBuilder.cs b/src/Persistence/EntityFramework/CompiledModels/TradeContext/TradeContextModelBuilder.cs index abe608af64..cb65bdcacc 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("474248ae-e21e-4a0b-88b4-317ac8d5ca08"), entityTypeCount: 46) + : base(skipDetectChanges: false, modelId: new Guid("39bbdc13-ccd0-4c76-8fea-9289097beff5"), entityTypeCount: 47) { } @@ -41,6 +41,7 @@ partial void Initialize() var gameServerConfigurationGameMapDefinition = GameServerConfigurationGameMapDefinitionEntityType.Create(this); var gensAbuse = GensAbuseEntityType.Create(this); var gensMember = GensMemberEntityType.Create(this); + var guildChatBinding = GuildChatBindingEntityType.Create(this); var item = ItemEntityType.Create(this); var itemAppearance = ItemAppearanceEntityType.Create(this); var itemAppearanceItemOptionType = ItemAppearanceItemOptionTypeEntityType.Create(this); @@ -116,6 +117,7 @@ partial void Initialize() GameServerConfigurationGameMapDefinitionEntityType.CreateAnnotations(gameServerConfigurationGameMapDefinition); GensAbuseEntityType.CreateAnnotations(gensAbuse); GensMemberEntityType.CreateAnnotations(gensMember); + GuildChatBindingEntityType.CreateAnnotations(guildChatBinding); ItemEntityType.CreateAnnotations(item); ItemAppearanceEntityType.CreateAnnotations(itemAppearance); ItemAppearanceItemOptionTypeEntityType.CreateAnnotations(itemAppearanceItemOptionType); diff --git a/src/Persistence/EntityFramework/EntityDataContext.cs b/src/Persistence/EntityFramework/EntityDataContext.cs index 7690b7cbc5..448e43b7d4 100644 --- a/src/Persistence/EntityFramework/EntityDataContext.cs +++ b/src/Persistence/EntityFramework/EntityDataContext.cs @@ -80,6 +80,11 @@ public class EntityDataContext : ExtendedTypeContext /// internal DbSet AccountExternalLinks => this.Set(); + /// + /// Gets the bindings of the chats of guilds to channels of external services. + /// + internal DbSet GuildChatBindings => this.Set(); + /// /// Gets the gens memberships of the characters. /// @@ -137,6 +142,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().Apply(); modelBuilder.Entity().Apply(); modelBuilder.Entity().Apply(); + modelBuilder.Entity().Apply(); modelBuilder.Entity().Apply(); modelBuilder.Entity().Apply(); modelBuilder.Entity().Apply(); diff --git a/src/Persistence/EntityFramework/Extensions/ModelBuilder/GuildChatBindingExtensions.cs b/src/Persistence/EntityFramework/Extensions/ModelBuilder/GuildChatBindingExtensions.cs new file mode 100644 index 0000000000..a5f32539e6 --- /dev/null +++ b/src/Persistence/EntityFramework/Extensions/ModelBuilder/GuildChatBindingExtensions.cs @@ -0,0 +1,32 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MUnique.OpenMU.Persistence.EntityFramework.Model; + +/// +/// Extensions for the of the . +/// +internal static class GuildChatBindingExtensions +{ + /// + /// Applies the settings for the entity. + /// + /// The builder. + public static void Apply(this EntityTypeBuilder builder) + { + builder.Property(binding => binding.Provider).IsRequired(); + builder.Property(binding => binding.ExternalServerId).IsRequired(); + builder.Property(binding => binding.ExternalChannelId).IsRequired(); + builder.HasIndex(binding => new { binding.GuildId, binding.Scope, binding.Provider }).IsUnique(); + builder.HasIndex(binding => new { binding.Provider, binding.ExternalChannelId }).IsUnique(); + builder.HasOne() + .WithMany() + .HasForeignKey(binding => binding.GuildId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Persistence/EntityFramework/GuildServerContext.cs b/src/Persistence/EntityFramework/GuildServerContext.cs index 2e2aae3590..83df4b49eb 100644 --- a/src/Persistence/EntityFramework/GuildServerContext.cs +++ b/src/Persistence/EntityFramework/GuildServerContext.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -61,6 +61,27 @@ join character in this.Context.Set() on member.Id equals characte .ToDictionaryAsync(member => member.Id, member => member.Name).ConfigureAwait(false); } + /// + public async ValueTask GetGuildMembershipAsync(string characterName) + { + return await CreateGuildMembershipQuery(this.Context, characterName).FirstOrDefaultAsync().ConfigureAwait(false); + } + + /// + /// Creates the query for the guild membership of a character. + /// + /// The context. + /// The name of the character. + /// The query. + internal static IQueryable CreateGuildMembershipQuery(DbContext context, string characterName) + { + return from member in context.Set() + join character in context.Set() on member.Id equals character.Id + join guild in context.Set() on member.GuildId equals guild.Id + where character.Name == characterName + select new GuildMembership(member.Id, member.GuildId, member.Status, guild.AllianceGuildId); + } + /// public async ValueTask> GetAlliancesAsync(Guid allianceMasterId) { diff --git a/src/Persistence/EntityFramework/Migrations/20261018102915_AddGuildChatBinding.Designer.cs b/src/Persistence/EntityFramework/Migrations/20261018102915_AddGuildChatBinding.Designer.cs new file mode 100644 index 0000000000..1a0f54d9a8 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20261018102915_AddGuildChatBinding.Designer.cs @@ -0,0 +1,6483 @@ +// +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("20261018102915_AddGuildChatBinding")] + partial class AddGuildChatBinding + { + /// + 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("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("MinimumItemLevel") + .HasColumnType("smallint"); + + 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.CrywolfData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsOccupied") + .HasColumnType("boolean"); + + b.Property("IsWarRunning") + .HasColumnType("boolean"); + + b.Property("LastBattleEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("WarStart") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("CrywolfData", "data"); + }); + + 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.GameMapTerrainVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("TerrainData") + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("GameMapDefinitionId"); + + b.ToTable("GameMapTerrainVariant", "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.Property("RewardClaimedAt") + .HasColumnType("timestamp with time zone"); + + 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.Property("StorePriceCoinType") + .HasColumnType("integer"); + + b.Property("StorePriceItemDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId"); + + b.HasIndex("ItemStorageId"); + + b.HasIndex("StorePriceItemDefinitionId"); + + 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.GameMapTerrainVariant", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", null) + .WithMany("RawTerrainVariants") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + }); + + 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.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawStorePriceItemDefinition") + .WithMany() + .HasForeignKey("StorePriceItemDefinitionId"); + + b.Navigation("RawDefinition"); + + b.Navigation("RawItemStorage"); + + b.Navigation("RawStorePriceItemDefinition"); + }); + + 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"); + + b.Navigation("RawTerrainVariants"); + }); + + 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/20261018102915_AddGuildChatBinding.cs b/src/Persistence/EntityFramework/Migrations/20261018102915_AddGuildChatBinding.cs new file mode 100644 index 0000000000..c1f6d4fc55 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20261018102915_AddGuildChatBinding.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations +{ + /// + public partial class AddGuildChatBinding : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "GuildChatBinding", + schema: "data", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + GuildId = table.Column(type: "uuid", nullable: false), + Scope = table.Column(type: "integer", nullable: false), + Provider = table.Column(type: "text", nullable: false), + ExternalServerId = table.Column(type: "text", nullable: false), + ExternalChannelId = table.Column(type: "text", nullable: false), + IsHosted = table.Column(type: "boolean", nullable: false), + BoundBy = table.Column(type: "text", nullable: true), + BoundAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GuildChatBinding", x => x.Id); + table.ForeignKey( + name: "FK_GuildChatBinding_Guild_GuildId", + column: x => x.GuildId, + principalSchema: "guild", + principalTable: "Guild", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_GuildChatBinding_GuildId_Scope_Provider", + schema: "data", + table: "GuildChatBinding", + columns: new[] { "GuildId", "Scope", "Provider" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GuildChatBinding_Provider_ExternalChannelId", + schema: "data", + table: "GuildChatBinding", + columns: new[] { "Provider", "ExternalChannelId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "GuildChatBinding", + schema: "data"); + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs b/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs index 80d4fe0971..3e83c6b242 100644 --- a/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs +++ b/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs @@ -2110,6 +2110,50 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -5082,6 +5126,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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) diff --git a/src/Persistence/EntityFramework/Model/BasicModelConverter.Generated.cs b/src/Persistence/EntityFramework/Model/BasicModelConverter.Generated.cs index 483bbb6480..a1be9309cd 100644 --- a/src/Persistence/EntityFramework/Model/BasicModelConverter.Generated.cs +++ b/src/Persistence/EntityFramework/Model/BasicModelConverter.Generated.cs @@ -96,6 +96,7 @@ public static TBasic Convert(object source) Gate value => this.Convert(value), GensAbuse value => this.Convert(value), GensMember value => this.Convert(value), + GuildChatBinding value => this.Convert(value), GuildMember value => this.Convert(value), Item value => this.Convert(value), ItemAppearance value => this.Convert(value), @@ -1663,6 +1664,27 @@ private BasicModel.Guild Convert(Guild source) return target; } + private BasicModel.GuildChatBinding Convert(GuildChatBinding source) + { + if (this._converted.TryGetValue(source, out var converted)) + { + return (BasicModel.GuildChatBinding)converted; + } + + var target = new BasicModel.GuildChatBinding(); + this._converted.Add(source, target); + target.Id = source.Id; + target.GuildId = source.GuildId; + target.Scope = source.Scope; + target.Provider = source.Provider; + target.ExternalServerId = source.ExternalServerId; + target.ExternalChannelId = source.ExternalChannelId; + target.IsHosted = source.IsHosted; + target.BoundBy = source.BoundBy; + target.BoundAt = source.BoundAt; + return target; + } + private BasicModel.GuildMember Convert(GuildMember source) { if (this._converted.TryGetValue(source, out var converted)) diff --git a/src/Persistence/EntityFramework/Model/ExtendedTypeContext.Generated.cs b/src/Persistence/EntityFramework/Model/ExtendedTypeContext.Generated.cs index 85b7b5ed1e..a0e2046a1b 100644 --- a/src/Persistence/EntityFramework/Model/ExtendedTypeContext.Generated.cs +++ b/src/Persistence/EntityFramework/Model/ExtendedTypeContext.Generated.cs @@ -41,6 +41,7 @@ protected override void OnModelCreating(Microsoft.EntityFrameworkCore.ModelBuild modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); + modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); diff --git a/src/Persistence/EntityFramework/Model/GuildChatBinding.Generated.cs b/src/Persistence/EntityFramework/Model/GuildChatBinding.Generated.cs new file mode 100644 index 0000000000..359f347c5f --- /dev/null +++ b/src/Persistence/EntityFramework/Model/GuildChatBinding.Generated.cs @@ -0,0 +1,47 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +//------------------------------------------------------------------------------ +// +// This source code was auto-generated by a roslyn code generator. +// +//------------------------------------------------------------------------------ + +// ReSharper disable All + +namespace MUnique.OpenMU.Persistence.EntityFramework.Model; + +using System.ComponentModel.DataAnnotations.Schema; +using MUnique.OpenMU.Persistence; + +/// +/// The Entity Framework Core implementation of . +/// +[Table(nameof(GuildChatBinding), Schema = SchemaNames.AccountData)] +internal partial class GuildChatBinding : MUnique.OpenMU.DataModel.Entities.GuildChatBinding, IIdentifiable +{ + + + + + /// + public override bool Equals(object obj) + { + var baseObject = obj as IIdentifiable; + if (baseObject != null) + { + return baseObject.Id == this.Id; + } + + return base.Equals(obj); + } + + /// + public override int GetHashCode() + { + return this.Id.GetHashCode(); + } + + +} diff --git a/src/Persistence/EntityFramework/PlayerContext.cs b/src/Persistence/EntityFramework/PlayerContext.cs index 42d7d3500d..5a1fe2e8e6 100644 --- a/src/Persistence/EntityFramework/PlayerContext.cs +++ b/src/Persistence/EntityFramework/PlayerContext.cs @@ -290,6 +290,22 @@ await links.Where(link => link.Provider == provider && link.ExternalUserId == ex } } + /// + public async ValueTask GetAccountChatBanUntilAsync( + Guid accountId, + 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.Id == accountId) + .Select(account => account.ChatBanUntil) + .FirstOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + } + } + /// public async ValueTask GetAccountExternalLinkAsync( Guid accountId, @@ -335,6 +351,51 @@ await links.Where(link => link.Provider == provider && link.ExternalUserId == ex } } + /// + public async ValueTask> GetAccountIdsByCharacterNamesAsync( + IReadOnlyCollection characterNames, + CancellationToken cancellationToken = default) + { + using var l = await this.LockAsync(cancellationToken).ConfigureAwait(false); + using (this.RepositoryProvider.ContextStack.UseContext(this)) + { + var rows = await CreateAccountIdsByCharacterNamesQuery(this.Context.Set(), characterNames) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + return rows.ToDictionary(row => row.Name, row => row.AccountId); + } + } + + /// + public async ValueTask> GetAccountExternalLinksAsync( + string provider, + IReadOnlyCollection accountIds, + CancellationToken cancellationToken = default) + { + using var l = await this.LockAsync(cancellationToken).ConfigureAwait(false); + using (this.RepositoryProvider.ContextStack.UseContext(this)) + { + return await this.Context.Set() + .Where(link => link.Provider == provider && accountIds.Contains(link.AccountId)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + } + + /// + /// Creates the query of the identifiers of the accounts of characters. + /// + /// The accounts. + /// The names of the characters. + /// The query. + internal static IQueryable CreateAccountIdsByCharacterNamesQuery(IQueryable accounts, IReadOnlyCollection characterNames) + { + return from account in accounts + from character in account.RawCharacters + where characterNames.Contains(character.Name) + select new CharacterAccountRow(character.Name, account.Id); + } + /// public async ValueTask> GetAccountExternalLinksAsync( Guid accountId, diff --git a/src/Persistence/GuildMembership.cs b/src/Persistence/GuildMembership.cs new file mode 100644 index 0000000000..214a96eeb8 --- /dev/null +++ b/src/Persistence/GuildMembership.cs @@ -0,0 +1,16 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence; + +using MUnique.OpenMU.Interfaces; + +/// +/// The membership of a character in a guild. +/// +/// The identifier of the character. +/// The identifier of the guild. +/// The position of the character in the guild. +/// The identifier of the master guild of the alliance of the guild, if it's a member of an alliance. +public sealed record GuildMembership(Guid CharacterId, Guid GuildId, GuildPosition Position, Guid? AllianceMasterGuildId); diff --git a/src/Persistence/IGuildServerContext.cs b/src/Persistence/IGuildServerContext.cs index f0b9f48c54..549cbd87e9 100644 --- a/src/Persistence/IGuildServerContext.cs +++ b/src/Persistence/IGuildServerContext.cs @@ -41,6 +41,13 @@ public interface IGuildServerContext : IContext /// Since names are stored in Character.Name and not duplicated. ValueTask> GetMemberNamesAsync(Guid guildId); + /// + /// Gets the guild membership of a character. + /// + /// The name of the character. + /// The membership; or null, if the character isn't a member of a guild. + ValueTask GetGuildMembershipAsync(string characterName); + /// /// Gets the alliances of a guild. /// diff --git a/src/Persistence/IPlayerContext.cs b/src/Persistence/IPlayerContext.cs index 0915d0d7fa..28aadc5be8 100644 --- a/src/Persistence/IPlayerContext.cs +++ b/src/Persistence/IPlayerContext.cs @@ -162,6 +162,16 @@ ValueTask> GetLatestCashShopCoinGrantsAsync( string reference, CancellationToken cancellationToken = default); + /// + /// Gets the time until which the chat of an account is banned, without loading the account. + /// + /// The persistent account identifier. + /// The cancellation token. + /// The time until which the chat is banned; or null, if it isn't banned. + ValueTask GetAccountChatBanUntilAsync( + Guid accountId, + CancellationToken cancellationToken = default); + /// /// Atomically consumes an unexpired link code and replaces the user's previous link. /// A rejected code must leave every existing link unchanged. @@ -217,6 +227,28 @@ ValueTask> GetLatestCashShopCoinGrantsAsync( string codeHash, CancellationToken cancellationToken = default); + /// + /// Gets the identifiers of the accounts of characters, without loading the accounts. + /// + /// The names of the characters. + /// The cancellation token. + /// The identifiers of the accounts by the names of the existing characters. + ValueTask> GetAccountIdsByCharacterNamesAsync( + IReadOnlyCollection characterNames, + CancellationToken cancellationToken = default); + + /// + /// Gets the links of accounts to users of an external service. + /// + /// The name of the external service, e.g. discord. + /// The persistent account identifiers. + /// The cancellation token. + /// The links. + ValueTask> GetAccountExternalLinksAsync( + string provider, + IReadOnlyCollection accountIds, + CancellationToken cancellationToken = default); + /// /// Gets the links of an account to users of external services. /// diff --git a/src/Persistence/InMemory/GuildServerInMemoryContext.cs b/src/Persistence/InMemory/GuildServerInMemoryContext.cs index d3a17b5fec..45aafcc364 100644 --- a/src/Persistence/InMemory/GuildServerInMemoryContext.cs +++ b/src/Persistence/InMemory/GuildServerInMemoryContext.cs @@ -56,6 +56,26 @@ public async ValueTask> GetMemberNamesAsync(Gu .ToDictionary(m => m.Id, m => m.Name); } + /// + public async ValueTask GetGuildMembershipAsync(string characterName) + { + var characters = await this.Provider.GetRepository().GetAllAsync().ConfigureAwait(false); + if (characters.FirstOrDefault(c => c.Name == characterName) is not { } character) + { + return null; + } + + var members = await this.Provider.GetRepository().GetAllAsync().ConfigureAwait(false); + if (members.FirstOrDefault(m => m.Id == character.Id) is not { } member) + { + return null; + } + + var guilds = await this.Provider.GetRepository().GetAllAsync().ConfigureAwait(false); + var allianceMaster = guilds.FirstOrDefault(g => g.GetId() == member.GuildId)?.AllianceGuild; + return new GuildMembership(member.Id, member.GuildId, member.Status, allianceMaster?.GetId()); + } + /// public async ValueTask> GetAlliancesAsync(Guid guildId) { diff --git a/src/Persistence/InMemory/PlayerInMemoryContext.cs b/src/Persistence/InMemory/PlayerInMemoryContext.cs index 475ae6c741..39eab88ec0 100644 --- a/src/Persistence/InMemory/PlayerInMemoryContext.cs +++ b/src/Persistence/InMemory/PlayerInMemoryContext.cs @@ -15,7 +15,7 @@ namespace MUnique.OpenMU.Persistence.InMemory; /// public class PlayerInMemoryContext : InMemoryContext, IPlayerContext { - // All contexts of one in-memory database share this lock. Different databases remain independent. + // All contexts of one in-memory database share this lock. private static readonly ConditionalWeakTable LinkLocks = new(); /// @@ -162,6 +162,17 @@ public async ValueTask CanSaveLetterAsync(Interfaces.LetterHeader letterHe return grants.FirstOrDefault(grant => grant.Reference == reference); } + /// + public async ValueTask GetAccountChatBanUntilAsync( + Guid accountId, + CancellationToken cancellationToken = default) + { + var accounts = await this.Provider.GetRepository() + .GetAllAsync(cancellationToken) + .ConfigureAwait(false); + return accounts.FirstOrDefault(account => account.Id == accountId)?.ChatBanUntil; + } + /// public async ValueTask<(Guid AccountId, string? CharacterName, string? PreviousUserId)?> TryLinkAccountAsync( string provider, @@ -230,6 +241,38 @@ public async ValueTask CanSaveLetterAsync(Interfaces.LetterHeader letterHe return links.FirstOrDefault(link => link.Provider == provider && link.CodeHash == codeHash); } + /// + public async ValueTask> GetAccountIdsByCharacterNamesAsync( + IReadOnlyCollection characterNames, + CancellationToken cancellationToken = default) + { + var accounts = await this.Provider.GetRepository() + .GetAllAsync(cancellationToken) + .ConfigureAwait(false); + var result = new Dictionary(); + foreach (var account in accounts) + { + foreach (var character in account.Characters.Where(c => characterNames.Contains(c.Name))) + { + result[character.Name] = account.Id; + } + } + + return result; + } + + /// + public async ValueTask> GetAccountExternalLinksAsync( + string provider, + IReadOnlyCollection accountIds, + CancellationToken cancellationToken = default) + { + var links = await this.Provider.GetRepository() + .GetAllAsync(cancellationToken) + .ConfigureAwait(false); + return links.Where(link => link.Provider == provider && accountIds.Contains(link.AccountId)).ToList(); + } + /// public async ValueTask> GetAccountExternalLinksAsync( Guid accountId, diff --git a/src/Startup/InMemoryEventPublisher.cs b/src/Startup/InMemoryEventPublisher.cs index 3fb2dbbfb7..e027991897 100644 --- a/src/Startup/InMemoryEventPublisher.cs +++ b/src/Startup/InMemoryEventPublisher.cs @@ -72,6 +72,15 @@ public async ValueTask AllianceMessageAsync(uint guildId, string sender, string } } + /// + public async ValueTask WorldChatMessageAsync(string sender, string message) + { + foreach (var gameServer in this._gameServers) + { + await gameServer.Value.WorldChatMessageAsync(sender, message).ConfigureAwait(false); + } + } + /// public async ValueTask PlayerAlreadyLoggedInAsync(byte serverId, string loginName) { diff --git a/tests/MUnique.OpenMU.Tests/CharacterRankingTest.cs b/tests/MUnique.OpenMU.Tests/CharacterRankingTest.cs index e56867acb0..91c3f78e40 100644 --- a/tests/MUnique.OpenMU.Tests/CharacterRankingTest.cs +++ b/tests/MUnique.OpenMU.Tests/CharacterRankingTest.cs @@ -31,6 +31,19 @@ public void EntityFrameworkQueryIsTranslatable() Assert.That(sql, Does.Contain("ORDER BY")); } + /// + /// Tests that the query of the accounts of characters can be translated to SQL. + /// + [Test] + public void AccountIdsByCharacterNamesQueryIsTranslatable() + { + using var context = new AccountContext(); + + var sql = PlayerContext.CreateAccountIdsByCharacterNamesQuery(context.Set(), new[] { "Hero", "Elf" }).ToQueryString(); + + Assert.That(sql, Does.Contain("JOIN")); + } + /// /// Tests that the characters are ranked by resets, master level and level, /// and that bots and game masters are not ranked. diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordChannelRoutingTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordChannelRoutingTest.cs index 309772582b..fe3c309094 100644 --- a/tests/MUnique.OpenMU.Tests/Discord/DiscordChannelRoutingTest.cs +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordChannelRoutingTest.cs @@ -14,12 +14,12 @@ namespace MUnique.OpenMU.Tests.Discord; public class DiscordChannelRoutingTest { /// - /// Tests that the notifications and the status are routed to the existing channels of the layout. + /// Tests that the notifications, the status and the world chat are routed to the existing channels of the layout. /// [Test] public void LayoutChannelsAreUsed() { - var channelIds = new Dictionary { { "events", 10 }, { "server-status", 20 } }; + var channelIds = new Dictionary { { "events", 10 }, { "server-status", 20 }, { "world-chat", 30 } }; var routing = DiscordChannelRouting.Create(new DiscordBotSettings(), DiscordServerLayout.LoadDefault(), channelIds); @@ -27,6 +27,7 @@ public void LayoutChannelsAreUsed() Assert.That(routing.GetChannelId(DiscordChannelCategory.CastleSiege), Is.EqualTo(10)); Assert.That(routing.GetChannelId(DiscordChannelCategory.WorldNews), Is.Null); Assert.That(routing.StatusChannelId, Is.EqualTo(20)); + Assert.That(routing.WorldChatChannelId, Is.EqualTo(30)); } /// diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordChatBridgeTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordChatBridgeTest.cs new file mode 100644 index 0000000000..038fa8a078 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordChatBridgeTest.cs @@ -0,0 +1,348 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.ComponentModel; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Discord.ChatBridge; +using MUnique.OpenMU.GameLogic.AccountLinking; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Persistence.InMemory; +using MUnique.OpenMU.PlugIns; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordChatBridgeTest +{ + private const uint LegendsRuntimeId = 1; + private const ulong ServerId = 500; + private const ulong ChannelId = 600; + private const ulong WorldChannelId = 700; + private const ulong GuildMasterUser = 42; + private const ulong MemberUser = 43; + private const ulong OutsiderUser = 44; + private const ulong AllyUser = 45; + + private InMemoryPersistenceContextProvider _persistence = null!; + private Mock _publisher = null!; + private DiscordChatBridgeSettings _settings = null!; + private Guid _legendsId; + + /// + /// Sets up the accounts, guilds and links: + /// Hero is the guild master of Legends, which is the master of an alliance with Allies. + /// Elf is a member of Legends, Ally a member of Allies, and Outsider isn't in a guild. + /// + [SetUp] + public async Task SetUpAsync() + { + this._persistence = new InMemoryPersistenceContextProvider(); + this._publisher = new Mock(); + this._settings = new DiscordChatBridgeSettings(); + + var hero = await this.CreateAccountAsync("hero", "Hero").ConfigureAwait(false); + var elf = await this.CreateAccountAsync("elf", "Elf").ConfigureAwait(false); + var ally = await this.CreateAccountAsync("ally", "Ally").ConfigureAwait(false); + var outsider = await this.CreateAccountAsync("outsider", "Outsider").ConfigureAwait(false); + + using (var guildContext = this._persistence.CreateNewGuildContext()) + { + var legends = guildContext.CreateNew(); + legends.Name = "Legends"; + legends.Members.Add(CreateMember(guildContext, legends, hero.CharacterId, GuildPosition.GuildMaster)); + legends.Members.Add(CreateMember(guildContext, legends, elf.CharacterId, GuildPosition.NormalMember)); + var allies = guildContext.CreateNew(); + allies.Name = "Allies"; + allies.AllianceGuild = legends; + allies.Members.Add(CreateMember(guildContext, allies, ally.CharacterId, GuildPosition.GuildMaster)); + await guildContext.SaveChangesAsync().ConfigureAwait(false); + this._legendsId = legends.GetId(); + } + + var linkService = this.CreateLinkService(); + await LinkAsync(linkService, hero.AccountId, "Hero", GuildMasterUser).ConfigureAwait(false); + await LinkAsync(linkService, elf.AccountId, "Elf", MemberUser).ConfigureAwait(false); + await LinkAsync(linkService, ally.AccountId, "Ally", AllyUser).ConfigureAwait(false); + await LinkAsync(linkService, outsider.AccountId, "Outsider", OutsiderUser).ConfigureAwait(false); + } + + /// + /// Tests that only the guild master can bind the chat of the guild. + /// + [Test] + public async Task OnlyGuildMasterCanBindAsync() + { + var bridge = this.CreateBridge(); + + var (memberResult, _) = await bridge.BindAsync(MemberUser, GuildChatScope.Guild, ServerId, ChannelId, false).ConfigureAwait(false); + var (notLinkedResult, _) = await bridge.BindAsync(99, GuildChatScope.Guild, ServerId, ChannelId, false).ConfigureAwait(false); + var (masterResult, target) = await bridge.BindAsync(GuildMasterUser, GuildChatScope.Guild, ServerId, ChannelId, false).ConfigureAwait(false); + + Assert.That(memberResult, Is.EqualTo(DiscordChatBindResult.NotGuildMaster)); + Assert.That(notLinkedResult, Is.EqualTo(DiscordChatBindResult.NotLinked)); + Assert.That(masterResult, Is.EqualTo(DiscordChatBindResult.Success)); + Assert.That(target, Is.EqualTo(new GuildChatTarget(this._legendsId, "Legends", "Hero"))); + var binding = bridge.Store.GetByChannel(ChannelId); + Assert.That(binding, Is.Not.Null); + Assert.That(binding!.GuildId, Is.EqualTo(this._legendsId)); + Assert.That(binding.IsHosted, Is.False); + + // The binding survives a restart. + var reloaded = this.CreateBridge(); + await reloaded.Store.ReloadAsync().ConfigureAwait(false); + Assert.That(reloaded.Store.GetByChannel(ChannelId)?.GuildId, Is.EqualTo(this._legendsId)); + } + + /// + /// Tests that a saved character name does not authorize its former owner after a transfer. + /// + [TestCase(GuildChatScope.Guild)] + [TestCase(GuildChatScope.Alliance)] + public async Task FormerCharacterOwnerCannotChangeBindingsAsync(GuildChatScope scope) + { + var bridge = this.CreateBridge(); + await bridge.BindAsync(GuildMasterUser, scope, ServerId, ChannelId, false).ConfigureAwait(false); + using (var context = this._persistence.CreateNewPlayerContext(new GameConfiguration())) + { + var previousOwner = await context.GetAccountByLoginNameAsync("hero").ConfigureAwait(false); + var newOwner = await context.GetAccountByLoginNameAsync("outsider").ConfigureAwait(false); + var character = previousOwner!.Characters.Single(c => c.Name == "Hero"); + previousOwner.Characters.Remove(character); + newOwner!.Characters.Add(character); + await context.SaveChangesAsync().ConfigureAwait(false); + } + + var (result, _) = await bridge.BindAsync(GuildMasterUser, scope, ServerId, ChannelId + 1, false).ConfigureAwait(false); + Assert.That(result, Is.EqualTo(DiscordChatBindResult.NotGuildMaster)); + Assert.That(await bridge.UnbindAsync(ChannelId, GuildMasterUser, false).ConfigureAwait(false), Is.EqualTo(DiscordChatBindResult.NotGuildMaster)); + Assert.That(bridge.Store.GetByChannel(ChannelId), Is.Not.Null); + Assert.That(bridge.Store.GetByChannel(ChannelId + 1), Is.Null); + } + + /// + /// Tests that the alliance chat can only be bound by the master of the alliance, and that a channel can't be bound twice. + /// + [Test] + public async Task AllianceChatIsBoundByAllianceMasterAsync() + { + var bridge = this.CreateBridge(); + await bridge.BindAsync(GuildMasterUser, GuildChatScope.Guild, ServerId, ChannelId, false).ConfigureAwait(false); + + var (allyResult, _) = await bridge.BindAsync(AllyUser, GuildChatScope.Alliance, ServerId, ChannelId + 1, false).ConfigureAwait(false); + var (inUseResult, _) = await bridge.BindAsync(GuildMasterUser, GuildChatScope.Alliance, ServerId, ChannelId, false).ConfigureAwait(false); + var (masterResult, _) = await bridge.BindAsync(GuildMasterUser, GuildChatScope.Alliance, ServerId, ChannelId + 1, false).ConfigureAwait(false); + + Assert.That(allyResult, Is.EqualTo(DiscordChatBindResult.NotAllianceMaster)); + Assert.That(inUseResult, Is.EqualTo(DiscordChatBindResult.ChannelInUse)); + Assert.That(masterResult, Is.EqualTo(DiscordChatBindResult.Success)); + } + + /// + /// Tests that the binding mode and the allowed Discord servers are respected. + /// + [Test] + public void SettingsRestrictBindings() + { + this._settings.BindingMode = DiscordGuildChatBindingMode.HostedOnly; + Assert.That(this.CreateBridge().CheckAllowed(ServerId, false), Is.EqualTo(DiscordChatBindResult.ModeNotAllowed)); + Assert.That(this.CreateBridge().CheckAllowed(ServerId, true), Is.Null); + + this._settings.BindingMode = DiscordGuildChatBindingMode.Both; + this._settings.AllowedDiscordServerIds.Add(ServerId + 1); + Assert.That(this.CreateBridge().CheckAllowed(ServerId, false), Is.EqualTo(DiscordChatBindResult.ServerNotAllowed)); + Assert.That(this.CreateBridge().CheckAllowed(ServerId + 1, false), Is.Null); + } + + /// + /// Tests that the guild and alliance chat messages of the game are mirrored to the bound channels. + /// + [Test] + public async Task GameMessagesAreMirroredToBoundChannelsAsync() + { + var bridge = this.CreateBridge(); + await bridge.BindAsync(GuildMasterUser, GuildChatScope.Guild, ServerId, ChannelId, false).ConfigureAwait(false); + + var guildMessage = await bridge.GetDiscordMessageAsync(new ChatMessageEvent(0, DateTime.UtcNow, GameChatChannel.Guild, LegendsRuntimeId, "Elf", "Hi *all*"), WorldChannelId).ConfigureAwait(false); + var allianceMessage = await bridge.GetDiscordMessageAsync(new ChatMessageEvent(0, DateTime.UtcNow, GameChatChannel.Alliance, LegendsRuntimeId, "Elf", "Hi"), WorldChannelId).ConfigureAwait(false); + var worldMessage = await bridge.GetDiscordMessageAsync(new ChatMessageEvent(0, DateTime.UtcNow, GameChatChannel.World, 0, "Elf", "Hi"), WorldChannelId).ConfigureAwait(false); + + Assert.That(guildMessage, Is.EqualTo((ChannelId, @"**Elf**: Hi \*all\*"))); + Assert.That(allianceMessage, Is.Null); + Assert.That(worldMessage, Is.EqualTo((WorldChannelId, "**Elf**: Hi"))); + } + + /// + /// Tests that a member of the guild can write into the chat of the game, as its character with a prefix. + /// + [Test] + public async Task MemberWritesIntoGuildChatAsync() + { + var bridge = this.CreateBridge(); + await bridge.BindAsync(GuildMasterUser, GuildChatScope.Guild, ServerId, ChannelId, false).ConfigureAwait(false); + + var result = await bridge.PostToGameAsync(ChannelId, WorldChannelId, MemberUser, "Hello <@123> **guild**").ConfigureAwait(false); + + Assert.That(result, Is.EqualTo((DiscordChatPostResult.Sent, "Elf", "Hello guild"))); + this._publisher.Verify(p => p.GuildMessageAsync(LegendsRuntimeId, "@Elf", "Hello guild"), Times.Once); + } + + /// + /// Tests the reasons why a message isn't sent to the game. + /// + [Test] + public async Task MessagesAreRejectedAsync() + { + var bridge = this.CreateBridge(); + await bridge.BindAsync(GuildMasterUser, GuildChatScope.Guild, ServerId, ChannelId, false).ConfigureAwait(false); + + Assert.That((await bridge.PostToGameAsync(ChannelId + 5, WorldChannelId, MemberUser, "Hi").ConfigureAwait(false)).Result, Is.EqualTo(DiscordChatPostResult.NotBridged)); + Assert.That((await bridge.PostToGameAsync(ChannelId, WorldChannelId, 99, "Hi").ConfigureAwait(false)).Result, Is.EqualTo(DiscordChatPostResult.NotLinked)); + Assert.That((await bridge.PostToGameAsync(ChannelId, WorldChannelId, OutsiderUser, "Hi").ConfigureAwait(false)).Result, Is.EqualTo(DiscordChatPostResult.NotMember)); + Assert.That((await bridge.PostToGameAsync(ChannelId, WorldChannelId, MemberUser, "<@123>").ConfigureAwait(false)).Result, Is.EqualTo(DiscordChatPostResult.Empty)); + Assert.That((await bridge.PostToGameAsync(ChannelId, WorldChannelId, MemberUser, "Hi").ConfigureAwait(false)).Result, Is.EqualTo(DiscordChatPostResult.Sent)); + + using (var context = this._persistence.CreateNewPlayerContext(new GameConfiguration())) + { + var account = await context.GetAccountByLoginNameAsync("elf").ConfigureAwait(false); + account!.ChatBanUntil = DateTime.UtcNow.AddHours(1); + await context.SaveChangesAsync().ConfigureAwait(false); + } + + Assert.That((await bridge.PostToGameAsync(ChannelId, WorldChannelId, MemberUser, "Hi").ConfigureAwait(false)).Result, Is.EqualTo(DiscordChatPostResult.ChatBanned)); + } + + /// + /// Tests that the flood limit is checked before anything else, so that spamming doesn't cause queries to the database. + /// + [Test] + public async Task FloodLimitIsCheckedFirstAsync() + { + this._settings.MaximumMessagesPerMinute = 1; + var bridge = this.CreateBridge(); + + var first = await bridge.PostToGameAsync(WorldChannelId, WorldChannelId, 99, "Hi").ConfigureAwait(false); + var second = await bridge.PostToGameAsync(WorldChannelId, WorldChannelId, 99, "Hi").ConfigureAwait(false); + + Assert.That(first.Result, Is.EqualTo(DiscordChatPostResult.NotLinked)); + Assert.That(second.Result, Is.EqualTo(DiscordChatPostResult.TooFast)); + } + + /// + /// Tests that a plugin of the server, e.g. a filter, can stop a message from Discord. + /// + [Test] + public async Task FilterPlugInStopsMessageAsync() + { + var plugInManager = new PlugInManager([], NullLoggerFactory.Instance, null, null); + var filter = new Mock(); + filter.Setup(f => f.ExternalChatMessageReceived("Outsider", "bad word", GameChatChannel.World, It.IsAny())) + .Callback((_, _, _, e) => e.Cancel = true); + plugInManager.RegisterPlugInAtPlugInPoint(filter.Object); + + var result = await this.CreateBridge(plugInManager).PostToGameAsync(WorldChannelId, WorldChannelId, OutsiderUser, "bad word").ConfigureAwait(false); + + Assert.That(result.Result, Is.EqualTo(DiscordChatPostResult.Filtered)); + this._publisher.Verify(p => p.WorldChatMessageAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Tests that every linked user can write into the world chat. + /// + [Test] + public async Task LinkedUserWritesIntoWorldChatAsync() + { + var result = await this.CreateBridge().PostToGameAsync(WorldChannelId, WorldChannelId, OutsiderUser, "Hello world").ConfigureAwait(false); + + Assert.That(result.Result, Is.EqualTo(DiscordChatPostResult.Sent)); + this._publisher.Verify(p => p.WorldChatMessageAsync("@Outsider", "Hello world"), Times.Once); + } + + /// + /// Tests that the binding can be removed by the guild master and by users who manage the channel. + /// + [Test] + public async Task UnbindAsync() + { + var bridge = this.CreateBridge(); + await bridge.BindAsync(GuildMasterUser, GuildChatScope.Guild, ServerId, ChannelId, false).ConfigureAwait(false); + + Assert.That(await bridge.UnbindAsync(ChannelId, MemberUser, false).ConfigureAwait(false), Is.EqualTo(DiscordChatBindResult.NotGuildMaster)); + Assert.That(await bridge.UnbindAsync(ChannelId, MemberUser, true).ConfigureAwait(false), Is.EqualTo(DiscordChatBindResult.Success)); + Assert.That(await bridge.UnbindAsync(ChannelId, GuildMasterUser, false).ConfigureAwait(false), Is.EqualTo(DiscordChatBindResult.NotBound)); + Assert.That(bridge.Store.Bindings, Is.Empty); + } + + /// + /// Tests that the linked members of a guild and an alliance are determined, e.g. for the visibility of hosted channels. + /// + [Test] + public async Task LinkedMembersAreDeterminedAsync() + { + var bridge = this.CreateBridge(); + await bridge.BindAsync(GuildMasterUser, GuildChatScope.Guild, ServerId, ChannelId, true).ConfigureAwait(false); + await bridge.BindAsync(GuildMasterUser, GuildChatScope.Alliance, ServerId, ChannelId + 1, true).ConfigureAwait(false); + + var guildMembers = await bridge.GetLinkedMembersAsync(bridge.Store.GetByChannel(ChannelId)!).ConfigureAwait(false); + var allianceMembers = await bridge.GetLinkedMembersAsync(bridge.Store.GetByChannel(ChannelId + 1)!).ConfigureAwait(false); + + Assert.That(guildMembers, Is.EquivalentTo(new[] { GuildMasterUser, MemberUser })); + Assert.That(allianceMembers, Is.EquivalentTo(new[] { GuildMasterUser, MemberUser, AllyUser })); + } + + private static GuildMember CreateMember(IContext context, DataModel.Entities.Guild guild, Guid characterId, GuildPosition position) + { + var member = context.CreateNew(characterId); + member.GuildId = guild.GetId(); + member.Status = position; + return member; + } + + private static async Task LinkAsync(AccountLinkService linkService, Guid accountId, string characterName, ulong userId) + { + var code = await linkService.CreateCodeAsync(accountId, AccountLinkService.DiscordProvider, characterName).ConfigureAwait(false); + await linkService.LinkAsync(AccountLinkService.DiscordProvider, code, userId.ToString(), characterName).ConfigureAwait(false); + } + + private AccountLinkService CreateLinkService() => new(() => this._persistence.CreateNewPlayerContext(new GameConfiguration())); + + private DiscordChatBridge CreateBridge(PlugInManager? plugInManager = null) + { + var guildServer = new Mock(); + guildServer.Setup(s => s.GetPersistentGuildIdAsync(LegendsRuntimeId)).ReturnsAsync(this._legendsId); + guildServer.Setup(s => s.GetPersistentAllianceMasterGuildIdAsync(LegendsRuntimeId)).ReturnsAsync(this._legendsId); + guildServer.Setup(s => s.GetGuildIdAsync(this._legendsId)).ReturnsAsync(LegendsRuntimeId); + guildServer.Setup(s => s.GetPersistentGuildNameAsync(this._legendsId)).ReturnsAsync("Legends"); + IPlayerContext CreateContext() => this._persistence.CreateNewPlayerContext(new GameConfiguration()); + return new DiscordChatBridge( + this._settings, + new GuildChatBindingStore(CreateContext), + this.CreateLinkService(), + guildServer.Object, + CreateContext, + this._persistence.CreateNewGuildContext, + () => this._publisher.Object, + NullLogger.Instance, + plugInManager); + } + + private async Task<(Guid AccountId, Guid CharacterId)> CreateAccountAsync(string loginName, string characterName) + { + using var context = this._persistence.CreateNewPlayerContext(new GameConfiguration()); + 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(), character.GetId()); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordChatQueueTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordChatQueueTest.cs new file mode 100644 index 0000000000..875a03e699 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordChatQueueTest.cs @@ -0,0 +1,69 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using System.Threading; +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.Discord.ChatBridge; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordChatQueueTest +{ + /// + /// Tests that messages which come in while one is sent are combined, but not beyond the maximum length of Discord. + /// + [Test] + public async Task MessagesAreCombinedUpToMaximumLengthAsync() + { + var messenger = new BlockingMessenger(); + await using (var queue = new DiscordChatQueue(1, messenger, NullLogger.Instance, 100)) + { + queue.Enqueue("first"); + await messenger.FirstSendStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + queue.Enqueue("second"); + queue.Enqueue("third"); + queue.Enqueue(new string('x', DiscordChatQueue.MaximumMessageLength - 5)); + messenger.Release.SetResult(); + await messenger.WaitForTextsAsync(3).ConfigureAwait(false); + } + + Assert.That(messenger.Texts, Has.Count.EqualTo(3)); + Assert.That(messenger.Texts[0], Is.EqualTo("first")); + Assert.That(messenger.Texts[1], Is.EqualTo("second\nthird")); + Assert.That(messenger.Texts[2], Has.Length.EqualTo(DiscordChatQueue.MaximumMessageLength - 5)); + } + + private sealed class BlockingMessenger : IDiscordChatMessenger + { + public TaskCompletionSource FirstSendStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public List Texts { get; } = new(); + + public async Task SendTextAsync(ulong channelId, string text, CancellationToken cancellationToken) + { + lock (this.Texts) + { + this.Texts.Add(text); + } + + this.FirstSendStarted.TrySetResult(); + await this.Release.Task.ConfigureAwait(false); + } + + public async Task WaitForTextsAsync(int count) + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (this.Texts.Count < count && DateTime.UtcNow < deadline) + { + await Task.Delay(10).ConfigureAwait(false); + } + } + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordChatTextTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordChatTextTest.cs new file mode 100644 index 0000000000..b85523b140 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordChatTextTest.cs @@ -0,0 +1,59 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using MUnique.OpenMU.Discord.ChatBridge; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordChatTextTest +{ + /// + /// Tests the conversion of Discord messages for the game. + /// + /// The Discord message. + /// The expected message for the game. + [TestCase("Hello", "Hello")] + [TestCase("**bold** _italic_ ~~strike~~ `code` ||spoiler||", "bold italic strike code spoiler")] + [TestCase("Hi <@123456> and <@&42> in <#77>", "Hi and in")] + [TestCase("@everyone look", "look")] + [TestCase("Nice <:pepe:123456789> ", "Nice :pepe: :dance:")] + [TestCase("Line 1\nLine 2\r\n\tLine 3", "Line 1 Line 2 Line 3")] + [TestCase(" ", "")] + public void MessageIsConvertedForTheGame(string text, string expected) + { + Assert.That(DiscordChatText.ToGame(text, 100), Is.EqualTo(expected)); + } + + /// + /// Tests that long messages are cut. + /// + [Test] + public void LongMessageIsCut() + { + Assert.That(DiscordChatText.ToGame("Hello wonderful world", 15), Is.EqualTo("Hello wonderful")); + } + + /// + /// Tests that the sender in the game has a prefix, and fits into the name field of the game. + /// + [Test] + public void SenderHasPrefixAndFits() + { + Assert.That(DiscordChatText.ToGameSender("Hero"), Is.EqualTo("@Hero")); + Assert.That(DiscordChatText.ToGameSender("LongName10"), Is.EqualTo("@LongName1")); + } + + /// + /// Tests that the markdown in messages of the game is escaped for Discord. + /// + [Test] + public void GameMessageIsEscapedForDiscord() + { + Assert.That(DiscordChatText.ToDiscord("Hero_1", "*wow*"), Is.EqualTo(@"**Hero\_1**: \*wow\*")); + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordFloodGuardTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordFloodGuardTest.cs new file mode 100644 index 0000000000..4be5fcb508 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordFloodGuardTest.cs @@ -0,0 +1,60 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Discord; + +using MUnique.OpenMU.Discord.ChatBridge; + +/// +/// Tests for the . +/// +[TestFixture] +public class DiscordFloodGuardTest +{ + /// + /// Tests that a user can send the maximum number of messages per minute, and again after a minute. + /// + [Test] + public void MessagesAreLimitedPerMinute() + { + var time = new ManualTimeProvider(); + var guard = new DiscordFloodGuard(2, time); + + Assert.That(guard.TryRegister(1), Is.True); + Assert.That(guard.TryRegister(1), Is.True); + Assert.That(guard.TryRegister(1), Is.False); + Assert.That(guard.TryRegister(2), Is.True); + + time.Advance(TimeSpan.FromMinutes(1)); + Assert.That(guard.TryRegister(1), Is.True); + } + + /// + /// Tests that the users who didn't send a message within a minute are forgotten, so that the guard doesn't grow forever. + /// + [Test] + public void InactiveUsersAreForgotten() + { + var time = new ManualTimeProvider(); + var guard = new DiscordFloodGuard(10, time); + for (ulong userId = 0; userId < 99; userId++) + { + guard.TryRegister(userId); + } + + time.Advance(TimeSpan.FromMinutes(2)); + guard.TryRegister(1000); + + Assert.That(guard.TrackedUserCount, Is.EqualTo(1)); + } + + private sealed class ManualTimeProvider : TimeProvider + { + private DateTimeOffset _now = new(2026, 10, 18, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => this._now; + + public void Advance(TimeSpan timeSpan) => this._now += timeSpan; + } +} diff --git a/tests/MUnique.OpenMU.Tests/Discord/DiscordServerLayoutTest.cs b/tests/MUnique.OpenMU.Tests/Discord/DiscordServerLayoutTest.cs index db560a09e1..5405b3d9de 100644 --- a/tests/MUnique.OpenMU.Tests/Discord/DiscordServerLayoutTest.cs +++ b/tests/MUnique.OpenMU.Tests/Discord/DiscordServerLayoutTest.cs @@ -16,7 +16,7 @@ namespace MUnique.OpenMU.Tests.Discord; public class DiscordServerLayoutTest { /// - /// Tests that the default layout is valid and has a channel for each category of notifications and for the status. + /// Tests that the default layout is valid and has a channel for each category of notifications, for the status and for the world chat. /// [Test] public void DefaultLayoutIsComplete() @@ -26,6 +26,8 @@ public void DefaultLayoutIsComplete() Assert.That(layout.Validate(), Is.Empty); Assert.That(layout.Channels.SelectMany(c => c.Notifications).Distinct(), Is.EquivalentTo(Enum.GetValues())); Assert.That(layout.Channels.Count(c => c.ShowsStatus), Is.EqualTo(1)); + Assert.That(layout.Channels.Count(c => c.IsWorldChat), Is.EqualTo(1)); + Assert.That(layout.Categories.Select(c => c.Key), Does.Contain(DiscordServerLayout.GuildsCategoryKey)); } /// diff --git a/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs b/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs index 019eca6c1e..fed81453a7 100644 --- a/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs +++ b/tests/MUnique.OpenMU.Tests/GameEventPublisherPlugInTest.cs @@ -15,8 +15,10 @@ namespace MUnique.OpenMU.Tests; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.PlayerActions.Chat; using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; using MUnique.OpenMU.GameLogic.PlugIns.GameEvents; using MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; +using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameServer; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence.InMemory; @@ -50,6 +52,8 @@ public class GameEventPublisherPlugInTest new GlobalNoticeEvent(1, DateTime.UtcNow, "GameMaster", "Hello"), new BossKilledEvent(1, DateTime.UtcNow, "Killer", "Kundun", "Kalima 7"), 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"), }; /// @@ -148,6 +152,76 @@ public async Task GlobalNoticeOfGameMasterIsPublishedAsync() Times.Once); } + /// + /// Tests that a guild chat message is published without its prefix, when it's enabled. + /// + [Test] + public async Task GuildChatIsPublishedWhenEnabledAsync() + { + var gameContext = this.CreateGameServerContext(); + var plugIn = CreatePlugIn(); + plugIn.Configuration!.PublishChatMessages = true; + var sender = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + sender.SelectedCharacter!.Name = "Hero"; + sender.GuildStatus = new GuildMemberStatus(7, GuildPosition.NormalMember); + + await plugIn.ChatMessageSentAsync(sender, "@Hello", ChatMessageType.Guild, null).ConfigureAwait(false); + + this._eventPublisher.Verify( + p => p.GameEventAsync(It.Is(e => e.ServerId == ServerId && e.Channel == GameChatChannel.Guild && e.GuildId == 7 && e.Sender == "Hero" && e.Message == "Hello")), + Times.Once); + } + + /// + /// Tests that chat messages aren't published by default, because the chat of the players is private. + /// + [Test] + public async Task ChatIsNotPublishedByDefaultAsync() + { + var gameContext = this.CreateGameServerContext(); + var sender = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + + await CreatePlugIn().ChatMessageSentAsync(sender, "Hello", ChatMessageType.World, null).ConfigureAwait(false); + + this._eventPublisher.Verify(p => p.GameEventAsync(It.IsAny()), Times.Never); + } + + /// + /// Tests that the world chat command sends the message to all game servers, and that it's published for Discord. + /// + [Test] + public async Task WorldChatCommandSendsAndPublishesAsync() + { + var gameContext = this.CreateGameServerContext(); + var plugIn = CreatePlugIn(); + plugIn.Configuration!.PublishChatMessages = true; + gameContext.PlugInManager.RegisterPlugInAtPlugInPoint(plugIn); + var sender = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + sender.SelectedCharacter!.Name = "Hero"; + + await new WorldChatChatCommandPlugIn().HandleCommandAsync(sender, "/world Hello there").ConfigureAwait(false); + + this._eventPublisher.Verify(p => p.WorldChatMessageAsync("Hero", "Hello there"), Times.Once); + this._eventPublisher.Verify( + p => p.GameEventAsync(It.Is(e => e.Channel == GameChatChannel.World && e.Sender == "Hero" && e.Message == "Hello there")), + Times.Once); + } + + /// + /// Tests that the world chat command respects the chat ban. + /// + [Test] + public async Task WorldChatCommandRespectsChatBanAsync() + { + var gameContext = this.CreateGameServerContext(); + var sender = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + sender.Account!.ChatBanUntil = DateTime.UtcNow.AddHours(1); + + await new WorldChatChatCommandPlugIn().HandleCommandAsync(sender, "/world Hello").ConfigureAwait(false); + + this._eventPublisher.Verify(p => p.WorldChatMessageAsync(It.IsAny(), It.IsAny()), Times.Never); + } + /// /// Tests that an excellent item which got dropped by a monster is published. /// diff --git a/tests/MUnique.OpenMU.Tests/GuildMembershipQueryTest.cs b/tests/MUnique.OpenMU.Tests/GuildMembershipQueryTest.cs new file mode 100644 index 0000000000..d0bb30c08e --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/GuildMembershipQueryTest.cs @@ -0,0 +1,34 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using Microsoft.EntityFrameworkCore; +using MUnique.OpenMU.Persistence.EntityFramework; + +/// +/// Tests for the query of the guild membership of a character. +/// +[TestFixture] +public class GuildMembershipQueryTest +{ + /// + /// Tests that the query can be translated to SQL. + /// + [Test] + public void QueryIsTranslatable() + { + if (!ConnectionConfigurator.IsInitialized) + { + ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider()); + } + + using var context = new GuildContext(); + + var sql = GuildServerContext.CreateGuildMembershipQuery(context, "Hero").ToQueryString(); + + Assert.That(sql, Does.Contain("JOIN")); + Assert.That(sql, Does.Contain("\"AllianceGuildId\"")); + } +}