diff --git a/docs/Packets/C2-F5-02-WeeklyQuestEntry_by-server.md b/docs/Packets/C2-F5-02-WeeklyQuestEntry_by-server.md new file mode 100644 index 000000000..71f29d4d1 --- /dev/null +++ b/docs/Packets/C2-F5-02-WeeklyQuestEntry_by-server.md @@ -0,0 +1,30 @@ +# C2 F5 02 - WeeklyQuestEntry (by server) + +## Is sent when + +After the client requested the list of available chat commands, one message is sent for each active weekly quest. When the progress of a quest changes, a single message is sent as update of this quest. + +## Causes the following actions on the client side + +The client shows the weekly quests and their progress in a window. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC2 | [Packet type](PacketTypes.md) | +| 1 | 2 | Short | 520 | Packet header - length of the packet | +| 3 | 1 | Byte | 0xF5 | Packet header - packet type identifier | +| 4 | 1 | Byte | 0x02 | Packet header - sub packet type identifier | +| 5 | 1 | Byte | | Index; The index of this quest within the list, starting at 0. The first message of a list replaces the previously known quests. | +| 6 | 1 | Byte | | Count; The total number of quests. A list without quests is sent as a single message with a count of 0. | +| 7 | 1 | Boolean | | IsUpdate; If true, this message updates the already known quest with the same id, instead of being part of a list. | +| 8 | 1 | Boolean | | IsCompleted; The objective of the quest has been reached. | +| 9 | 1 | Boolean | | IsRewarded; The rewards of the quest have been handed out. If the quest is completed but not rewarded, the reward is pending, e.g. because the inventory was full. | +| 12 | 4 | IntegerLittleEndian | | CurrentCount | +| 16 | 4 | IntegerLittleEndian | | RequiredCount | +| 20 | 4 | IntegerLittleEndian | | SecondsUntilReset; The seconds until the weekly progress is reset. | +| 24 | 64 | String | | Id; The identifier of the quest. | +| 88 | 48 | String | | Name | +| 136 | 256 | String | | Description | +| 392 | 128 | String | | Rewards; The rewards of the quest as text, in the language of the player. | \ No newline at end of file diff --git a/docs/Packets/C2-F5-03-QuestDetails_by-server.md b/docs/Packets/C2-F5-03-QuestDetails_by-server.md new file mode 100644 index 000000000..c43c9d2e0 --- /dev/null +++ b/docs/Packets/C2-F5-03-QuestDetails_by-server.md @@ -0,0 +1,39 @@ +# C2 F5 03 - QuestDetails (by server) + +## Is sent when + +Right after each WeeklyQuestEntry message, with the same quest id. Clients which don't know it can ignore it and just show the WeeklyQuestEntry. + +## Causes the following actions on the client side + +The client shows the quest under the tab of its category, with a checklist of its objectives (steps). + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC2 | [Packet type](PacketTypes.md) | +| 1 | 2 | Short | | Packet header - length of the packet | +| 3 | 1 | Byte | 0xF5 | Packet header - packet type identifier | +| 4 | 1 | Byte | 0x03 | Packet header - sub packet type identifier | +| 5 | 1 | Byte | | Category; The category of the quest: 0 = weekly, 1 = daily, 2 = main story, 3 = class, 4 = zone. | +| 6 | 1 | Byte | | Period; When the progress starts over: 0 = weekly, 1 = daily, 2 = never (the quest is done once). | +| 7 | 1 | Byte | | CurrentStep; The index of the first objective which isn't done yet. It's the number of objectives when all are done. | +| 8 | 1 | Byte | | ObjectiveCount | +| 9 | 1 | Boolean | | IsSequential; If true, the objectives have to be done in their order. | +| 12 | 4 | IntegerLittleEndian | | SecondsUntilReset; The seconds until the progress of this quest starts over. 0 for quests which are done once. | +| 16 | 64 | String | | Id; The identifier of the quest, as in the WeeklyQuestEntry message. | +| 80 | QuestObjective.Length * ObjectiveCount | Array of QuestObjective | | Objectives; The objectives of the quest, in their order. | + +### QuestObjective Structure + +An objective (step) of a quest. + +Length: 76 Bytes + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 4 | IntegerLittleEndian | | CurrentCount | +| 4 | 4 | IntegerLittleEndian | | RequiredCount | +| 8 | 1 | Boolean | | IsDone | +| 12 | 64 | String | | Text; The text of the objective, in the language of the player, e.g. 'Kill 50 Skeletons'. | \ No newline at end of file diff --git a/docs/Packets/C2-F5-04-SkillRequirements_by-server.md b/docs/Packets/C2-F5-04-SkillRequirements_by-server.md new file mode 100644 index 000000000..656e1c696 --- /dev/null +++ b/docs/Packets/C2-F5-04-SkillRequirements_by-server.md @@ -0,0 +1,37 @@ +# C2 F5 04 - SkillRequirements (by server) + +## Is sent when + +Once after a successful login, before the character selection. + +## Causes the following actions on the client side + +The client uses these requirements and costs for the rest of the session, instead of the ones of its own data files, so that it shows and checks exactly what the server checks. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC2 | [Packet type](PacketTypes.md) | +| 1 | 2 | Short | | Packet header - length of the packet | +| 3 | 1 | Byte | 0xF5 | Packet header - packet type identifier | +| 4 | 1 | Byte | 0x04 | Packet header - sub packet type identifier | +| 6 | 2 | ShortLittleEndian | | SkillCount | +| 8 | SkillRequirement.Length * SkillCount | Array of SkillRequirement | | Skills; The requirements of every skill of the game configuration. | + +### SkillRequirement Structure + +The requirements and costs of a skill, as the server checks them. A value of 0 means there is no such requirement. + +Length: 16 Bytes + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 2 | ShortLittleEndian | | SkillNumber | +| 2 | 2 | ShortLittleEndian | | Level; The required character level. | +| 4 | 2 | ShortLittleEndian | | Energy; The required total energy. | +| 6 | 2 | ShortLittleEndian | | Leadership; The required total leadership (command). | +| 8 | 2 | ShortLittleEndian | | Strength; The required total strength. | +| 10 | 2 | ShortLittleEndian | | Agility; The required total agility. | +| 12 | 2 | ShortLittleEndian | | Mana; The mana which is consumed by using the skill. | +| 14 | 2 | ShortLittleEndian | | AbilityGauge; The ability (AG) which is consumed by using the skill. | \ No newline at end of file diff --git a/docs/Packets/C2-F5-05-LearnableItemRequirements_by-server.md b/docs/Packets/C2-F5-05-LearnableItemRequirements_by-server.md new file mode 100644 index 000000000..3956d7176 --- /dev/null +++ b/docs/Packets/C2-F5-05-LearnableItemRequirements_by-server.md @@ -0,0 +1,38 @@ +# C2 F5 05 - LearnableItemRequirements (by server) + +## Is sent when + +Once after a successful login, right after the SkillRequirements message. + +## Causes the following actions on the client side + +The client shows these requirements for the items which teach a skill (orbs, scrolls, parchments, crystals), instead of the ones it calculates from its own data files. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC2 | [Packet type](PacketTypes.md) | +| 1 | 2 | Short | | Packet header - length of the packet | +| 3 | 1 | Byte | 0xF5 | Packet header - packet type identifier | +| 4 | 1 | Byte | 0x05 | Packet header - sub packet type identifier | +| 6 | 2 | ShortLittleEndian | | ItemCount | +| 8 | LearnableItemRequirement.Length * ItemCount | Array of LearnableItemRequirement | | Items; The requirements to learn the skill of each item. | + +### LearnableItemRequirement Structure + +The requirements to learn a skill with an item: the highest of the requirements of the item and the requirements of the skill. A value of 0 means there is no such requirement. + +Length: 16 Bytes + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | | Group | +| 1 | 1 | Byte | | ItemLevel; The item level these requirements are for, or 0xFF if they apply to every level. Items which teach a different skill per level (Orb of Summoning) have one entry per level. | +| 2 | 2 | ShortLittleEndian | | Number | +| 4 | 2 | ShortLittleEndian | | Level; The required character level. | +| 6 | 2 | ShortLittleEndian | | Energy; The required total energy. | +| 8 | 2 | ShortLittleEndian | | Leadership; The required total leadership (command). | +| 10 | 2 | ShortLittleEndian | | Strength; The required total strength. | +| 12 | 2 | ShortLittleEndian | | Agility; The required total agility. | +| 14 | 2 | ShortLittleEndian | | SkillNumber; The skill which is learned with the item. | \ No newline at end of file diff --git a/docs/Packets/ServerToClient.md b/docs/Packets/ServerToClient.md index 73d5b31f9..5193d6fb6 100644 --- a/docs/Packets/ServerToClient.md +++ b/docs/Packets/ServerToClient.md @@ -284,6 +284,10 @@ * [C1 F3 52 - MasterSkillLevelUpdate (by server)](C1-F3-52-MasterSkillLevelUpdate_by-server.md) * [C2 F3 53 - MasterSkillList (by server)](C2-F3-53-MasterSkillList_by-server.md) * [C2 F5 01 - AvailableChatCommand (by server)](C2-F5-01-AvailableChatCommand_by-server.md) + * [C2 F5 02 - WeeklyQuestEntry (by server)](C2-F5-02-WeeklyQuestEntry_by-server.md) + * [C2 F5 03 - QuestDetails (by server)](C2-F5-03-QuestDetails_by-server.md) + * [C2 F5 04 - SkillRequirements (by server)](C2-F5-04-SkillRequirements_by-server.md) + * [C2 F5 05 - LearnableItemRequirements (by server)](C2-F5-05-LearnableItemRequirements_by-server.md) * [C1 F6 03 - QuestEventResponse (by server)](C1-F6-03-QuestEventResponse_by-server.md) * [C1 F6 0A - AvailableQuests (by server)](C1-F6-0A-AvailableQuests_by-server.md) * [C1 F6 0B - QuestStepInfo (by server)](C1-F6-0B-QuestStepInfo_by-server.md) diff --git a/src/Dapr/Common/Extensions.cs b/src/Dapr/Common/Extensions.cs index 88b2890e8..38b5fb793 100644 --- a/src/Dapr/Common/Extensions.cs +++ b/src/Dapr/Common/Extensions.cs @@ -17,6 +17,8 @@ namespace MUnique.OpenMU.Dapr.Common; using MUnique.OpenMU.Persistence.AdminAuth; using MUnique.OpenMU.Persistence.EntityFramework; using MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; +using MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests; +using MUnique.OpenMU.Persistence.WeeklyQuests; using MUnique.OpenMU.PlugIns; using Nito.AsyncEx.Synchronous; using OpenTelemetry.Exporter; @@ -57,6 +59,7 @@ public static IServiceCollection AddPeristenceProvider(this IServiceCollection s .AddSingleton(s => (IPersistenceContextProvider)s.GetService()!) .AddSingleton(s => new Lazy(s.GetRequiredService)) .AddAdminUserRepository() + .AddWeeklyQuestProgressRepository() .AddSingleton(s => new BackupService( s.GetRequiredService(), s.GetRequiredService())) @@ -245,6 +248,7 @@ public static WebApplication BuildAndConfigure(this WebApplicationBuilder builde var useReverseProxy = !string.IsNullOrWhiteSpace(pathBase); var app = builder.Build(); + WeeklyQuestProgressRepositoryRegistry.Current = app.Services.GetService(); if (useReverseProxy) { diff --git a/src/GameLogic/DroppedItem.cs b/src/GameLogic/DroppedItem.cs index a39053c58..e0dbd05b3 100644 --- a/src/GameLogic/DroppedItem.cs +++ b/src/GameLogic/DroppedItem.cs @@ -74,6 +74,12 @@ public DroppedItem(Item item, Point position, GameMap map, Player? dropper, IEnu /// public Item Item { get; } + /// + /// Gets a value indicating whether the item was dropped from the inventory of a player, + /// instead of by a monster, an event or a game master. + /// + public bool IsDroppedFromInventory { get; init; } + /// public Point Position { get; set; } diff --git a/src/GameLogic/ItemExtensions.cs b/src/GameLogic/ItemExtensions.cs index d9f24aa36..3c563b8e9 100644 --- a/src/GameLogic/ItemExtensions.cs +++ b/src/GameLogic/ItemExtensions.cs @@ -297,6 +297,17 @@ public static bool IsBook(this Item item, [NotNullWhen(true)] out float? bookRis return result; } + /// + /// Gets the attribute of the character which is compared with a requirement attribute of an item, + /// e.g. for . + /// + /// The attribute of the requirement. + /// The attribute of the character which has to reach the required value. + public static AttributeDefinition GetRequiredAttribute(AttributeDefinition requirementAttribute) + { + return RequirementAttributeMapping.TryGetValue(requirementAttribute, out var totalAttribute) ? totalAttribute : requirementAttribute; + } + /// /// Gets the requirement as a tuple of an attribute and the corresponding value. /// diff --git a/src/GameLogic/LearnableSkillRequirements.cs b/src/GameLogic/LearnableSkillRequirements.cs new file mode 100644 index 000000000..1db8f959a --- /dev/null +++ b/src/GameLogic/LearnableSkillRequirements.cs @@ -0,0 +1,99 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Configuration.Items; + +/// +/// The skill which an item (orb, scroll, parchment, crystal) teaches, and what it takes to learn it. +/// +/// +/// A skill which can't be used after learning it shouldn't be learnable either, so learning it +/// requires the highest of the requirements of the item and the requirements of the skill. +/// +public static class LearnableSkillRequirements +{ + /// + /// Gets the skill which is learned by consuming an item. + /// + /// The definition of the item. + /// The level of the item. + /// The game configuration. + /// The skill, or null if the item teaches none. + public static Skill? GetLearnableSkill(ItemDefinition definition, byte itemLevel, GameConfiguration configuration) + { + if (definition.Skill is not { } skill) + { + return null; + } + + // There is only one Orb of Summoning, which teaches a different skill per item level. + if (IsSummoningOrb(definition)) + { + var skillNumber = skill.Number + itemLevel; + return configuration.Skills.FirstOrDefault(s => s.Number == skillNumber); + } + + return skill; + } + + /// + /// Determines whether the item teaches a different skill per item level. + /// + /// The definition of the item. + /// true, if it's the Orb of Summoning. + public static bool IsSummoningOrb(ItemDefinition definition) + { + return definition.Group == ItemConstants.SummonOrb.Group && definition.Number == ItemConstants.SummonOrb.Number; + } + + /// + /// Gets the requirements to learn the skill with the item, by the attribute of the character which has to reach them. + /// + /// The definition of the item. It's not wearable, so its requirements don't depend on its level or options. + /// The skill which is learned with the item. + /// The required value per attribute. + public static IReadOnlyDictionary GetRequirements(ItemDefinition definition, Skill skill) + { + var result = new Dictionary(); + foreach (var requirement in definition.Requirements) + { + if (requirement.Attribute is { } attribute) + { + Raise(result, ItemExtensions.GetRequiredAttribute(attribute), requirement.MinimumValue); + } + } + + foreach (var requirement in skill.Requirements) + { + if (requirement.Attribute is { } attribute) + { + Raise(result, attribute, requirement.MinimumValue); + } + } + + return result; + } + + /// + /// Determines whether the player complies with the requirements to use the skill. + /// + /// The player. + /// The skill. + /// true, if the player can use the skill. + public static bool CompliesRequirements(this Player player, Skill skill) + { + return skill.Requirements.All(r => r.Attribute is null || player.Attributes![r.Attribute] >= r.MinimumValue); + } + + private static void Raise(Dictionary requirements, AttributeDefinition attribute, int value) + { + if (!requirements.TryGetValue(attribute, out var current) || current < value) + { + requirements[attribute] = value; + } + } +} diff --git a/src/GameLogic/MiniGames/MiniGameContext.cs b/src/GameLogic/MiniGames/MiniGameContext.cs index 8676c30fa..454fb7af0 100644 --- a/src/GameLogic/MiniGames/MiniGameContext.cs +++ b/src/GameLogic/MiniGames/MiniGameContext.cs @@ -671,6 +671,11 @@ private async ValueTask StopAsync() var players = await this._players.GetSnapshotAsync().ConfigureAwait(false); await this.GameEndedAsync(players).ConfigureAwait(false); + + if (this._gameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint) + { + await plugInPoint.MiniGameEndedAsync(this, players).ConfigureAwait(false); + } } private GameMap CreateMap() diff --git a/src/GameLogic/PlayerActions/ItemConsumeActions/LearnablesConsumeHandlerPlugIn.cs b/src/GameLogic/PlayerActions/ItemConsumeActions/LearnablesConsumeHandlerPlugIn.cs index a5592bc3d..fac500989 100644 --- a/src/GameLogic/PlayerActions/ItemConsumeActions/LearnablesConsumeHandlerPlugIn.cs +++ b/src/GameLogic/PlayerActions/ItemConsumeActions/LearnablesConsumeHandlerPlugIn.cs @@ -40,8 +40,14 @@ public override async ValueTask ConsumeItemAsync(Player player, Item item, /// protected override bool CheckPreconditions(Player player, Item item) { - return base.CheckPreconditions(player, item) - && player.CompliesRequirements(item); + if (!base.CheckPreconditions(player, item) || !player.CompliesRequirements(item)) + { + return false; + } + + // A skill which can't be used after learning it can't be learned either. + return this.GetLearnableSkill(item, player.GameContext.Configuration) is not { } skill + || player.CompliesRequirements(skill); } /// @@ -52,6 +58,8 @@ protected override bool CheckPreconditions(Player player, Item item) /// The skill to learn. protected virtual Skill? GetLearnableSkill(Item item, GameConfiguration gameConfiguration) { - return item.Definition?.Skill; + return item.Definition is { } definition + ? LearnableSkillRequirements.GetLearnableSkill(definition, item.Level, gameConfiguration) + : null; } } \ No newline at end of file diff --git a/src/GameLogic/PlayerActions/ItemConsumeActions/SummoningOrbConsumeHandlerPlugIn.cs b/src/GameLogic/PlayerActions/ItemConsumeActions/SummoningOrbConsumeHandlerPlugIn.cs index 41f04b563..e71621f48 100644 --- a/src/GameLogic/PlayerActions/ItemConsumeActions/SummoningOrbConsumeHandlerPlugIn.cs +++ b/src/GameLogic/PlayerActions/ItemConsumeActions/SummoningOrbConsumeHandlerPlugIn.cs @@ -11,8 +11,8 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions; /// /// The summoning orb consume handler. /// There is only one "Orb" item definition which allows to learn different skills, depending on the item level. -/// This consume handler determines the skill by adding the item level to the skill number -/// of the . +/// The skill is determined by adding the item level to the skill number of the , +/// see . /// [Guid("71C8E542-4868-487E-BC92-0B7CC7CAEC8B")] [PlugIn] @@ -21,21 +21,4 @@ public class SummoningOrbConsumeHandlerPlugIn : LearnablesConsumeHandlerPlugIn { /// public override ItemIdentifier Key => ItemConstants.SummonOrb; - - /// - /// Gets the learnable skill. - /// - /// The item. - /// The game configuration. - /// - /// The skill to learn. - /// - protected override Skill GetLearnableSkill(Item item, GameConfiguration gameConfiguration) - { - item.ThrowNotInitializedProperty(item.Definition?.Skill is null, "Definition.Skill"); - - var baseSkillNumber = item.Definition.Skill.Number; - var targetSkillNumber = baseSkillNumber + item.Level; - return gameConfiguration.Skills.First(s => s.Number == targetSkillNumber); - } } \ No newline at end of file diff --git a/src/GameLogic/PlayerActions/Items/DropItemAction.cs b/src/GameLogic/PlayerActions/Items/DropItemAction.cs index 7dd55977c..66e01c598 100644 --- a/src/GameLogic/PlayerActions/Items/DropItemAction.cs +++ b/src/GameLogic/PlayerActions/Items/DropItemAction.cs @@ -80,7 +80,10 @@ private async ValueTask DropItemAsync(Player player, Item item, Point target) // However, to check this in the right order, we need to extend IContext to // give us this information. var wasItemPersisted = player.PersistenceContext.Detach(item); - var droppedItem = new DroppedItem(item, target, player.CurrentMap!, player, owners, wasItemPersisted); + var droppedItem = new DroppedItem(item, target, player.CurrentMap!, player, owners, wasItemPersisted) + { + IsDroppedFromInventory = true, + }; await player.CurrentMap!.AddAsync(droppedItem).ConfigureAwait(false); } diff --git a/src/GameLogic/PlayerActions/Items/PickupItemAction.cs b/src/GameLogic/PlayerActions/Items/PickupItemAction.cs index 14db5fe1e..d3cbc9660 100644 --- a/src/GameLogic/PlayerActions/Items/PickupItemAction.cs +++ b/src/GameLogic/PlayerActions/Items/PickupItemAction.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Items; using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.DataModel.Configuration.Quests; +using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameLogic.Views.Inventory; using MUnique.OpenMU.Interfaces; @@ -162,6 +163,10 @@ private static bool PlayerHasActiveQuestForItem(Player player, Item item) if (result.Success) { await player.OnPickedUpItemAsync(droppedItem).ConfigureAwait(false); + if (player.GameContext.PlugInManager.GetPlugInPoint() is { } itemPickedUpPlugIn) + { + await itemPickedUpPlugIn.ItemPickedUpAsync(player, droppedItem.Item, droppedItem.IsDroppedFromInventory).ConfigureAwait(false); + } } return result; diff --git a/src/GameLogic/PlayerActions/LoginAction.cs b/src/GameLogic/PlayerActions/LoginAction.cs index e23289670..7cefa78b2 100644 --- a/src/GameLogic/PlayerActions/LoginAction.cs +++ b/src/GameLogic/PlayerActions/LoginAction.cs @@ -5,6 +5,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions; using System.Threading; +using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameLogic.Views.Login; /// @@ -213,5 +214,8 @@ private async ValueTask FinishLoginAsync(Player player, string username, Account } await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.Ok)).ConfigureAwait(false); + + // Once per session, so that the client shows and checks the skill requirements of this server. + await player.InvokeViewPlugInAsync(p => p.ShowSkillRequirementsAsync()).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/GameLogic/PlayerActions/TalkNpcAction.cs b/src/GameLogic/PlayerActions/TalkNpcAction.cs index 05009e24f..1f7e31d87 100644 --- a/src/GameLogic/PlayerActions/TalkNpcAction.cs +++ b/src/GameLogic/PlayerActions/TalkNpcAction.cs @@ -45,6 +45,11 @@ public async ValueTask TalkToNpcAsync(Player player, NonPlayerCharacter npc) } player.OpenedNpc = npc; + if (player.GameContext.PlugInManager.GetPlugInPoint() is { } talkStartedPlugIn) + { + await talkStartedPlugIn.NpcTalkStartedAsync(player, npc).ConfigureAwait(false); + } + if (npcStats.MerchantStore != null && npcStats.MerchantStore.Items.Count > 0) { await Task.Delay(500).ConfigureAwait(false); diff --git a/src/GameLogic/PlugIns/ChatCommands/WeeklyQuestsChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/WeeklyQuestsChatCommandPlugIn.cs new file mode 100644 index 000000000..b1946e794 --- /dev/null +++ b/src/GameLogic/PlugIns/ChatCommands/WeeklyQuestsChatCommandPlugIn.cs @@ -0,0 +1,88 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands.Arguments; +using MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.PlugIns; + +/// +/// A chat command plugin which shows the progress of the weekly quests. +/// +[Guid("8A1D4C7E-2B9F-4E83-B6A5-0C3E7F1D9A24")] +[PlugIn] +[Display(Name = nameof(PlugInResources.WeeklyQuestsChatCommandPlugIn_Name), Description = nameof(PlugInResources.WeeklyQuestsChatCommandPlugIn_Description), ResourceType = typeof(PlugInResources))] +[ChatCommandHelp(Command, typeof(EmptyChatCommandArgs), CharacterStatus.Normal)] +public class WeeklyQuestsChatCommandPlugIn : ChatCommandPlugInBase +{ + private const string Command = "/weekly"; + + /// + public override string Key => Command; + + /// + public override CharacterStatus MinCharacterStatusRequirement => CharacterStatus.Normal; + + /// + protected override async ValueTask DoHandleCommandAsync(Player player, EmptyChatCommandArgs arguments) + { + var plugIn = WeeklyQuestsPlugIn.GetTrackingPlugIn(player); + if (plugIn is null || !player.GameContext.PlugInManager.IsPlugInActive(typeof(WeeklyQuestsPlugIn).GUID)) + { + await player.ShowBlueMessageAsync("Las quests no están disponibles.").ConfigureAwait(false); + return; + } + + if (await plugIn.GetOverviewAsync(player).ConfigureAwait(false) is not { } overview) + { + await player.ShowBlueMessageAsync("No se pudo cargar tu progreso. Probá de nuevo en un rato.").ConfigureAwait(false); + return; + } + + if (overview.Entries.Count == 0) + { + await player.ShowBlueMessageAsync("No hay quests activas.").ConfigureAwait(false); + return; + } + + foreach (var entry in overview.Entries) + { + var status = entry switch + { + { IsRewarded: true } => "[OK]", + { IsCompleted: true } => "[Premio pendiente]", + _ => $"{entry.Count}/{entry.Required}", + }; + await player.ShowBlueMessageAsync($"[{GetCategoryName(entry.Quest.Category)}] {entry.Quest.Name}: {status} - {entry.Quest.Description}").ConfigureAwait(false); + if (entry.Objectives.Count > 1 && !entry.IsCompleted) + { + foreach (var objective in entry.Objectives) + { + var mark = objective.IsDone ? "[x]" : "[ ]"; + var count = objective.Required > 1 && !objective.IsDone ? $" {objective.Count}/{objective.Required}" : string.Empty; + await player.ShowBlueMessageAsync($" {mark} {objective.Objective.GetDisplayText(player.Culture)}{count}").ConfigureAwait(false); + } + } + } + + var remaining = overview.NextResetUtc - DateTime.UtcNow; + var remainingDaily = overview.NextDailyResetUtc - DateTime.UtcNow; + await player.ShowBlueMessageAsync($"Reinicio semanal en {(int)remaining.TotalDays}d {remaining.Hours}h {remaining.Minutes}m, diario en {remainingDaily.Hours}h {remainingDaily.Minutes}m.").ConfigureAwait(false); + + // The configuration may have changed since the client got the list, so the window is refreshed, too. + await player.InvokeViewPlugInAsync(p => p.ShowWeeklyQuestsAsync(overview)).ConfigureAwait(false); + } + + private static string GetCategoryName(QuestCategory category) => category switch + { + QuestCategory.Main => "Historia", + QuestCategory.Daily => "Diaria", + QuestCategory.Class => "Clase", + QuestCategory.Zone => "Zona", + _ => "Semanal", + }; +} diff --git a/src/GameLogic/PlugIns/ICharacterResetPlugIn.cs b/src/GameLogic/PlugIns/ICharacterResetPlugIn.cs new file mode 100644 index 000000000..dd883f54b --- /dev/null +++ b/src/GameLogic/PlugIns/ICharacterResetPlugIn.cs @@ -0,0 +1,23 @@ +// +// 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 MUnique.OpenMU.PlugIns; + +/// +/// A plugin interface which is called when a did a reset of the character. +/// +[Guid("5E0E7B9C-3A0F-4B46-9C3B-6F5B8A2D9E41")] +[PlugInPoint("Character reset", "Plugins which will be executed when a character has been reset.")] +public interface ICharacterResetPlugIn +{ + /// + /// Is called when the character of a has been reset. + /// + /// The player. + /// The reset count after the reset. + ValueTask CharacterResetAsync(Player player, int resetCount); +} diff --git a/src/GameLogic/PlugIns/IItemPickedUpPlugIn.cs b/src/GameLogic/PlugIns/IItemPickedUpPlugIn.cs new file mode 100644 index 000000000..5046df76a --- /dev/null +++ b/src/GameLogic/PlugIns/IItemPickedUpPlugIn.cs @@ -0,0 +1,24 @@ +// +// 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 MUnique.OpenMU.PlugIns; + +/// +/// A plugin interface which is called when a player picked up an item from the ground. +/// +[Guid("7B3E9C14-2D6A-4F81-A5C0-8E1B4D7F2A93")] +[PlugInPoint("Item picked up", "Plugins which are called when a player picked up an item from the ground.")] +public interface IItemPickedUpPlugIn +{ + /// + /// Is called when a player picked up an item from the ground. + /// + /// The player. + /// The picked up item. If it has been stacked onto another item, it's the item which was on the ground. + /// If set to true, the item was dropped from the inventory of a player, instead of by a monster or an event. + ValueTask ItemPickedUpAsync(Player player, Item item, bool fromPlayerInventory); +} diff --git a/src/GameLogic/PlugIns/IMiniGameEndedPlugIn.cs b/src/GameLogic/PlugIns/IMiniGameEndedPlugIn.cs new file mode 100644 index 000000000..9741b66f8 --- /dev/null +++ b/src/GameLogic/PlugIns/IMiniGameEndedPlugIn.cs @@ -0,0 +1,24 @@ +// +// 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 MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin interface which is called when a mini game (e.g. Blood Castle, Devil Square, Chaos Castle) has been ended. +/// +[Guid("B7C3E1A2-8D4F-4E6B-A915-2C7D3F8E6B10")] +[PlugInPoint("Mini game ended", "Plugins which will be executed when a mini game has been ended.")] +public interface IMiniGameEndedPlugIn +{ + /// + /// Is called when a mini game has been ended. + /// + /// The mini game. + /// The players which stayed in the game until its end. + ValueTask MiniGameEndedAsync(MiniGameContext miniGame, ICollection finishers); +} diff --git a/src/GameLogic/PlugIns/INpcTalkStartedPlugIn.cs b/src/GameLogic/PlugIns/INpcTalkStartedPlugIn.cs new file mode 100644 index 000000000..0c37efa56 --- /dev/null +++ b/src/GameLogic/PlugIns/INpcTalkStartedPlugIn.cs @@ -0,0 +1,28 @@ +// +// 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 MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin interface which is called when a player starts talking to any NPC, e.g. a merchant. +/// +/// +/// Unlike , which is only called for NPCs without a window, it's called for every NPC. +/// It can't change what happens, it's just a notification. +/// +[Guid("5A0D3E7C-8B41-4F26-9C1E-2F7A6B9D4C58")] +[PlugInPoint("NPC talk started", "Plugins which are called when a player starts talking to any NPC.")] +public interface INpcTalkStartedPlugIn +{ + /// + /// Is called when a player starts talking to an NPC. + /// + /// The player. + /// The NPC. + ValueTask NpcTalkStartedAsync(Player player, NonPlayerCharacter npc); +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/QuestCategory.cs b/src/GameLogic/PlugIns/WeeklyQuests/QuestCategory.cs new file mode 100644 index 000000000..d36905ecb --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/QuestCategory.cs @@ -0,0 +1,44 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +/// +/// The category of a , which decides under which tab the quest is shown. +/// +/// +/// The values are sent to the client, so they must not be renumbered. +/// +public enum QuestCategory +{ + /// + /// A weekly quest. + /// + [Display(Name = "Semanal")] + Weekly = 0, + + /// + /// A daily quest. + /// + [Display(Name = "Diaria")] + Daily = 1, + + /// + /// A chapter of the main story. + /// + [Display(Name = "Historia")] + Main = 2, + + /// + /// A quest for a character class. + /// + [Display(Name = "Clase")] + Class = 3, + + /// + /// A quest of a map or region. + /// + [Display(Name = "Zona")] + Zone = 4, +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/QuestPeriod.cs b/src/GameLogic/PlugIns/WeeklyQuests/QuestPeriod.cs new file mode 100644 index 000000000..2cf31101a --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/QuestPeriod.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.WeeklyQuests; + +/// +/// The period after which the progress of a starts over. +/// +/// +/// The values are sent to the client, so they must not be renumbered. +/// +public enum QuestPeriod +{ + /// + /// The progress starts over every week, see . + /// + [Display(Name = "Semanal")] + Weekly = 0, + + /// + /// The progress starts over every day, see . + /// + [Display(Name = "Diaria")] + Daily = 1, + + /// + /// The progress never starts over, so the quest can be completed only once per character. + /// + [Display(Name = "Una sola vez")] + Once = 2, +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/QuestPeriodStarts.cs b/src/GameLogic/PlugIns/WeeklyQuests/QuestPeriodStarts.cs new file mode 100644 index 000000000..ae1005f34 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/QuestPeriodStarts.cs @@ -0,0 +1,30 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +/// +/// The starts (UTC) of the current periods of the quests. +/// +/// The start of the current week. +/// The start of the current day. +public readonly record struct QuestPeriodStarts(DateTime Weekly, DateTime Daily) +{ + /// + /// Gets all distinct period starts, including the one of the quests which are done once. + /// + public IReadOnlyCollection All => new HashSet { this.Weekly, this.Daily, WeeklyPeriod.OncePeriodStartUtc }; + + /// + /// Gets the start of the current period of the specified kind. + /// + /// The kind of the period. + /// The start of the current period. + public DateTime Get(QuestPeriod period) => period switch + { + QuestPeriod.Daily => this.Daily, + QuestPeriod.Once => WeeklyPeriod.OncePeriodStartUtc, + _ => this.Weekly, + }; +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyPeriod.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyPeriod.cs new file mode 100644 index 000000000..4db4f3130 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyPeriod.cs @@ -0,0 +1,109 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +/// +/// Calculates the weekly periods of the weekly quests. +/// +public static class WeeklyPeriod +{ + /// + /// The period start of the quests which are done . + /// It's a fixed date instead of , which the database may store as "-infinity". + /// + public static readonly DateTime OncePeriodStartUtc = new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// Gets the starts (UTC) of the periods which contain the specified point in time. + /// + /// The point in time (UTC). + /// The configuration with the reset times. + /// The time zone of the server. + /// The starts of the periods. + public static QuestPeriodStarts GetPeriodStarts(DateTime utcNow, WeeklyQuestsConfiguration configuration, TimeZoneInfo timeZone) + { + return new QuestPeriodStarts( + GetPeriodStartUtc(utcNow, configuration.ResetDay, configuration.ResetTime, timeZone), + GetDailyPeriodStartUtc(utcNow, configuration.DailyResetTime, timeZone)); + } + + /// + /// Gets the start (UTC) of the daily period which contains the specified point in time. + /// + /// The point in time (UTC). + /// The time of the day, in the , on which a period starts. + /// The time zone of the server. + /// The start of the period as UTC. + public static DateTime GetDailyPeriodStartUtc(DateTime utcNow, TimeOnly resetTime, TimeZoneInfo timeZone) + { + var localNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utcNow, DateTimeKind.Utc), timeZone); + var localStart = localNow.Date.Add(resetTime.ToTimeSpan()); + if (localStart > localNow) + { + localStart = localStart.AddDays(-1); + } + + return ToUtc(localStart, timeZone); + } + + /// + /// Gets the start (UTC) of the daily period which follows the period with the specified start. + /// + /// The start of the current daily period (UTC). + /// The time zone of the server. + /// The start of the next daily period as UTC. + public static DateTime GetNextDailyPeriodStartUtc(DateTime periodStartUtc, TimeZoneInfo timeZone) + { + var localStart = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(periodStartUtc, DateTimeKind.Utc), timeZone); + return ToUtc(localStart.AddDays(1), timeZone); + } + + /// + /// Gets the start (UTC) of the period which contains the specified point in time. + /// + /// The point in time (UTC). + /// The day of the week on which a period starts. + /// The time of the day, in the , on which a period starts. + /// The time zone of the server. + /// The start of the period as UTC. + public static DateTime GetPeriodStartUtc(DateTime utcNow, DayOfWeek resetDay, TimeOnly resetTime, TimeZoneInfo timeZone) + { + var localNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utcNow, DateTimeKind.Utc), timeZone); + var daysSinceResetDay = ((int)localNow.DayOfWeek - (int)resetDay + 7) % 7; + var localStart = localNow.Date.AddDays(-daysSinceResetDay).Add(resetTime.ToTimeSpan()); + if (localStart > localNow) + { + localStart = localStart.AddDays(-7); + } + + return ToUtc(localStart, timeZone); + } + + /// + /// Gets the start (UTC) of the period which follows the period with the specified start. + /// + /// The start of the current period (UTC). + /// The time zone of the server. + /// The start of the next period as UTC. + public static DateTime GetNextPeriodStartUtc(DateTime periodStartUtc, TimeZoneInfo timeZone) + { + // Adding the week in local time keeps the configured time of day across daylight saving time changes. + var localStart = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(periodStartUtc, DateTimeKind.Utc), timeZone); + return ToUtc(localStart.AddDays(7), timeZone); + } + + private static DateTime ToUtc(DateTime local, TimeZoneInfo timeZone) + { + local = DateTime.SpecifyKind(local, DateTimeKind.Unspecified); + + // A time which is skipped by a daylight saving time change doesn't exist, so we take the next valid one. + while (timeZone.IsInvalidTime(local)) + { + local = local.AddMinutes(30); + } + + return TimeZoneInfo.ConvertTimeToUtc(local, timeZone); + } +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestCharacterInfo.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestCharacterInfo.cs new file mode 100644 index 000000000..44221de59 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestCharacterInfo.cs @@ -0,0 +1,13 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +/// +/// The data of a character which decides which weekly quests are available for it. +/// +/// The class of the character. +/// The level of the character. +/// The reset count of the character. +public sealed record WeeklyQuestCharacterInfo(CharacterClass? Class, int Level, int Resets); diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs new file mode 100644 index 000000000..5c0893293 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs @@ -0,0 +1,302 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +using MUnique.OpenMU.DataModel.Composition; +using MUnique.OpenMU.DataModel.Configuration.Items; + +/// +/// The definition of a weekly quest. +/// +public class WeeklyQuestDefinition +{ + private IReadOnlyList? _objectives; + private int _objectivesSourceCount; + + /// + /// Gets or sets the identifier of the quest. The progress of the characters is stored by it. + /// + [Display(Name = "Id", Description = "Identificador único y estable, p. ej. \"bc-3\". El progreso se guarda con este valor.")] + [Required] + [StringLength(64)] + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the name which is shown to the player. + /// + [Display(Name = "Nombre")] + [Required] + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the description which is shown to the player. + /// + [Display(Name = "Descripción")] + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether this quest is active. + /// + [Display(Name = "Activa")] + public bool IsActive { get; set; } = true; + + /// + /// Gets or sets the category, which decides under which tab the quest is shown. + /// + [Display(Name = "Categoría", Description = "La pestaña en la que se muestra: Historia, Diaria, Semanal, Clase o Zona.")] + public QuestCategory Category { get; set; } + + /// + /// Gets or sets the period after which the progress starts over. + /// + [Display(Name = "Reinicio", Description = "Semanal, Diaria o Una sola vez (p. ej. los capítulos de la historia).")] + public QuestPeriod Period { get; set; } + + /// + /// Gets or sets the identifier of the quest which has to be completed before this quest is available. + /// + [Display(Name = "Requiere quest", Description = "Opcional. Id de la quest que hay que completar antes, p. ej. el capítulo anterior. Hasta entonces, esta quest no se muestra.")] + [StringLength(64)] + public string? PrerequisiteQuestId { get; set; } + + /// + /// Gets or sets a value indicating whether this quest is part of every week, when the quests rotate + /// (). Otherwise, it's part of the drawing. + /// + [Display(Name = "Siempre incluida", Description = "Solo con rotación: la quest aparece todas las semanas y no entra en el sorteo.")] + public bool AlwaysIncluded { get; set; } + + /// + /// Gets or sets the minimum character level to make progress in this quest. + /// + [Display(Name = "Nivel mínimo")] + [Range(0, int.MaxValue)] + public int MinimumLevel { get; set; } + + /// + /// Gets or sets the maximum character level for this quest. 0 means no limit. + /// Characters above it don't see the quest, unless they already made progress in it. + /// + [Display(Name = "Nivel máximo", Description = "0 = sin límite. Arriba de este nivel la quest no se muestra, salvo que ya tenga progreso.")] + [Range(0, int.MaxValue)] + public int MaximumLevel { get; set; } + + /// + /// Gets or sets the minimum reset count to make progress in this quest. + /// + [Display(Name = "Resets mínimos")] + [Range(0, int.MaxValue)] + public int MinimumResets { get; set; } + + /// + /// Gets or sets the maximum reset count for this quest. 0 means no limit. + /// Characters above it don't see the quest, unless they already made progress in it. + /// + [Display(Name = "Resets máximos", Description = "0 = sin límite. Arriba de estos resets la quest no se muestra, salvo que ya tenga progreso.")] + [Range(0, int.MaxValue)] + public int MaximumResets { get; set; } + + /// + /// Gets or sets a value indicating whether the rewards of this quest can be received only by one character per account and week. + /// + [Display(Name = "Una vez por cuenta", Description = "Si un personaje de la cuenta ya cobró el premio esta semana, los demás personajes no ven la quest.")] + public bool OncePerAccount { get; set; } + + /// + /// Gets or sets the character classes which can make progress in this quest. + /// Each class includes its following es, + /// e.g. Dark Wizard includes Soul Master and Grand Master. If empty, every class qualifies. + /// + [Display(Name = "Clases permitidas", Description = "Opcional. Vacío = todas las clases. Cada clase incluye sus evoluciones: Dark Wizard = DW, SM y GrM; Soul Master = SM y GrM.")] + public ICollection QualifiedCharacters { get; set; } = new List(); + + /// + /// Gets or sets the objectives (steps) of the quest. If empty, the single objective of the fields + /// , etc. is used. + /// + [Display(Name = "Pasos", Description = "Opcional. Varios objetivos, p. ej. hablar con un NPC, matar 50 Skeletons y derrotar al jefe. Si está vacío, se usa el objetivo simple de abajo.")] + [MemberOfAggregate] + [ScaffoldColumn(true)] + public ICollection Objectives { get; set; } = new List(); + + /// + /// Gets or sets a value indicating whether the have to be done in their order. + /// Otherwise, they make progress at the same time. + /// + [Display(Name = "Pasos en orden", Description = "Cada paso cuenta recién cuando se completó el anterior.")] + public bool SequentialObjectives { get; set; } + + /// + /// Gets or sets the type of the objective. + /// + [Display(Name = "Tipo de objetivo", Description = "Objetivo simple: solo se usa si la quest no tiene pasos.")] + public WeeklyQuestObjectiveType ObjectiveType { get; set; } + + /// + /// Gets or sets the required count to complete the objective. + /// + [Display(Name = "Cantidad requerida")] + [Range(1, int.MaxValue)] + public int RequiredCount { get; set; } = 1; + + /// + /// Gets or sets the monster which has to be killed, for , + /// or the NPC to talk to, for . + /// + [Display(Name = "Monstruo / NPC", Description = "Solo para \"Matar monstruo\" y \"Hablar con NPC\".")] + public virtual MonsterDefinition? Monster { get; set; } + + /// + /// Gets or sets the map on which the kills have to happen. If empty, kills on every map count. + /// + [Display(Name = "Mapa", Description = "Obligatorio para \"Entrar a un mapa\". Para los demás es opcional: vacío = cualquier mapa.")] + public virtual GameMapDefinition? Map { get; set; } + + /// + /// Gets or sets the item which has to be picked up, for . + /// + [Display(Name = "Item", Description = "Solo para \"Juntar items\". Solo cuentan los items que dropean monstruos o eventos, no los que tira un jugador.")] + public virtual ItemDefinition? Item { get; set; } + + /// + /// Gets or sets the minimum level of the picked up item, for . + /// + [Display(Name = "Nivel mínimo del item", Description = "Solo para \"Juntar items\".")] + [Range(0, 15)] + public int MinimumItemLevel { get; set; } + + /// + /// Gets or sets the type of the mini game, for . + /// means any mini game. + /// + [Display(Name = "Evento", Description = "Solo para \"Completar evento\". Undefined = cualquier evento.")] + public MiniGameType MiniGameType { get; set; } + + /// + /// Gets or sets the minimum level of a killed player, for . + /// + [Display(Name = "Nivel mínimo de la víctima", Description = "Solo para \"Matar jugadores\". Evita farmear con personajes bajos.")] + [Range(0, int.MaxValue)] + public int MinimumVictimLevel { get; set; } + + /// + /// Gets or sets a value indicating whether kills of players with the same IP address don't count, for . + /// + [Display(Name = "Ignorar misma IP", Description = "Solo para \"Matar jugadores\". No cuentan las víctimas conectadas desde la misma IP.")] + public bool IgnoreSameIp { get; set; } = true; + + /// + /// Gets or sets a value indicating whether kills of players of the same guild don't count, for . + /// + [Display(Name = "Ignorar misma guild", Description = "Solo para \"Matar jugadores\".")] + public bool IgnoreSameGuild { get; set; } = true; + + /// + /// Gets or sets a value indicating whether kills of players of the same party don't count, for . + /// + [Display(Name = "Ignorar misma party", Description = "Solo para \"Matar jugadores\".")] + public bool IgnoreSameParty { get; set; } = true; + + /// + /// Gets or sets the minutes until the same victim counts again, for . 0 means no cooldown. + /// + [Display(Name = "Cooldown por víctima (minutos)", Description = "Solo para \"Matar jugadores\". 0 = sin cooldown. Matar otra vez a la misma víctima no cuenta hasta que pase este tiempo.")] + [Range(0, int.MaxValue)] + public int VictimCooldownMinutes { get; set; } + + /// + /// Gets or sets the rewards which are given when the quest has been completed. + /// + [Display(Name = "Premios")] + [MemberOfAggregate] + [ScaffoldColumn(true)] + public ICollection Rewards { get; set; } = new List(); + + /// + /// Determines whether a character of the specified class can make progress in this quest. + /// + /// The class of the character. + /// true, if the class is one of the or one of their evolutions. + public bool IsQualified(CharacterClass? characterClass) + { + if (this.QualifiedCharacters.Count == 0) + { + return true; + } + + if (characterClass is null) + { + return false; + } + + foreach (var qualified in this.QualifiedCharacters) + { + // The step limit guards against a misconfigured, circular chain of classes. + var current = qualified; + for (var steps = 0; current is not null && steps < 10; steps++) + { + if (current.Number == characterClass.Number) + { + return true; + } + + current = current.NextGenerationClass; + } + } + + return false; + } + + /// + /// Gets the objectives of the quest: the configured , or the single objective + /// of the fields , etc. when there are none. + /// + /// The objectives; at least one. + /// + /// The result is cached, because it's needed for every event which may make progress. + /// A changed plugin configuration is deserialized into new objects, so the cache doesn't get outdated. + /// + public IReadOnlyList GetObjectives() + { + var count = this.Objectives.Count; + if (this._objectives is { } cached && this._objectivesSourceCount == count) + { + return cached; + } + + this._objectivesSourceCount = count; + this._objectives = count == 0 ? [this.CreateSingleObjective()] : this.Objectives.ToArray(); + return this._objectives; + } + + /// + /// Gets the text which describes all rewards to a player. + /// + /// The culture of the player. + /// The rewards, separated by commas. + public string GetRewardsText(System.Globalization.CultureInfo culture) + { + return string.Join(", ", this.Rewards.Select(r => r.GetDisplayText(culture))); + } + + /// + public override string ToString() => $"{this.Id}: {this.Name}"; + + private WeeklyQuestObjective CreateSingleObjective() => new() + { + ObjectiveType = this.ObjectiveType, + RequiredCount = this.RequiredCount, + Monster = this.Monster, + Map = this.Map, + Item = this.Item, + MinimumItemLevel = this.MinimumItemLevel, + MiniGameType = this.MiniGameType, + MinimumVictimLevel = this.MinimumVictimLevel, + IgnoreSameIp = this.IgnoreSameIp, + IgnoreSameGuild = this.IgnoreSameGuild, + IgnoreSameParty = this.IgnoreSameParty, + VictimCooldownMinutes = this.VictimCooldownMinutes, + }; +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestIndex.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestIndex.cs new file mode 100644 index 000000000..d131da44a --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestIndex.cs @@ -0,0 +1,95 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +/// +/// An index of the objectives of a by their type, so that an event +/// (e.g. a monster kill) only looks at the objectives which it concerns, instead of all quests. +/// +internal sealed class WeeklyQuestIndex +{ + private static readonly IReadOnlyList NoObjectives = []; + + private readonly ICollection _quests; + private readonly int _questCount; + private readonly Dictionary> _objectivesByType = new(); + private readonly Dictionary _questsById = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The configuration. + public WeeklyQuestIndex(WeeklyQuestsConfiguration configuration) + { + this.Configuration = configuration; + this._quests = configuration.Quests; + this._questCount = configuration.Quests.Count; + foreach (var quest in configuration.Quests) + { + if (string.IsNullOrWhiteSpace(quest.Id)) + { + continue; + } + + this._questsById.TryAdd(quest.Id, quest); + var objectives = quest.GetObjectives(); + for (var i = 0; i < objectives.Count; i++) + { + if (!this._objectivesByType.TryGetValue(objectives[i].ObjectiveType, out var list)) + { + list = new List(); + this._objectivesByType.Add(objectives[i].ObjectiveType, list); + } + + list.Add(new IndexedObjective(quest, i, objectives[i])); + } + } + } + + /// + /// Gets the configuration of the index. + /// + public WeeklyQuestsConfiguration Configuration { get; } + + /// + /// Determines whether the index doesn't reflect the specified configuration anymore. + /// + /// The current configuration. + /// true, if the index has to be built again. + public bool IsOutdated(WeeklyQuestsConfiguration configuration) + { + return !ReferenceEquals(configuration, this.Configuration) + || !ReferenceEquals(configuration.Quests, this._quests) + || configuration.Quests.Count != this._questCount; + } + + /// + /// Gets the objectives of the specified type, of all quests (including the inactive ones), ordered by quest and index. + /// + /// The type of the objectives. + /// The objectives. + public IReadOnlyList Get(WeeklyQuestObjectiveType type) + { + return this._objectivesByType.TryGetValue(type, out var list) ? list : NoObjectives; + } + + /// + /// Finds the quest with the specified identifier. + /// + /// The identifier of the quest. + /// The quest, if it exists. + public WeeklyQuestDefinition? FindQuest(string questId) + { + return this._questsById.GetValueOrDefault(questId); + } + + /// + /// An objective of a quest. + /// + /// The quest. + /// The index of the objective in . + /// The objective. + internal readonly record struct IndexedObjective(WeeklyQuestDefinition Quest, int Index, WeeklyQuestObjective Objective); +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjective.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjective.cs new file mode 100644 index 000000000..cb250abf0 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjective.cs @@ -0,0 +1,141 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +using System.Globalization; +using MUnique.OpenMU.DataModel.Configuration.Items; + +/// +/// An objective (step) of a , e.g. "Kill 50 Skeletons". +/// +public class WeeklyQuestObjective +{ + /// + /// Gets or sets the text which is shown in the checklist of the quest. + /// If it's empty, a text is generated from the objective. + /// + [Display(Name = "Texto", Description = "Opcional. Lo que ve el jugador en la lista de pasos, p. ej. \"Mata 50 Skeletons\". Vacío = se genera solo.")] + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the type of the objective. + /// + [Display(Name = "Tipo de objetivo")] + public WeeklyQuestObjectiveType ObjectiveType { get; set; } + + /// + /// Gets or sets the required count to complete the objective. + /// + [Display(Name = "Cantidad requerida")] + [Range(1, int.MaxValue)] + public int RequiredCount { get; set; } = 1; + + /// + /// Gets or sets the monster which has to be killed, for , + /// or the NPC to talk to, for . + /// + [Display(Name = "Monstruo / NPC", Description = "Para \"Matar monstruo\" y \"Hablar con NPC\".")] + public virtual MonsterDefinition? Monster { get; set; } + + /// + /// Gets or sets the map on which the objective has to be done. If empty, every map counts. + /// For , it's the map to enter. + /// + [Display(Name = "Mapa", Description = "Obligatorio para \"Entrar a un mapa\". Para los demás es opcional: vacío = cualquier mapa.")] + public virtual GameMapDefinition? Map { get; set; } + + /// + /// Gets or sets the item which has to be picked up, for . + /// + [Display(Name = "Item", Description = "Solo para \"Juntar items\". Solo cuentan los items que dropean monstruos o eventos, no los que tira un jugador.")] + public virtual ItemDefinition? Item { get; set; } + + /// + /// Gets or sets the minimum level of the picked up item, for . + /// + [Display(Name = "Nivel mínimo del item", Description = "Solo para \"Juntar items\".")] + [Range(0, 15)] + public int MinimumItemLevel { get; set; } + + /// + /// Gets or sets the type of the mini game, for . + /// means any mini game. + /// + [Display(Name = "Evento", Description = "Solo para \"Completar evento\". Undefined = cualquier evento.")] + public MiniGameType MiniGameType { get; set; } + + /// + /// Gets or sets the minimum level of a killed player, for . + /// + [Display(Name = "Nivel mínimo de la víctima", Description = "Solo para \"Matar jugadores\". Evita farmear con personajes bajos.")] + [Range(0, int.MaxValue)] + public int MinimumVictimLevel { get; set; } + + /// + /// Gets or sets a value indicating whether kills of players with the same IP address don't count, for . + /// + [Display(Name = "Ignorar misma IP", Description = "Solo para \"Matar jugadores\". No cuentan las víctimas conectadas desde la misma IP.")] + public bool IgnoreSameIp { get; set; } = true; + + /// + /// Gets or sets a value indicating whether kills of players of the same guild don't count, for . + /// + [Display(Name = "Ignorar misma guild", Description = "Solo para \"Matar jugadores\".")] + public bool IgnoreSameGuild { get; set; } = true; + + /// + /// Gets or sets a value indicating whether kills of players of the same party don't count, for . + /// + [Display(Name = "Ignorar misma party", Description = "Solo para \"Matar jugadores\".")] + public bool IgnoreSameParty { get; set; } = true; + + /// + /// Gets or sets the minutes until the same victim counts again, for . 0 means no cooldown. + /// + [Display(Name = "Cooldown por víctima (minutos)", Description = "Solo para \"Matar jugadores\". 0 = sin cooldown. Matar otra vez a la misma víctima no cuenta hasta que pase este tiempo.")] + [Range(0, int.MaxValue)] + public int VictimCooldownMinutes { get; set; } + + /// + /// Gets the text which describes the objective to a player. + /// + /// The culture of the player. + /// The configured , or a generated text like "Matá 50 Skeleton". + public string GetDisplayText(CultureInfo culture) + { + if (!string.IsNullOrWhiteSpace(this.Description)) + { + return this.Description; + } + + var count = this.RequiredCount.ToString("N0", culture); + var monster = this.Monster?.Designation.GetTranslation(culture) ?? "?"; + var map = this.Map?.Name.GetTranslation(culture); + var onMap = map is null ? string.Empty : $" en {map}"; + return this.ObjectiveType switch + { + WeeklyQuestObjectiveType.KillMonster => $"Matá {count} {monster}{onMap}", + WeeklyQuestObjectiveType.KillAnyMonster => $"Matá {count} monstruos{onMap}", + WeeklyQuestObjectiveType.GainLevels => $"Subí {count} niveles", + WeeklyQuestObjectiveType.GainMasterLevels => $"Subí {count} niveles master", + WeeklyQuestObjectiveType.GainResets => $"Hacé {count} resets", + WeeklyQuestObjectiveType.CompleteMiniGame => this.MiniGameType == MiniGameType.Undefined + ? $"Terminá {count} eventos" + : $"Terminá {count} {this.MiniGameType}", + WeeklyQuestObjectiveType.KillPlayer => $"Derrotá {count} jugadores{onMap}", + WeeklyQuestObjectiveType.CollectItem => $"Juntá {count} {this.Item?.Name.GetTranslation(culture) ?? "?"}{(this.MinimumItemLevel > 0 ? $" +{this.MinimumItemLevel}" : string.Empty)}{onMap}", + WeeklyQuestObjectiveType.TalkToNpc => $"Hablá con {monster}{onMap}", + WeeklyQuestObjectiveType.EnterMap => $"Entrá a {map ?? "?"}", + _ => $"{this.ObjectiveType} {count}", + }; + } + + /// + /// Determines whether a monster kill on the specified map counts for this objective. + /// + /// The number of the map of the kill. + /// true, if the map matches. + public bool IsOnMap(short? mapNumber) => this.Map is null || this.Map.Number == mapNumber; +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveProgress.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveProgress.cs new file mode 100644 index 000000000..44a9c7bbc --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveProgress.cs @@ -0,0 +1,14 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +/// +/// The progress of an objective of a . +/// +/// The objective. +/// The achieved count. +/// The required count. +/// A value indicating whether the objective is done. +public record WeeklyQuestObjectiveProgress(WeeklyQuestObjective Objective, int Count, int Required, bool IsDone); diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveType.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveType.cs new file mode 100644 index 000000000..21262890f --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveType.cs @@ -0,0 +1,71 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +/// +/// The type of the objective of a . +/// +public enum WeeklyQuestObjectiveType +{ + /// + /// Kill a specific monster. + /// + [Display(Name = "Matar monstruo")] + KillMonster, + + /// + /// Kill any monster. + /// + [Display(Name = "Matar cualquier monstruo")] + KillAnyMonster, + + /// + /// Gain character levels. + /// + [Display(Name = "Subir niveles")] + GainLevels, + + /// + /// Gain master levels. + /// + [Display(Name = "Subir niveles master")] + GainMasterLevels, + + /// + /// Do character resets. + /// + [Display(Name = "Hacer resets")] + GainResets, + + /// + /// Stay in a mini game (e.g. Blood Castle) until its end. + /// + [Display(Name = "Completar evento")] + CompleteMiniGame, + + /// + /// Kill other players. + /// + [Display(Name = "Matar jugadores")] + KillPlayer, + + /// + /// Pick up a specific item which was dropped by a monster or an event. + /// + [Display(Name = "Juntar items")] + CollectItem, + + /// + /// Talk to a specific NPC. + /// + [Display(Name = "Hablar con NPC")] + TalkToNpc, + + /// + /// Enter a map. + /// + [Display(Name = "Entrar a un mapa")] + EnterMap, +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestOverview.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestOverview.cs new file mode 100644 index 000000000..e2cfa8fd3 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestOverview.cs @@ -0,0 +1,13 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +/// +/// The overview of the quests of a player. +/// +/// The entries of the available quests. +/// The point in time (UTC) when the next week starts. +/// The point in time (UTC) when the next day starts. +public record WeeklyQuestOverview(IReadOnlyList Entries, DateTime NextResetUtc, DateTime NextDailyResetUtc); diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestOverviewEntry.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestOverviewEntry.cs new file mode 100644 index 000000000..29efdd405 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestOverviewEntry.cs @@ -0,0 +1,24 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +/// +/// An entry of a . +/// +/// The quest. +/// The achieved count of the objective; with several objectives, the number of the completed ones. +/// The required count of the objective; with several objectives, their number. +/// A value indicating whether the objectives have been reached. +/// A value indicating whether the rewards have been handed out. +/// The index of the first objective which isn't done yet; the number of objectives when all are done. +/// The progress of each objective. +public record WeeklyQuestOverviewEntry( + WeeklyQuestDefinition Quest, + int Count, + int Required, + bool IsCompleted, + bool IsRewarded, + int CurrentStep, + IReadOnlyList Objectives); diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestPlayerState.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestPlayerState.cs new file mode 100644 index 000000000..5732e2f1c --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestPlayerState.cs @@ -0,0 +1,141 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +using MUnique.OpenMU.Persistence.WeeklyQuests; +using Nito.AsyncEx; + +/// +/// The quest progress of a player in the current periods, which is kept in memory +/// while the player is in the game. +/// +internal sealed class WeeklyQuestPlayerState +{ + /// + /// Initializes a new instance of the class. + /// + /// The plugin which tracks the progress. + /// The identifier of the character. + /// The identifier of the account of the character. + public WeeklyQuestPlayerState(WeeklyQuestsPlugIn owner, Guid characterId, Guid? accountId) + { + this.Owner = owner; + this.CharacterId = characterId; + this.AccountId = accountId; + } + + /// + /// Gets the plugin which tracks the progress. + /// + public WeeklyQuestsPlugIn Owner { get; } + + /// + /// Gets the lock which has to be held while accessing the state. + /// + public AsyncLock Lock { get; } = new(); + + /// + /// Gets the identifier of the character. + /// + public Guid CharacterId { get; } + + /// + /// Gets the identifier of the account of the character. + /// + public Guid? AccountId { get; } + + /// + /// Gets or sets the ids of the quests whose rewards another character of the account already received in the current periods. + /// + public HashSet RewardedByOtherCharacters { get; set; } = new(); + + /// + /// Gets the last time (UTC) per victim character when a player kill counted for a quest. + /// It's only kept in memory, so it starts over after leaving the game. + /// + public Dictionary<(string QuestId, Guid VictimId), DateTime> LastCountedVictimKills { get; } = new(); + + /// + /// Gets or sets a value indicating whether the progress of the has been loaded. + /// + public bool IsLoaded { get; set; } + + /// + /// Gets or sets the starts of the periods of the loaded progress. + /// + public QuestPeriodStarts Periods { get; set; } + + /// + /// Gets or sets the progress per quest id. + /// + public Dictionary Progress { get; set; } = new(); + + /// + /// Gets the ids of the quests whose progress has not been saved yet. + /// + public HashSet DirtyQuestIds { get; } = new(); + + /// + /// Gets the ids of the quests whose progress has not been sent to the client yet. + /// + public HashSet PendingUpdateIds { get; } = new(); + + /// + /// Gets or sets a value indicating whether sending the has been scheduled. + /// + public bool IsUpdateScheduled { get; set; } + + /// + /// Gets or sets the number of the map which the player entered last, so that a respawn on the same map doesn't count as entering it. + /// + public short? LastMapNumber { get; set; } + + /// + /// Gets or sets the cached available quests. See . + /// + public List? AvailableQuests { get; set; } + + /// + /// Gets or sets the cached available quests as set, for fast lookups. + /// + public HashSet? AvailableQuestSet { get; set; } + + /// + /// Gets or sets the data of the character and configuration for which the were determined. + /// + public (WeeklyQuestIndex Index, int ClassNumber, int Level, int Resets) AvailableQuestsKey { get; set; } + + /// + /// Forgets the cached , e.g. after a quest has been completed which is the prerequisite of another. + /// + public void InvalidateAvailableQuests() + { + this.AvailableQuests = null; + this.AvailableQuestSet = null; + } + + /// + /// Gets the progress of the quest, and creates it if it doesn't exist yet. + /// + /// The quest identifier. + /// The period of the quest. + /// The progress. + public WeeklyQuestProgress GetOrCreateProgress(string questId, QuestPeriod period) + { + if (!this.Progress.TryGetValue(questId, out var progress)) + { + progress = new WeeklyQuestProgress + { + CharacterId = this.CharacterId, + AccountId = this.AccountId, + PeriodStart = this.Periods.Get(period), + QuestId = questId, + }; + this.Progress.Add(questId, progress); + } + + return progress; + } +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestReward.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestReward.cs new file mode 100644 index 000000000..dad15a5df --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestReward.cs @@ -0,0 +1,87 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +using System.Globalization; +using MUnique.OpenMU.DataModel.Configuration.Items; + +/// +/// A reward of a . +/// +public class WeeklyQuestReward +{ + /// + /// Gets or sets the type of the reward. + /// + [Display(Name = "Tipo")] + public WeeklyQuestRewardType RewardType { get; set; } + + /// + /// Gets or sets the amount: zen or experience points, or the number of items. + /// + [Display(Name = "Cantidad", Description = "Zen, puntos de experiencia o cantidad de items.")] + [Range(1, int.MaxValue)] + public int Amount { get; set; } = 1; + + /// + /// Gets or sets the item, for . + /// + [Display(Name = "Item", Description = "Solo para premios de tipo Item.")] + public virtual ItemDefinition? Item { get; set; } + + /// + /// Gets or sets the level of the item. + /// + [Display(Name = "Nivel del item")] + [Range(0, 15)] + public byte ItemLevel { get; set; } + + /// + /// Gets or sets a value indicating whether the item has its skill. + /// + [Display(Name = "Skill")] + public bool Skill { get; set; } + + /// + /// Gets or sets a value indicating whether the item has luck. + /// + [Display(Name = "Luck")] + public bool Luck { get; set; } + + /// + /// Gets or sets the level of the additional option of the item (0 to 4). + /// + [Display(Name = "Nivel de opción adicional", Description = "0 a 4.")] + [Range(0, 4)] + public byte OptionLevel { get; set; } + + /// + /// Gets or sets the excellent options of the item as bit mask, like the /item command: + /// 1, 2, 4, 8, 16, 32 for the first to sixth option. 63 = all options. + /// + [Display(Name = "Opciones excelentes", Description = "Máscara de bits como en /item: 1, 2, 4, 8, 16, 32. 63 = todas.")] + [Range(0, 63)] + public byte ExcellentOptions { get; set; } + + /// + public override string ToString() => this.GetDisplayText(CultureInfo.InvariantCulture); + + /// + /// Gets the text which describes the reward to a player. + /// + /// The culture of the player. + /// The text, e.g. "5.000.000 Zen" or "1x Jewel of Bless +0". + public string GetDisplayText(CultureInfo culture) + { + return this.RewardType switch + { + WeeklyQuestRewardType.Item => $"{this.Amount}x {this.Item?.Name.GetTranslation(culture) ?? "?"}{(this.ItemLevel > 0 ? $" +{this.ItemLevel}" : string.Empty)}", + WeeklyQuestRewardType.Money => $"{this.Amount.ToString("N0", culture)} Zen", + WeeklyQuestRewardType.Experience => $"{this.Amount.ToString("N0", culture)} EXP", + WeeklyQuestRewardType.MasterExperience => $"{this.Amount.ToString("N0", culture)} Master EXP", + _ => this.Amount.ToString("N0", culture), + }; + } +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestRewardType.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestRewardType.cs new file mode 100644 index 000000000..923abab9f --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestRewardType.cs @@ -0,0 +1,35 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +/// +/// The type of a . +/// +public enum WeeklyQuestRewardType +{ + /// + /// An item which is added to the inventory. + /// + [Display(Name = "Item")] + Item, + + /// + /// Zen. + /// + [Display(Name = "Zen")] + Money, + + /// + /// Experience. + /// + [Display(Name = "Experiencia")] + Experience, + + /// + /// Master experience. + /// + [Display(Name = "Experiencia master")] + MasterExperience, +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestRewarder.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestRewarder.cs new file mode 100644 index 000000000..487062f23 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestRewarder.cs @@ -0,0 +1,155 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.GameLogic.Views.Inventory; + +/// +/// Hands out the rewards of a . +/// +/// +/// Unlike the classic quests, items are never dropped on the ground when the inventory is full. +/// The rewards are handed out completely or not at all, so they can be retried later. +/// +public static class WeeklyQuestRewarder +{ + /// + /// Tries to give all rewards of the quest to the player. + /// + /// The player. + /// The quest. + /// true, if all rewards have been given; false, if nothing has been given. + public static async ValueTask TryGiveRewardsAsync(Player player, WeeklyQuestDefinition quest) + { + if (player.Inventory is null || player.SelectedCharacter is null) + { + return false; + } + + var money = quest.Rewards + .Where(r => r.RewardType == WeeklyQuestRewardType.Money) + .Sum(r => (long)r.Amount); + if (money > 0 && player.Money + money > player.GameContext.Configuration.MaximumInventoryMoney) + { + return false; + } + + var addedItems = new List(); + foreach (var reward in quest.Rewards.Where(r => r.RewardType == WeeklyQuestRewardType.Item)) + { + if (ResolveItemDefinition(player, reward) is not { } itemDefinition) + { + player.Logger.LogWarning("Weekly quest {quest} has an item reward without a valid item.", quest.Id); + continue; + } + + for (var i = 0; i < reward.Amount; i++) + { + var item = CreateItem(itemDefinition, reward).MakePersistent(player.PersistenceContext); + if (!await player.Inventory.AddItemAsync(item).ConfigureAwait(false)) + { + await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); + await RollbackAsync(player, addedItems).ConfigureAwait(false); + return false; + } + + addedItems.Add(item); + } + } + + foreach (var item in addedItems) + { + await player.InvokeViewPlugInAsync(p => p.ItemAppearAsync(item)).ConfigureAwait(false); + } + + if (money > 0) + { + player.TryAddMoney((int)money); + } + + foreach (var reward in quest.Rewards) + { + switch (reward.RewardType) + { + case WeeklyQuestRewardType.Experience: + await player.AddExperienceAsync(reward.Amount, null).ConfigureAwait(false); + break; + case WeeklyQuestRewardType.MasterExperience: + await player.AddMasterExperienceAsync(reward.Amount, null).ConfigureAwait(false); + break; + default: + // Items and money are handled above. + break; + } + } + + return true; + } + + private static async ValueTask RollbackAsync(Player player, List addedItems) + { + foreach (var item in addedItems) + { + await player.Inventory!.RemoveItemAsync(item).ConfigureAwait(false); + await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); + } + } + + private static ItemDefinition? ResolveItemDefinition(Player player, WeeklyQuestReward reward) + { + if (reward.Item is not { } configured) + { + return null; + } + + // The configuration of the plugin may hold an instance of another context, so we use the one of the game. + return player.GameContext.Configuration.Items + .FirstOrDefault(i => i.Group == configured.Group && i.Number == configured.Number) + ?? configured; + } + + private static TemporaryItem CreateItem(ItemDefinition itemDefinition, WeeklyQuestReward reward) + { + var item = new TemporaryItem + { + Definition = itemDefinition, + Level = Math.Min(reward.ItemLevel, itemDefinition.MaximumItemLevel), + SocketCount = itemDefinition.MaximumSockets, + }; + item.Durability = item.IsStackable() ? 1 : itemDefinition.Durability; + item.HasSkill = itemDefinition.Skill is not null && reward.Skill; + + var possibleOptions = itemDefinition.PossibleItemOptions.SelectMany(o => o.PossibleOptions).ToList(); + if (reward.OptionLevel > 0 + && possibleOptions.FirstOrDefault(o => o.OptionType == ItemOptionTypes.Option) is { } option) + { + item.ItemOptions.Add(new ItemOptionLink { ItemOption = option, Level = reward.OptionLevel }); + } + + if (reward.Luck + && possibleOptions.FirstOrDefault(o => o.OptionType == ItemOptionTypes.Luck) is { } luck) + { + item.ItemOptions.Add(new ItemOptionLink { ItemOption = luck }); + } + + if (reward.ExcellentOptions > 0) + { + var excellentOptions = possibleOptions + .Where(o => o.OptionType == ItemOptionTypes.Excellent) + .Where(o => ((1 << (o.Number - 1)) & reward.ExcellentOptions) > 0) + .ToList(); + foreach (var excellentOption in excellentOptions) + { + item.ItemOptions.Add(new ItemOptionLink { ItemOption = excellentOption }); + } + + // Like the /item command: every excellent item has its skill, if the item definition has one. + item.HasSkill |= excellentOptions.Count > 0 && itemDefinition.Skill is not null; + } + + return item; + } +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestSelector.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestSelector.cs new file mode 100644 index 000000000..8ea064509 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestSelector.cs @@ -0,0 +1,287 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +using System.Text; +using MUnique.OpenMU.Persistence.WeeklyQuests; + +/// +/// Decides which quests are part of the current periods and which of them are available for a character. +/// +/// +/// It doesn't depend on a , so that it can be used for characters which are not in the game, too. +/// +public static class WeeklyQuestSelector +{ + /// + /// The identifier of the progress of the . + /// + public const string AllCompletedBonusId = "__all__"; + + /// + /// Gets the quests of the current periods: all active quests, or the drawn ones of the daily and weekly quests when they rotate. + /// + /// The configuration. + /// The starts of the current periods. + /// The quests of the periods, in the configured order. + public static IReadOnlyList GetQuestsOfPeriod(WeeklyQuestsConfiguration configuration, QuestPeriodStarts periods) + { + var active = configuration.Quests + .Where(q => q.IsActive && !string.IsNullOrWhiteSpace(q.Id) && q.Id != AllCompletedBonusId) + .ToList(); + var drawnWeekly = Draw(active, QuestPeriod.Weekly, configuration.QuestsPerWeek, periods.Weekly); + var drawnDaily = Draw(active, QuestPeriod.Daily, configuration.QuestsPerDay, periods.Daily); + if (drawnWeekly is null && drawnDaily is null) + { + return active; + } + + return active.Where(q => q.AlwaysIncluded || q.Period switch + { + QuestPeriod.Weekly => drawnWeekly?.Contains(q) ?? true, + QuestPeriod.Daily => drawnDaily?.Contains(q) ?? true, + _ => true, + }).ToList(); + } + + /// + /// Determines whether the quest is available for the character, so it's shown and can make progress. + /// + /// The quest. + /// The character. + /// If set to true, the character already made progress in the quest. + /// If set to true, another character of the account already received the rewards of the quest. + /// If set to true, the is not completed yet. + /// true, if the quest is available. + /// + /// The minimum level and resets are not checked here: such quests are shown, but don't make progress yet. + /// + public static bool IsAvailable(WeeklyQuestDefinition quest, WeeklyQuestCharacterInfo character, bool hasProgress, bool isBlockedByAccount, bool isLocked = false) + { + if (isBlockedByAccount) + { + return false; + } + + if (hasProgress) + { + // A quest which was started isn't hidden, even if the character outgrew it meanwhile. + return true; + } + + if (isLocked || !quest.IsQualified(character.Class)) + { + return false; + } + + return (quest.MaximumLevel <= 0 || character.Level <= quest.MaximumLevel) + && (quest.MaximumResets <= 0 || character.Resets <= quest.MaximumResets); + } + + /// + /// Determines whether the prerequisite of the quest is not completed yet. + /// + /// The quest. + /// The progress of the character in the current periods, per quest id. + /// true, if the quest is locked. + public static bool IsLocked(WeeklyQuestDefinition quest, IReadOnlyDictionary progress) + { + return !string.IsNullOrWhiteSpace(quest.PrerequisiteQuestId) + && !(progress.TryGetValue(quest.PrerequisiteQuestId, out var p) && p.CompletedAt is not null); + } + + /// + /// Gets the quests of the current periods which are available for the character. + /// + /// The configuration. + /// The starts of the current periods. + /// The character. + /// The progress of the character in the current periods, per quest id. + /// The ids of the quests whose rewards another character of the account received in the current periods. + /// The available quests. + public static List GetAvailableQuests( + WeeklyQuestsConfiguration configuration, + QuestPeriodStarts periods, + WeeklyQuestCharacterInfo character, + IReadOnlyDictionary progress, + IReadOnlySet rewardedByOtherCharacters) + { + return GetQuestsOfPeriod(configuration, periods) + .Where(quest => IsAvailable( + quest, + character, + progress.TryGetValue(quest.Id, out var p) && p.HasAnyProgress, + quest.OncePerAccount && rewardedByOtherCharacters.Contains(quest.Id), + IsLocked(quest, progress))) + .ToList(); + } + + /// + /// Creates the overview entries of the available quests of the character, including the bonus for completing all weekly quests. + /// + /// The configuration. + /// The starts of the current periods. + /// The character. + /// The progress of the character in the current periods, per quest id. + /// The ids of the quests whose rewards another character of the account received in the current periods. + /// The entries. + public static List CreateEntries( + WeeklyQuestsConfiguration configuration, + QuestPeriodStarts periods, + WeeklyQuestCharacterInfo character, + IReadOnlyDictionary progress, + IReadOnlySet rewardedByOtherCharacters) + { + return CreateEntries(configuration, GetAvailableQuests(configuration, periods, character, progress, rewardedByOtherCharacters), progress); + } + + /// + /// Creates the overview entries of the specified available quests, including the bonus for completing all weekly quests. + /// + /// The configuration. + /// The available quests of the character. + /// The progress of the character in the current periods, per quest id. + /// The entries. + public static List CreateEntries( + WeeklyQuestsConfiguration configuration, + IReadOnlyList availableQuests, + IReadOnlyDictionary progress) + { + var entries = availableQuests.Select(quest => CreateEntry(quest, progress)).ToList(); + var weeklyCount = CountBonusQuests(availableQuests); + if (weeklyCount > 0 && CreateAllCompletedBonusQuest(configuration, weeklyCount) is { } bonusQuest) + { + entries.Add(CreateEntry(bonusQuest, progress)); + } + + return entries; + } + + /// + /// Counts the quests which have to be completed for the , i.e. the weekly ones. + /// + /// The available quests of the character. + /// The number of the quests. + public static int CountBonusQuests(IEnumerable availableQuests) + { + return availableQuests.Count(q => q.Period == QuestPeriod.Weekly); + } + + /// + /// Creates the overview entry of a quest. + /// + /// The quest. + /// The progress of the character in the current periods, per quest id. + /// The entry. + public static WeeklyQuestOverviewEntry CreateEntry(WeeklyQuestDefinition quest, IReadOnlyDictionary progress) + { + progress.TryGetValue(quest.Id, out var p); + var isCompleted = p?.CompletedAt is not null; + var objectives = quest.GetObjectives(); + var objectiveProgress = new WeeklyQuestObjectiveProgress[objectives.Count]; + var currentStep = -1; + for (var i = 0; i < objectives.Count; i++) + { + var required = Math.Max(1, objectives[i].RequiredCount); + var count = Math.Min(p?.GetCount(i) ?? 0, required); + var isDone = isCompleted || count >= required; + if (!isDone && currentStep < 0) + { + currentStep = i; + } + + objectiveProgress[i] = new WeeklyQuestObjectiveProgress(objectives[i], count, required, isDone); + } + + if (currentStep < 0) + { + currentStep = objectives.Count; + } + + var (entryCount, entryRequired) = objectives.Count == 1 + ? (objectiveProgress[0].Count, objectiveProgress[0].Required) + : (objectiveProgress.Count(o => o.IsDone), objectives.Count); + return new WeeklyQuestOverviewEntry(quest, entryCount, entryRequired, isCompleted, p?.RewardedAt is not null, currentStep, objectiveProgress); + } + + /// + /// Determines whether the character meets the minimum requirements to make progress in the quest. + /// + /// The quest. + /// The character. + /// true, if the character can make progress. + public static bool MeetsMinimumRequirements(WeeklyQuestDefinition quest, WeeklyQuestCharacterInfo character) + { + return character.Level >= quest.MinimumLevel && character.Resets >= quest.MinimumResets; + } + + /// + /// Determines whether the configuration has a bonus for completing all quests. + /// + /// The configuration. + /// true, if there is a bonus. + public static bool HasAllCompletedBonus(WeeklyQuestsConfiguration configuration) + { + return configuration.AllCompletedBonus is { Rewards.Count: > 0 }; + } + + /// + /// Creates the quest which represents the , + /// with the number of the quests which have to be completed as . + /// + /// The configuration. + /// The number of the weekly quests of the character. + /// The quest; null, if there is no bonus. + public static WeeklyQuestDefinition? CreateAllCompletedBonusQuest(WeeklyQuestsConfiguration configuration, int questCount) + { + if (!HasAllCompletedBonus(configuration) || configuration.AllCompletedBonus is not { } bonus) + { + return null; + } + + return new WeeklyQuestDefinition + { + Id = AllCompletedBonusId, + Name = string.IsNullOrWhiteSpace(bonus.Name) ? "Bonus semanal" : bonus.Name, + Description = bonus.Description, + Category = QuestCategory.Weekly, + Period = QuestPeriod.Weekly, + RequiredCount = Math.Max(1, questCount), + Rewards = bonus.Rewards, + }; + } + + private static HashSet? Draw(List active, QuestPeriod period, int count, DateTime periodStartUtc) + { + if (count <= 0) + { + return null; + } + + // Ordering by a hash of the id and the period gives each period another selection, which is the same + // on all servers and after restarts. Adding a quest only changes it, if the new one gets into the drawing. + return active + .Where(q => q.Period == period && !q.AlwaysIncluded) + .OrderBy(q => GetStableHash(q.Id, periodStartUtc)) + .ThenBy(q => q.Id, StringComparer.Ordinal) + .Take(count) + .ToHashSet(); + } + + /// + /// Gets a hash which is the same on all processes, unlike . + /// + private static uint GetStableHash(string questId, DateTime periodStartUtc) + { + // FNV-1a + var hash = 2166136261; + foreach (var b in Encoding.UTF8.GetBytes($"{questId}|{periodStartUtc.Ticks}")) + { + hash = (hash ^ b) * 16777619; + } + + return hash; + } +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsConfiguration.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsConfiguration.cs new file mode 100644 index 000000000..256d1aabe --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsConfiguration.cs @@ -0,0 +1,193 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +using MUnique.OpenMU.DataModel.Composition; +using MUnique.OpenMU.Interfaces; + +/// +/// The configuration of the . +/// +public class WeeklyQuestsConfiguration +{ + /// + /// Gets or sets the day of the week on which a new weekly period starts. + /// + [Display(Name = "Día de reinicio", Description = "Día de la semana en que se reinicia el progreso de todas las quests semanales.")] + public DayOfWeek ResetDay { get; set; } = DayOfWeek.Monday; + + /// + /// Gets or sets the time of the , in the time zone of the server, on which a new weekly period starts. + /// + [Display(Name = "Hora de reinicio", Description = "Hora del día de reinicio, en la zona horaria del servidor.")] + public TimeOnly ResetTime { get; set; } = TimeOnly.MinValue; + + /// + /// Gets or sets the time of the day, in the time zone of the server, on which a new daily period starts. + /// + [Display(Name = "Hora de reinicio diario", Description = "Hora del día en que se reinician las quests diarias, en la zona horaria del servidor.")] + public TimeOnly DailyResetTime { get; set; } = TimeOnly.MinValue; + + /// + /// Gets or sets the number of quests which are drawn each week from the active weekly quests. + /// The quests which are are added to them. + /// 0 means that all active quests are available every week. + /// + [Display(Name = "Quests por semana", Description = "Solo quests con reinicio semanal. 0 = sin rotación, todas las activas. Si es mayor, cada semana se sortean esa cantidad entre las activas (más las \"Siempre incluidas\"). El sorteo es igual para todos; una quest exclusiva de una clase que sale sorteada deja a las demás clases con menos quests esa semana.")] + [Range(0, int.MaxValue)] + public int QuestsPerWeek { get; set; } + + /// + /// Gets or sets the number of quests which are drawn each day from the active daily quests. + /// 0 means that all active daily quests are available every day. + /// + [Display(Name = "Quests por día", Description = "Solo quests con reinicio diario. 0 = sin rotación, todas las activas. Si es mayor, cada día se sortean esa cantidad (más las \"Siempre incluidas\").")] + [Range(0, int.MaxValue)] + public int QuestsPerDay { get; set; } + + /// + /// Gets or sets the number of weeks for which the progress of past daily and weekly periods is kept in the database. + /// 0 means that it's kept forever. The progress of the quests which are done once is always kept. + /// + [Display(Name = "Semanas de historial", Description = "El progreso de días y semanas anteriores se borra de la base después de estas semanas. 0 = nunca. Las quests de una sola vez nunca se borran.")] + [Range(0, int.MaxValue)] + public int HistoryWeeks { get; set; } = 8; + + /// + /// Gets or sets a value indicating whether the monster kills count for the party members nearby, like the experience. + /// + [Display(Name = "Kills compartidos en party", Description = "Los kills de monstruos cuentan para los miembros de la party que estén cerca, igual que la experiencia.")] + public bool ShareKillsWithParty { get; set; } = true; + + /// + /// Gets or sets the bonus which is given when a character completed all of its quests of the week. + /// Its , and + /// are used. Without rewards, there is no bonus. + /// + [Display(Name = "Bonus por completar todas", Description = "Premio extra al completar todas las quests con reinicio semanal (las diarias y las de historia no cuentan). Se usan Nombre, Descripción y Premios; el resto de los campos se ignora. Sin premios = sin bonus.")] + [MemberOfAggregate] + public WeeklyQuestDefinition? AllCompletedBonus { get; set; } + + /// + /// Gets or sets the message which is shown when the progress of a quest reached a milestone (25%, 50%, 75%). + /// Placeholders: {0} = quest name, {1} = current count, {2} = required count. + /// + [Display(Name = "Mensaje de progreso", Description = "{0} = nombre de la quest, {1} = progreso actual, {2} = objetivo.")] + public LocalizedString ProgressMessage { get; set; } = "[Quest] {0}: {1}/{2}"; + + /// + /// Gets or sets the message which is shown when a step of a quest with several objectives has been completed. + /// Placeholders: {0} = quest name, {1} = text of the next step. + /// + [Display(Name = "Mensaje de paso completado", Description = "{0} = nombre de la quest, {1} = el siguiente paso.")] + public LocalizedString StepCompletedMessage { get; set; } = "[Quest] {0} - siguiente: {1}"; + + /// + /// Gets or sets the message which is shown when a quest has been completed and rewarded. + /// Placeholder: {0} = quest name. + /// + [Display(Name = "Mensaje de quest completada", Description = "{0} = nombre de la quest.")] + public LocalizedString CompletedMessage { get; set; } = "¡Quest completada: {0}!"; + + /// + /// Gets or sets the message which is shown when the rewards of a completed quest couldn't be handed out. + /// Placeholder: {0} = quest name. + /// + [Display(Name = "Mensaje de premio pendiente", Description = "Se muestra cuando no hay lugar en el inventario o se supera el máximo de zen. {0} = nombre de la quest.")] + public LocalizedString RewardPendingMessage { get; set; } = "[Quest] Liberá espacio en el inventario para recibir el premio de {0}."; + + /// + /// Gets or sets the quests. + /// + [Display(Name = "Quests", Description = "Todas las quests: historia, diarias, semanales, de clase y de zona. El progreso se guarda por el Id, así que no lo cambies en una quest que ya está en curso.")] + [MemberOfAggregate] + [ScaffoldColumn(true)] + public ICollection Quests { get; set; } = new List(); + + /// + /// Gets the default configuration, with a few example quests which are not active. + /// + public static WeeklyQuestsConfiguration Default => new() + { + Quests = new List + { + new() + { + Id = "kill-500", + Name = "Cazador", + Description = "Matá 500 monstruos.", + IsActive = false, + ObjectiveType = WeeklyQuestObjectiveType.KillAnyMonster, + RequiredCount = 500, + Rewards = new List + { + new() { RewardType = WeeklyQuestRewardType.Money, Amount = 5_000_000 }, + }, + }, + new() + { + Id = "bc-3", + Name = "Defensor del castillo", + Description = "Terminá 3 Blood Castle.", + IsActive = false, + ObjectiveType = WeeklyQuestObjectiveType.CompleteMiniGame, + MiniGameType = MiniGameType.BloodCastle, + RequiredCount = 3, + Rewards = new List + { + new() { RewardType = WeeklyQuestRewardType.Experience, Amount = 1_000_000 }, + }, + }, + new() + { + Id = "daily-kill-100", + Name = "Patrulla diaria", + Description = "Matá 100 monstruos hoy.", + IsActive = false, + Category = QuestCategory.Daily, + Period = QuestPeriod.Daily, + ObjectiveType = WeeklyQuestObjectiveType.KillAnyMonster, + RequiredCount = 100, + Rewards = new List + { + new() { RewardType = WeeklyQuestRewardType.Money, Amount = 500_000 }, + }, + }, + new() + { + // The NPC, the monsters, the item and the map have to be chosen in the admin panel. + Id = "main-1", + Name = "Capítulo I - El regreso de Kundun", + Description = "El Guardián presiente que Kundun vuelve. Averiguá qué está pasando.", + IsActive = false, + Category = QuestCategory.Main, + Period = QuestPeriod.Once, + SequentialObjectives = true, + Objectives = new List + { + new() { Description = "Habla con el Guardián", ObjectiveType = WeeklyQuestObjectiveType.TalkToNpc }, + new() { Description = "Mata 50 Skeletons", ObjectiveType = WeeklyQuestObjectiveType.KillMonster, RequiredCount = 50 }, + new() { Description = "Encuentra el Fragmento del Chaos", ObjectiveType = WeeklyQuestObjectiveType.CollectItem }, + new() { Description = "Derrota al jefe", ObjectiveType = WeeklyQuestObjectiveType.KillMonster }, + }, + Rewards = new List + { + new() { RewardType = WeeklyQuestRewardType.Experience, Amount = 5_000_000 }, + }, + }, + new() + { + Id = "main-2", + Name = "Capítulo II - Las puertas de Lost Tower", + Description = "Seguí el rastro de Kundun.", + IsActive = false, + Category = QuestCategory.Main, + Period = QuestPeriod.Once, + PrerequisiteQuestId = "main-1", + ObjectiveType = WeeklyQuestObjectiveType.EnterMap, + }, + }, + }; +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs new file mode 100644 index 000000000..bcc73f7bb --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs @@ -0,0 +1,1002 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Persistence.WeeklyQuests; +using MUnique.OpenMU.PlugIns; + +/// +/// Tracks the progress of the quests (story chapters, daily, weekly, class and zone quests) and hands out their rewards. +/// The quests are configured in the custom configuration of this plugin. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.WeeklyQuestsPlugIn_Name), Description = nameof(PlugInResources.WeeklyQuestsPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("3F2C8E61-9B7A-4D25-8E1F-6A4C0D9B7E52")] +public class WeeklyQuestsPlugIn : + IPlayerStateChangedPlugIn, + IAttackableGotKilledPlugIn, + ICharacterLevelUpPlugIn, + ICharacterMasterLevelUpPlugIn, + ICharacterResetPlugIn, + IMiniGameEndedPlugIn, + IItemPickedUpPlugIn, + INpcTalkStartedPlugIn, + IPlayerTalkToNpcPlugIn, + IObjectAddedToMapPlugIn, + IPeriodicTaskPlugIn, + ISupportCustomConfiguration, + ISupportDefaultCustomConfiguration +{ + /// + /// The interval in which the progress of the players is saved. + /// Completed quests and players who leave the game are saved immediately. + /// + private static readonly TimeSpan SaveInterval = TimeSpan.FromSeconds(60); + + /// + /// The interval in which the progress of past periods is deleted, see . + /// + private static readonly TimeSpan PurgeInterval = TimeSpan.FromHours(24); + + /// + /// The repository which is used when no other is registered, e.g. in the demo mode. + /// It's shared, so that all game servers of the process see the same progress. + /// + private static readonly InMemoryWeeklyQuestProgressRepository FallbackRepository = new(); + + private static readonly ConditionalWeakTable States = new(); + + private static readonly int[] Milestones = [25, 50, 75]; + + private readonly IWeeklyQuestProgressRepository? _repository; + + private DateTime _nextSaveUtc = DateTime.UtcNow + SaveInterval; + + /// + /// The first purge happens a while after the start, so that it doesn't slow the start down. + /// + private DateTime _nextPurgeUtc = DateTime.UtcNow + TimeSpan.FromMinutes(10); + + private volatile WeeklyQuestIndex? _index; + + /// + /// Initializes a new instance of the class, + /// which uses the repository of the . + /// + public WeeklyQuestsPlugIn() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// It's internal on purpose: the plugin manager must only see the parameterless constructor. + /// + /// The repository of the progress. + internal WeeklyQuestsPlugIn(IWeeklyQuestProgressRepository repository) + { + this._repository = repository; + } + + /// + public WeeklyQuestsConfiguration? Configuration { get; set; } + + /// + /// Gets or sets the delay after which progress updates are sent to the client. The updates of this time + /// are sent together, instead of one message per kill. Completed steps and quests are sent immediately. + /// + internal TimeSpan UpdateDelay { get; set; } = TimeSpan.FromMilliseconds(500); + + /// + /// Gets the repository. It's resolved on each use, because the host may set it after the plugin has been created. + /// + private IWeeklyQuestProgressRepository Repository => this._repository ?? WeeklyQuestProgressRepositoryRegistry.Current ?? FallbackRepository; + + /// + public object CreateDefaultConfig() => WeeklyQuestsConfiguration.Default; + + /// + /// Gets the plugin which tracks the quests of the player. + /// + /// The player. + /// The plugin, if the player is tracked by one. + public static WeeklyQuestsPlugIn? GetTrackingPlugIn(Player player) + { + return States.TryGetValue(player, out var state) ? state.Owner : null; + } + + /// + public async ValueTask PlayerStateChangedAsync(Player player, State previousState, State currentState) + { + try + { + if (currentState.IsDisconnectedOrFinished() || currentState == PlayerState.CharacterSelection) + { + if (States.TryGetValue(player, out var leavingState)) + { + States.Remove(player); + using var l = await leavingState.Lock.LockAsync().ConfigureAwait(false); + await this.SaveAsync(player, leavingState).ConfigureAwait(false); + } + + return; + } + + if (previousState != PlayerState.CharacterSelection || currentState != PlayerState.EnteredWorld) + { + return; + } + + if (this.GetOrCreateState(player) is not { } state) + { + return; + } + + using (await state.Lock.LockAsync().ConfigureAwait(false)) + { + if (await this.EnsureLoadedAsync(player, state).ConfigureAwait(false)) + { + await this.RewardPendingAsync(player, state, true).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + player.Logger.LogError(ex, "Unexpected error handling the quests at a player state change."); + } + } + + /// + public async ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) + { + var player = killer as Player ?? (killer as Monster)?.SummonedBy; + if (player is null) + { + return; + } + + if (killed is Monster monster) + { + if (monster.SummonedBy is not null || this.GetIndex() is not { } index + || (index.Get(WeeklyQuestObjectiveType.KillAnyMonster).Count == 0 && index.Get(WeeklyQuestObjectiveType.KillMonster).Count == 0)) + { + return; + } + + var mapNumber = monster.CurrentMap?.Definition.Number; + var monsterNumber = monster.Definition.Number; + foreach (var receiver in await this.GetKillReceiversAsync(player).ConfigureAwait(false)) + { + await this.AddProgressAsync(receiver, WeeklyQuestObjectiveType.KillAnyMonster, 1, (_, objective, _) => objective.IsOnMap(mapNumber)).ConfigureAwait(false); + await this.AddProgressAsync( + receiver, + WeeklyQuestObjectiveType.KillMonster, + 1, + (_, objective, _) => objective.IsOnMap(mapNumber) && objective.Monster is not null && objective.Monster.Number == monsterNumber).ConfigureAwait(false); + } + } + else if (killed is Player victim && victim != player) + { + await this.AddProgressAsync( + player, + WeeklyQuestObjectiveType.KillPlayer, + 1, + (quest, objective, state) => CountsAsPlayerKill(quest, objective, state, player, victim)).ConfigureAwait(false); + } + } + + /// + public void CharacterLeveledUp(Player player) + { + if (this.GetIndex()?.Get(WeeklyQuestObjectiveType.GainLevels).Count is not > 0) + { + return; + } + + // This plugin point is synchronous, so we handle it in the background. + _ = Task.Run(async () => + { + try + { + await this.AddProgressAsync(player, WeeklyQuestObjectiveType.GainLevels, 1).ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogError(ex, "Unexpected error when adding quest progress for a level up."); + } + }); + } + + /// + public ValueTask CharacterMasterLeveledUpAsync(Player player) + { + return this.AddProgressAsync(player, WeeklyQuestObjectiveType.GainMasterLevels, 1); + } + + /// + public ValueTask CharacterResetAsync(Player player, int resetCount) + { + return this.AddProgressAsync(player, WeeklyQuestObjectiveType.GainResets, 1); + } + + /// + public async ValueTask MiniGameEndedAsync(MiniGameContext miniGame, ICollection finishers) + { + var type = miniGame.Definition.Type; + foreach (var player in finishers) + { + await this.AddProgressAsync( + player, + WeeklyQuestObjectiveType.CompleteMiniGame, + 1, + (_, objective, _) => objective.MiniGameType == MiniGameType.Undefined || objective.MiniGameType == type).ConfigureAwait(false); + } + } + + /// + public ValueTask ItemPickedUpAsync(Player player, Item item, bool fromPlayerInventory) + { + // Items which were dropped by a player don't count, otherwise the same item could be dropped and picked up again. + if (fromPlayerInventory || item.Definition is not { } definition) + { + return ValueTask.CompletedTask; + } + + var mapNumber = player.CurrentMap?.Definition.Number; + return this.AddProgressAsync( + player, + WeeklyQuestObjectiveType.CollectItem, + 1, + (_, objective, _) => objective.IsOnMap(mapNumber) + && objective.Item is { } questItem + && questItem.Group == definition.Group + && questItem.Number == definition.Number + && item.Level >= objective.MinimumItemLevel); + } + + /// + public ValueTask NpcTalkStartedAsync(Player player, NonPlayerCharacter npc) + { + var npcNumber = npc.Definition.Number; + var mapNumber = npc.CurrentMap?.Definition.Number; + return this.AddProgressAsync( + player, + WeeklyQuestObjectiveType.TalkToNpc, + 1, + (_, objective, _) => objective.Monster is not null && objective.Monster.Number == npcNumber && objective.IsOnMap(mapNumber)); + } + + /// + public ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs) + { + // This is only called for NPCs without a window. An NPC which only exists for a quest would show + // "talking is not implemented", so we mark it as handled. The progress is counted by NpcTalkStartedAsync. + // It has to happen synchronously, because the caller doesn't await this method. + if (player.CurrentMiniGame is null + && this.GetIndex() is { } index + && index.Get(WeeklyQuestObjectiveType.TalkToNpc).Any(o => o.Quest.IsActive && o.Objective.Monster?.Number == npc.Definition.Number)) + { + eventArgs.HasBeenHandled = true; + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask ObjectAddedToMapAsync(GameMap map, ILocateable addedObject) + { + // This is called for every object, including each monster respawn, so it has to return fast. + if (addedObject is not Player player + || this.GetIndex()?.Get(WeeklyQuestObjectiveType.EnterMap).Count is not > 0 + || this.GetOrCreateState(player) is not { } state) + { + return ValueTask.CompletedTask; + } + + // A respawn on the same map isn't entering it. + var mapNumber = map.Definition.Number; + if (state.LastMapNumber == mapNumber) + { + return ValueTask.CompletedTask; + } + + state.LastMapNumber = mapNumber; + return this.AddProgressAsync( + player, + WeeklyQuestObjectiveType.EnterMap, + 1, + (_, objective, _) => objective.Map is not null && objective.Map.Number == mapNumber); + } + + /// + public async ValueTask ExecuteTaskAsync(GameContext gameContext) + { + var now = DateTime.UtcNow; + if (now >= this._nextPurgeUtc) + { + this._nextPurgeUtc = now + PurgeInterval; + await this.PurgeExpiredProgressAsync(gameContext).ConfigureAwait(false); + } + + if (now < this._nextSaveUtc) + { + return; + } + + this._nextSaveUtc = now + SaveInterval; + foreach (var player in await gameContext.GetPlayersAsync().ConfigureAwait(false)) + { + if (!States.TryGetValue(player, out var state)) + { + continue; + } + + try + { + using var l = await state.Lock.LockAsync().ConfigureAwait(false); + await this.SaveAsync(player, state).ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogError(ex, "Unexpected error when saving the quest progress periodically."); + } + } + } + + /// + public void ForceStart() + { + this._nextSaveUtc = DateTime.UtcNow; + } + + /// + /// Gets the overview of the available quests and the progress of the player. + /// + /// The player. + /// The overview; null, if the progress is not available. + public async ValueTask GetOverviewAsync(Player player) + { + if (this.Configuration is not { } configuration || this.GetOrCreateState(player) is not { } state) + { + return null; + } + + using var l = await state.Lock.LockAsync().ConfigureAwait(false); + if (!await this.EnsureLoadedAsync(player, state).ConfigureAwait(false)) + { + return null; + } + + // The player may have freed some inventory space in the meantime. + await this.RewardPendingAsync(player, state, false).ConfigureAwait(false); + + // The available quests may have changed, e.g. by a level up, so the bonus may be reached now. + await this.UpdateAllCompletedBonusAsync(player, state, configuration).ConfigureAwait(false); + + return this.CreateOverview(configuration, state, player); + } + + /// + /// Sends the available quests and the progress of the player to the client, so that it can show them in a window. + /// + /// The player. + public async ValueTask SendListAsync(Player player) + { + if (await this.GetOverviewAsync(player).ConfigureAwait(false) is { } overview) + { + await player.InvokeViewPlugInAsync(p => p.ShowWeeklyQuestsAsync(overview)).ConfigureAwait(false); + } + } + + /// + /// Gets the players for whom a monster kill of the killer counts. + /// + /// The killer. + /// The killer and, if configured, its party members nearby. + internal async ValueTask> GetKillReceiversAsync(Player killer) + { + if (this.Configuration is not { ShareKillsWithParty: true } || killer.Party is not { } party) + { + return [killer]; + } + + // Like the experience: the party members who see the killer, i.e. who are nearby on the same map. + using (await killer.ObserverLock.ReaderLockAsync()) + { + return party.PartyList + .OfType() + .Where(p => p == killer || (p.IsAlive && killer.Observers.Contains(p))) + .ToList(); + } + } + + private static WeeklyQuestCharacterInfo GetCharacterInfo(Player player) + { + return new WeeklyQuestCharacterInfo( + player.SelectedCharacter?.CharacterClass, + player.Level, + (int)(player.Attributes?[Stats.Resets] ?? 0)); + } + + private static bool CountsAsPlayerKill(WeeklyQuestDefinition quest, WeeklyQuestObjective objective, WeeklyQuestPlayerState state, Player killer, Player victim) + { + if (victim.Level < objective.MinimumVictimLevel || !objective.IsOnMap(killer.CurrentMap?.Definition.Number)) + { + return false; + } + + if (objective.IgnoreSameIp + && (killer as IHasIpAddress)?.IpAddress is { Length: > 0 } killerIp + && killerIp == (victim as IHasIpAddress)?.IpAddress) + { + return false; + } + + if (objective.IgnoreSameGuild + && killer.GuildStatus is { } killerGuild + && killerGuild.GuildId == victim.GuildStatus?.GuildId) + { + return false; + } + + if (objective.IgnoreSameParty && killer.Party is not null && killer.Party == victim.Party) + { + return false; + } + + if (objective.VictimCooldownMinutes > 0 && victim.SelectedCharacter is { } victimCharacter) + { + var key = (quest.Id, victimCharacter.GetId()); + var now = DateTime.UtcNow; + if (state.LastCountedVictimKills.TryGetValue(key, out var lastCounted) + && now - lastCounted < TimeSpan.FromMinutes(objective.VictimCooldownMinutes)) + { + return false; + } + + state.LastCountedVictimKills[key] = now; + } + + return true; + } + + private static bool AreObjectivesDone(IReadOnlyList objectives, WeeklyQuestProgress? progress, int count) + { + for (var i = 0; i < count; i++) + { + if ((progress?.GetCount(i) ?? 0) < Math.Max(1, objectives[i].RequiredCount)) + { + return false; + } + } + + return true; + } + + private static WeeklyQuestObjective? GetNextObjective(IReadOnlyList objectives, WeeklyQuestProgress progress) + { + for (var i = 0; i < objectives.Count; i++) + { + if (progress.GetCount(i) < Math.Max(1, objectives[i].RequiredCount)) + { + return objectives[i]; + } + } + + return null; + } + + private static string Format(LocalizedString template, Player player, params object?[] args) + { + var text = template.GetTranslation(player.Culture) ?? string.Empty; + try + { + return string.Format(text, args); + } + catch (FormatException) + { + // A misconfigured message shouldn't break the quest. + return text; + } + } + + private static ValueTask ShowGoldenMessageAsync(Player player, string message) + { + return player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)); + } + + private static (DateTime Weekly, DateTime Daily) GetNextResetsUtc(WeeklyQuestPlayerState state, Player player) + { + var timeZone = player.GameContext.ServerTimeZone; + return (WeeklyPeriod.GetNextPeriodStartUtc(state.Periods.Weekly, timeZone), WeeklyPeriod.GetNextDailyPeriodStartUtc(state.Periods.Daily, timeZone)); + } + + /// + /// Gets the index of the objectives of the current configuration, and builds it again when the configuration changed. + /// + private WeeklyQuestIndex? GetIndex() + { + if (this.Configuration is not { } configuration) + { + return null; + } + + var index = this._index; + if (index is null || index.IsOutdated(configuration)) + { + index = new WeeklyQuestIndex(configuration); + this._index = index; + } + + return index; + } + + /// + /// Gets the available quests of the player. They're cached, because they're needed for every kill, + /// and only change with the level, resets, class, the period or when a quest has been completed. + /// + private HashSet GetAvailableQuestSet(WeeklyQuestsConfiguration configuration, WeeklyQuestIndex index, WeeklyQuestPlayerState state, WeeklyQuestCharacterInfo character) + { + this.GetAvailableQuests(configuration, index, state, character); + return state.AvailableQuestSet!; + } + + private List GetAvailableQuests(WeeklyQuestsConfiguration configuration, WeeklyQuestIndex index, WeeklyQuestPlayerState state, WeeklyQuestCharacterInfo character) + { + var key = (index, character.Class?.Number ?? -1, character.Level, character.Resets); + if (state.AvailableQuests is { } cached && state.AvailableQuestSet is not null && state.AvailableQuestsKey == key) + { + return cached; + } + + var quests = WeeklyQuestSelector.GetAvailableQuests(configuration, state.Periods, character, state.Progress, state.RewardedByOtherCharacters); + state.AvailableQuests = quests; + state.AvailableQuestSet = quests.ToHashSet(); + state.AvailableQuestsKey = key; + return quests; + } + + private WeeklyQuestOverview CreateOverview(WeeklyQuestsConfiguration configuration, WeeklyQuestPlayerState state, Player player) + { + var index = this.GetIndex()!; + var quests = this.GetAvailableQuests(configuration, index, state, GetCharacterInfo(player)); + var entries = WeeklyQuestSelector.CreateEntries(configuration, quests, state.Progress); + var (nextReset, nextDailyReset) = GetNextResetsUtc(state, player); + return new WeeklyQuestOverview(entries, nextReset, nextDailyReset); + } + + private WeeklyQuestPlayerState? GetOrCreateState(Player player) + { + if (States.TryGetValue(player, out var state)) + { + return state; + } + + if (player.SelectedCharacter is not { } character + || player.PlayerState.CurrentState.IsDisconnectedOrFinished() + || player.PlayerState.CurrentState == PlayerState.CharacterSelection) + { + return null; + } + + var accountId = player.Account?.GetId(); + return States.GetValue(player, _ => new WeeklyQuestPlayerState(this, character.GetId(), accountId == Guid.Empty ? null : accountId)); + } + + private async ValueTask AddProgressAsync(Player player, WeeklyQuestObjectiveType objectiveType, int amount, Func? filter = null) + { + if (this.Configuration is not { } configuration || this.GetIndex() is not { } index) + { + return; + } + + // A quick check before the state is loaded, as most events don't concern any quest. + var candidates = index.Get(objectiveType); + if (candidates.Count == 0 || this.GetOrCreateState(player) is not { } state) + { + return; + } + + try + { + using var l = await state.Lock.LockAsync().ConfigureAwait(false); + var previousPeriods = state.IsLoaded ? state.Periods : (QuestPeriodStarts?)null; + if (!await this.EnsureLoadedAsync(player, state).ConfigureAwait(false)) + { + return; + } + + var character = GetCharacterInfo(player); + var available = this.GetAvailableQuestSet(configuration, index, state, character); + var hasCompleted = false; + var sendImmediately = false; + var changedQuests = new List(); + foreach (var (quest, objectiveIndex, objective) in candidates) + { + if (!available.Contains(quest) || !WeeklyQuestSelector.MeetsMinimumRequirements(quest, character)) + { + continue; + } + + state.Progress.TryGetValue(quest.Id, out var progress); + var required = Math.Max(1, objective.RequiredCount); + var previousCount = progress?.GetCount(objectiveIndex) ?? 0; + if (progress?.CompletedAt is not null || previousCount >= required) + { + continue; + } + + var objectives = quest.GetObjectives(); + if (quest.SequentialObjectives + && (!AreObjectivesDone(objectives, progress, objectiveIndex) || changedQuests.Contains(quest))) + { + // Only the current step counts, and one event makes progress in only one step. + continue; + } + + if (filter is not null && !filter(quest, objective, state)) + { + continue; + } + + progress ??= state.GetOrCreateProgress(quest.Id, quest.Period); + var count = (int)Math.Min((long)previousCount + amount, required); + progress.SetCount(objectiveIndex, count); + state.DirtyQuestIds.Add(quest.Id); + if (!changedQuests.Contains(quest)) + { + changedQuests.Add(quest); + } + + if (count >= required) + { + sendImmediately = true; + if (AreObjectivesDone(objectives, progress, objectives.Count)) + { + progress.CompletedAt = DateTime.UtcNow; + hasCompleted = true; + + // The completed quest may be the prerequisite of another one. + state.InvalidateAvailableQuests(); + await this.TryRewardAsync(player, quest, progress, true).ConfigureAwait(false); + } + else if (GetNextObjective(objectives, progress) is { } next) + { + await ShowGoldenMessageAsync(player, Format(configuration.StepCompletedMessage, player, quest.Name, next.GetDisplayText(player.Culture))).ConfigureAwait(false); + } + } + else if (Milestones.Any(m => previousCount * 100 / required < m && count * 100 / required >= m)) + { + await player.ShowBlueMessageAsync(Format(configuration.ProgressMessage, player, quest.Name, count, required)).ConfigureAwait(false); + } + } + + if (hasCompleted && await this.UpdateAllCompletedBonusAsync(player, state, configuration).ConfigureAwait(false) is { } bonusQuest) + { + changedQuests.Add(bonusQuest); + } + + // A completion is saved immediately, so that it can't get lost. + if (hasCompleted) + { + await this.SaveAsync(player, state).ConfigureAwait(false); + } + + var isNewPeriod = previousPeriods is not null && previousPeriods != state.Periods; + await this.SendChangesAsync(player, state, configuration, changedQuests, isNewPeriod, sendImmediately).ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogError(ex, "Unexpected error when adding quest progress of type {objectiveType}.", objectiveType); + } + } + + /// + /// Updates the progress of the , and rewards it when all weekly quests are completed. + /// + /// The quest of the bonus, if its progress changed. + private async ValueTask UpdateAllCompletedBonusAsync(Player player, WeeklyQuestPlayerState state, WeeklyQuestsConfiguration configuration) + { + if (!WeeklyQuestSelector.HasAllCompletedBonus(configuration)) + { + return null; + } + + var quests = this.GetAvailableQuests(configuration, this.GetIndex()!, state, GetCharacterInfo(player)) + .Where(q => q.Period == QuestPeriod.Weekly) + .ToList(); + if (quests.Count == 0 || WeeklyQuestSelector.CreateAllCompletedBonusQuest(configuration, quests.Count) is not { } bonusQuest) + { + return null; + } + + var completedCount = quests.Count(q => state.Progress.TryGetValue(q.Id, out var p) && p.CompletedAt is not null); + var progress = state.GetOrCreateProgress(bonusQuest.Id, QuestPeriod.Weekly); + if (progress.CompletedAt is not null || progress.Count == completedCount) + { + return null; + } + + progress.Count = completedCount; + state.DirtyQuestIds.Add(bonusQuest.Id); + if (completedCount >= quests.Count) + { + progress.CompletedAt = DateTime.UtcNow; + await this.TryRewardAsync(player, bonusQuest, progress, true).ConfigureAwait(false); + await this.SaveAsync(player, state).ConfigureAwait(false); + } + + return bonusQuest; + } + + private async ValueTask SendChangesAsync(Player player, WeeklyQuestPlayerState state, WeeklyQuestsConfiguration configuration, List changedQuests, bool isNewPeriod, bool sendImmediately) + { + // After a reset, all quests of the client are outdated, not only the changed ones. + if (isNewPeriod) + { + state.PendingUpdateIds.Clear(); + var overview = this.CreateOverview(configuration, state, player); + await player.InvokeViewPlugInAsync(p => p.ShowWeeklyQuestsAsync(overview)).ConfigureAwait(false); + return; + } + + if (changedQuests.Count == 0) + { + return; + } + + foreach (var quest in changedQuests) + { + state.PendingUpdateIds.Add(quest.Id); + } + + if (sendImmediately) + { + await this.SendPendingUpdatesAsync(player, state, configuration).ConfigureAwait(false); + return; + } + + // Many kills in a short time are sent as one update per quest, instead of one message per kill. + if (!state.IsUpdateScheduled) + { + state.IsUpdateScheduled = true; + _ = this.SendPendingUpdatesLaterAsync(player, state); + } + } + + private async Task SendPendingUpdatesLaterAsync(Player player, WeeklyQuestPlayerState state) + { + try + { + await Task.Delay(this.UpdateDelay).ConfigureAwait(false); + using var l = await state.Lock.LockAsync().ConfigureAwait(false); + state.IsUpdateScheduled = false; + if (this.Configuration is { } configuration + && States.TryGetValue(player, out var currentState) + && currentState == state) + { + await this.SendPendingUpdatesAsync(player, state, configuration).ConfigureAwait(false); + } + } + catch (Exception ex) + { + player.Logger.LogError(ex, "Unexpected error when sending the quest progress."); + } + } + + private async ValueTask SendPendingUpdatesAsync(Player player, WeeklyQuestPlayerState state, WeeklyQuestsConfiguration configuration) + { + if (state.PendingUpdateIds.Count == 0 || this.GetIndex() is not { } index) + { + return; + } + + var entries = new List(state.PendingUpdateIds.Count); + foreach (var questId in state.PendingUpdateIds) + { + var quest = questId == WeeklyQuestSelector.AllCompletedBonusId + ? WeeklyQuestSelector.CreateAllCompletedBonusQuest(configuration, WeeklyQuestSelector.CountBonusQuests(this.GetAvailableQuests(configuration, index, state, GetCharacterInfo(player)))) + : index.FindQuest(questId); + if (quest is not null) + { + entries.Add(WeeklyQuestSelector.CreateEntry(quest, state.Progress)); + } + } + + state.PendingUpdateIds.Clear(); + var (nextReset, nextDailyReset) = GetNextResetsUtc(state, player); + foreach (var entry in entries) + { + await player.InvokeViewPlugInAsync(p => p.UpdateWeeklyQuestAsync(entry, nextReset, nextDailyReset)).ConfigureAwait(false); + } + } + + private async ValueTask EnsureLoadedAsync(Player player, WeeklyQuestPlayerState state) + { + if (this.Configuration is not { } configuration) + { + return false; + } + + var periods = WeeklyPeriod.GetPeriodStarts(DateTime.UtcNow, configuration, player.GameContext.ServerTimeZone); + if (state.IsLoaded && state.Periods == periods) + { + return true; + } + + try + { + if (state.IsLoaded) + { + // A new day or week began while the player is online. The progress of the last period is saved first. + await this.SaveAsync(player, state).ConfigureAwait(false); + state.IsLoaded = false; + } + + // One query for the progress of all periods: the current day, week and the quests which are done once. + var loaded = await this.Repository.LoadAsync(state.CharacterId, periods.All).ConfigureAwait(false); + state.RewardedByOtherCharacters = await this.LoadRewardedByOtherCharactersAsync(state, periods).ConfigureAwait(false); + state.Progress = this.SelectCurrentProgress(loaded, periods); + state.Periods = periods; + state.DirtyQuestIds.Clear(); + state.InvalidateAvailableQuests(); + state.IsLoaded = true; + return true; + } + catch (Exception ex) + { + player.Logger.LogWarning(ex, "Couldn't load the quest progress of character {characterId}.", state.CharacterId); + return false; + } + } + + /// + /// Selects the progress entries which belong to the current period of their quest. + /// + /// + /// When the period of a quest was changed in the configuration, there may be entries of the same quest + /// in several of the loaded periods. Only the one of the configured period counts. + /// + private Dictionary SelectCurrentProgress(IEnumerable loaded, QuestPeriodStarts periods) + { + var index = this.GetIndex(); + var result = new Dictionary(); + foreach (var progress in loaded) + { + var period = progress.QuestId == WeeklyQuestSelector.AllCompletedBonusId + ? QuestPeriod.Weekly + : index?.FindQuest(progress.QuestId)?.Period; + if (period is { } p && progress.PeriodStart == periods.Get(p)) + { + result[progress.QuestId] = progress; + } + } + + return result; + } + + private async ValueTask> LoadRewardedByOtherCharactersAsync(WeeklyQuestPlayerState state, QuestPeriodStarts periods) + { + // Only one character of an account can be in the game at the same time, so loading it once per period is enough. + if (state.AccountId is not { } accountId + || this.Configuration?.Quests.Any(q => q.OncePerAccount) != true) + { + return new HashSet(); + } + + var index = this.GetIndex(); + var rewarded = await this.Repository.LoadRewardedByAccountAsync(accountId, periods.All).ConfigureAwait(false); + return rewarded + .Where(p => p.CharacterId != state.CharacterId + && index?.FindQuest(p.QuestId) is { } quest + && p.PeriodStart == periods.Get(quest.Period)) + .Select(p => p.QuestId) + .ToHashSet(); + } + + private async ValueTask SaveAsync(Player player, WeeklyQuestPlayerState state) + { + if (state.DirtyQuestIds.Count == 0) + { + return; + } + + var entries = state.DirtyQuestIds + .Where(state.Progress.ContainsKey) + .Select(id => state.Progress[id]) + .ToList(); + try + { + await this.Repository.SaveAsync(entries).ConfigureAwait(false); + state.DirtyQuestIds.Clear(); + } + catch (Exception ex) + { + // The entries stay dirty, so they're saved at the next attempt. + player.Logger.LogError(ex, "Couldn't save the quest progress of character {characterId}.", state.CharacterId); + } + } + + private async ValueTask PurgeExpiredProgressAsync(GameContext gameContext) + { + if (this.Configuration is not { HistoryWeeks: > 0 } configuration) + { + return; + } + + try + { + var olderThan = DateTime.UtcNow.AddDays(-7 * configuration.HistoryWeeks); + var deleted = await this.Repository.DeleteExpiredAsync(olderThan, WeeklyPeriod.OncePeriodStartUtc).ConfigureAwait(false); + if (deleted > 0) + { + gameContext.LoggerFactory.CreateLogger().LogInformation("Deleted {count} quest progress entries of periods before {olderThan}.", deleted, olderThan); + } + } + catch (Exception ex) + { + gameContext.LoggerFactory.CreateLogger().LogWarning(ex, "Couldn't delete the quest progress of past periods."); + } + } + + private async ValueTask RewardPendingAsync(Player player, WeeklyQuestPlayerState state, bool showPendingMessage) + { + if (this.Configuration is not { } configuration) + { + return; + } + + var index = this.GetIndex(); + var rewarded = false; + foreach (var progress in state.Progress.Values.Where(p => p.CompletedAt is not null && p.RewardedAt is null).ToList()) + { + var quest = progress.QuestId == WeeklyQuestSelector.AllCompletedBonusId + ? WeeklyQuestSelector.CreateAllCompletedBonusQuest(configuration, progress.Count) + : index?.FindQuest(progress.QuestId); + if (quest is not null) + { + rewarded |= await this.TryRewardAsync(player, quest, progress, showPendingMessage).ConfigureAwait(false); + } + } + + if (rewarded) + { + await this.SaveAsync(player, state).ConfigureAwait(false); + } + } + + private async ValueTask TryRewardAsync(Player player, WeeklyQuestDefinition quest, WeeklyQuestProgress progress, bool showPendingMessage) + { + var configuration = this.Configuration!; + if (!await WeeklyQuestRewarder.TryGiveRewardsAsync(player, quest).ConfigureAwait(false)) + { + if (showPendingMessage) + { + await player.ShowBlueMessageAsync(Format(configuration.RewardPendingMessage, player, quest.Name)).ConfigureAwait(false); + } + + return false; + } + + progress.RewardedAt = DateTime.UtcNow; + if (States.TryGetValue(player, out var state)) + { + state.DirtyQuestIds.Add(progress.QuestId); + } + + await ShowGoldenMessageAsync(player, Format(configuration.CompletedMessage, player, quest.Name)).ConfigureAwait(false); + player.Logger.LogInformation("Character {characterId} completed the quest {quest}.", progress.CharacterId, quest.Id); + return true; + } +} diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 02d82f39d..026471ee4 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -3616,5 +3616,41 @@ public static string CastleSiegeLifeStoneConsumeHandlerPlugIn_Name { return ResourceManager.GetString("CastleSiegeLifeStoneConsumeHandlerPlugIn_Name", resourceCulture); } } + + /// + /// Looks up a localized string similar to Weekly Quests. + /// + public static string WeeklyQuestsPlugIn_Name { + get { + return ResourceManager.GetString("WeeklyQuestsPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Quests semanales con objetivos y premios configurables.. + /// + public static string WeeklyQuestsPlugIn_Description { + get { + return ResourceManager.GetString("WeeklyQuestsPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Weekly quests chat command. + /// + public static string WeeklyQuestsChatCommandPlugIn_Name { + get { + return ResourceManager.GetString("WeeklyQuestsChatCommandPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Handles the chat command '/weekly'. Shows the progress of the weekly quests.. + /// + public static string WeeklyQuestsChatCommandPlugIn_Description { + get { + return ResourceManager.GetString("WeeklyQuestsChatCommandPlugIn_Description", resourceCulture); + } + } } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index a5b2ce76a..28e41d50e 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1302,4 +1302,16 @@ Configures the run of the doppelganger event, e.g. its monsters, their paths and multipliers. + + Weekly Quests + + + Quests de historia, diarias, semanales, de clase y de zona, con uno o varios pasos (kills, niveles, resets, eventos, PvP, items, hablar con NPCs, entrar a mapas) y premios configurables. + + + Weekly quests chat command + + + Handles the chat command '/weekly'. Shows the progress of the weekly quests. + diff --git a/src/GameLogic/Resets/ResetCharacterAction.cs b/src/GameLogic/Resets/ResetCharacterAction.cs index 5b18b0666..03a6159b0 100644 --- a/src/GameLogic/Resets/ResetCharacterAction.cs +++ b/src/GameLogic/Resets/ResetCharacterAction.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.GameLogic.Resets; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.PlayerActions; +using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.Views.Character; using MUnique.OpenMU.GameLogic.Views.Login; using MUnique.OpenMU.GameLogic.Views.NPC; @@ -85,6 +86,12 @@ public async ValueTask ResetCharacterAsync() this._player.Attributes[Stats.Level] = configuration.LevelAfterReset; this._player.SelectedCharacter.Experience = 0; this.UpdateStats(configuration, resetProgression); + + if (this._player.GameContext.PlugInManager.GetPlugInPoint() is { } resetPlugInPoint) + { + await resetPlugInPoint.CharacterResetAsync(this._player, resetProgression.NextResetCount).ConfigureAwait(false); + } + if (configuration.MoveHome) { await this.MoveHomeAsync().ConfigureAwait(false); diff --git a/src/GameLogic/Views/ISkillRequirementsViewPlugIn.cs b/src/GameLogic/Views/ISkillRequirementsViewPlugIn.cs new file mode 100644 index 000000000..c1c26c038 --- /dev/null +++ b/src/GameLogic/Views/ISkillRequirementsViewPlugIn.cs @@ -0,0 +1,18 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Views; + +/// +/// Interface of a view whose client takes the requirements of the skills, and of the items which +/// teach them, from the server, instead of its own data files. +/// +public interface ISkillRequirementsViewPlugIn : IViewPlugIn +{ + /// + /// Sends the requirements and costs of all skills and the requirements to learn them with items, + /// as the server checks them. The client keeps them for the rest of the session. + /// + ValueTask ShowSkillRequirementsAsync(); +} diff --git a/src/GameLogic/Views/IWeeklyQuestListViewPlugIn.cs b/src/GameLogic/Views/IWeeklyQuestListViewPlugIn.cs new file mode 100644 index 000000000..ac6555f8f --- /dev/null +++ b/src/GameLogic/Views/IWeeklyQuestListViewPlugIn.cs @@ -0,0 +1,27 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Views; + +using MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; + +/// +/// Interface of a view whose client can show the weekly quests and their progress in a window. +/// +public interface IWeeklyQuestListViewPlugIn : IViewPlugIn +{ + /// + /// Shows the active weekly quests and the progress of the player. It replaces the quests which the client knew before. + /// + /// The overview of the weekly quests. + ValueTask ShowWeeklyQuestsAsync(WeeklyQuestOverview overview); + + /// + /// Updates the progress of one quest which the client already knows. + /// + /// The entry of the quest. + /// The point in time (UTC) when the next week starts. + /// The point in time (UTC) when the next day starts. + ValueTask UpdateWeeklyQuestAsync(WeeklyQuestOverviewEntry entry, DateTime nextResetUtc, DateTime nextDailyResetUtc); +} diff --git a/src/GameServer/MessageHandler/ChatCommandListRequestHandlerPlugIn.cs b/src/GameServer/MessageHandler/ChatCommandListRequestHandlerPlugIn.cs index bbdcda88f..ab7f9368e 100644 --- a/src/GameServer/MessageHandler/ChatCommandListRequestHandlerPlugIn.cs +++ b/src/GameServer/MessageHandler/ChatCommandListRequestHandlerPlugIn.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.GameServer.MessageHandler; using System.Runtime.InteropServices; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; +using MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.Network.Packets.ClientToServer; using MUnique.OpenMU.Network.PlugIns; @@ -38,5 +39,13 @@ public async ValueTask HandlePacketAsync(Player player, Memory packet) var commands = player.GetAvailableChatCommandInfos().ToList(); await player.InvokeViewPlugInAsync(p => p.ShowChatCommandListAsync(commands)).ConfigureAwait(false); + + // The client asks for its lists when it entered the map, which makes this the moment + // where it's ready to receive the weekly quests, too. + if (player.GameContext.PlugInManager.IsPlugInActive(typeof(WeeklyQuestsPlugIn).GUID) + && WeeklyQuestsPlugIn.GetTrackingPlugIn(player) is { } weeklyQuests) + { + await weeklyQuests.SendListAsync(player).ConfigureAwait(false); + } } } diff --git a/src/GameServer/Properties/PlugInResources.Designer.cs b/src/GameServer/Properties/PlugInResources.Designer.cs index c95bd29cf..14b3317bb 100644 --- a/src/GameServer/Properties/PlugInResources.Designer.cs +++ b/src/GameServer/Properties/PlugInResources.Designer.cs @@ -7151,5 +7151,41 @@ public static string CastleSiegeLifeStoneStatePlugIn_Name { return ResourceManager.GetString("CastleSiegeLifeStoneStatePlugIn_Name", resourceCulture); } } + + /// + /// Looks up a localized string similar to Weekly quest list view. + /// + public static string WeeklyQuestListViewPlugIn_Name { + get { + return ResourceManager.GetString("WeeklyQuestListViewPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends the weekly quests and their progress, so that the client can show them in a window.. + /// + public static string WeeklyQuestListViewPlugIn_Description { + get { + return ResourceManager.GetString("WeeklyQuestListViewPlugIn_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Skill requirements view. + /// + public static string SkillRequirementsViewPlugIn_Name { + get { + return ResourceManager.GetString("SkillRequirementsViewPlugIn_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends the requirements of the skills and of the items which teach them after the login, so that the client shows and checks the ones of the server instead of its own data files.. + /// + public static string SkillRequirementsViewPlugIn_Description { + get { + return ResourceManager.GetString("SkillRequirementsViewPlugIn_Description", resourceCulture); + } + } } } diff --git a/src/GameServer/Properties/PlugInResources.resx b/src/GameServer/Properties/PlugInResources.resx index 5b4e5b0a4..98590b4e0 100644 --- a/src/GameServer/Properties/PlugInResources.resx +++ b/src/GameServer/Properties/PlugInResources.resx @@ -2481,4 +2481,16 @@ Sends Castle Siege Life Stone creation progress to the game client. + + Weekly quest list view + + + Sends the weekly quests and their progress, so that the client can show them in a window. + + + Skill requirements view + + + Sends the requirements of the skills and of the items which teach them after the login, so that the client shows and checks the ones of the server instead of its own data files. + diff --git a/src/GameServer/RemoteView/SkillRequirementsViewPlugIn.cs b/src/GameServer/RemoteView/SkillRequirementsViewPlugIn.cs new file mode 100644 index 000000000..5c004e194 --- /dev/null +++ b/src/GameServer/RemoteView/SkillRequirementsViewPlugIn.cs @@ -0,0 +1,164 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.RemoteView; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which sends one +/// message with all skills, and one +/// message with all items which teach a skill. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.SkillRequirementsViewPlugIn_Name), Description = nameof(PlugInResources.SkillRequirementsViewPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("3E9B2D71-8C4A-4F06-B5D8-1A7E6C0F92B4")] +[MinimumClient(106, 3, ClientLanguage.Invariant)] +public class SkillRequirementsViewPlugIn : ISkillRequirementsViewPlugIn +{ + /// + /// The value of for requirements which apply to every item level. + /// + private const byte AnyItemLevel = 0xFF; + + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public SkillRequirementsViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask ShowSkillRequirementsAsync() + { + if (this._player.Connection is not { Connected: true } connection) + { + return; + } + + var configuration = this._player.GameContext.Configuration; + await SendSkillsAsync(connection, configuration).ConfigureAwait(false); + await SendItemsAsync(connection, configuration).ConfigureAwait(false); + } + + private static ValueTask SendSkillsAsync(IConnection connection, GameConfiguration configuration) + { + // Both counts have to fit into the C2 message, which the client couldn't read otherwise. + var maximumCount = (ushort.MaxValue - SkillRequirementsRef.GetRequiredSize(0)) / SkillRequirementsRef.SkillRequirementRef.Length; + var skills = configuration.Skills + .Where(s => s.Number >= 0) + .OrderBy(s => s.Number) + .Take(maximumCount) + .ToList(); + + int Write() + { + var size = SkillRequirementsRef.GetRequiredSize(skills.Count); + var span = connection.Output.GetSpan(size)[..size]; + var packet = new SkillRequirementsRef(span) + { + SkillCount = (ushort)skills.Count, + }; + + for (var i = 0; i < skills.Count; i++) + { + var skill = skills[i]; + var target = packet[i]; + target.SkillNumber = (ushort)skill.Number; + target.Level = GetValue(skill.Requirements, Stats.Level); + target.Energy = GetValue(skill.Requirements, Stats.TotalEnergy); + target.Leadership = GetValue(skill.Requirements, Stats.TotalLeadership); + target.Strength = GetValue(skill.Requirements, Stats.TotalStrength); + target.Agility = GetValue(skill.Requirements, Stats.TotalAgility); + target.Mana = GetValue(skill.ConsumeRequirements, Stats.CurrentMana); + target.AbilityGauge = GetValue(skill.ConsumeRequirements, Stats.CurrentAbility); + } + + return size; + } + + return connection.SendAsync(Write); + } + + private static ValueTask SendItemsAsync(IConnection connection, GameConfiguration configuration) + { + var maximumCount = (ushort.MaxValue - LearnableItemRequirementsRef.GetRequiredSize(0)) / LearnableItemRequirementsRef.LearnableItemRequirementRef.Length; + var entries = GetLearnableItems(configuration).Take(maximumCount).ToList(); + + int Write() + { + var size = LearnableItemRequirementsRef.GetRequiredSize(entries.Count); + var span = connection.Output.GetSpan(size)[..size]; + var packet = new LearnableItemRequirementsRef(span) + { + ItemCount = (ushort)entries.Count, + }; + + for (var i = 0; i < entries.Count; i++) + { + var (definition, itemLevel, skill) = entries[i]; + var requirements = LearnableSkillRequirements.GetRequirements(definition, skill); + var target = packet[i]; + target.Group = definition.Group; + target.ItemLevel = itemLevel; + target.Number = (ushort)definition.Number; + target.Level = GetValue(requirements, Stats.Level); + target.Energy = GetValue(requirements, Stats.TotalEnergy); + target.Leadership = GetValue(requirements, Stats.TotalLeadership); + target.Strength = GetValue(requirements, Stats.TotalStrength); + target.Agility = GetValue(requirements, Stats.TotalAgility); + target.SkillNumber = (ushort)skill.Number; + } + + return size; + } + + return connection.SendAsync(Write); + } + + /// + /// Gets the items which teach a skill when they're consumed, with the item level they're for. + /// + private static IEnumerable<(ItemDefinition Definition, byte ItemLevel, Skill Skill)> GetLearnableItems(GameConfiguration configuration) + { + foreach (var definition in configuration.Items.Where(d => d.Skill is not null && d.ItemSlot is null).OrderBy(d => d.Group).ThenBy(d => d.Number)) + { + if (!LearnableSkillRequirements.IsSummoningOrb(definition)) + { + yield return (definition, AnyItemLevel, definition.Skill!); + continue; + } + + for (var level = 0; level <= definition.MaximumItemLevel && level < AnyItemLevel; level++) + { + if (LearnableSkillRequirements.GetLearnableSkill(definition, (byte)level, configuration) is { } skill) + { + yield return (definition, (byte)level, skill); + } + } + } + } + + private static ushort GetValue(IEnumerable requirements, AttributeDefinition attribute) + { + var value = requirements.Where(r => r.Attribute == attribute).Select(r => r.MinimumValue).DefaultIfEmpty(0).Max(); + return (ushort)Math.Clamp(value, 0, ushort.MaxValue); + } + + private static ushort GetValue(IReadOnlyDictionary requirements, AttributeDefinition attribute) + { + return requirements.TryGetValue(attribute, out var value) ? (ushort)Math.Clamp(value, 0, ushort.MaxValue) : (ushort)0; + } +} diff --git a/src/GameServer/RemoteView/WeeklyQuestListViewPlugIn.cs b/src/GameServer/RemoteView/WeeklyQuestListViewPlugIn.cs new file mode 100644 index 000000000..828ea1cc6 --- /dev/null +++ b/src/GameServer/RemoteView/WeeklyQuestListViewPlugIn.cs @@ -0,0 +1,183 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.RemoteView; + +using System.Runtime.InteropServices; +using System.Text; +using MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.Network.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which sends +/// one message per quest to the game client, each followed by +/// a message with its category and objectives. +/// +[PlugIn] +[Display(Name = nameof(PlugInResources.WeeklyQuestListViewPlugIn_Name), Description = nameof(PlugInResources.WeeklyQuestListViewPlugIn_Description), ResourceType = typeof(PlugInResources))] +[Guid("C4F1A7B2-6E3D-4B89-9A0C-5D2E8F7B1A36")] +[MinimumClient(106, 3, ClientLanguage.Invariant)] +public class WeeklyQuestListViewPlugIn : IWeeklyQuestListViewPlugIn +{ + /// + /// The maximum number of objectives which are sent per quest. More wouldn't fit into the window anyway. + /// + private const int MaximumObjectives = 16; + + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public WeeklyQuestListViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask ShowWeeklyQuestsAsync(WeeklyQuestOverview overview) + { + if (this._player.Connection is not { Connected: true } connection) + { + return; + } + + var seconds = GetSecondsUntil(overview.NextResetUtc); + + // The client can't count more than a byte, and nobody reads that many quests anyway. + var entries = overview.Entries.Take(byte.MaxValue).ToList(); + if (entries.Count == 0) + { + // An empty list is sent as one message without a quest, so that the client forgets the previous ones. + await this.SendEntryAsync(connection, null, 0, 0, false, seconds).ConfigureAwait(false); + return; + } + + for (var i = 0; i < entries.Count; i++) + { + await this.SendEntryAsync(connection, entries[i], (byte)i, (byte)entries.Count, false, seconds).ConfigureAwait(false); + await this.SendDetailsAsync(connection, entries[i], overview.NextResetUtc, overview.NextDailyResetUtc).ConfigureAwait(false); + } + } + + /// + public async ValueTask UpdateWeeklyQuestAsync(WeeklyQuestOverviewEntry entry, DateTime nextResetUtc, DateTime nextDailyResetUtc) + { + if (this._player.Connection is not { Connected: true } connection) + { + return; + } + + await this.SendEntryAsync(connection, entry, 0, 1, true, GetSecondsUntil(nextResetUtc)).ConfigureAwait(false); + await this.SendDetailsAsync(connection, entry, nextResetUtc, nextDailyResetUtc).ConfigureAwait(false); + } + + private static uint GetSecondsUntil(DateTime utc) + { + return (uint)Math.Clamp((utc - DateTime.UtcNow).TotalSeconds, 0, uint.MaxValue); + } + + /// + /// Cuts the text, so that it fits into a string field of the specified size, without cutting a character in half. + /// + private static string Fit(string? text, int maxBytes) + { + text ??= string.Empty; + if (Encoding.UTF8.GetByteCount(text) <= maxBytes) + { + return text; + } + + var length = Math.Min(text.Length, maxBytes); + while (length > 0 && Encoding.UTF8.GetByteCount(text.AsSpan(0, length)) > maxBytes) + { + length--; + } + + if (length > 0 && char.IsHighSurrogate(text[length - 1])) + { + length--; + } + + return text[..length]; + } + + private ValueTask SendEntryAsync(IConnection connection, WeeklyQuestOverviewEntry? entry, byte index, byte count, bool isUpdate, uint secondsUntilReset) + { + var culture = this._player.Culture; + + int Write() + { + var size = WeeklyQuestEntryRef.Length; + var span = connection.Output.GetSpan(size)[..size]; + var packet = new WeeklyQuestEntryRef(span) + { + Index = index, + Count = count, + IsUpdate = isUpdate, + SecondsUntilReset = secondsUntilReset, + }; + + if (entry is not null) + { + packet.IsCompleted = entry.IsCompleted; + packet.IsRewarded = entry.IsRewarded; + packet.CurrentCount = (uint)Math.Max(0, entry.Count); + packet.RequiredCount = (uint)Math.Max(0, entry.Required); + packet.Id = Fit(entry.Quest.Id, 64); + packet.Name = Fit(entry.Quest.Name, 48); + packet.Description = Fit(entry.Quest.Description, 256); + packet.Rewards = Fit(entry.Quest.GetRewardsText(culture), 128); + } + + return size; + } + + return connection.SendAsync(Write); + } + + private ValueTask SendDetailsAsync(IConnection connection, WeeklyQuestOverviewEntry entry, DateTime nextResetUtc, DateTime nextDailyResetUtc) + { + var culture = this._player.Culture; + var objectives = entry.Objectives.Take(MaximumObjectives).ToList(); + var secondsUntilReset = entry.Quest.Period switch + { + QuestPeriod.Once => 0u, + QuestPeriod.Daily => GetSecondsUntil(nextDailyResetUtc), + _ => GetSecondsUntil(nextResetUtc), + }; + + int Write() + { + var size = QuestDetailsRef.GetRequiredSize(objectives.Count); + var span = connection.Output.GetSpan(size)[..size]; + var packet = new QuestDetailsRef(span) + { + Category = (byte)entry.Quest.Category, + Period = (byte)entry.Quest.Period, + CurrentStep = (byte)Math.Clamp(entry.CurrentStep, 0, objectives.Count), + ObjectiveCount = (byte)objectives.Count, + IsSequential = entry.Quest.SequentialObjectives, + SecondsUntilReset = secondsUntilReset, + Id = Fit(entry.Quest.Id, 64), + }; + + for (var i = 0; i < objectives.Count; i++) + { + var objective = objectives[i]; + var target = packet[i]; + target.CurrentCount = (uint)Math.Max(0, objective.Count); + target.RequiredCount = (uint)Math.Max(0, objective.Required); + target.IsDone = objective.IsDone; + target.Text = Fit(objective.Objective.GetDisplayText(culture), 64); + } + + return size; + } + + return connection.SendAsync(Write); + } +} diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index 43deceab9..95f61adbd 100644 --- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs +++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs @@ -6464,6 +6464,56 @@ int WritePacket() await connection.SendAsync(WritePacket).ConfigureAwait(false); } + /// + /// Sends a to this connection. + /// + /// The connection. + /// The index of this quest within the list, starting at 0. The first message of a list replaces the previously known quests. + /// The total number of quests. A list without quests is sent as a single message with a count of 0. + /// If true, this message updates the already known quest with the same id, instead of being part of a list. + /// The objective of the quest has been reached. + /// The rewards of the quest have been handed out. If the quest is completed but not rewarded, the reward is pending, e.g. because the inventory was full. + /// The current count. + /// The required count. + /// The seconds until the weekly progress is reset. + /// The identifier of the quest. + /// The name. + /// The description. + /// The rewards of the quest as text, in the language of the player. + /// + /// Is sent by the server when: After the client requested the list of available chat commands, one message is sent for each active weekly quest. When the progress of a quest changes, a single message is sent as update of this quest. + /// Causes reaction on client side: The client shows the weekly quests and their progress in a window. + /// + public static async ValueTask SendWeeklyQuestEntryAsync(this IConnection? connection, byte @index, byte @count, bool @isUpdate, bool @isCompleted, bool @isRewarded, uint @currentCount, uint @requiredCount, uint @secondsUntilReset, string @id, string @name, string @description, string @rewards) + { + if (connection is null) + { + return; + } + + int WritePacket() + { + var length = WeeklyQuestEntryRef.Length; + var packet = new WeeklyQuestEntryRef(connection.Output.GetSpan(length)[..length]); + packet.Index = @index; + packet.Count = @count; + packet.IsUpdate = @isUpdate; + packet.IsCompleted = @isCompleted; + packet.IsRewarded = @isRewarded; + packet.CurrentCount = @currentCount; + packet.RequiredCount = @requiredCount; + packet.SecondsUntilReset = @secondsUntilReset; + packet.Id = @id; + packet.Name = @name; + packet.Description = @description; + packet.Rewards = @rewards; + + return packet.Header.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + /// /// Sends a to this connection. /// diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs index 9436b1ad1..3fe1a9fdb 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -31697,6 +31697,782 @@ public string ValidValues } +/// +/// Is sent by the server when: After the client requested the list of available chat commands, one message is sent for each active weekly quest. When the progress of a quest changes, a single message is sent as update of this quest. +/// Causes reaction on client side: The client shows the weekly quests and their progress in a window. +/// +public readonly struct WeeklyQuestEntry +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public WeeklyQuestEntry(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private WeeklyQuestEntry(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x02; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 520; + + /// + /// Gets the header of this packet. + /// + public C2HeaderWithSubCode Header => new (this._data); + + /// + /// Gets or sets the index of this quest within the list, starting at 0. The first message of a list replaces the previously known quests. + /// + public byte Index + { + get => this._data.Span[5]; + set => this._data.Span[5] = value; + } + + /// + /// Gets or sets the total number of quests. A list without quests is sent as a single message with a count of 0. + /// + public byte Count + { + get => this._data.Span[6]; + set => this._data.Span[6] = value; + } + + /// + /// Gets or sets if true, this message updates the already known quest with the same id, instead of being part of a list. + /// + public bool IsUpdate + { + get => this._data.Span[7..].GetBoolean(); + set => this._data.Span[7..].SetBoolean(value); + } + + /// + /// Gets or sets the objective of the quest has been reached. + /// + public bool IsCompleted + { + get => this._data.Span[8..].GetBoolean(); + set => this._data.Span[8..].SetBoolean(value); + } + + /// + /// Gets or sets the rewards of the quest have been handed out. If the quest is completed but not rewarded, the reward is pending, e.g. because the inventory was full. + /// + public bool IsRewarded + { + get => this._data.Span[9..].GetBoolean(); + set => this._data.Span[9..].SetBoolean(value); + } + + /// + /// Gets or sets the current count. + /// + public uint CurrentCount + { + get => ReadUInt32LittleEndian(this._data.Span[12..]); + set => WriteUInt32LittleEndian(this._data.Span[12..], value); + } + + /// + /// Gets or sets the required count. + /// + public uint RequiredCount + { + get => ReadUInt32LittleEndian(this._data.Span[16..]); + set => WriteUInt32LittleEndian(this._data.Span[16..], value); + } + + /// + /// Gets or sets the seconds until the weekly progress is reset. + /// + public uint SecondsUntilReset + { + get => ReadUInt32LittleEndian(this._data.Span[20..]); + set => WriteUInt32LittleEndian(this._data.Span[20..], value); + } + + /// + /// Gets or sets the identifier of the quest. + /// + public string Id + { + get => this._data.Span.ExtractString(24, 64, System.Text.Encoding.UTF8); + set => this._data.Slice(24, 64).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the name. + /// + public string Name + { + get => this._data.Span.ExtractString(88, 48, System.Text.Encoding.UTF8); + set => this._data.Slice(88, 48).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the description. + /// + public string Description + { + get => this._data.Span.ExtractString(136, 256, System.Text.Encoding.UTF8); + set => this._data.Slice(136, 256).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the rewards of the quest as text, in the language of the player. + /// + public string Rewards + { + get => this._data.Span.ExtractString(392, 128, System.Text.Encoding.UTF8); + set => this._data.Slice(392, 128).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator WeeklyQuestEntry(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(WeeklyQuestEntry packet) => packet._data; +} + + +/// +/// Is sent by the server when: Right after each WeeklyQuestEntry message, with the same quest id. Clients which don't know it can ignore it and just show the WeeklyQuestEntry. +/// Causes reaction on client side: The client shows the quest under the tab of its category, with a checklist of its objectives (steps). +/// +public readonly struct QuestDetails +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public QuestDetails(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private QuestDetails(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)data.Length; + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x03; + + /// + /// Gets the header of this packet. + /// + public C2HeaderWithSubCode Header => new (this._data); + + /// + /// Gets or sets the category of the quest: 0 = weekly, 1 = daily, 2 = main story, 3 = class, 4 = zone. + /// + public byte Category + { + get => this._data.Span[5]; + set => this._data.Span[5] = value; + } + + /// + /// Gets or sets when the progress starts over: 0 = weekly, 1 = daily, 2 = never (the quest is done once). + /// + public byte Period + { + get => this._data.Span[6]; + set => this._data.Span[6] = value; + } + + /// + /// Gets or sets the index of the first objective which isn't done yet. It's the number of objectives when all are done. + /// + public byte CurrentStep + { + get => this._data.Span[7]; + set => this._data.Span[7] = value; + } + + /// + /// Gets or sets the objective count. + /// + public byte ObjectiveCount + { + get => this._data.Span[8]; + set => this._data.Span[8] = value; + } + + /// + /// Gets or sets if true, the objectives have to be done in their order. + /// + public bool IsSequential + { + get => this._data.Span[9..].GetBoolean(); + set => this._data.Span[9..].SetBoolean(value); + } + + /// + /// Gets or sets the seconds until the progress of this quest starts over. 0 for quests which are done once. + /// + public uint SecondsUntilReset + { + get => ReadUInt32LittleEndian(this._data.Span[12..]); + set => WriteUInt32LittleEndian(this._data.Span[12..], value); + } + + /// + /// Gets or sets the identifier of the quest, as in the WeeklyQuestEntry message. + /// + public string Id + { + get => this._data.Span.ExtractString(16, 64, System.Text.Encoding.UTF8); + set => this._data.Slice(16, 64).Span.WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets the of the specified index. + /// + public QuestObjective this[int index] => new (this._data.Slice(80 + index * QuestObjective.Length)); + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator QuestDetails(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(QuestDetails packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified count of . + /// + /// The count of from which the size will be calculated. + + public static int GetRequiredSize(int objectivesCount) => objectivesCount * QuestObjective.Length + 80; + + +/// +/// An objective (step) of a quest.. +/// +public readonly struct QuestObjective +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public QuestObjective(Memory data) + { + this._data = data; + } + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 76; + + /// + /// Gets or sets the current count. + /// + public uint CurrentCount + { + get => ReadUInt32LittleEndian(this._data.Span); + set => WriteUInt32LittleEndian(this._data.Span, value); + } + + /// + /// Gets or sets the required count. + /// + public uint RequiredCount + { + get => ReadUInt32LittleEndian(this._data.Span[4..]); + set => WriteUInt32LittleEndian(this._data.Span[4..], value); + } + + /// + /// Gets or sets the is done. + /// + public bool IsDone + { + get => this._data.Span[8..].GetBoolean(); + set => this._data.Span[8..].SetBoolean(value); + } + + /// + /// Gets or sets the text of the objective, in the language of the player, e.g. 'Kill 50 Skeletons'. + /// + public string Text + { + get => this._data.Span.ExtractString(12, 64, System.Text.Encoding.UTF8); + set => this._data.Slice(12, 64).Span.WriteString(value, System.Text.Encoding.UTF8); + } +} +} + + +/// +/// Is sent by the server when: Once after a successful login, before the character selection. +/// Causes reaction on client side: The client uses these requirements and costs for the rest of the session, instead of the ones of its own data files, so that it shows and checks exactly what the server checks. +/// +public readonly struct SkillRequirements +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public SkillRequirements(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private SkillRequirements(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)data.Length; + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x04; + + /// + /// Gets the header of this packet. + /// + public C2HeaderWithSubCode Header => new (this._data); + + /// + /// Gets or sets the skill count. + /// + public ushort SkillCount + { + get => ReadUInt16LittleEndian(this._data.Span[6..]); + set => WriteUInt16LittleEndian(this._data.Span[6..], value); + } + + /// + /// Gets the of the specified index. + /// + public SkillRequirement this[int index] => new (this._data.Slice(8 + index * SkillRequirement.Length)); + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator SkillRequirements(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(SkillRequirements packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified count of . + /// + /// The count of from which the size will be calculated. + + public static int GetRequiredSize(int skillsCount) => skillsCount * SkillRequirement.Length + 8; + + +/// +/// The requirements and costs of a skill, as the server checks them. A value of 0 means there is no such requirement.. +/// +public readonly struct SkillRequirement +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public SkillRequirement(Memory data) + { + this._data = data; + } + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 16; + + /// + /// Gets or sets the skill number. + /// + public ushort SkillNumber + { + get => ReadUInt16LittleEndian(this._data.Span); + set => WriteUInt16LittleEndian(this._data.Span, value); + } + + /// + /// Gets or sets the required character level. + /// + public ushort Level + { + get => ReadUInt16LittleEndian(this._data.Span[2..]); + set => WriteUInt16LittleEndian(this._data.Span[2..], value); + } + + /// + /// Gets or sets the required total energy. + /// + public ushort Energy + { + get => ReadUInt16LittleEndian(this._data.Span[4..]); + set => WriteUInt16LittleEndian(this._data.Span[4..], value); + } + + /// + /// Gets or sets the required total leadership (command). + /// + public ushort Leadership + { + get => ReadUInt16LittleEndian(this._data.Span[6..]); + set => WriteUInt16LittleEndian(this._data.Span[6..], value); + } + + /// + /// Gets or sets the required total strength. + /// + public ushort Strength + { + get => ReadUInt16LittleEndian(this._data.Span[8..]); + set => WriteUInt16LittleEndian(this._data.Span[8..], value); + } + + /// + /// Gets or sets the required total agility. + /// + public ushort Agility + { + get => ReadUInt16LittleEndian(this._data.Span[10..]); + set => WriteUInt16LittleEndian(this._data.Span[10..], value); + } + + /// + /// Gets or sets the mana which is consumed by using the skill. + /// + public ushort Mana + { + get => ReadUInt16LittleEndian(this._data.Span[12..]); + set => WriteUInt16LittleEndian(this._data.Span[12..], value); + } + + /// + /// Gets or sets the ability (AG) which is consumed by using the skill. + /// + public ushort AbilityGauge + { + get => ReadUInt16LittleEndian(this._data.Span[14..]); + set => WriteUInt16LittleEndian(this._data.Span[14..], value); + } +} +} + + +/// +/// Is sent by the server when: Once after a successful login, right after the SkillRequirements message. +/// Causes reaction on client side: The client shows these requirements for the items which teach a skill (orbs, scrolls, parchments, crystals), instead of the ones it calculates from its own data files. +/// +public readonly struct LearnableItemRequirements +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public LearnableItemRequirements(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private LearnableItemRequirements(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)data.Length; + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x05; + + /// + /// Gets the header of this packet. + /// + public C2HeaderWithSubCode Header => new (this._data); + + /// + /// Gets or sets the item count. + /// + public ushort ItemCount + { + get => ReadUInt16LittleEndian(this._data.Span[6..]); + set => WriteUInt16LittleEndian(this._data.Span[6..], value); + } + + /// + /// Gets the of the specified index. + /// + public LearnableItemRequirement this[int index] => new (this._data.Slice(8 + index * LearnableItemRequirement.Length)); + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator LearnableItemRequirements(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(LearnableItemRequirements packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified count of . + /// + /// The count of from which the size will be calculated. + + public static int GetRequiredSize(int itemsCount) => itemsCount * LearnableItemRequirement.Length + 8; + + +/// +/// The requirements to learn a skill with an item: the highest of the requirements of the item and the requirements of the skill. A value of 0 means there is no such requirement.. +/// +public readonly struct LearnableItemRequirement +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public LearnableItemRequirement(Memory data) + { + this._data = data; + } + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 16; + + /// + /// Gets or sets the group. + /// + public byte Group + { + get => this._data.Span[0]; + set => this._data.Span[0] = value; + } + + /// + /// Gets or sets the item level these requirements are for, or 0xFF if they apply to every level. Items which teach a different skill per level (Orb of Summoning) have one entry per level. + /// + public byte ItemLevel + { + get => this._data.Span[1]; + set => this._data.Span[1] = value; + } + + /// + /// Gets or sets the number. + /// + public ushort Number + { + get => ReadUInt16LittleEndian(this._data.Span[2..]); + set => WriteUInt16LittleEndian(this._data.Span[2..], value); + } + + /// + /// Gets or sets the required character level. + /// + public ushort Level + { + get => ReadUInt16LittleEndian(this._data.Span[4..]); + set => WriteUInt16LittleEndian(this._data.Span[4..], value); + } + + /// + /// Gets or sets the required total energy. + /// + public ushort Energy + { + get => ReadUInt16LittleEndian(this._data.Span[6..]); + set => WriteUInt16LittleEndian(this._data.Span[6..], value); + } + + /// + /// Gets or sets the required total leadership (command). + /// + public ushort Leadership + { + get => ReadUInt16LittleEndian(this._data.Span[8..]); + set => WriteUInt16LittleEndian(this._data.Span[8..], value); + } + + /// + /// Gets or sets the required total strength. + /// + public ushort Strength + { + get => ReadUInt16LittleEndian(this._data.Span[10..]); + set => WriteUInt16LittleEndian(this._data.Span[10..], value); + } + + /// + /// Gets or sets the required total agility. + /// + public ushort Agility + { + get => ReadUInt16LittleEndian(this._data.Span[12..]); + set => WriteUInt16LittleEndian(this._data.Span[12..], value); + } + + /// + /// Gets or sets the skill which is learned with the item. + /// + public ushort SkillNumber + { + get => ReadUInt16LittleEndian(this._data.Span[14..]); + set => WriteUInt16LittleEndian(this._data.Span[14..], value); + } +} +} + + /// /// Is sent by the server when: The player receives the result of registering Rena or Event Chips at the Golden Archer NPC. /// Causes reaction on client side: The client updates the Golden Archer interface with total registered count and remaining count in inventory. diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index 4ccbd901c..3eacd509a 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -11475,6 +11475,347 @@ + + C2HeaderWithSubCode + F5 + 02 + WeeklyQuestEntry + 520 + ServerToClient + After the client requested the list of available chat commands, one message is sent for each active weekly quest. When the progress of a quest changes, a single message is sent as update of this quest. + The client shows the weekly quests and their progress in a window. + + + 5 + Byte + Index + The index of this quest within the list, starting at 0. The first message of a list replaces the previously known quests. + + + 6 + Byte + Count + The total number of quests. A list without quests is sent as a single message with a count of 0. + + + 7 + Boolean + IsUpdate + If true, this message updates the already known quest with the same id, instead of being part of a list. + + + 8 + Boolean + IsCompleted + The objective of the quest has been reached. + + + 9 + Boolean + IsRewarded + The rewards of the quest have been handed out. If the quest is completed but not rewarded, the reward is pending, e.g. because the inventory was full. + + + 12 + IntegerLittleEndian + CurrentCount + + + 16 + IntegerLittleEndian + RequiredCount + + + 20 + IntegerLittleEndian + SecondsUntilReset + The seconds until the weekly progress is reset. + + + 24 + String + Id + 64 + The identifier of the quest. + + + 88 + String + Name + 48 + + + 136 + String + Description + 256 + + + 392 + String + Rewards + 128 + The rewards of the quest as text, in the language of the player. + + + + + C2HeaderWithSubCode + F5 + 03 + QuestDetails + ServerToClient + Right after each WeeklyQuestEntry message, with the same quest id. Clients which don't know it can ignore it and just show the WeeklyQuestEntry. + The client shows the quest under the tab of its category, with a checklist of its objectives (steps). + + + 5 + Byte + Category + The category of the quest: 0 = weekly, 1 = daily, 2 = main story, 3 = class, 4 = zone. + + + 6 + Byte + Period + When the progress starts over: 0 = weekly, 1 = daily, 2 = never (the quest is done once). + + + 7 + Byte + CurrentStep + The index of the first objective which isn't done yet. It's the number of objectives when all are done. + + + 8 + Byte + ObjectiveCount + + + 9 + Boolean + IsSequential + If true, the objectives have to be done in their order. + + + 12 + IntegerLittleEndian + SecondsUntilReset + The seconds until the progress of this quest starts over. 0 for quests which are done once. + + + 16 + String + Id + 64 + The identifier of the quest, as in the WeeklyQuestEntry message. + + + 80 + Structure[] + QuestObjective + Objectives + ObjectiveCount + The objectives of the quest, in their order. + + + + + QuestObjective + An objective (step) of a quest. + 76 + + + 0 + IntegerLittleEndian + CurrentCount + + + 4 + IntegerLittleEndian + RequiredCount + + + 8 + Boolean + IsDone + + + 12 + String + Text + 64 + The text of the objective, in the language of the player, e.g. 'Kill 50 Skeletons'. + + + + + + + C2HeaderWithSubCode + F5 + 04 + SkillRequirements + ServerToClient + Once after a successful login, before the character selection. + The client uses these requirements and costs for the rest of the session, instead of the ones of its own data files, so that it shows and checks exactly what the server checks. + + + 6 + ShortLittleEndian + SkillCount + + + 8 + Structure[] + SkillRequirement + Skills + SkillCount + The requirements of every skill of the game configuration. + + + + + SkillRequirement + The requirements and costs of a skill, as the server checks them. A value of 0 means there is no such requirement. + 16 + + + 0 + ShortLittleEndian + SkillNumber + + + 2 + ShortLittleEndian + Level + The required character level. + + + 4 + ShortLittleEndian + Energy + The required total energy. + + + 6 + ShortLittleEndian + Leadership + The required total leadership (command). + + + 8 + ShortLittleEndian + Strength + The required total strength. + + + 10 + ShortLittleEndian + Agility + The required total agility. + + + 12 + ShortLittleEndian + Mana + The mana which is consumed by using the skill. + + + 14 + ShortLittleEndian + AbilityGauge + The ability (AG) which is consumed by using the skill. + + + + + + + C2HeaderWithSubCode + F5 + 05 + LearnableItemRequirements + ServerToClient + Once after a successful login, right after the SkillRequirements message. + The client shows these requirements for the items which teach a skill (orbs, scrolls, parchments, crystals), instead of the ones it calculates from its own data files. + + + 6 + ShortLittleEndian + ItemCount + + + 8 + Structure[] + LearnableItemRequirement + Items + ItemCount + The requirements to learn the skill of each item. + + + + + LearnableItemRequirement + The requirements to learn a skill with an item: the highest of the requirements of the item and the requirements of the skill. A value of 0 means there is no such requirement. + 16 + + + 0 + Byte + Group + + + 1 + Byte + ItemLevel + The item level these requirements are for, or 0xFF if they apply to every level. Items which teach a different skill per level (Orb of Summoning) have one entry per level. + + + 2 + ShortLittleEndian + Number + + + 4 + ShortLittleEndian + Level + The required character level. + + + 6 + ShortLittleEndian + Energy + The required total energy. + + + 8 + ShortLittleEndian + Leadership + The required total leadership (command). + + + 10 + ShortLittleEndian + Strength + The required total strength. + + + 12 + ShortLittleEndian + Agility + The required total agility. + + + 14 + ShortLittleEndian + SkillNumber + The skill which is learned with the item. + + + + + C1Header 94 diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index 63519dcdd..8d1561050 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs @@ -29946,6 +29946,782 @@ public string ValidValues } +/// +/// Is sent by the server when: After the client requested the list of available chat commands, one message is sent for each active weekly quest. When the progress of a quest changes, a single message is sent as update of this quest. +/// Causes reaction on client side: The client shows the weekly quests and their progress in a window. +/// +public readonly ref struct WeeklyQuestEntryRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public WeeklyQuestEntryRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private WeeklyQuestEntryRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)Math.Min(data.Length, Length); + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x02; + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 520; + + /// + /// Gets the header of this packet. + /// + public C2HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Gets or sets the index of this quest within the list, starting at 0. The first message of a list replaces the previously known quests. + /// + public byte Index + { + get => this._data[5]; + set => this._data[5] = value; + } + + /// + /// Gets or sets the total number of quests. A list without quests is sent as a single message with a count of 0. + /// + public byte Count + { + get => this._data[6]; + set => this._data[6] = value; + } + + /// + /// Gets or sets if true, this message updates the already known quest with the same id, instead of being part of a list. + /// + public bool IsUpdate + { + get => this._data[7..].GetBoolean(); + set => this._data[7..].SetBoolean(value); + } + + /// + /// Gets or sets the objective of the quest has been reached. + /// + public bool IsCompleted + { + get => this._data[8..].GetBoolean(); + set => this._data[8..].SetBoolean(value); + } + + /// + /// Gets or sets the rewards of the quest have been handed out. If the quest is completed but not rewarded, the reward is pending, e.g. because the inventory was full. + /// + public bool IsRewarded + { + get => this._data[9..].GetBoolean(); + set => this._data[9..].SetBoolean(value); + } + + /// + /// Gets or sets the current count. + /// + public uint CurrentCount + { + get => ReadUInt32LittleEndian(this._data[12..]); + set => WriteUInt32LittleEndian(this._data[12..], value); + } + + /// + /// Gets or sets the required count. + /// + public uint RequiredCount + { + get => ReadUInt32LittleEndian(this._data[16..]); + set => WriteUInt32LittleEndian(this._data[16..], value); + } + + /// + /// Gets or sets the seconds until the weekly progress is reset. + /// + public uint SecondsUntilReset + { + get => ReadUInt32LittleEndian(this._data[20..]); + set => WriteUInt32LittleEndian(this._data[20..], value); + } + + /// + /// Gets or sets the identifier of the quest. + /// + public string Id + { + get => this._data.ExtractString(24, 64, System.Text.Encoding.UTF8); + set => this._data.Slice(24, 64).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the name. + /// + public string Name + { + get => this._data.ExtractString(88, 48, System.Text.Encoding.UTF8); + set => this._data.Slice(88, 48).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the description. + /// + public string Description + { + get => this._data.ExtractString(136, 256, System.Text.Encoding.UTF8); + set => this._data.Slice(136, 256).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets or sets the rewards of the quest as text, in the language of the player. + /// + public string Rewards + { + get => this._data.ExtractString(392, 128, System.Text.Encoding.UTF8); + set => this._data.Slice(392, 128).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator WeeklyQuestEntryRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(WeeklyQuestEntryRef packet) => packet._data; +} + + +/// +/// Is sent by the server when: Right after each WeeklyQuestEntry message, with the same quest id. Clients which don't know it can ignore it and just show the WeeklyQuestEntry. +/// Causes reaction on client side: The client shows the quest under the tab of its category, with a checklist of its objectives (steps). +/// +public readonly ref struct QuestDetailsRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public QuestDetailsRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private QuestDetailsRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)data.Length; + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x03; + + /// + /// Gets the header of this packet. + /// + public C2HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Gets or sets the category of the quest: 0 = weekly, 1 = daily, 2 = main story, 3 = class, 4 = zone. + /// + public byte Category + { + get => this._data[5]; + set => this._data[5] = value; + } + + /// + /// Gets or sets when the progress starts over: 0 = weekly, 1 = daily, 2 = never (the quest is done once). + /// + public byte Period + { + get => this._data[6]; + set => this._data[6] = value; + } + + /// + /// Gets or sets the index of the first objective which isn't done yet. It's the number of objectives when all are done. + /// + public byte CurrentStep + { + get => this._data[7]; + set => this._data[7] = value; + } + + /// + /// Gets or sets the objective count. + /// + public byte ObjectiveCount + { + get => this._data[8]; + set => this._data[8] = value; + } + + /// + /// Gets or sets if true, the objectives have to be done in their order. + /// + public bool IsSequential + { + get => this._data[9..].GetBoolean(); + set => this._data[9..].SetBoolean(value); + } + + /// + /// Gets or sets the seconds until the progress of this quest starts over. 0 for quests which are done once. + /// + public uint SecondsUntilReset + { + get => ReadUInt32LittleEndian(this._data[12..]); + set => WriteUInt32LittleEndian(this._data[12..], value); + } + + /// + /// Gets or sets the identifier of the quest, as in the WeeklyQuestEntry message. + /// + public string Id + { + get => this._data.ExtractString(16, 64, System.Text.Encoding.UTF8); + set => this._data.Slice(16, 64).WriteString(value, System.Text.Encoding.UTF8); + } + + /// + /// Gets the of the specified index. + /// + public QuestObjectiveRef this[int index] => new (this._data[(80 + index * QuestObjectiveRef.Length)..]); + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator QuestDetailsRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(QuestDetailsRef packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified count of . + /// + /// The count of from which the size will be calculated. + + public static int GetRequiredSize(int objectivesCount) => objectivesCount * QuestObjectiveRef.Length + 80; + + +/// +/// An objective (step) of a quest.. +/// +public readonly ref struct QuestObjectiveRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public QuestObjectiveRef(Span data) + { + this._data = data; + } + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 76; + + /// + /// Gets or sets the current count. + /// + public uint CurrentCount + { + get => ReadUInt32LittleEndian(this._data); + set => WriteUInt32LittleEndian(this._data, value); + } + + /// + /// Gets or sets the required count. + /// + public uint RequiredCount + { + get => ReadUInt32LittleEndian(this._data[4..]); + set => WriteUInt32LittleEndian(this._data[4..], value); + } + + /// + /// Gets or sets the is done. + /// + public bool IsDone + { + get => this._data[8..].GetBoolean(); + set => this._data[8..].SetBoolean(value); + } + + /// + /// Gets or sets the text of the objective, in the language of the player, e.g. 'Kill 50 Skeletons'. + /// + public string Text + { + get => this._data.ExtractString(12, 64, System.Text.Encoding.UTF8); + set => this._data.Slice(12, 64).WriteString(value, System.Text.Encoding.UTF8); + } +} +} + + +/// +/// Is sent by the server when: Once after a successful login, before the character selection. +/// Causes reaction on client side: The client uses these requirements and costs for the rest of the session, instead of the ones of its own data files, so that it shows and checks exactly what the server checks. +/// +public readonly ref struct SkillRequirementsRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public SkillRequirementsRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private SkillRequirementsRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)data.Length; + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x04; + + /// + /// Gets the header of this packet. + /// + public C2HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Gets or sets the skill count. + /// + public ushort SkillCount + { + get => ReadUInt16LittleEndian(this._data[6..]); + set => WriteUInt16LittleEndian(this._data[6..], value); + } + + /// + /// Gets the of the specified index. + /// + public SkillRequirementRef this[int index] => new (this._data[(8 + index * SkillRequirementRef.Length)..]); + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator SkillRequirementsRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(SkillRequirementsRef packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified count of . + /// + /// The count of from which the size will be calculated. + + public static int GetRequiredSize(int skillsCount) => skillsCount * SkillRequirementRef.Length + 8; + + +/// +/// The requirements and costs of a skill, as the server checks them. A value of 0 means there is no such requirement.. +/// +public readonly ref struct SkillRequirementRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public SkillRequirementRef(Span data) + { + this._data = data; + } + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 16; + + /// + /// Gets or sets the skill number. + /// + public ushort SkillNumber + { + get => ReadUInt16LittleEndian(this._data); + set => WriteUInt16LittleEndian(this._data, value); + } + + /// + /// Gets or sets the required character level. + /// + public ushort Level + { + get => ReadUInt16LittleEndian(this._data[2..]); + set => WriteUInt16LittleEndian(this._data[2..], value); + } + + /// + /// Gets or sets the required total energy. + /// + public ushort Energy + { + get => ReadUInt16LittleEndian(this._data[4..]); + set => WriteUInt16LittleEndian(this._data[4..], value); + } + + /// + /// Gets or sets the required total leadership (command). + /// + public ushort Leadership + { + get => ReadUInt16LittleEndian(this._data[6..]); + set => WriteUInt16LittleEndian(this._data[6..], value); + } + + /// + /// Gets or sets the required total strength. + /// + public ushort Strength + { + get => ReadUInt16LittleEndian(this._data[8..]); + set => WriteUInt16LittleEndian(this._data[8..], value); + } + + /// + /// Gets or sets the required total agility. + /// + public ushort Agility + { + get => ReadUInt16LittleEndian(this._data[10..]); + set => WriteUInt16LittleEndian(this._data[10..], value); + } + + /// + /// Gets or sets the mana which is consumed by using the skill. + /// + public ushort Mana + { + get => ReadUInt16LittleEndian(this._data[12..]); + set => WriteUInt16LittleEndian(this._data[12..], value); + } + + /// + /// Gets or sets the ability (AG) which is consumed by using the skill. + /// + public ushort AbilityGauge + { + get => ReadUInt16LittleEndian(this._data[14..]); + set => WriteUInt16LittleEndian(this._data[14..], value); + } +} +} + + +/// +/// Is sent by the server when: Once after a successful login, right after the SkillRequirements message. +/// Causes reaction on client side: The client shows these requirements for the items which teach a skill (orbs, scrolls, parchments, crystals), instead of the ones it calculates from its own data files. +/// +public readonly ref struct LearnableItemRequirementsRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public LearnableItemRequirementsRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private LearnableItemRequirementsRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (ushort)data.Length; + header.SubCode = SubCode; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC2; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xF5; + + /// + /// Gets the operation sub-code of this data packet. + /// The is used as a grouping key. + /// + public static byte SubCode => 0x05; + + /// + /// Gets the header of this packet. + /// + public C2HeaderWithSubCodeRef Header => new (this._data); + + /// + /// Gets or sets the item count. + /// + public ushort ItemCount + { + get => ReadUInt16LittleEndian(this._data[6..]); + set => WriteUInt16LittleEndian(this._data[6..], value); + } + + /// + /// Gets the of the specified index. + /// + public LearnableItemRequirementRef this[int index] => new (this._data[(8 + index * LearnableItemRequirementRef.Length)..]); + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator LearnableItemRequirementsRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(LearnableItemRequirementsRef packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified count of . + /// + /// The count of from which the size will be calculated. + + public static int GetRequiredSize(int itemsCount) => itemsCount * LearnableItemRequirementRef.Length + 8; + + +/// +/// The requirements to learn a skill with an item: the highest of the requirements of the item and the requirements of the skill. A value of 0 means there is no such requirement.. +/// +public readonly ref struct LearnableItemRequirementRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public LearnableItemRequirementRef(Span data) + { + this._data = data; + } + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 16; + + /// + /// Gets or sets the group. + /// + public byte Group + { + get => this._data[0]; + set => this._data[0] = value; + } + + /// + /// Gets or sets the item level these requirements are for, or 0xFF if they apply to every level. Items which teach a different skill per level (Orb of Summoning) have one entry per level. + /// + public byte ItemLevel + { + get => this._data[1]; + set => this._data[1] = value; + } + + /// + /// Gets or sets the number. + /// + public ushort Number + { + get => ReadUInt16LittleEndian(this._data[2..]); + set => WriteUInt16LittleEndian(this._data[2..], value); + } + + /// + /// Gets or sets the required character level. + /// + public ushort Level + { + get => ReadUInt16LittleEndian(this._data[4..]); + set => WriteUInt16LittleEndian(this._data[4..], value); + } + + /// + /// Gets or sets the required total energy. + /// + public ushort Energy + { + get => ReadUInt16LittleEndian(this._data[6..]); + set => WriteUInt16LittleEndian(this._data[6..], value); + } + + /// + /// Gets or sets the required total leadership (command). + /// + public ushort Leadership + { + get => ReadUInt16LittleEndian(this._data[8..]); + set => WriteUInt16LittleEndian(this._data[8..], value); + } + + /// + /// Gets or sets the required total strength. + /// + public ushort Strength + { + get => ReadUInt16LittleEndian(this._data[10..]); + set => WriteUInt16LittleEndian(this._data[10..], value); + } + + /// + /// Gets or sets the required total agility. + /// + public ushort Agility + { + get => ReadUInt16LittleEndian(this._data[12..]); + set => WriteUInt16LittleEndian(this._data[12..], value); + } + + /// + /// Gets or sets the skill which is learned with the item. + /// + public ushort SkillNumber + { + get => ReadUInt16LittleEndian(this._data[14..]); + set => WriteUInt16LittleEndian(this._data[14..], value); + } +} +} + + /// /// Is sent by the server when: The player receives the result of registering Rena or Event Chips at the Golden Archer NPC. /// Causes reaction on client side: The client updates the Golden Archer interface with total registered count and remaining count in inventory. diff --git a/src/Persistence/EntityFramework/ConnectionSettings.xml b/src/Persistence/EntityFramework/ConnectionSettings.xml index ea87a8d6a..bfcd8671d 100644 --- a/src/Persistence/EntityFramework/ConnectionSettings.xml +++ b/src/Persistence/EntityFramework/ConnectionSettings.xml @@ -15,6 +15,12 @@ Server=localhost;Port=5432;User Id=postgres;Password=admin;Database=openmu;Command Timeout=120; Npgsql + + + MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests.WeeklyQuestContext + Server=localhost;Port=5432;User Id=postgres;Password=admin;Database=openmu;Command Timeout=120; + Npgsql + MUnique.OpenMU.Persistence.EntityFramework.TypedContext diff --git a/src/Persistence/EntityFramework/DatabaseSnapshotService.cs b/src/Persistence/EntityFramework/DatabaseSnapshotService.cs index 610cc81e5..9a62d96bc 100644 --- a/src/Persistence/EntityFramework/DatabaseSnapshotService.cs +++ b/src/Persistence/EntityFramework/DatabaseSnapshotService.cs @@ -12,6 +12,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; +using MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests; using Npgsql; /// @@ -42,6 +43,7 @@ public class DatabaseSnapshotService : IDatabaseSnapshotService SchemaNames.Guild, SchemaNames.Friend, SchemaNames.AdminPanel, + SchemaNames.WeeklyQuests, ]; /// @@ -126,6 +128,11 @@ public async Task RestoreSnapshotAsync(Stream inputStream, CancellationToken can await MigrateToSnapshotStateAsync(manifest.Migrations, cancellationToken).ConfigureAwait(false); await MigrateToSnapshotStateAsync(manifest.AdminPanelMigrations, cancellationToken).ConfigureAwait(false); + // The weekly quest progress isn't tracked in the manifest. It has only its initial migration yet, + // so the current schema is also the one of the snapshot. When it gets further migrations, + // they have to be added to the manifest like the ones of the admin panel. + await MigrateToAsync(null, cancellationToken).ConfigureAwait(false); + await using var connection = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false); var existingTables = (await GetTableNamesAsync(connection, cancellationToken).ConfigureAwait(false)) .Select(table => table.ToString()) diff --git a/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260925190758_InitialWeeklyQuests.Designer.cs b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260925190758_InitialWeeklyQuests.Designer.cs new file mode 100644 index 000000000..caabd8281 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260925190758_InitialWeeklyQuests.Designer.cs @@ -0,0 +1,59 @@ +// +using System; +using MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests; +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.WeeklyQuests +{ + [DbContext(typeof(WeeklyQuestContext))] + [Migration("20260925190758_InitialWeeklyQuests")] + partial class InitialWeeklyQuests + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("weekly") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.WeeklyQuests.WeeklyQuestProgress", b => + { + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("QuestId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("RewardedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CharacterId", "PeriodStart", "QuestId"); + + b.HasIndex("PeriodStart"); + + b.ToTable("WeeklyQuestProgress", "weekly"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260925190758_InitialWeeklyQuests.cs b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260925190758_InitialWeeklyQuests.cs new file mode 100644 index 000000000..c817908bf --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260925190758_InitialWeeklyQuests.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations.WeeklyQuests +{ + /// + public partial class InitialWeeklyQuests : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "weekly"); + + migrationBuilder.CreateTable( + name: "WeeklyQuestProgress", + schema: "weekly", + columns: table => new + { + CharacterId = table.Column(type: "uuid", nullable: false), + PeriodStart = table.Column(type: "timestamp with time zone", nullable: false), + QuestId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Count = table.Column(type: "integer", nullable: false), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + RewardedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WeeklyQuestProgress", x => new { x.CharacterId, x.PeriodStart, x.QuestId }); + }); + + migrationBuilder.CreateIndex( + name: "IX_WeeklyQuestProgress_PeriodStart", + schema: "weekly", + table: "WeeklyQuestProgress", + column: "PeriodStart"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WeeklyQuestProgress", + schema: "weekly"); + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926125402_AddWeeklyQuestAccountId.Designer.cs b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926125402_AddWeeklyQuestAccountId.Designer.cs new file mode 100644 index 000000000..410d53bdc --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926125402_AddWeeklyQuestAccountId.Designer.cs @@ -0,0 +1,64 @@ +// +using System; +using MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests; +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.WeeklyQuests +{ + [DbContext(typeof(WeeklyQuestContext))] + [Migration("20260926125402_AddWeeklyQuestAccountId")] + partial class AddWeeklyQuestAccountId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("weekly") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.WeeklyQuests.WeeklyQuestProgress", b => + { + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("QuestId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("RewardedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CharacterId", "PeriodStart", "QuestId"); + + b.HasIndex("PeriodStart"); + + b.HasIndex("AccountId", "PeriodStart", "QuestId"); + + b.ToTable("WeeklyQuestProgress", "weekly"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926125402_AddWeeklyQuestAccountId.cs b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926125402_AddWeeklyQuestAccountId.cs new file mode 100644 index 000000000..dc6e68458 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926125402_AddWeeklyQuestAccountId.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations.WeeklyQuests +{ + /// + public partial class AddWeeklyQuestAccountId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AccountId", + schema: "weekly", + table: "WeeklyQuestProgress", + type: "uuid", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_WeeklyQuestProgress_AccountId_PeriodStart_QuestId", + schema: "weekly", + table: "WeeklyQuestProgress", + columns: new[] { "AccountId", "PeriodStart", "QuestId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_WeeklyQuestProgress_AccountId_PeriodStart_QuestId", + schema: "weekly", + table: "WeeklyQuestProgress"); + + migrationBuilder.DropColumn( + name: "AccountId", + schema: "weekly", + table: "WeeklyQuestProgress"); + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926221251_AddWeeklyQuestObjectiveCounts.Designer.cs b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926221251_AddWeeklyQuestObjectiveCounts.Designer.cs new file mode 100644 index 000000000..defa4f326 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926221251_AddWeeklyQuestObjectiveCounts.Designer.cs @@ -0,0 +1,68 @@ +// +using System; +using MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests; +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.WeeklyQuests +{ + [DbContext(typeof(WeeklyQuestContext))] + [Migration("20260926221251_AddWeeklyQuestObjectiveCounts")] + partial class AddWeeklyQuestObjectiveCounts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("weekly") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.WeeklyQuests.WeeklyQuestProgress", b => + { + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("QuestId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("AdditionalCounts") + .IsRequired() + .HasColumnType("integer[]"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("RewardedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CharacterId", "PeriodStart", "QuestId"); + + b.HasIndex("PeriodStart"); + + b.HasIndex("AccountId", "PeriodStart", "QuestId"); + + b.ToTable("WeeklyQuestProgress", "weekly"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926221251_AddWeeklyQuestObjectiveCounts.cs b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926221251_AddWeeklyQuestObjectiveCounts.cs new file mode 100644 index 000000000..1459b0e5d --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926221251_AddWeeklyQuestObjectiveCounts.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations.WeeklyQuests +{ + /// + public partial class AddWeeklyQuestObjectiveCounts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AdditionalCounts", + schema: "weekly", + table: "WeeklyQuestProgress", + type: "integer[]", + nullable: false, + defaultValue: new int[0]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AdditionalCounts", + schema: "weekly", + table: "WeeklyQuestProgress"); + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/WeeklyQuests/WeeklyQuestContextModelSnapshot.cs b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/WeeklyQuestContextModelSnapshot.cs new file mode 100644 index 000000000..33362c610 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/WeeklyQuestContextModelSnapshot.cs @@ -0,0 +1,65 @@ +// +using System; +using MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations.WeeklyQuests +{ + [DbContext(typeof(WeeklyQuestContext))] + partial class WeeklyQuestContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("weekly") + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.WeeklyQuests.WeeklyQuestProgress", b => + { + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("QuestId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.PrimitiveCollection("AdditionalCounts") + .IsRequired() + .HasColumnType("integer[]"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Count") + .HasColumnType("integer"); + + b.Property("RewardedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CharacterId", "PeriodStart", "QuestId"); + + b.HasIndex("PeriodStart"); + + b.HasIndex("AccountId", "PeriodStart", "QuestId"); + + b.ToTable("WeeklyQuestProgress", "weekly"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/EntityFramework/SchemaNames.cs b/src/Persistence/EntityFramework/SchemaNames.cs index b19bbf747..c7f511655 100644 --- a/src/Persistence/EntityFramework/SchemaNames.cs +++ b/src/Persistence/EntityFramework/SchemaNames.cs @@ -36,4 +36,9 @@ internal static class SchemaNames /// It is deliberately not granted to any of the game server database roles. /// internal const string AdminPanel = "admin"; + + /// + /// The schema name for the progress of the weekly quests. + /// + internal const string WeeklyQuests = "weekly"; } \ No newline at end of file diff --git a/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContext.cs b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContext.cs new file mode 100644 index 000000000..3c739f7fb --- /dev/null +++ b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContext.cs @@ -0,0 +1,53 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using MUnique.OpenMU.Persistence.WeeklyQuests; + +/// +/// The context which holds the progress of the weekly quests. +/// +/// +/// Like the , it uses an own schema, an own migration history +/// and an own set of migrations. This keeps the generated game data model untouched, so the +/// feature doesn't conflict with changes of the upstream project. +/// +public class WeeklyQuestContext : DbContext +{ + /// + /// Gets or sets the progress entries. + /// + public DbSet Progress { get; set; } = null!; + + /// + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + this.Configure(optionsBuilder); + + optionsBuilder.UseNpgsql( + ConnectionConfigurator.GetConnectionString(), + options => options.MigrationsHistoryTable(HistoryRepository.DefaultTableName, SchemaNames.WeeklyQuests)); + } + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.HasDefaultSchema(SchemaNames.WeeklyQuests); + modelBuilder.Entity(entity => + { + entity.ToTable(nameof(WeeklyQuestProgress), SchemaNames.WeeklyQuests); + entity.HasKey(p => new { p.CharacterId, p.PeriodStart, p.QuestId }); + entity.Property(p => p.QuestId).IsRequired().HasMaxLength(64); + entity.Property(p => p.AdditionalCounts).IsRequired(); + entity.Ignore(p => p.HasAnyProgress); + entity.HasIndex(p => p.PeriodStart); + entity.HasIndex(p => new { p.AccountId, p.PeriodStart, p.QuestId }); + }); + } +} diff --git a/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContextFactory.cs b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContextFactory.cs new file mode 100644 index 000000000..048116dc9 --- /dev/null +++ b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContextFactory.cs @@ -0,0 +1,24 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests; + +using Microsoft.EntityFrameworkCore.Design; + +/// +/// Design-time factory for . +/// +public class WeeklyQuestContextFactory : IDesignTimeDbContextFactory +{ + /// + public WeeklyQuestContext CreateDbContext(string[] args) + { + if (!ConnectionConfigurator.IsInitialized) + { + ConnectionConfigurator.Initialize(new ConfigFileDatabaseConnectionStringProvider()); + } + + return new WeeklyQuestContext(); + } +} diff --git a/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestProgressRepository.cs b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestProgressRepository.cs new file mode 100644 index 000000000..bbbf560f1 --- /dev/null +++ b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestProgressRepository.cs @@ -0,0 +1,181 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests; + +using System.Threading; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.Persistence.WeeklyQuests; +using Nito.AsyncEx; + +/// +/// Implementation of the which stores the progress +/// in the weekly schema of the configured PostgreSQL database. +/// +public sealed class WeeklyQuestProgressRepository : IWeeklyQuestProgressRepository, IDisposable +{ + /// + /// The time to wait before the storage is probed again after a failed attempt. + /// + private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(30); + + private readonly ILogger _logger; + private readonly SetupService? _setupService; + private readonly AsyncLock _storageLock = new(); + private bool _isStorageReady; + private DateTime _nextProbeAt = DateTime.MinValue; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The setup service, which notifies about a re-created database. + public WeeklyQuestProgressRepository(ILogger logger, SetupService? setupService = null) + { + this._logger = logger; + this._setupService = setupService; + if (this._setupService is not null) + { + this._setupService.DatabaseInitialized += this.OnDatabaseInitialized; + } + } + + /// + public void Dispose() + { + if (this._setupService is not null) + { + this._setupService.DatabaseInitialized -= this.OnDatabaseInitialized; + } + } + + /// + public async ValueTask> LoadAsync(Guid characterId, IReadOnlyCollection periodStarts, CancellationToken cancellationToken = default) + { + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + var starts = periodStarts.ToArray(); + await using var context = new WeeklyQuestContext(); + return await context.Progress + .AsNoTracking() + .Where(p => p.CharacterId == characterId && starts.Contains(p.PeriodStart)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async ValueTask SaveAsync(IEnumerable progress, CancellationToken cancellationToken = default) + { + var entries = progress.ToList(); + if (entries.Count == 0) + { + return; + } + + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + await using var context = new WeeklyQuestContext(); + foreach (var group in entries.GroupBy(e => (e.CharacterId, e.PeriodStart))) + { + var questIds = group.Select(e => e.QuestId).ToList(); + var existing = await context.Progress + .Where(p => p.CharacterId == group.Key.CharacterId && p.PeriodStart == group.Key.PeriodStart && questIds.Contains(p.QuestId)) + .ToDictionaryAsync(p => p.QuestId, cancellationToken) + .ConfigureAwait(false); + + foreach (var entry in group) + { + if (existing.TryGetValue(entry.QuestId, out var stored)) + { + stored.AccountId ??= entry.AccountId; + stored.Count = entry.Count; + stored.AdditionalCounts = (int[])entry.AdditionalCounts.Clone(); + stored.CompletedAt = entry.CompletedAt; + stored.RewardedAt = entry.RewardedAt; + } + else + { + context.Progress.Add(new WeeklyQuestProgress + { + CharacterId = entry.CharacterId, + AccountId = entry.AccountId, + PeriodStart = entry.PeriodStart, + QuestId = entry.QuestId, + Count = entry.Count, + AdditionalCounts = (int[])entry.AdditionalCounts.Clone(), + CompletedAt = entry.CompletedAt, + RewardedAt = entry.RewardedAt, + }); + } + } + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask> LoadRewardedByAccountAsync(Guid accountId, IReadOnlyCollection periodStarts, CancellationToken cancellationToken = default) + { + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + var starts = periodStarts.ToArray(); + await using var context = new WeeklyQuestContext(); + return await context.Progress + .AsNoTracking() + .Where(p => p.AccountId == accountId && starts.Contains(p.PeriodStart) && p.RewardedAt != null) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + + /// + public async ValueTask DeleteExpiredAsync(DateTime olderThan, DateTime keepPeriodStart, CancellationToken cancellationToken = default) + { + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + await using var context = new WeeklyQuestContext(); + return await context.Progress + .Where(p => p.PeriodStart < olderThan && p.PeriodStart != keepPeriodStart) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + } + + private async ValueTask EnsureAvailableStorageAsync(CancellationToken cancellationToken) + { + if (this._isStorageReady) + { + return; + } + + if (DateTime.UtcNow < this._nextProbeAt) + { + throw new InvalidOperationException("The weekly quest storage is not available. Please check the database connection."); + } + + using var l = await this._storageLock.LockAsync(cancellationToken).ConfigureAwait(false); + if (this._isStorageReady) + { + return; + } + + try + { + await using var context = new WeeklyQuestContext(); + await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + this._isStorageReady = true; + } + catch (Exception ex) + { + this._nextProbeAt = DateTime.UtcNow + RetryDelay; + this._logger.LogWarning(ex, "The weekly quest storage is not available (yet)."); + throw new InvalidOperationException("The weekly quest storage is not available. Please check the database connection.", ex); + } + } + + private ValueTask OnDatabaseInitialized() + { + this._isStorageReady = false; + return ValueTask.CompletedTask; + } +} diff --git a/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestServiceCollectionExtensions.cs b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestServiceCollectionExtensions.cs new file mode 100644 index 000000000..d163572e1 --- /dev/null +++ b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using MUnique.OpenMU.Persistence.WeeklyQuests; + +/// +/// Extensions to register the persistence of the weekly quests. +/// +public static class WeeklyQuestServiceCollectionExtensions +{ + /// + /// Adds the database backed to the service collection. + /// + /// The service collection. + /// The same instance, to allow chaining of further calls. + public static IServiceCollection AddWeeklyQuestProgressRepository(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } +} diff --git a/src/Persistence/WeeklyQuests/IWeeklyQuestProgressRepository.cs b/src/Persistence/WeeklyQuests/IWeeklyQuestProgressRepository.cs new file mode 100644 index 000000000..671428f9e --- /dev/null +++ b/src/Persistence/WeeklyQuests/IWeeklyQuestProgressRepository.cs @@ -0,0 +1,47 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.WeeklyQuests; + +using System.Threading; + +/// +/// A repository for the of characters. +/// +public interface IWeeklyQuestProgressRepository +{ + /// + /// Loads the progress of a character for the specified periods, with one query. + /// + /// The identifier of the character. + /// The starts of the periods, e.g. of the current day, week and of the quests which are done once. + /// The cancellation token. + /// The stored progress of the character in these periods. + ValueTask> LoadAsync(Guid characterId, IReadOnlyCollection periodStarts, CancellationToken cancellationToken = default); + + /// + /// Inserts or updates the specified progress entries. + /// + /// The progress entries. + /// The cancellation token. + ValueTask SaveAsync(IEnumerable progress, CancellationToken cancellationToken = default); + + /// + /// Loads the rewarded progress entries of all characters of an account for the specified periods. + /// + /// The identifier of the account. + /// The starts of the periods. + /// The cancellation token. + /// The rewarded progress entries of the characters of the account in these periods. + ValueTask> LoadRewardedByAccountAsync(Guid accountId, IReadOnlyCollection periodStarts, CancellationToken cancellationToken = default); + + /// + /// Deletes the progress of the periods which started before the specified point in time. + /// + /// The progress of periods which started before this point in time (UTC) is deleted. + /// The start of a period whose progress is kept anyway, i.e. the one of the quests which are done once. + /// The cancellation token. + /// The number of deleted entries. + ValueTask DeleteExpiredAsync(DateTime olderThan, DateTime keepPeriodStart, CancellationToken cancellationToken = default); +} diff --git a/src/Persistence/WeeklyQuests/InMemoryWeeklyQuestProgressRepository.cs b/src/Persistence/WeeklyQuests/InMemoryWeeklyQuestProgressRepository.cs new file mode 100644 index 000000000..391e728a4 --- /dev/null +++ b/src/Persistence/WeeklyQuests/InMemoryWeeklyQuestProgressRepository.cs @@ -0,0 +1,82 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.WeeklyQuests; + +using System.Collections.Concurrent; +using System.Threading; + +/// +/// A which keeps the progress only in memory. +/// It's used when no database backed repository is registered, e.g. for the demo mode and tests. +/// +public class InMemoryWeeklyQuestProgressRepository : IWeeklyQuestProgressRepository +{ + private readonly ConcurrentDictionary<(Guid CharacterId, DateTime PeriodStart, string QuestId), WeeklyQuestProgress> _entries = new(); + private int _loadCount; + + /// + /// Gets the number of calls of , for tests. + /// + public int LoadCount => this._loadCount; + + /// + public ValueTask> LoadAsync(Guid characterId, IReadOnlyCollection periodStarts, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._loadCount); + IList result = this._entries.Values + .Where(e => e.CharacterId == characterId && periodStarts.Contains(e.PeriodStart)) + .Select(Clone) + .ToList(); + return ValueTask.FromResult(result); + } + + /// + public ValueTask SaveAsync(IEnumerable progress, CancellationToken cancellationToken = default) + { + foreach (var entry in progress) + { + this._entries[(entry.CharacterId, entry.PeriodStart, entry.QuestId)] = Clone(entry); + } + + return ValueTask.CompletedTask; + } + + /// + public ValueTask> LoadRewardedByAccountAsync(Guid accountId, IReadOnlyCollection periodStarts, CancellationToken cancellationToken = default) + { + IList result = this._entries.Values + .Where(e => e.AccountId == accountId && periodStarts.Contains(e.PeriodStart) && e.RewardedAt is not null) + .Select(Clone) + .ToList(); + return ValueTask.FromResult(result); + } + + /// + public ValueTask DeleteExpiredAsync(DateTime olderThan, DateTime keepPeriodStart, CancellationToken cancellationToken = default) + { + var deleted = 0; + foreach (var key in this._entries.Keys.Where(k => k.PeriodStart < olderThan && k.PeriodStart != keepPeriodStart).ToList()) + { + if (this._entries.TryRemove(key, out _)) + { + deleted++; + } + } + + return ValueTask.FromResult(deleted); + } + + private static WeeklyQuestProgress Clone(WeeklyQuestProgress source) => new() + { + CharacterId = source.CharacterId, + AccountId = source.AccountId, + PeriodStart = source.PeriodStart, + QuestId = source.QuestId, + Count = source.Count, + AdditionalCounts = (int[])source.AdditionalCounts.Clone(), + CompletedAt = source.CompletedAt, + RewardedAt = source.RewardedAt, + }; +} diff --git a/src/Persistence/WeeklyQuests/WeeklyQuestProgress.cs b/src/Persistence/WeeklyQuests/WeeklyQuestProgress.cs new file mode 100644 index 000000000..34d2a44aa --- /dev/null +++ b/src/Persistence/WeeklyQuests/WeeklyQuestProgress.cs @@ -0,0 +1,110 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.WeeklyQuests; + +/// +/// The progress of a character in one weekly quest of one weekly period. +/// +/// +/// The progress is keyed by the start of the period, so a new week automatically starts +/// with fresh rows - there is no job which has to reset anything. +/// +public class WeeklyQuestProgress +{ + /// + /// Gets or sets the identifier of the character. + /// + public Guid CharacterId { get; set; } + + /// + /// Gets or sets the identifier of the account of the character. + /// + /// + /// It's null for entries which were stored before it was introduced. + /// + public Guid? AccountId { get; set; } + + /// + /// Gets or sets the start of the weekly period (UTC) to which this progress belongs. + /// + public DateTime PeriodStart { get; set; } + + /// + /// Gets or sets the identifier of the quest, as configured in the plugin configuration. + /// + public string QuestId { get; set; } = string.Empty; + + /// + /// Gets or sets the achieved count towards the (first) objective of the quest. + /// + public int Count { get; set; } + + /// + /// Gets or sets the achieved counts towards the further objectives of a quest with several objectives. + /// The element 0 belongs to the second objective, because the first one is . + /// + public int[] AdditionalCounts { get; set; } = []; + + /// + /// Gets or sets the timestamp (UTC) when the objective has been reached. + /// + public DateTime? CompletedAt { get; set; } + + /// + /// Gets or sets the timestamp (UTC) when the rewards have been handed out. + /// + /// + /// It stays null as long as a completed quest couldn't be rewarded yet, e.g. because the inventory was full. + /// + public DateTime? RewardedAt { get; set; } + + /// + /// Gets a value indicating whether any progress has been made in the quest. + /// + public bool HasAnyProgress => this.Count > 0 || this.CompletedAt is not null || this.AdditionalCounts.Any(c => c > 0); + + /// + /// Gets the achieved count towards the objective with the specified index. + /// + /// The index of the objective. + /// The achieved count. + public int GetCount(int objectiveIndex) + { + if (objectiveIndex == 0) + { + return this.Count; + } + + return objectiveIndex - 1 < this.AdditionalCounts.Length ? this.AdditionalCounts[objectiveIndex - 1] : 0; + } + + /// + /// Sets the achieved count towards the objective with the specified index. + /// + /// The index of the objective. + /// The achieved count. + public void SetCount(int objectiveIndex, int count) + { + if (objectiveIndex == 0) + { + this.Count = count; + return; + } + + // A new array, so that the change is detected when the entry is saved. + var counts = this.AdditionalCounts; + if (objectiveIndex - 1 >= counts.Length) + { + Array.Resize(ref counts, objectiveIndex); + } + else + { + counts = (int[])counts.Clone(); + } + + counts[objectiveIndex - 1] = count; + this.AdditionalCounts = counts; + } +} diff --git a/src/Persistence/WeeklyQuests/WeeklyQuestProgressRepositoryRegistry.cs b/src/Persistence/WeeklyQuests/WeeklyQuestProgressRepositoryRegistry.cs new file mode 100644 index 000000000..53d91dd88 --- /dev/null +++ b/src/Persistence/WeeklyQuests/WeeklyQuestProgressRepositoryRegistry.cs @@ -0,0 +1,21 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.WeeklyQuests; + +/// +/// Holds the of the process. +/// +/// +/// The weekly quest plugin needs a parameterless constructor, because the default configuration +/// of plugins is created with . That's why it can't get +/// the repository by constructor injection. The host sets it here after it has been built. +/// +public static class WeeklyQuestProgressRepositoryRegistry +{ + /// + /// Gets or sets the repository. If it's null, the progress is kept in memory. + /// + public static IWeeklyQuestProgressRepository? Current { get; set; } +} diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs index 0995c234c..93fe0ad42 100644 --- a/src/Startup/Program.cs +++ b/src/Startup/Program.cs @@ -32,6 +32,8 @@ namespace MUnique.OpenMU.Startup; using MUnique.OpenMU.Persistence.EntityFramework; using MUnique.OpenMU.Persistence.EntityFramework.AdminAuth; using MUnique.OpenMU.Persistence.EntityFramework.Json; +using MUnique.OpenMU.Persistence.EntityFramework.WeeklyQuests; +using MUnique.OpenMU.Persistence.WeeklyQuests; using MUnique.OpenMU.Persistence.Initialization.Version075; using MUnique.OpenMU.Persistence.InMemory; using MUnique.OpenMU.PlugIns; @@ -279,6 +281,12 @@ private async Task CreateHostAsync(string[] args) builder.AddAdminPanel(includeMapApp: true); } + if (!args.Contains("-demo")) + { + // Without it, the weekly quests plugin keeps the progress in memory only. + builder.Services.AddWeeklyQuestProgressRepository(); + } + builder.Services.AddSingleton(this._servers) .AddSingleton() .AddSingleton() @@ -350,6 +358,7 @@ private async Task CreateHostAsync(string[] args) this.AddActorControlEndpoint(builder.Services); var host = builder.Build(); + WeeklyQuestProgressRepositoryRegistry.Current = host.Services.GetService(); // NpgsqlLoggingConfiguration.InitializeLogging(host.Services.GetRequiredService()) this._logger.Information("Host created"); diff --git a/src/Web/AdminPanel/API/WebsiteController.cs b/src/Web/AdminPanel/API/WebsiteController.cs new file mode 100644 index 000000000..2f671cd07 --- /dev/null +++ b/src/Web/AdminPanel/API/WebsiteController.cs @@ -0,0 +1,237 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.API; + +using System.Globalization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; +using MUnique.OpenMU.GameServer; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Persistence.WeeklyQuests; +using MUnique.OpenMU.PlugIns; +using MUnique.OpenMU.Web.AdminPanel.Auth; + +/// +/// API for the community website: who is in the game, and the tools of the player's account panel. +/// +/// +/// It only exposes what the game server knows and the database doesn't (the in-memory session state). +/// The website decides which account the signed-in visitor owns, so the API key must never leave the +/// website's server. +/// +[Route("api/web/")] +[Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.ApiSchemes, Policy = AdminPolicies.Viewer)] +public class WebsiteController : Controller +{ + /// + /// The culture of the texts for the website, e.g. the names of the reward items. + /// + private static readonly CultureInfo WebsiteCulture = CultureInfo.GetCultureInfo("es"); + + private readonly IDictionary _gameServers; + private readonly ILoginServer _loginServer; + private readonly IPersistenceContextProvider _contextProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The game servers. + /// The login server. + /// The persistence context provider. + public WebsiteController(IDictionary gameServers, ILoginServer loginServer, IPersistenceContextProvider contextProvider) + { + this._gameServers = gameServers; + this._loginServer = loginServer; + this._contextProvider = contextProvider; + } + + /// + /// Gets the names of the characters which are currently playing. + /// + /// The online character names. + [HttpGet] + [Route("online")] + public async Task GetOnlineAsync() + { + var names = new List(); + foreach (var server in this._gameServers.Values.OfType()) + { + var players = await server.Context.GetPlayersAsync().ConfigureAwait(false); + names.AddRange(players.Select(p => p.SelectedCharacter?.Name).OfType()); + } + + return this.Ok(new { characters = names }); + } + + /// + /// Gets the session state of an account. + /// + /// The login name of the account. + /// + /// connected: the login server considers the account logged in; + /// inGame: a game server really has a player of this account. + /// A connected account which is not in game is a stuck session. + /// + [HttpGet] + [Route("account/{login}")] + public async Task GetAccountAsync(string login) + { + var connected = (await this._loginServer.GetSnapshotAsync().ConfigureAwait(false)) + .Keys.Any(k => string.Equals(k, login, StringComparison.OrdinalIgnoreCase)); + var (inGame, character) = await this.FindPlayerAsync(login).ConfigureAwait(false); + return this.Ok(new { connected, inGame, character }); + } + + /// + /// Ends the session of an account: kicks its player, if any, and releases the login. + /// + /// The login name of the account. + /// Whether a player was kicked and whether a stuck login was released. + [HttpPost] + [Route("account/{login}/disconnect")] + [Authorize(AuthenticationSchemes = ApiKeyAuthenticationDefaults.ApiSchemes, Policy = AdminPolicies.Operator)] + public async Task DisconnectAccountAsync(string login) + { + var kicked = false; + foreach (var server in this._gameServers.Values) + { + kicked |= await server.DisconnectAccountAsync(login).ConfigureAwait(false); + } + + // Same as the "set offline" button of the admin panel: it also clears a login which has no player. + var snapshot = await this._loginServer.GetSnapshotAsync().ConfigureAwait(false); + var released = false; + foreach (var (name, serverId) in snapshot.Where(e => string.Equals(e.Key, login, StringComparison.OrdinalIgnoreCase))) + { + await this._loginServer.LogOffAsync(name, serverId).ConfigureAwait(false); + released = true; + } + + return this.Ok(new { kicked, released }); + } + + /// + /// Gets the weekly quests of the characters of an account, with their progress in the current week. + /// + /// The login name of the account. + /// + /// enabled: whether the weekly quests plugin is active; + /// nextResetUtc: when the next week starts; + /// characters: the quests per character, like they're shown in the game. + /// + /// + /// The progress of a character which is in the game is saved every minute, so it may be a bit behind. + /// + [HttpGet] + [Route("account/{login}/weekly-quests")] + public async Task GetWeeklyQuestsAsync(string login) + { + var context = this._gameServers.Values.OfType().FirstOrDefault()?.Context; + var plugInId = typeof(WeeklyQuestsPlugIn).GUID; + if (context is null + || WeeklyQuestProgressRepositoryRegistry.Current is not { } repository + || !context.PlugInManager.IsPlugInActive(plugInId) + || context.Configuration.PlugInConfigurations.FirstOrDefault(c => c.TypeId == plugInId) + ?.GetConfiguration(context.PlugInManager.CustomConfigReferenceHandler) is not { } configuration) + { + return this.Ok(new { enabled = false }); + } + + using var playerContext = this._contextProvider.CreateNewPlayerContext(context.Configuration); + var account = await playerContext.GetAccountByLoginNameAsync(login).ConfigureAwait(false); + if (account is null) + { + return this.NotFound(); + } + + var periods = WeeklyPeriod.GetPeriodStarts(DateTime.UtcNow, configuration, context.ServerTimeZone); + var questsById = configuration.Quests + .Where(q => !string.IsNullOrWhiteSpace(q.Id)) + .GroupBy(q => q.Id) + .ToDictionary(g => g.Key, g => g.First()); + var progressPerCharacter = new Dictionary>(); + foreach (var character in account.Characters) + { + var id = character.GetId(); + var loaded = await repository.LoadAsync(id, periods.All).ConfigureAwait(false); + + // Only the progress of the current period of each quest counts. + progressPerCharacter[id] = loaded + .Where(p => p.PeriodStart == periods.Get(p.QuestId == WeeklyQuestSelector.AllCompletedBonusId + ? QuestPeriod.Weekly + : questsById.GetValueOrDefault(p.QuestId)?.Period ?? QuestPeriod.Weekly)) + .GroupBy(p => p.QuestId) + .ToDictionary(g => g.Key, g => g.First()); + } + + var characters = account.Characters + .OrderBy(c => c.CharacterSlot) + .Select(character => + { + var id = character.GetId(); + var rewardedByOthers = progressPerCharacter + .Where(p => p.Key != id) + .SelectMany(p => p.Value.Values) + .Where(p => p.RewardedAt is not null) + .Select(p => p.QuestId) + .ToHashSet(); + var info = new WeeklyQuestCharacterInfo( + character.CharacterClass, + (int)(character.Attributes.FirstOrDefault(a => a.Definition == Stats.Level)?.Value ?? 1), + (int)(character.Attributes.FirstOrDefault(a => a.Definition == Stats.Resets)?.Value ?? 0)); + var quests = WeeklyQuestSelector.CreateEntries(configuration, periods, info, progressPerCharacter[id], rewardedByOthers) + .Select(e => new + { + id = e.Quest.Id, + name = e.Quest.Name, + description = e.Quest.Description, + count = e.Count, + required = e.Required, + completed = e.IsCompleted, + rewarded = e.IsRewarded, + rewards = e.Quest.GetRewardsText(WebsiteCulture), + isBonus = e.Quest.Id == WeeklyQuestSelector.AllCompletedBonusId, + category = e.Quest.Category.ToString().ToLowerInvariant(), + period = e.Quest.Period.ToString().ToLowerInvariant(), + sequential = e.Quest.SequentialObjectives, + currentStep = e.CurrentStep, + objectives = e.Objectives.Count > 1 + ? e.Objectives.Select(o => new + { + text = o.Objective.GetDisplayText(WebsiteCulture), + count = o.Count, + required = o.Required, + done = o.IsDone, + }).ToList() + : null, + }) + .ToList(); + return new { name = character.Name, quests }; + }) + .ToList(); + + var nextResetUtc = WeeklyPeriod.GetNextPeriodStartUtc(periods.Weekly, context.ServerTimeZone); + var nextDailyResetUtc = WeeklyPeriod.GetNextDailyPeriodStartUtc(periods.Daily, context.ServerTimeZone); + return this.Ok(new { enabled = true, nextResetUtc, nextDailyResetUtc, characters }); + } + + private async Task<(bool InGame, string? Character)> FindPlayerAsync(string login) + { + foreach (var server in this._gameServers.Values.OfType()) + { + var players = await server.Context.GetPlayersAsync().ConfigureAwait(false); + var player = players.FirstOrDefault(p => string.Equals(p.Account?.LoginName, login, StringComparison.OrdinalIgnoreCase)); + if (player is not null) + { + return (true, player.SelectedCharacter?.Name); + } + } + + return (false, null); + } +} diff --git a/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor b/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor index 492ce0c87..a47737628 100644 --- a/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor +++ b/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor @@ -1,38 +1,45 @@ -@using MUnique.OpenMU.DataModel.Configuration +@using MUnique.OpenMU.DataModel.Configuration @using MUnique.OpenMU.DataModel.Configuration.Items @using MUnique.OpenMU.Web.AdminPanel.Properties +@* The entries are grouped by topic and carry a short Spanish tooltip; labels and links are the original ones. *@ + + + + @if (this.GameConfigurationId is { } gameConfigurationId ) { } + @@ -89,7 +98,7 @@ @if (AdminPanelEnvironment.IsHostingEmbedded) { @@ -118,7 +127,7 @@ @if (this.IsNetworkAnalyzerAvailable) { diff --git a/src/Web/AdminPanel/Components/Layout/NavMenu.razor.css b/src/Web/AdminPanel/Components/Layout/NavMenu.razor.css index 1ea4f9476..509546ac2 100644 --- a/src/Web/AdminPanel/Components/Layout/NavMenu.razor.css +++ b/src/Web/AdminPanel/Components/Layout/NavMenu.razor.css @@ -103,3 +103,13 @@ overflow-y: auto; } } + +/* Section headings which group the menu entries. */ +.nav-section { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.6; + padding-top: 0.9rem; + padding-bottom: 0.25rem; +} diff --git a/src/Web/AdminPanel/Pages/Help.razor b/src/Web/AdminPanel/Pages/Help.razor new file mode 100644 index 000000000..4998bb615 --- /dev/null +++ b/src/Web/AdminPanel/Pages/Help.razor @@ -0,0 +1,99 @@ +@page "/ayuda" + +@using MUnique.OpenMU.DataModel.Configuration +@using MUnique.OpenMU.DataModel.Configuration.Items + +Ayuda +

Ayuda

+ + +

Guía rápida en español para las tareas más comunes del panel. Los nombres de menús y campos siguen en inglés, igual que en el resto de la documentación de MU.

+ +
+ + El ícono de info. Al lado de casi todos los campos aparece un pequeño ícono de info. Al tocarlo se abre la explicación en español de qué es el campo y, cuando corresponde, qué impacto tiene cambiarlo. Debajo se mantiene el texto original en inglés. +
+ +

¿Cómo hago...?

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Quiero...Dónde se haceTené en cuenta
Cambiar la experiencia del servidorGame configuration > General: campo Experience Rate (y Master Experience Rate).La experiencia final se multiplica por el rate general, por el rate de cada servidor de juego (en Servers) y por el multiplicador de cada mapa. El Happy Hour se suma aparte, desde Plugins.
Cambiar qué vende un NPCMerchant stores: elegí el NPC, agregá, movés o borrás ítems y guardá con Save changes.La cantidad (Amount) de un ítem apilable no puede superar su Durability. Cada ítem ocupa tantas casillas como su tamaño.
Vender o apilar más pociones o joyas por casillaGame configuration > Items: buscá el ítem y subí su campo Durability.Es el tamaño máximo de la pila en todo el juego: inventario, baúl, comercio y tiendas. No cambia lo que ya tienen los jugadores. Probalo en el cliente después de cambiarlo.
Poner más monstruos en una zonaMap editor, o Game maps > el mapa > Monster Spawns: campo Quantity.Más monstruos significa más experiencia y drops por hora, pero también más carga para el servidor. Si la zona es chica, quedan apilados: agrandá el rectángulo (X1, Y1, X2, Y2).
Cambiar la fuerza o la vida de un monstruoGame configuration > Monsters: sección Attributes.Afecta a todos los mapas donde aparece ese monstruo. Para un jefe más duro solo en una zona, usá Maximum Health Override en la zona de aparición.
Cambiar qué sueltan los monstruosDrop item groups (grupos generales) y el campo Drop Item Groups de cada monstruo o mapa.El campo Chance va de 0.0 a 1.0. Los cambios se notan enseguida en la economía: hacelos de a poco.
Activar Happy Hour, invasiones o resetsPlugins: activá el plugin y editá sus horarios.Los horarios están en hora UTC. Algunos sistemas vienen apagados por defecto a propósito.
Habilitar o deshabilitar un comando de chatChat commands.Hay comandos solo para GM y otros para todos los jugadores.
Desconectar una cuenta que quedó trabadaOnline Accounts: botón para poner la cuenta offline.Libera la cuenta para que el jugador pueda volver a entrar. Los jugadores también pueden hacerlo desde su cuenta en la web.
Dar acceso al panel a otra personaUsers: creá un usuario propio (contraseña de al menos 12 caracteres).Cada persona debería tener su usuario. El rol Administrator puede gestionar usuarios; Operator puede operar el servidor.
Conectar un programa externo (la web)API keys.El rol Operator permite ejecutar acciones, como desconectar cuentas. Guardá la clave solo en el servidor de la web.
Ver qué pasó con un errorLog Files.Buscá la hora del problema y el nombre de la cuenta o el personaje.
+
+ +

Antes de tocar nada

+
    +
  • Hacé un backup de la base de datos antes de cambios grandes (rates, fórmulas, drops, ítems).
  • +
  • Algunos cambios se aplican al instante y otros necesitan reiniciar el servidor de juego (desde Servers). Si no ves el cambio en el juego, reiniciá.
  • +
  • Los jugadores conectados no reciben el cambio hasta que vuelven a entrar.
  • +
  • Cambiá una cosa por vez y probala en el juego antes de seguir.
  • +
diff --git a/src/Web/Shared/Components/Form/DataTable.razor b/src/Web/Shared/Components/Form/DataTable.razor index 7fcdbb946..cc5d85595 100644 --- a/src/Web/Shared/Components/Form/DataTable.razor +++ b/src/Web/Shared/Components/Form/DataTable.razor @@ -130,6 +130,7 @@ private async Task GetPageAsync(int page) { + var restartFromFirstPage = false; this._isLoading = true; try { @@ -151,6 +152,10 @@ else { this._hasMoreEntries = false; + + // A refresh of a page which does not exist anymore (for example after a filter reduced the results) would + // show an empty table without a way to notice why. Start over from the first page instead. + restartFromFirstPage = isRefresh && page > 1; } } finally @@ -158,5 +163,10 @@ this._isLoading = false; await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); } + + if (restartFromFirstPage) + { + await this.GetPageAsync(1); + } } } diff --git a/src/Web/Shared/Components/Form/FieldLabel.razor b/src/Web/Shared/Components/Form/FieldLabel.razor index fb8786709..744e2d465 100644 --- a/src/Web/Shared/Components/Form/FieldLabel.razor +++ b/src/Web/Shared/Components/Form/FieldLabel.razor @@ -9,7 +9,24 @@ {