From e5906023ed6f5230627fc4167a7accd0f1102c63 Mon Sep 17 00:00:00 2001 From: Marcelo Oviedo Date: Fri, 25 Sep 2026 17:29:41 -0300 Subject: [PATCH 1/9] Add website API: online characters, account session state and disconnect Co-Authored-By: Claude Sonnet 5 --- src/Web/AdminPanel/API/WebsiteController.cs | 118 ++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 src/Web/AdminPanel/API/WebsiteController.cs diff --git a/src/Web/AdminPanel/API/WebsiteController.cs b/src/Web/AdminPanel/API/WebsiteController.cs new file mode 100644 index 000000000..d5d8a5c8d --- /dev/null +++ b/src/Web/AdminPanel/API/WebsiteController.cs @@ -0,0 +1,118 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.API; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MUnique.OpenMU.GameServer; +using MUnique.OpenMU.Interfaces; +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 +{ + private readonly IDictionary _gameServers; + private readonly ILoginServer _loginServer; + + /// + /// Initializes a new instance of the class. + /// + /// The game servers. + /// The login server. + public WebsiteController(IDictionary gameServers, ILoginServer loginServer) + { + this._gameServers = gameServers; + this._loginServer = loginServer; + } + + /// + /// 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 }); + } + + 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); + } +} From 23ce4aae0080e0656551dc724ae8555e3caffc09 Mon Sep 17 00:00:00 2001 From: Diego Amatte Date: Fri, 25 Sep 2026 17:51:10 -0300 Subject: [PATCH 2/9] Add weekly quests plugin with configurable objectives and rewards Weekly quests are configured in the custom configuration of the new WeeklyQuestsPlugIn (admin panel -> Plugins). Supported objectives: kill a specific or any monster (optionally per map), gain levels / master levels, do resets, finish a mini game and kill players. Rewards: items, zen, experience and master experience. - Progress is stored per character and weekly period in its own "weekly" schema (WeeklyQuestContext with own migrations), so the generated game data model stays untouched. A new week starts with fresh rows, no reset job is needed. - Rewards are handed out completely or not at all. When the inventory is full they stay pending and are retried at the next login or /weekly. - New plugin points ICharacterResetPlugIn and IMiniGameEndedPlugIn. - New /weekly chat command which shows the progress. --- src/Dapr/Common/Extensions.cs | 4 + src/GameLogic/MiniGames/MiniGameContext.cs | 5 + .../WeeklyQuestsChatCommandPlugIn.cs | 65 +++ .../PlugIns/ICharacterResetPlugIn.cs | 23 + src/GameLogic/PlugIns/IMiniGameEndedPlugIn.cs | 24 + .../PlugIns/WeeklyQuests/WeeklyPeriod.cs | 58 +++ .../WeeklyQuests/WeeklyQuestDefinition.cs | 104 ++++ .../WeeklyQuests/WeeklyQuestObjectiveType.cs | 53 ++ .../WeeklyQuests/WeeklyQuestOverview.cs | 21 + .../WeeklyQuests/WeeklyQuestPlayerState.cs | 82 +++ .../PlugIns/WeeklyQuests/WeeklyQuestReward.cs | 71 +++ .../WeeklyQuests/WeeklyQuestRewardType.cs | 35 ++ .../WeeklyQuests/WeeklyQuestRewarder.cs | 155 ++++++ .../WeeklyQuests/WeeklyQuestsConfiguration.cs | 92 ++++ .../WeeklyQuests/WeeklyQuestsPlugIn.cs | 491 ++++++++++++++++++ .../Properties/PlugInResources.Designer.cs | 36 ++ src/GameLogic/Properties/PlugInResources.resx | 12 + src/GameLogic/Resets/ResetCharacterAction.cs | 7 + .../EntityFramework/ConnectionSettings.xml | 6 + .../DatabaseSnapshotService.cs | 7 + ...0925190758_InitialWeeklyQuests.Designer.cs | 59 +++ .../20260925190758_InitialWeeklyQuests.cs | 49 ++ .../WeeklyQuestContextModelSnapshot.cs | 56 ++ .../EntityFramework/SchemaNames.cs | 5 + .../WeeklyQuests/WeeklyQuestContext.cs | 50 ++ .../WeeklyQuests/WeeklyQuestContextFactory.cs | 24 + .../WeeklyQuestProgressRepository.cs | 150 ++++++ .../WeeklyQuestServiceCollectionExtensions.cs | 26 + .../IWeeklyQuestProgressRepository.cs | 29 ++ .../InMemoryWeeklyQuestProgressRepository.cs | 48 ++ .../WeeklyQuests/WeeklyQuestProgress.cs | 48 ++ .../WeeklyQuestProgressRepositoryRegistry.cs | 21 + src/Startup/Program.cs | 9 + .../WeeklyQuestsPlugInTest.cs | 198 +++++++ 34 files changed, 2123 insertions(+) create mode 100644 src/GameLogic/PlugIns/ChatCommands/WeeklyQuestsChatCommandPlugIn.cs create mode 100644 src/GameLogic/PlugIns/ICharacterResetPlugIn.cs create mode 100644 src/GameLogic/PlugIns/IMiniGameEndedPlugIn.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyPeriod.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveType.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestOverview.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestPlayerState.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestReward.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestRewardType.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestRewarder.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsConfiguration.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs create mode 100644 src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260925190758_InitialWeeklyQuests.Designer.cs create mode 100644 src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260925190758_InitialWeeklyQuests.cs create mode 100644 src/Persistence/EntityFramework/Migrations/WeeklyQuests/WeeklyQuestContextModelSnapshot.cs create mode 100644 src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContext.cs create mode 100644 src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContextFactory.cs create mode 100644 src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestProgressRepository.cs create mode 100644 src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestServiceCollectionExtensions.cs create mode 100644 src/Persistence/WeeklyQuests/IWeeklyQuestProgressRepository.cs create mode 100644 src/Persistence/WeeklyQuests/InMemoryWeeklyQuestProgressRepository.cs create mode 100644 src/Persistence/WeeklyQuests/WeeklyQuestProgress.cs create mode 100644 src/Persistence/WeeklyQuests/WeeklyQuestProgressRepositoryRegistry.cs create mode 100644 tests/MUnique.OpenMU.Tests/WeeklyQuestsPlugInTest.cs 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/MiniGames/MiniGameContext.cs b/src/GameLogic/MiniGames/MiniGameContext.cs index ba4ce7c5f..0af424995 100644 --- a/src/GameLogic/MiniGames/MiniGameContext.cs +++ b/src/GameLogic/MiniGames/MiniGameContext.cs @@ -832,6 +832,11 @@ private async ValueTask StopAsync() } 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/PlugIns/ChatCommands/WeeklyQuestsChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/WeeklyQuestsChatCommandPlugIn.cs new file mode 100644 index 000000000..f6546b2e5 --- /dev/null +++ b/src/GameLogic/PlugIns/ChatCommands/WeeklyQuestsChatCommandPlugIn.cs @@ -0,0 +1,65 @@ +// +// 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.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 semanales 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 semanal. Probá de nuevo en un rato.").ConfigureAwait(false); + return; + } + + if (overview.Entries.Count == 0) + { + await player.ShowBlueMessageAsync("No hay quests semanales activas.").ConfigureAwait(false); + return; + } + + foreach (var entry in overview.Entries) + { + var status = entry switch + { + { IsRewarded: true } => "[OK]", + { IsCompleted: true } => "[Premio pendiente]", + _ => $"{entry.Count}/{entry.Quest.RequiredCount}", + }; + await player.ShowBlueMessageAsync($"{entry.Quest.Name}: {status} - {entry.Quest.Description}").ConfigureAwait(false); + } + + var remaining = overview.NextResetUtc - DateTime.UtcNow; + await player.ShowBlueMessageAsync($"Reinicio en {(int)remaining.TotalDays}d {remaining.Hours}h {remaining.Minutes}m.").ConfigureAwait(false); + } +} 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/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/WeeklyQuests/WeeklyPeriod.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyPeriod.cs new file mode 100644 index 000000000..be805fc07 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyPeriod.cs @@ -0,0 +1,58 @@ +// +// 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 +{ + /// + /// 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/WeeklyQuestDefinition.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs new file mode 100644 index 000000000..808b2fdf7 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs @@ -0,0 +1,104 @@ +// +// 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; + +/// +/// The definition of a weekly quest. +/// +public class WeeklyQuestDefinition +{ + /// + /// 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 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 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 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 . + /// + [Display(Name = "Monstruo", Description = "Solo para \"Matar monstruo\".")] + 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 = "Opcional, para objetivos de kills. Vacío = cualquier mapa.")] + public virtual GameMapDefinition? Map { 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 the rewards which are given when the quest has been completed. + /// + [Display(Name = "Premios")] + [MemberOfAggregate] + [ScaffoldColumn(true)] + public ICollection Rewards { get; set; } = new List(); + + /// + public override string ToString() => $"{this.Id}: {this.Name}"; +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveType.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveType.cs new file mode 100644 index 000000000..e0e7a8a0e --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveType.cs @@ -0,0 +1,53 @@ +// +// 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, +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestOverview.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestOverview.cs new file mode 100644 index 000000000..f13c664c4 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestOverview.cs @@ -0,0 +1,21 @@ +// +// 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 weekly quests of a player. +/// +/// The entries of the active quests. +/// The point in time (UTC) when the next period starts. +public record WeeklyQuestOverview(IReadOnlyList Entries, DateTime NextResetUtc); + +/// +/// An entry of a . +/// +/// The quest. +/// The achieved count. +/// A value indicating whether the objective has been reached. +/// A value indicating whether the rewards have been handed out. +public record WeeklyQuestOverviewEntry(WeeklyQuestDefinition Quest, int Count, bool IsCompleted, bool IsRewarded); diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestPlayerState.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestPlayerState.cs new file mode 100644 index 000000000..e95b8c821 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestPlayerState.cs @@ -0,0 +1,82 @@ +// +// 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 weekly quest progress of a player in the current period, 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. + public WeeklyQuestPlayerState(WeeklyQuestsPlugIn owner, Guid characterId) + { + this.Owner = owner; + this.CharacterId = characterId; + } + + /// + /// 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 or sets a value indicating whether the progress of the has been loaded. + /// + public bool IsLoaded { get; set; } + + /// + /// Gets or sets the start of the period of the loaded progress. + /// + public DateTime PeriodStartUtc { 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 progress of the quest, and creates it if it doesn't exist yet. + /// + /// The quest identifier. + /// The progress. + public WeeklyQuestProgress GetOrCreateProgress(string questId) + { + if (!this.Progress.TryGetValue(questId, out var progress)) + { + progress = new WeeklyQuestProgress + { + CharacterId = this.CharacterId, + PeriodStart = this.PeriodStartUtc, + 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..44a57093c --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestReward.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; + +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.RewardType == WeeklyQuestRewardType.Item + ? $"{this.Amount}x {this.Item?.Name ?? "?"} +{this.ItemLevel}" + : $"{this.Amount} {this.RewardType}"; +} 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/WeeklyQuestsConfiguration.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsConfiguration.cs new file mode 100644 index 000000000..34a119574 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsConfiguration.cs @@ -0,0 +1,92 @@ +// +// 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 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; } = "[Semanal] {0}: {1}/{2}"; + + /// + /// 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 semanal 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; } = "[Semanal] Liberá espacio en el inventario para recibir el premio de {0}."; + + /// + /// Gets or sets the quests. + /// + [Display(Name = "Quests", Description = "Las quests semanales. 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 }, + }, + }, + }, + }; +} diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs new file mode 100644 index 000000000..d91d8c766 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs @@ -0,0 +1,491 @@ +// +// 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 weekly 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, + 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 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; + + /// + /// 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; + } + + /// + /// 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 WeeklyQuestsConfiguration? Configuration { get; set; } + + /// + public object CreateDefaultConfig() => WeeklyQuestsConfiguration.Default; + + /// + 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 weekly 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) + { + return; + } + + var mapNumber = monster.CurrentMap?.Definition.Number; + bool IsOnMap(WeeklyQuestDefinition quest) => quest.Map is null || quest.Map.Number == mapNumber; + + await this.AddProgressAsync(player, WeeklyQuestObjectiveType.KillAnyMonster, 1, IsOnMap).ConfigureAwait(false); + await this.AddProgressAsync( + player, + WeeklyQuestObjectiveType.KillMonster, + 1, + quest => IsOnMap(quest) && quest.Monster is not null && quest.Monster.Number == monster.Definition.Number).ConfigureAwait(false); + } + else if (killed is Player victim && victim != player) + { + await this.AddProgressAsync( + player, + WeeklyQuestObjectiveType.KillPlayer, + 1, + quest => victim.Level >= quest.MinimumVictimLevel).ConfigureAwait(false); + } + } + + /// + public void CharacterLeveledUp(Player player) + { + // 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 weekly 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, + quest => quest.MiniGameType == MiniGameType.Undefined || quest.MiniGameType == type).ConfigureAwait(false); + } + } + + /// + public async ValueTask ExecuteTaskAsync(GameContext gameContext) + { + if (DateTime.UtcNow < this._nextSaveUtc) + { + return; + } + + this._nextSaveUtc = DateTime.UtcNow + 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 weekly quest progress periodically."); + } + } + } + + /// + public void ForceStart() + { + this._nextSaveUtc = DateTime.UtcNow; + } + + /// + /// Gets the overview of the active 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); + + var entries = GetActiveQuests(configuration) + .Select(quest => + { + state.Progress.TryGetValue(quest.Id, out var progress); + return new WeeklyQuestOverviewEntry(quest, progress?.Count ?? 0, progress?.CompletedAt is not null, progress?.RewardedAt is not null); + }) + .ToList(); + return new WeeklyQuestOverview(entries, WeeklyPeriod.GetNextPeriodStartUtc(state.PeriodStartUtc, player.GameContext.ServerTimeZone)); + } + + /// + /// Gets the plugin which tracks the weekly quests of the player. + /// + /// The player. + /// The plugin, if the player is tracked by one. + internal static WeeklyQuestsPlugIn? GetTrackingPlugIn(Player player) + { + return States.TryGetValue(player, out var state) ? state.Owner : null; + } + + private static IEnumerable GetActiveQuests(WeeklyQuestsConfiguration configuration) + { + return configuration.Quests.Where(q => q.IsActive && !string.IsNullOrWhiteSpace(q.Id)); + } + + 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 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; + } + + return States.GetValue(player, _ => new WeeklyQuestPlayerState(this, character.GetId())); + } + + private async ValueTask AddProgressAsync(Player player, WeeklyQuestObjectiveType objectiveType, int amount, Func? filter = null) + { + if (this.Configuration is not { } configuration) + { + return; + } + + var level = player.Level; + var resets = (int)(player.Attributes?[Stats.Resets] ?? 0); + var quests = GetActiveQuests(configuration) + .Where(q => q.ObjectiveType == objectiveType + && level >= q.MinimumLevel + && resets >= q.MinimumResets + && (filter is null || filter(q))) + .ToList(); + if (quests.Count == 0 || this.GetOrCreateState(player) is not { } state) + { + return; + } + + try + { + using var l = await state.Lock.LockAsync().ConfigureAwait(false); + if (!await this.EnsureLoadedAsync(player, state).ConfigureAwait(false)) + { + return; + } + + var hasCompleted = false; + foreach (var quest in quests) + { + var progress = state.GetOrCreateProgress(quest.Id); + if (progress.CompletedAt is not null) + { + continue; + } + + var previousCount = progress.Count; + progress.Count = (int)Math.Min((long)previousCount + amount, quest.RequiredCount); + state.DirtyQuestIds.Add(quest.Id); + + if (progress.Count >= quest.RequiredCount) + { + progress.CompletedAt = DateTime.UtcNow; + hasCompleted = true; + await this.TryRewardAsync(player, quest, progress, true).ConfigureAwait(false); + } + else if (Milestones.Any(m => previousCount * 100 / quest.RequiredCount < m && progress.Count * 100 / quest.RequiredCount >= m)) + { + await player.ShowBlueMessageAsync(Format(configuration.ProgressMessage, player, quest.Name, progress.Count, quest.RequiredCount)).ConfigureAwait(false); + } + } + + // A completion is saved immediately, so that it can't get lost. + if (hasCompleted) + { + await this.SaveAsync(player, state).ConfigureAwait(false); + } + } + catch (Exception ex) + { + player.Logger.LogError(ex, "Unexpected error when adding weekly quest progress of type {objectiveType}.", objectiveType); + } + } + + private async ValueTask EnsureLoadedAsync(Player player, WeeklyQuestPlayerState state) + { + var periodStart = WeeklyPeriod.GetPeriodStartUtc( + DateTime.UtcNow, + this.Configuration?.ResetDay ?? DayOfWeek.Monday, + this.Configuration?.ResetTime ?? TimeOnly.MinValue, + player.GameContext.ServerTimeZone); + if (state.IsLoaded && state.PeriodStartUtc == periodStart) + { + return true; + } + + try + { + if (state.IsLoaded) + { + // A new week began while the player is online. The progress of the last week is saved first. + await this.SaveAsync(player, state).ConfigureAwait(false); + state.IsLoaded = false; + } + + var loaded = await this.Repository.LoadAsync(state.CharacterId, periodStart).ConfigureAwait(false); + state.Progress = loaded.ToDictionary(p => p.QuestId); + state.PeriodStartUtc = periodStart; + state.DirtyQuestIds.Clear(); + state.IsLoaded = true; + return true; + } + catch (Exception ex) + { + player.Logger.LogWarning(ex, "Couldn't load the weekly quest progress of character {characterId}.", state.CharacterId); + return false; + } + } + + 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 weekly quest progress of character {characterId}.", state.CharacterId); + } + } + + private async ValueTask RewardPendingAsync(Player player, WeeklyQuestPlayerState state, bool showPendingMessage) + { + if (this.Configuration is not { } configuration) + { + return; + } + + var rewarded = false; + foreach (var progress in state.Progress.Values.Where(p => p.CompletedAt is not null && p.RewardedAt is null).ToList()) + { + if (configuration.Quests.FirstOrDefault(q => q.Id == progress.QuestId) is { } quest) + { + 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 weekly 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 70c41af10..dce85d4a8 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -3508,5 +3508,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 e868f4060..260eedb39 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1266,4 +1266,16 @@ Consumes Life Stones after they are successfully placed during Castle Siege. + + Weekly Quests + + + Quests semanales con objetivos (kills, niveles, resets, eventos, PvP) y premios configurables. El progreso se reinicia cada semana. + + + 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/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/WeeklyQuestContextModelSnapshot.cs b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/WeeklyQuestContextModelSnapshot.cs new file mode 100644 index 000000000..2e22849ef --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/WeeklyQuestContextModelSnapshot.cs @@ -0,0 +1,56 @@ +// +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("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/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..2af5f0c11 --- /dev/null +++ b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContext.cs @@ -0,0 +1,50 @@ +// +// 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.HasIndex(p => p.PeriodStart); + }); + } +} 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..c214b06da --- /dev/null +++ b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestProgressRepository.cs @@ -0,0 +1,150 @@ +// +// 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, DateTime periodStart, CancellationToken cancellationToken = default) + { + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + await using var context = new WeeklyQuestContext(); + return await context.Progress + .AsNoTracking() + .Where(p => p.CharacterId == characterId && p.PeriodStart == 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.Count = entry.Count; + stored.CompletedAt = entry.CompletedAt; + stored.RewardedAt = entry.RewardedAt; + } + else + { + context.Progress.Add(new WeeklyQuestProgress + { + CharacterId = entry.CharacterId, + PeriodStart = entry.PeriodStart, + QuestId = entry.QuestId, + Count = entry.Count, + CompletedAt = entry.CompletedAt, + RewardedAt = entry.RewardedAt, + }); + } + } + } + + await context.SaveChangesAsync(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..6230e864b --- /dev/null +++ b/src/Persistence/WeeklyQuests/IWeeklyQuestProgressRepository.cs @@ -0,0 +1,29 @@ +// +// 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 period. + /// + /// The identifier of the character. + /// The start of the period. + /// The cancellation token. + /// The stored progress of the character in this period. + ValueTask> LoadAsync(Guid characterId, DateTime periodStart, CancellationToken cancellationToken = default); + + /// + /// Inserts or updates the specified progress entries. + /// + /// The progress entries. + /// The cancellation token. + ValueTask SaveAsync(IEnumerable progress, CancellationToken cancellationToken = default); +} diff --git a/src/Persistence/WeeklyQuests/InMemoryWeeklyQuestProgressRepository.cs b/src/Persistence/WeeklyQuests/InMemoryWeeklyQuestProgressRepository.cs new file mode 100644 index 000000000..26938e70f --- /dev/null +++ b/src/Persistence/WeeklyQuests/InMemoryWeeklyQuestProgressRepository.cs @@ -0,0 +1,48 @@ +// +// 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(); + + /// + public ValueTask> LoadAsync(Guid characterId, DateTime periodStart, CancellationToken cancellationToken = default) + { + IList result = this._entries.Values + .Where(e => e.CharacterId == characterId && e.PeriodStart == 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; + } + + private static WeeklyQuestProgress Clone(WeeklyQuestProgress source) => new() + { + CharacterId = source.CharacterId, + PeriodStart = source.PeriodStart, + QuestId = source.QuestId, + Count = source.Count, + 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..803e8a90b --- /dev/null +++ b/src/Persistence/WeeklyQuests/WeeklyQuestProgress.cs @@ -0,0 +1,48 @@ +// +// 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 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 objective of the quest. + /// + public int Count { 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; } +} 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 788b0efee..89201c563 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() @@ -338,6 +346,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/tests/MUnique.OpenMU.Tests/WeeklyQuestsPlugInTest.cs b/tests/MUnique.OpenMU.Tests/WeeklyQuestsPlugInTest.cs new file mode 100644 index 000000000..11eda18c6 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/WeeklyQuestsPlugInTest.cs @@ -0,0 +1,198 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; +using MUnique.OpenMU.Persistence.WeeklyQuests; + +/// +/// Tests for the and . +/// +[TestFixture] +public class WeeklyQuestsPlugInTest +{ + private const string QuestId = "resets-2"; + + /// + /// Tests that the period starts at the configured day and time of the current week. + /// + [Test] + public void PeriodStartsAtResetDayOfCurrentWeek() + { + // 2026-09-25 is a friday. + var now = new DateTime(2026, 9, 25, 15, 0, 0, DateTimeKind.Utc); + var start = WeeklyPeriod.GetPeriodStartUtc(now, DayOfWeek.Monday, new TimeOnly(3, 0), TimeZoneInfo.Utc); + Assert.That(start, Is.EqualTo(new DateTime(2026, 9, 21, 3, 0, 0, DateTimeKind.Utc))); + } + + /// + /// Tests that a point in time on the reset day, but before the reset time, belongs to the previous period. + /// + [Test] + public void PeriodBeforeResetTimeBelongsToPreviousWeek() + { + var now = new DateTime(2026, 9, 21, 2, 59, 0, DateTimeKind.Utc); + var start = WeeklyPeriod.GetPeriodStartUtc(now, DayOfWeek.Monday, new TimeOnly(3, 0), TimeZoneInfo.Utc); + Assert.That(start, Is.EqualTo(new DateTime(2026, 9, 14, 3, 0, 0, DateTimeKind.Utc))); + } + + /// + /// Tests that the reset time is interpreted in the time zone of the server. + /// + [Test] + public void PeriodUsesServerTimeZone() + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone("UTC-3", TimeSpan.FromHours(-3), "UTC-3", "UTC-3"); + var now = new DateTime(2026, 9, 21, 2, 0, 0, DateTimeKind.Utc); // sunday 23:00 local + var start = WeeklyPeriod.GetPeriodStartUtc(now, DayOfWeek.Monday, TimeOnly.MinValue, timeZone); + // Monday 00:00 local is 03:00 UTC. Sunday 23:00 local still belongs to the week which started on the 14th. + Assert.That(start, Is.EqualTo(new DateTime(2026, 9, 14, 3, 0, 0, DateTimeKind.Utc))); + Assert.That(WeeklyPeriod.GetNextPeriodStartUtc(start, timeZone), Is.EqualTo(new DateTime(2026, 9, 21, 3, 0, 0, DateTimeKind.Utc))); + } + + /// + /// Tests that the progress is counted and the reward is given exactly once when the objective is reached. + /// + [Test] + public async Task CompletesQuestAndRewardsOnceAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var plugIn = CreatePlugIn(new InMemoryWeeklyQuestProgressRepository()); + + await plugIn.CharacterResetAsync(player, 1).ConfigureAwait(false); + Assert.That(player.Money, Is.EqualTo(0)); + + await plugIn.CharacterResetAsync(player, 2).ConfigureAwait(false); + Assert.That(player.Money, Is.EqualTo(1000)); + + await plugIn.CharacterResetAsync(player, 3).ConfigureAwait(false); + Assert.That(player.Money, Is.EqualTo(1000)); + + var overview = await plugIn.GetOverviewAsync(player).ConfigureAwait(false); + var entry = overview!.Entries.Single(); + Assert.That(entry.Count, Is.EqualTo(2)); + Assert.That(entry.IsCompleted, Is.True); + Assert.That(entry.IsRewarded, Is.True); + } + + /// + /// Tests that a completed quest is persisted, so that it's not rewarded again after a restart. + /// + [Test] + public async Task CompletedQuestIsPersistedAsync() + { + var repository = new InMemoryWeeklyQuestProgressRepository(); + var player = await CreatePlayerAsync().ConfigureAwait(false); + var plugIn = CreatePlugIn(repository); + await plugIn.CharacterResetAsync(player, 1).ConfigureAwait(false); + await plugIn.CharacterResetAsync(player, 2).ConfigureAwait(false); + + Assert.That(player.Money, Is.EqualTo(1000)); + + // Leaving the game drops the in-memory state, so the next plugin has to load it from the repository. + await plugIn.PlayerStateChangedAsync(player, PlayerState.EnteredWorld, PlayerState.CharacterSelection).ConfigureAwait(false); + var restartedPlugIn = CreatePlugIn(repository); + await restartedPlugIn.CharacterResetAsync(player, 3).ConfigureAwait(false); + + Assert.That(player.Money, Is.EqualTo(1000)); + var overview = await restartedPlugIn.GetOverviewAsync(player).ConfigureAwait(false); + Assert.That(overview!.Entries.Single().Count, Is.EqualTo(2)); + Assert.That(overview.Entries.Single().IsRewarded, Is.True); + } + + /// + /// Tests that the reward stays pending when it can't be given, and that it's given later. + /// + [Test] + public async Task RewardStaysPendingUntilItFitsAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + player.GameContext.Configuration.MaximumInventoryMoney = 500; + var plugIn = CreatePlugIn(new InMemoryWeeklyQuestProgressRepository()); + + await plugIn.CharacterResetAsync(player, 1).ConfigureAwait(false); + await plugIn.CharacterResetAsync(player, 2).ConfigureAwait(false); + Assert.That(player.Money, Is.EqualTo(0)); + + var overview = await plugIn.GetOverviewAsync(player).ConfigureAwait(false); + Assert.That(overview!.Entries.Single().IsRewarded, Is.False); + + player.GameContext.Configuration.MaximumInventoryMoney = int.MaxValue; + overview = await plugIn.GetOverviewAsync(player).ConfigureAwait(false); + Assert.That(overview!.Entries.Single().IsRewarded, Is.True); + Assert.That(player.Money, Is.EqualTo(1000)); + } + + /// + /// Tests that no progress is made when the character doesn't meet the minimum level. + /// + [Test] + public async Task NoProgressBelowMinimumLevelAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var plugIn = CreatePlugIn(new InMemoryWeeklyQuestProgressRepository()); + plugIn.Configuration!.Quests.Single().MinimumLevel = 100; + + await plugIn.CharacterResetAsync(player, 1).ConfigureAwait(false); + + var overview = await plugIn.GetOverviewAsync(player).ConfigureAwait(false); + Assert.That(overview!.Entries.Single().Count, Is.EqualTo(0)); + + player.Attributes![Stats.Level] = 100; + await plugIn.CharacterResetAsync(player, 1).ConfigureAwait(false); + overview = await plugIn.GetOverviewAsync(player).ConfigureAwait(false); + Assert.That(overview!.Entries.Single().Count, Is.EqualTo(1)); + } + + /// + /// Tests that inactive quests are ignored. + /// + [Test] + public async Task InactiveQuestIsIgnoredAsync() + { + var player = await CreatePlayerAsync().ConfigureAwait(false); + var plugIn = CreatePlugIn(new InMemoryWeeklyQuestProgressRepository()); + plugIn.Configuration!.Quests.Single().IsActive = false; + + await plugIn.CharacterResetAsync(player, 1).ConfigureAwait(false); + + var overview = await plugIn.GetOverviewAsync(player).ConfigureAwait(false); + Assert.That(overview!.Entries, Is.Empty); + } + + private static WeeklyQuestsPlugIn CreatePlugIn(IWeeklyQuestProgressRepository repository) + { + return new WeeklyQuestsPlugIn(repository) + { + Configuration = new WeeklyQuestsConfiguration + { + Quests = new List + { + new() + { + Id = QuestId, + Name = "Resets", + ObjectiveType = WeeklyQuestObjectiveType.GainResets, + RequiredCount = 2, + Rewards = new List + { + new() { RewardType = WeeklyQuestRewardType.Money, Amount = 1000 }, + }, + }, + }, + }, + }; + } + + private static async ValueTask CreatePlayerAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + player.GameContext.Configuration.MaximumInventoryMoney = int.MaxValue; + await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); + return player; + } +} From ba3d0f3f412d0c56126b8e702a3bd823425561f8 Mon Sep 17 00:00:00 2001 From: Diego Amatte Date: Sat, 26 Sep 2026 07:58:50 -0300 Subject: [PATCH 3/9] feat: Add weekly quests list view & packet Introduce weekly-quests support: adds C2 F5 02 WeeklyQuestEntry packet and XML definition, IWeeklyQuestListViewPlugIn interface, and RemoteView implementation WeeklyQuestListViewPlugIn to send quest lists/updates. Integrates into WeeklyQuestsPlugIn (overview creation, change notifications, SendList) and triggers on chat-command list request. Adds reward text helpers (WeeklyQuestReward/GetDisplayText, WeeklyQuestDefinition/GetRewardsText), localization resources, Mapster configuration tweaks, and unit tests for packet and view behaviour. Also includes docs for the packet and updates ServerToClient packet refs. --- .../C2-F5-02-WeeklyQuestEntry_by-server.md | 30 +++ docs/Packets/ServerToClient.md | 1 + .../WeeklyQuestsChatCommandPlugIn.cs | 4 + .../WeeklyQuests/WeeklyQuestDefinition.cs | 10 + .../PlugIns/WeeklyQuests/WeeklyQuestReward.cs | 22 ++- .../WeeklyQuests/WeeklyQuestsPlugIn.cs | 64 +++++- .../Views/IWeeklyQuestListViewPlugIn.cs | 26 +++ .../ChatCommandListRequestHandlerPlugIn.cs | 9 + .../Properties/PlugInResources.Designer.cs | 18 ++ .../Properties/PlugInResources.resx | 6 + .../RemoteView/WeeklyQuestListViewPlugIn.cs | 107 ++++++++++ .../ServerToClient/ConnectionExtensions.cs | 50 +++++ .../ServerToClient/ServerToClientPackets.cs | 185 ++++++++++++++++++ .../ServerToClient/ServerToClientPackets.xml | 84 ++++++++ .../ServerToClientPacketsRef.cs | 185 ++++++++++++++++++ .../Model/MapsterConfigurator.Generated.cs | 28 +++ .../ServerToClientPacketTests.cs | 62 ++++++ .../WeeklyQuestListViewPlugInTest.cs | 111 +++++++++++ 18 files changed, 990 insertions(+), 12 deletions(-) create mode 100644 docs/Packets/C2-F5-02-WeeklyQuestEntry_by-server.md create mode 100644 src/GameLogic/Views/IWeeklyQuestListViewPlugIn.cs create mode 100644 src/GameServer/RemoteView/WeeklyQuestListViewPlugIn.cs create mode 100644 tests/MUnique.OpenMU.Tests/WeeklyQuestListViewPlugInTest.cs 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/ServerToClient.md b/docs/Packets/ServerToClient.md index 7fbc988d0..b03581a83 100644 --- a/docs/Packets/ServerToClient.md +++ b/docs/Packets/ServerToClient.md @@ -277,6 +277,7 @@ * [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) * [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/GameLogic/PlugIns/ChatCommands/WeeklyQuestsChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/WeeklyQuestsChatCommandPlugIn.cs index f6546b2e5..2a9982c74 100644 --- a/src/GameLogic/PlugIns/ChatCommands/WeeklyQuestsChatCommandPlugIn.cs +++ b/src/GameLogic/PlugIns/ChatCommands/WeeklyQuestsChatCommandPlugIn.cs @@ -7,6 +7,7 @@ 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; /// @@ -61,5 +62,8 @@ protected override async ValueTask DoHandleCommandAsync(Player player, EmptyChat var remaining = overview.NextResetUtc - DateTime.UtcNow; await player.ShowBlueMessageAsync($"Reinicio en {(int)remaining.TotalDays}d {remaining.Hours}h {remaining.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); } } diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs index 808b2fdf7..1a610f3a0 100644 --- a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs @@ -99,6 +99,16 @@ public class WeeklyQuestDefinition [ScaffoldColumn(true)] public ICollection Rewards { get; set; } = new List(); + /// + /// 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}"; } diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestReward.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestReward.cs index 44a57093c..dad15a5df 100644 --- a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestReward.cs +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestReward.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; +using System.Globalization; using MUnique.OpenMU.DataModel.Configuration.Items; /// @@ -65,7 +66,22 @@ public class WeeklyQuestReward public byte ExcellentOptions { get; set; } /// - public override string ToString() => this.RewardType == WeeklyQuestRewardType.Item - ? $"{this.Amount}x {this.Item?.Name ?? "?"} +{this.ItemLevel}" - : $"{this.Amount} {this.RewardType}"; + 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/WeeklyQuestsPlugIn.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs index d91d8c766..39b16042d 100644 --- a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs @@ -259,14 +259,19 @@ public void ForceStart() // The player may have freed some inventory space in the meantime. await this.RewardPendingAsync(player, state, false).ConfigureAwait(false); - var entries = GetActiveQuests(configuration) - .Select(quest => - { - state.Progress.TryGetValue(quest.Id, out var progress); - return new WeeklyQuestOverviewEntry(quest, progress?.Count ?? 0, progress?.CompletedAt is not null, progress?.RewardedAt is not null); - }) - .ToList(); - return new WeeklyQuestOverview(entries, WeeklyPeriod.GetNextPeriodStartUtc(state.PeriodStartUtc, player.GameContext.ServerTimeZone)); + return CreateOverview(configuration, state, player); + } + + /// + /// Sends the active 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); + } } /// @@ -274,7 +279,7 @@ public void ForceStart() /// /// The player. /// The plugin, if the player is tracked by one. - internal static WeeklyQuestsPlugIn? GetTrackingPlugIn(Player player) + public static WeeklyQuestsPlugIn? GetTrackingPlugIn(Player player) { return States.TryGetValue(player, out var state) ? state.Owner : null; } @@ -284,6 +289,23 @@ private static IEnumerable GetActiveQuests(WeeklyQuestsCo return configuration.Quests.Where(q => q.IsActive && !string.IsNullOrWhiteSpace(q.Id)); } + private static WeeklyQuestOverviewEntry CreateEntry(WeeklyQuestDefinition quest, WeeklyQuestPlayerState state) + { + state.Progress.TryGetValue(quest.Id, out var progress); + return new WeeklyQuestOverviewEntry(quest, progress?.Count ?? 0, progress?.CompletedAt is not null, progress?.RewardedAt is not null); + } + + private static WeeklyQuestOverview CreateOverview(WeeklyQuestsConfiguration configuration, WeeklyQuestPlayerState state, Player player) + { + var entries = GetActiveQuests(configuration).Select(quest => CreateEntry(quest, state)).ToList(); + return new WeeklyQuestOverview(entries, GetNextResetUtc(state, player)); + } + + private static DateTime GetNextResetUtc(WeeklyQuestPlayerState state, Player player) + { + return WeeklyPeriod.GetNextPeriodStartUtc(state.PeriodStartUtc, player.GameContext.ServerTimeZone); + } + private static string Format(LocalizedString template, Player player, params object?[] args) { var text = template.GetTranslation(player.Culture) ?? string.Empty; @@ -343,12 +365,14 @@ private async ValueTask AddProgressAsync(Player player, WeeklyQuestObjectiveType try { using var l = await state.Lock.LockAsync().ConfigureAwait(false); + var previousPeriodStart = state.IsLoaded ? state.PeriodStartUtc : (DateTime?)null; if (!await this.EnsureLoadedAsync(player, state).ConfigureAwait(false)) { return; } var hasCompleted = false; + var changedQuests = new List(); foreach (var quest in quests) { var progress = state.GetOrCreateProgress(quest.Id); @@ -357,6 +381,7 @@ private async ValueTask AddProgressAsync(Player player, WeeklyQuestObjectiveType continue; } + changedQuests.Add(quest); var previousCount = progress.Count; progress.Count = (int)Math.Min((long)previousCount + amount, quest.RequiredCount); state.DirtyQuestIds.Add(quest.Id); @@ -378,6 +403,9 @@ private async ValueTask AddProgressAsync(Player player, WeeklyQuestObjectiveType { await this.SaveAsync(player, state).ConfigureAwait(false); } + + var isNewPeriod = previousPeriodStart is not null && previousPeriodStart != state.PeriodStartUtc; + await this.SendChangesAsync(player, state, configuration, changedQuests, isNewPeriod).ConfigureAwait(false); } catch (Exception ex) { @@ -385,6 +413,24 @@ private async ValueTask AddProgressAsync(Player player, WeeklyQuestObjectiveType } } + private async ValueTask SendChangesAsync(Player player, WeeklyQuestPlayerState state, WeeklyQuestsConfiguration configuration, List changedQuests, bool isNewPeriod) + { + // After a reset, all quests of the client are outdated, not only the changed ones. + if (isNewPeriod) + { + var overview = CreateOverview(configuration, state, player); + await player.InvokeViewPlugInAsync(p => p.ShowWeeklyQuestsAsync(overview)).ConfigureAwait(false); + return; + } + + var nextResetUtc = GetNextResetUtc(state, player); + foreach (var quest in changedQuests) + { + var entry = CreateEntry(quest, state); + await player.InvokeViewPlugInAsync(p => p.UpdateWeeklyQuestAsync(entry, nextResetUtc)).ConfigureAwait(false); + } + } + private async ValueTask EnsureLoadedAsync(Player player, WeeklyQuestPlayerState state) { var periodStart = WeeklyPeriod.GetPeriodStartUtc( diff --git a/src/GameLogic/Views/IWeeklyQuestListViewPlugIn.cs b/src/GameLogic/Views/IWeeklyQuestListViewPlugIn.cs new file mode 100644 index 000000000..0f9a4410f --- /dev/null +++ b/src/GameLogic/Views/IWeeklyQuestListViewPlugIn.cs @@ -0,0 +1,26 @@ +// +// 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 weekly quest which the client already knows. + /// + /// The entry of the quest. + /// The point in time (UTC) when the next period starts. + ValueTask UpdateWeeklyQuestAsync(WeeklyQuestOverviewEntry entry, DateTime nextResetUtc); +} 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 7e767d042..82500b55a 100644 --- a/src/GameServer/Properties/PlugInResources.Designer.cs +++ b/src/GameServer/Properties/PlugInResources.Designer.cs @@ -7115,5 +7115,23 @@ 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); + } + } } } diff --git a/src/GameServer/Properties/PlugInResources.resx b/src/GameServer/Properties/PlugInResources.resx index e9361de3f..35b44dcde 100644 --- a/src/GameServer/Properties/PlugInResources.resx +++ b/src/GameServer/Properties/PlugInResources.resx @@ -2469,4 +2469,10 @@ 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. + diff --git a/src/GameServer/RemoteView/WeeklyQuestListViewPlugIn.cs b/src/GameServer/RemoteView/WeeklyQuestListViewPlugIn.cs new file mode 100644 index 000000000..bea189779 --- /dev/null +++ b/src/GameServer/RemoteView/WeeklyQuestListViewPlugIn.cs @@ -0,0 +1,107 @@ +// +// 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.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 weekly quest to the game client. +/// +[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 +{ + 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.SendAsync(connection, null, 0, 0, false, seconds).ConfigureAwait(false); + return; + } + + for (var i = 0; i < entries.Count; i++) + { + await this.SendAsync(connection, entries[i], (byte)i, (byte)entries.Count, false, seconds).ConfigureAwait(false); + } + } + + /// + public async ValueTask UpdateWeeklyQuestAsync(WeeklyQuestOverviewEntry entry, DateTime nextResetUtc) + { + if (this._player.Connection is not { Connected: true } connection) + { + return; + } + + await this.SendAsync(connection, entry, 0, 1, true, GetSecondsUntil(nextResetUtc)).ConfigureAwait(false); + } + + private static uint GetSecondsUntil(DateTime utc) + { + return (uint)Math.Clamp((utc - DateTime.UtcNow).TotalSeconds, 0, uint.MaxValue); + } + + private ValueTask SendAsync(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.Quest.RequiredCount); + packet.Id = entry.Quest.Id; + packet.Name = entry.Quest.Name; + packet.Description = entry.Quest.Description; + packet.Rewards = entry.Quest.GetRewardsText(culture); + } + + return size; + } + + return connection.SendAsync(Write); + } +} diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs index 7d50718b0..0aef4e4a8 100644 --- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs +++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs @@ -6290,6 +6290,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 7f6fee195..205fbf662 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -30888,6 +30888,191 @@ 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: 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 a2ddfb957..92b597814 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -11173,6 +11173,90 @@ + + 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. + + + C1Header 94 diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index fff09c959..4f3b8267b 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs @@ -29251,6 +29251,191 @@ 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: 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/Model/MapsterConfigurator.Generated.cs b/src/Persistence/EntityFramework/Model/MapsterConfigurator.Generated.cs index 928d2579f..b1c4c2c9d 100644 --- a/src/Persistence/EntityFramework/Model/MapsterConfigurator.Generated.cs +++ b/src/Persistence/EntityFramework/Model/MapsterConfigurator.Generated.cs @@ -12,6 +12,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Model; +using MUnique.OpenMU.DataModel.Composition; using MUnique.OpenMU.Persistence; using Mapster; @@ -35,6 +36,18 @@ public static void EnsureConfigured() Mapster.TypeAdapterConfig.GlobalSettings.Default.PreserveReference(true); Mapster.TypeAdapterConfig.GlobalSettings.Default.IgnoreMember((member, side) => member.Name.StartsWith("Raw")); + // Transient properties just hold run-time information and are not persisted. + // Some of them (e.g. of the SkillEntry) can't be mapped by Mapster at all, because their types are interfaces with events. + Mapster.TypeAdapterConfig.GlobalSettings.Default.IgnoreMember( + (member, side) => member.GetCustomAttributes(true).OfType().Any()); + + // Collections of value types (e.g. ItemSlotType.ItemSlots) are only filled when the collection of the + // destination is used. Otherwise, Mapster creates a new, empty one and the values would be lost. + Mapster.TypeAdapterConfig.GlobalSettings.Default.UseDestinationValue( + member => member.Type.IsGenericType + && member.Type.GetGenericTypeDefinition() == typeof(ICollection<>) + && member.Type.GetGenericArguments()[0].IsValueType); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); @@ -71,6 +84,9 @@ public static void EnsureConfigured() Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); + Mapster.TypeAdapterConfig.GlobalSettings.ForType() + .ConstructUsing(source => new BasicModel.GuildMember(source.Id)); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); @@ -227,6 +243,9 @@ public static void EnsureConfigured() Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); + Mapster.TypeAdapterConfig.GlobalSettings.ForType() + .ConstructUsing(source => new BasicModel.StatAttributeDefinition(source.Attribute, source.BaseValue, source.IncreasableByPlayer)); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); @@ -311,15 +330,24 @@ public static void EnsureConfigured() Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); + Mapster.TypeAdapterConfig.GlobalSettings.ForType() + .ConstructUsing(source => new BasicModel.AttributeDefinition(source.Id, source.Designation, source.Description)); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); + Mapster.TypeAdapterConfig.GlobalSettings.ForType() + .ConstructUsing(source => new BasicModel.ConstValueAttribute(source.Value, source.Definition, source.AggregateType)); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); + Mapster.TypeAdapterConfig.GlobalSettings.ForType() + .ConstructUsing(source => new BasicModel.AttributeRelationship(source.TargetAttribute, source.InputOperand, source.InputAttribute, source.AggregateType)); + Mapster.TypeAdapterConfig.GlobalSettings.NewConfig() .Include(); diff --git a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs index 0aa5975ef..034f1d2a4 100644 --- a/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs +++ b/tests/MUnique.OpenMU.Network.Packets.Tests/ServerToClientPacketTests.cs @@ -6452,6 +6452,68 @@ public void AvailableChatCommand_PacketSizeValidation() "Field 'Description' has invalid negative index"); } + /// + /// Tests the packet size calculation for WeeklyQuestEntry. + /// + [Test] + public void WeeklyQuestEntry_PacketSizeValidation() + { + // Fixed-length packet validation + const int expectedLength = 520; + var actualLength = WeeklyQuestEntryRef.Length; + + Assert.That(actualLength, Is.EqualTo(expectedLength), + "Packet length mismatch: declared length does not match calculated size"); + + // Validate field 'Index' boundary + Assert.That(5 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'Index' exceeds packet boundary"); + + // Validate field 'Count' boundary + Assert.That(6 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'Count' exceeds packet boundary"); + + // Validate field 'IsUpdate' boundary + Assert.That(7 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'IsUpdate' exceeds packet boundary"); + + // Validate field 'IsCompleted' boundary + Assert.That(8 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'IsCompleted' exceeds packet boundary"); + + // Validate field 'IsRewarded' boundary + Assert.That(9 + 1, Is.LessThanOrEqualTo(expectedLength), + "Field 'IsRewarded' exceeds packet boundary"); + + // Validate field 'CurrentCount' boundary + Assert.That(12 + 4, Is.LessThanOrEqualTo(expectedLength), + "Field 'CurrentCount' exceeds packet boundary"); + + // Validate field 'RequiredCount' boundary + Assert.That(16 + 4, Is.LessThanOrEqualTo(expectedLength), + "Field 'RequiredCount' exceeds packet boundary"); + + // Validate field 'SecondsUntilReset' boundary + Assert.That(20 + 4, Is.LessThanOrEqualTo(expectedLength), + "Field 'SecondsUntilReset' exceeds packet boundary"); + + // Validate field 'Id' boundary + Assert.That(24 + 64, Is.LessThanOrEqualTo(expectedLength), + "Field 'Id' exceeds packet boundary"); + + // Validate field 'Name' boundary + Assert.That(88 + 48, Is.LessThanOrEqualTo(expectedLength), + "Field 'Name' exceeds packet boundary"); + + // Validate field 'Description' boundary + Assert.That(136 + 256, Is.LessThanOrEqualTo(expectedLength), + "Field 'Description' exceeds packet boundary"); + + // Validate field 'Rewards' boundary + Assert.That(392 + 128, Is.LessThanOrEqualTo(expectedLength), + "Field 'Rewards' exceeds packet boundary"); + } + /// /// Tests the packet size calculation for EventChipRegistrationResult. /// diff --git a/tests/MUnique.OpenMU.Tests/WeeklyQuestListViewPlugInTest.cs b/tests/MUnique.OpenMU.Tests/WeeklyQuestListViewPlugInTest.cs new file mode 100644 index 000000000..5833f1a58 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/WeeklyQuestListViewPlugInTest.cs @@ -0,0 +1,111 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using System.Globalization; +using MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; +using MUnique.OpenMU.GameServer.RemoteView; +using MUnique.OpenMU.Network.Packets.ServerToClient; + +/// +/// Tests for the . +/// +[TestFixture] +public class WeeklyQuestListViewPlugInTest +{ + /// + /// Tests that one message is sent per quest, with the progress and the texts of the quest. + /// + [Test] + public async Task SendsOneMessagePerQuestAsync() + { + var (player, output) = CastleSiegeRemoteViewTestHelper.CreatePlayer(); + var view = new WeeklyQuestListViewPlugIn(player); + var overview = new WeeklyQuestOverview( + new List + { + new(CreateQuest("a", "Cazador"), 3, false, false), + new(CreateQuest("b", "Castillo"), 5, true, true), + }, + DateTime.UtcNow.AddHours(1)); + + await view.ShowWeeklyQuestsAsync(overview).ConfigureAwait(false); + + var data = output.ToArray(); + Assert.That(data, Has.Length.EqualTo(2 * WeeklyQuestEntry.Length)); + + var first = new WeeklyQuestEntry(data.AsMemory(0, WeeklyQuestEntry.Length)); + Assert.That(first.Index, Is.EqualTo(0)); + Assert.That(first.Count, Is.EqualTo(2)); + Assert.That(first.IsUpdate, Is.False); + Assert.That(first.Id, Is.EqualTo("a")); + Assert.That(first.Name, Is.EqualTo("Cazador")); + Assert.That(first.CurrentCount, Is.EqualTo(3)); + Assert.That(first.RequiredCount, Is.EqualTo(5)); + Assert.That(first.SecondsUntilReset, Is.InRange(3500u, 3600u)); + + var second = new WeeklyQuestEntry(data.AsMemory(WeeklyQuestEntry.Length, WeeklyQuestEntry.Length)); + Assert.That(second.Index, Is.EqualTo(1)); + Assert.That(second.IsCompleted, Is.True); + Assert.That(second.IsRewarded, Is.True); + } + + /// + /// Tests that an empty list is sent as one message with a count of 0, so that the client forgets the previous quests. + /// + [Test] + public async Task SendsEmptyListAsOneMessageAsync() + { + var (player, output) = CastleSiegeRemoteViewTestHelper.CreatePlayer(); + var view = new WeeklyQuestListViewPlugIn(player); + + await view.ShowWeeklyQuestsAsync(new WeeklyQuestOverview(new List(), DateTime.UtcNow)).ConfigureAwait(false); + + var data = output.ToArray(); + Assert.That(data, Has.Length.EqualTo(WeeklyQuestEntry.Length)); + Assert.That(new WeeklyQuestEntry(data).Count, Is.EqualTo(0)); + } + + /// + /// Tests that an update is marked as such. + /// + [Test] + public async Task SendsUpdateAsync() + { + var (player, output) = CastleSiegeRemoteViewTestHelper.CreatePlayer(); + var view = new WeeklyQuestListViewPlugIn(player); + + await view.UpdateWeeklyQuestAsync(new(CreateQuest("a", "Cazador"), 4, false, false), DateTime.UtcNow).ConfigureAwait(false); + + var packet = new WeeklyQuestEntry(output.ToArray()); + Assert.That(packet.IsUpdate, Is.True); + Assert.That(packet.Id, Is.EqualTo("a")); + Assert.That(packet.CurrentCount, Is.EqualTo(4)); + } + + /// + /// Tests the text of the rewards. + /// + [Test] + public void RewardsText() + { + var quest = CreateQuest("a", "Cazador"); + quest.Rewards.Add(new WeeklyQuestReward { RewardType = WeeklyQuestRewardType.Experience, Amount = 2500 }); + + Assert.That(quest.GetRewardsText(CultureInfo.GetCultureInfo("es-AR")), Is.EqualTo("1.000.000 Zen, 2.500 EXP")); + } + + private static WeeklyQuestDefinition CreateQuest(string id, string name) => new() + { + Id = id, + Name = name, + Description = "Descripción", + RequiredCount = 5, + Rewards = new List + { + new() { RewardType = WeeklyQuestRewardType.Money, Amount = 1_000_000 }, + }, + }; +} From 5389904c5eaa9eeaf8c1689d3f513a538e5dc008 Mon Sep 17 00:00:00 2001 From: Diego Amatte Date: Sat, 26 Sep 2026 18:55:58 -0300 Subject: [PATCH 4/9] feat: Add weekly quests and stacked modal support This patch adds a weekly quest system with period rotation, class/level gating, item pickup objectives, account-wide reward locking, and all-completed bonus tracking. It also introduces the item pickup plugin hook and persistence support for account-scoped quest progress. On the UI side, modal dialogs now stack correctly instead of cancelling the underlying one, and the related tests cover quest selection, progress rules, and modal behavior. --- src/GameLogic/DroppedItem.cs | 6 + .../PlayerActions/Items/DropItemAction.cs | 5 +- .../PlayerActions/Items/PickupItemAction.cs | 5 + src/GameLogic/PlugIns/IItemPickedUpPlugIn.cs | 24 ++ .../WeeklyQuests/WeeklyQuestCharacterInfo.cs | 13 + .../WeeklyQuests/WeeklyQuestDefinition.cs | 113 ++++++- .../WeeklyQuests/WeeklyQuestObjectiveType.cs | 6 + .../WeeklyQuests/WeeklyQuestPlayerState.cs | 21 +- .../WeeklyQuests/WeeklyQuestSelector.cs | 205 +++++++++++++ .../WeeklyQuests/WeeklyQuestsConfiguration.cs | 24 ++ .../WeeklyQuests/WeeklyQuestsPlugIn.cs | 206 +++++++++++-- ...125402_AddWeeklyQuestAccountId.Designer.cs | 64 ++++ .../20260926125402_AddWeeklyQuestAccountId.cs | 42 +++ .../WeeklyQuestContextModelSnapshot.cs | 5 + .../WeeklyQuests/WeeklyQuestContext.cs | 1 + .../WeeklyQuestProgressRepository.cs | 15 + .../IWeeklyQuestProgressRepository.cs | 9 + .../InMemoryWeeklyQuestProgressRepository.cs | 11 + .../WeeklyQuests/WeeklyQuestProgress.cs | 8 + src/Web/AdminPanel/API/WebsiteController.cs | 96 +++++- .../Components/Modal/ModalContainer.razor | 112 ++++--- src/Web/Shared/Services/ModalService.cs | 53 ++-- .../WeeklyQuestSelectorTest.cs | 124 ++++++++ .../WeeklyQuestsPlugInTest.cs | 284 +++++++++++++++++- .../ModalServiceTest.cs | 46 +++ 25 files changed, 1400 insertions(+), 98 deletions(-) create mode 100644 src/GameLogic/PlugIns/IItemPickedUpPlugIn.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestCharacterInfo.cs create mode 100644 src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestSelector.cs create mode 100644 src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926125402_AddWeeklyQuestAccountId.Designer.cs create mode 100644 src/Persistence/EntityFramework/Migrations/WeeklyQuests/20260926125402_AddWeeklyQuestAccountId.cs create mode 100644 tests/MUnique.OpenMU.Tests/WeeklyQuestSelectorTest.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/ModalServiceTest.cs 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/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/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/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 index 1a610f3a0..9852a191c 100644 --- a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestDefinition.cs @@ -5,6 +5,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.WeeklyQuests; using MUnique.OpenMU.DataModel.Composition; +using MUnique.OpenMU.DataModel.Configuration.Items; /// /// The definition of a weekly quest. @@ -38,6 +39,13 @@ public class WeeklyQuestDefinition [Display(Name = "Activa")] public bool IsActive { get; set; } = true; + /// + /// 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. /// @@ -45,6 +53,14 @@ public class WeeklyQuestDefinition [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. /// @@ -52,6 +68,28 @@ public class WeeklyQuestDefinition [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 type of the objective. /// @@ -74,9 +112,22 @@ public class WeeklyQuestDefinition /// /// Gets or sets the map on which the kills have to happen. If empty, kills on every map count. /// - [Display(Name = "Mapa", Description = "Opcional, para objetivos de kills. Vacío = cualquier mapa.")] + [Display(Name = "Mapa", Description = "Opcional, para objetivos de kills y de juntar items. 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. @@ -91,6 +142,31 @@ public class WeeklyQuestDefinition [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. /// @@ -99,6 +175,41 @@ public class WeeklyQuestDefinition [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 text which describes all rewards to a player. /// diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveType.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveType.cs index e0e7a8a0e..69a9bb839 100644 --- a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveType.cs +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestObjectiveType.cs @@ -50,4 +50,10 @@ public enum WeeklyQuestObjectiveType /// [Display(Name = "Matar jugadores")] KillPlayer, + + /// + /// Pick up a specific item which was dropped by a monster or an event. + /// + [Display(Name = "Juntar items")] + CollectItem, } diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestPlayerState.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestPlayerState.cs index e95b8c821..5e4b56817 100644 --- a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestPlayerState.cs +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestPlayerState.cs @@ -18,10 +18,12 @@ internal sealed class WeeklyQuestPlayerState /// /// The plugin which tracks the progress. /// The identifier of the character. - public WeeklyQuestPlayerState(WeeklyQuestsPlugIn owner, Guid characterId) + /// 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; } /// @@ -39,6 +41,22 @@ public WeeklyQuestPlayerState(WeeklyQuestsPlugIn owner, Guid characterId) /// 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 this period. + /// + 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. /// @@ -71,6 +89,7 @@ public WeeklyQuestProgress GetOrCreateProgress(string questId) progress = new WeeklyQuestProgress { CharacterId = this.CharacterId, + AccountId = this.AccountId, PeriodStart = this.PeriodStartUtc, QuestId = questId, }; diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestSelector.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestSelector.cs new file mode 100644 index 000000000..9dbdd8826 --- /dev/null +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestSelector.cs @@ -0,0 +1,205 @@ +// +// 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 weekly quests are part of a period 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 period: all active quests, or the drawn ones when the quests rotate. + /// + /// The configuration. + /// The start of the period. + /// The quests of the period, in the configured order. + public static IReadOnlyList GetQuestsOfPeriod(WeeklyQuestsConfiguration configuration, DateTime periodStartUtc) + { + var active = configuration.Quests + .Where(q => q.IsActive && !string.IsNullOrWhiteSpace(q.Id) && q.Id != AllCompletedBonusId) + .ToList(); + if (configuration.QuestsPerWeek <= 0) + { + return active; + } + + // Ordering by a hash of the id and the period gives each week 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. + var drawn = active + .Where(q => !q.AlwaysIncluded) + .OrderBy(q => GetStableHash(q.Id, periodStartUtc)) + .ThenBy(q => q.Id, StringComparer.Ordinal) + .Take(configuration.QuestsPerWeek) + .ToHashSet(); + return active.Where(q => q.AlwaysIncluded || drawn.Contains(q)).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. + /// 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) + { + if (isBlockedByAccount) + { + return false; + } + + if (hasProgress) + { + // A quest which was started isn't hidden, even if the character outgrew it meanwhile. + return true; + } + + if (!quest.IsQualified(character.Class)) + { + return false; + } + + return (quest.MaximumLevel <= 0 || character.Level <= quest.MaximumLevel) + && (quest.MaximumResets <= 0 || character.Resets <= quest.MaximumResets); + } + + /// + /// Gets the quests of the period which are available for the character. + /// + /// The configuration. + /// The start of the period. + /// The character. + /// The progress of the character in the period, per quest id. + /// The ids of the quests whose rewards another character of the account received in the period. + /// The available quests. + public static List GetAvailableQuests( + WeeklyQuestsConfiguration configuration, + DateTime periodStartUtc, + WeeklyQuestCharacterInfo character, + IReadOnlyDictionary progress, + IReadOnlySet rewardedByOtherCharacters) + { + return GetQuestsOfPeriod(configuration, periodStartUtc) + .Where(quest => IsAvailable( + quest, + character, + progress.TryGetValue(quest.Id, out var p) && p.Count > 0, + quest.OncePerAccount && rewardedByOtherCharacters.Contains(quest.Id))) + .ToList(); + } + + /// + /// Creates the overview entries of the available quests of the character, including the bonus for completing all of them. + /// + /// The configuration. + /// The start of the period. + /// The character. + /// The progress of the character in the period, per quest id. + /// The ids of the quests whose rewards another character of the account received in the period. + /// The entries. + public static List CreateEntries( + WeeklyQuestsConfiguration configuration, + DateTime periodStartUtc, + WeeklyQuestCharacterInfo character, + IReadOnlyDictionary progress, + IReadOnlySet rewardedByOtherCharacters) + { + var quests = GetAvailableQuests(configuration, periodStartUtc, character, progress, rewardedByOtherCharacters); + var entries = quests.Select(quest => CreateEntry(quest, progress)).ToList(); + if (quests.Count > 0 && CreateAllCompletedBonusQuest(configuration, quests.Count) is { } bonusQuest) + { + entries.Add(CreateEntry(bonusQuest, progress)); + } + + return entries; + } + + /// + /// Creates the overview entry of a quest. + /// + /// The quest. + /// The progress of the character in the period, per quest id. + /// The entry. + public static WeeklyQuestOverviewEntry CreateEntry(WeeklyQuestDefinition quest, IReadOnlyDictionary progress) + { + progress.TryGetValue(quest.Id, out var p); + return new WeeklyQuestOverviewEntry(quest, p?.Count ?? 0, p?.CompletedAt is not null, p?.RewardedAt is not null); + } + + /// + /// 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 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, + RequiredCount = Math.Max(1, questCount), + Rewards = bonus.Rewards, + }; + } + + /// + /// 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 index 34a119574..2e535f94b 100644 --- a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsConfiguration.cs +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsConfiguration.cs @@ -24,6 +24,30 @@ public class WeeklyQuestsConfiguration [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 number of quests which are drawn each week from the active 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 = "0 = sin rotación, todas las quests 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 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 de la semana. 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. diff --git a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs index 39b16042d..1885f40a0 100644 --- a/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs +++ b/src/GameLogic/PlugIns/WeeklyQuests/WeeklyQuestsPlugIn.cs @@ -29,6 +29,7 @@ public class WeeklyQuestsPlugIn : ICharacterMasterLevelUpPlugIn, ICharacterResetPlugIn, IMiniGameEndedPlugIn, + IItemPickedUpPlugIn, IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration @@ -144,12 +145,15 @@ public async ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? k var mapNumber = monster.CurrentMap?.Definition.Number; bool IsOnMap(WeeklyQuestDefinition quest) => quest.Map is null || quest.Map.Number == mapNumber; - await this.AddProgressAsync(player, WeeklyQuestObjectiveType.KillAnyMonster, 1, IsOnMap).ConfigureAwait(false); - await this.AddProgressAsync( - player, - WeeklyQuestObjectiveType.KillMonster, - 1, - quest => IsOnMap(quest) && quest.Monster is not null && quest.Monster.Number == monster.Definition.Number).ConfigureAwait(false); + foreach (var receiver in await this.GetKillReceiversAsync(player).ConfigureAwait(false)) + { + await this.AddProgressAsync(receiver, WeeklyQuestObjectiveType.KillAnyMonster, 1, (quest, _) => IsOnMap(quest)).ConfigureAwait(false); + await this.AddProgressAsync( + receiver, + WeeklyQuestObjectiveType.KillMonster, + 1, + (quest, _) => IsOnMap(quest) && quest.Monster is not null && quest.Monster.Number == monster.Definition.Number).ConfigureAwait(false); + } } else if (killed is Player victim && victim != player) { @@ -157,7 +161,7 @@ await this.AddProgressAsync( player, WeeklyQuestObjectiveType.KillPlayer, 1, - quest => victim.Level >= quest.MinimumVictimLevel).ConfigureAwait(false); + (quest, state) => CountsAsPlayerKill(quest, state, player, victim)).ConfigureAwait(false); } } @@ -200,10 +204,31 @@ await this.AddProgressAsync( player, WeeklyQuestObjectiveType.CompleteMiniGame, 1, - quest => quest.MiniGameType == MiniGameType.Undefined || quest.MiniGameType == type).ConfigureAwait(false); + (quest, _) => quest.MiniGameType == MiniGameType.Undefined || quest.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, + (quest, _) => (quest.Map is null || quest.Map.Number == mapNumber) + && quest.Item is { } questItem + && questItem.Group == definition.Group + && questItem.Number == definition.Number + && item.Level >= quest.MinimumItemLevel); + } + /// public async ValueTask ExecuteTaskAsync(GameContext gameContext) { @@ -259,6 +284,9 @@ public void ForceStart() // 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 CreateOverview(configuration, state, player); } @@ -284,20 +312,86 @@ public async ValueTask SendListAsync(Player player) return States.TryGetValue(player, out var state) ? state.Owner : null; } - private static IEnumerable GetActiveQuests(WeeklyQuestsConfiguration configuration) + /// + /// 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) { - return configuration.Quests.Where(q => q.IsActive && !string.IsNullOrWhiteSpace(q.Id)); + 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 WeeklyQuestOverviewEntry CreateEntry(WeeklyQuestDefinition quest, WeeklyQuestPlayerState state) + private static WeeklyQuestCharacterInfo GetCharacterInfo(Player player) { - state.Progress.TryGetValue(quest.Id, out var progress); - return new WeeklyQuestOverviewEntry(quest, progress?.Count ?? 0, progress?.CompletedAt is not null, progress?.RewardedAt is not null); + return new WeeklyQuestCharacterInfo( + player.SelectedCharacter?.CharacterClass, + player.Level, + (int)(player.Attributes?[Stats.Resets] ?? 0)); + } + + private static List GetAvailableQuests(WeeklyQuestsConfiguration configuration, WeeklyQuestPlayerState state, WeeklyQuestCharacterInfo character) + { + return WeeklyQuestSelector.GetAvailableQuests(configuration, state.PeriodStartUtc, character, state.Progress, state.RewardedByOtherCharacters); + } + + private static bool CountsAsPlayerKill(WeeklyQuestDefinition quest, WeeklyQuestPlayerState state, Player killer, Player victim) + { + if (victim.Level < quest.MinimumVictimLevel) + { + return false; + } + + if (quest.IgnoreSameIp + && (killer as IHasIpAddress)?.IpAddress is { Length: > 0 } killerIp + && killerIp == (victim as IHasIpAddress)?.IpAddress) + { + return false; + } + + if (quest.IgnoreSameGuild + && killer.GuildStatus is { } killerGuild + && killerGuild.GuildId == victim.GuildStatus?.GuildId) + { + return false; + } + + if (quest.IgnoreSameParty && killer.Party is not null && killer.Party == victim.Party) + { + return false; + } + + if (quest.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(quest.VictimCooldownMinutes)) + { + return false; + } + + state.LastCountedVictimKills[key] = now; + } + + return true; } private static WeeklyQuestOverview CreateOverview(WeeklyQuestsConfiguration configuration, WeeklyQuestPlayerState state, Player player) { - var entries = GetActiveQuests(configuration).Select(quest => CreateEntry(quest, state)).ToList(); + var entries = WeeklyQuestSelector.CreateEntries(configuration, state.PeriodStartUtc, GetCharacterInfo(player), state.Progress, state.RewardedByOtherCharacters); return new WeeklyQuestOverview(entries, GetNextResetUtc(state, player)); } @@ -339,25 +433,20 @@ private static ValueTask ShowGoldenMessageAsync(Player player, string message) return null; } - return States.GetValue(player, _ => new WeeklyQuestPlayerState(this, character.GetId())); + 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) + private async ValueTask AddProgressAsync(Player player, WeeklyQuestObjectiveType objectiveType, int amount, Func? filter = null) { if (this.Configuration is not { } configuration) { return; } - var level = player.Level; - var resets = (int)(player.Attributes?[Stats.Resets] ?? 0); - var quests = GetActiveQuests(configuration) - .Where(q => q.ObjectiveType == objectiveType - && level >= q.MinimumLevel - && resets >= q.MinimumResets - && (filter is null || filter(q))) - .ToList(); - if (quests.Count == 0 || this.GetOrCreateState(player) is not { } state) + // A quick check before the state is loaded, as most events don't concern any quest. + if (!configuration.Quests.Any(q => q.IsActive && q.ObjectiveType == objectiveType) + || this.GetOrCreateState(player) is not { } state) { return; } @@ -371,6 +460,13 @@ private async ValueTask AddProgressAsync(Player player, WeeklyQuestObjectiveType return; } + var character = GetCharacterInfo(player); + var quests = GetAvailableQuests(configuration, state, character) + .Where(q => q.ObjectiveType == objectiveType + && WeeklyQuestSelector.MeetsMinimumRequirements(q, character) + && (filter is null || filter(q, state))) + .ToList(); + var hasCompleted = false; var changedQuests = new List(); foreach (var quest in quests) @@ -398,6 +494,11 @@ private async ValueTask AddProgressAsync(Player player, WeeklyQuestObjectiveType } } + 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) { @@ -413,6 +514,37 @@ private async ValueTask AddProgressAsync(Player player, WeeklyQuestObjectiveType } } + /// + /// Updates the progress of the , and rewards it when all quests are completed. + /// + /// The quest of the bonus, if its progress changed. + private async ValueTask UpdateAllCompletedBonusAsync(Player player, WeeklyQuestPlayerState state, WeeklyQuestsConfiguration configuration) + { + var quests = GetAvailableQuests(configuration, state, GetCharacterInfo(player)); + 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); + 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) { // After a reset, all quests of the client are outdated, not only the changed ones. @@ -426,7 +558,7 @@ private async ValueTask SendChangesAsync(Player player, WeeklyQuestPlayerState s var nextResetUtc = GetNextResetUtc(state, player); foreach (var quest in changedQuests) { - var entry = CreateEntry(quest, state); + var entry = WeeklyQuestSelector.CreateEntry(quest, state.Progress); await player.InvokeViewPlugInAsync(p => p.UpdateWeeklyQuestAsync(entry, nextResetUtc)).ConfigureAwait(false); } } @@ -453,6 +585,7 @@ private async ValueTask EnsureLoadedAsync(Player player, WeeklyQuestPlayer } var loaded = await this.Repository.LoadAsync(state.CharacterId, periodStart).ConfigureAwait(false); + state.RewardedByOtherCharacters = await this.LoadRewardedByOtherCharactersAsync(state, periodStart).ConfigureAwait(false); state.Progress = loaded.ToDictionary(p => p.QuestId); state.PeriodStartUtc = periodStart; state.DirtyQuestIds.Clear(); @@ -466,6 +599,22 @@ private async ValueTask EnsureLoadedAsync(Player player, WeeklyQuestPlayer } } + private async ValueTask> LoadRewardedByOtherCharactersAsync(WeeklyQuestPlayerState state, DateTime periodStart) + { + // 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 rewarded = await this.Repository.LoadRewardedByAccountAsync(accountId, periodStart).ConfigureAwait(false); + return rewarded + .Where(p => p.CharacterId != state.CharacterId) + .Select(p => p.QuestId) + .ToHashSet(); + } + private async ValueTask SaveAsync(Player player, WeeklyQuestPlayerState state) { if (state.DirtyQuestIds.Count == 0) @@ -499,7 +648,10 @@ private async ValueTask RewardPendingAsync(Player player, WeeklyQuestPlayerState var rewarded = false; foreach (var progress in state.Progress.Values.Where(p => p.CompletedAt is not null && p.RewardedAt is null).ToList()) { - if (configuration.Quests.FirstOrDefault(q => q.Id == progress.QuestId) is { } quest) + var quest = progress.QuestId == WeeklyQuestSelector.AllCompletedBonusId + ? WeeklyQuestSelector.CreateAllCompletedBonusQuest(configuration, progress.Count) + : configuration.Quests.FirstOrDefault(q => q.Id == progress.QuestId); + if (quest is not null) { rewarded |= await this.TryRewardAsync(player, quest, progress, showPendingMessage).ConfigureAwait(false); } 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/WeeklyQuestContextModelSnapshot.cs b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/WeeklyQuestContextModelSnapshot.cs index 2e22849ef..76e828ec2 100644 --- a/src/Persistence/EntityFramework/Migrations/WeeklyQuests/WeeklyQuestContextModelSnapshot.cs +++ b/src/Persistence/EntityFramework/Migrations/WeeklyQuests/WeeklyQuestContextModelSnapshot.cs @@ -35,6 +35,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(64) .HasColumnType("character varying(64)"); + b.Property("AccountId") + .HasColumnType("uuid"); + b.Property("CompletedAt") .HasColumnType("timestamp with time zone"); @@ -48,6 +51,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("PeriodStart"); + b.HasIndex("AccountId", "PeriodStart", "QuestId"); + b.ToTable("WeeklyQuestProgress", "weekly"); }); #pragma warning restore 612, 618 diff --git a/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContext.cs b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContext.cs index 2af5f0c11..2b85794d0 100644 --- a/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContext.cs +++ b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestContext.cs @@ -45,6 +45,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(p => new { p.CharacterId, p.PeriodStart, p.QuestId }); entity.Property(p => p.QuestId).IsRequired().HasMaxLength(64); entity.HasIndex(p => p.PeriodStart); + entity.HasIndex(p => new { p.AccountId, p.PeriodStart, p.QuestId }); }); } } diff --git a/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestProgressRepository.cs b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestProgressRepository.cs index c214b06da..e5df6898c 100644 --- a/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestProgressRepository.cs +++ b/src/Persistence/EntityFramework/WeeklyQuests/WeeklyQuestProgressRepository.cs @@ -88,6 +88,7 @@ public async ValueTask SaveAsync(IEnumerable progress, Canc { if (existing.TryGetValue(entry.QuestId, out var stored)) { + stored.AccountId ??= entry.AccountId; stored.Count = entry.Count; stored.CompletedAt = entry.CompletedAt; stored.RewardedAt = entry.RewardedAt; @@ -97,6 +98,7 @@ public async ValueTask SaveAsync(IEnumerable progress, Canc context.Progress.Add(new WeeklyQuestProgress { CharacterId = entry.CharacterId, + AccountId = entry.AccountId, PeriodStart = entry.PeriodStart, QuestId = entry.QuestId, Count = entry.Count, @@ -110,6 +112,19 @@ public async ValueTask SaveAsync(IEnumerable progress, Canc await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } + /// + public async ValueTask> LoadRewardedByAccountAsync(Guid accountId, DateTime periodStart, CancellationToken cancellationToken = default) + { + await this.EnsureAvailableStorageAsync(cancellationToken).ConfigureAwait(false); + + await using var context = new WeeklyQuestContext(); + return await context.Progress + .AsNoTracking() + .Where(p => p.AccountId == accountId && p.PeriodStart == periodStart && p.RewardedAt != null) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + private async ValueTask EnsureAvailableStorageAsync(CancellationToken cancellationToken) { if (this._isStorageReady) diff --git a/src/Persistence/WeeklyQuests/IWeeklyQuestProgressRepository.cs b/src/Persistence/WeeklyQuests/IWeeklyQuestProgressRepository.cs index 6230e864b..e90a0aeb1 100644 --- a/src/Persistence/WeeklyQuests/IWeeklyQuestProgressRepository.cs +++ b/src/Persistence/WeeklyQuests/IWeeklyQuestProgressRepository.cs @@ -26,4 +26,13 @@ public interface IWeeklyQuestProgressRepository /// 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 period. + /// + /// The identifier of the account. + /// The start of the period. + /// The cancellation token. + /// The rewarded progress entries of the characters of the account in this period. + ValueTask> LoadRewardedByAccountAsync(Guid accountId, DateTime periodStart, CancellationToken cancellationToken = default); } diff --git a/src/Persistence/WeeklyQuests/InMemoryWeeklyQuestProgressRepository.cs b/src/Persistence/WeeklyQuests/InMemoryWeeklyQuestProgressRepository.cs index 26938e70f..bb279a771 100644 --- a/src/Persistence/WeeklyQuests/InMemoryWeeklyQuestProgressRepository.cs +++ b/src/Persistence/WeeklyQuests/InMemoryWeeklyQuestProgressRepository.cs @@ -36,9 +36,20 @@ public ValueTask SaveAsync(IEnumerable progress, Cancellati return ValueTask.CompletedTask; } + /// + public ValueTask> LoadRewardedByAccountAsync(Guid accountId, DateTime periodStart, CancellationToken cancellationToken = default) + { + IList result = this._entries.Values + .Where(e => e.AccountId == accountId && e.PeriodStart == periodStart && e.RewardedAt is not null) + .Select(Clone) + .ToList(); + return ValueTask.FromResult(result); + } + private static WeeklyQuestProgress Clone(WeeklyQuestProgress source) => new() { CharacterId = source.CharacterId, + AccountId = source.AccountId, PeriodStart = source.PeriodStart, QuestId = source.QuestId, Count = source.Count, diff --git a/src/Persistence/WeeklyQuests/WeeklyQuestProgress.cs b/src/Persistence/WeeklyQuests/WeeklyQuestProgress.cs index 803e8a90b..925c3f8a7 100644 --- a/src/Persistence/WeeklyQuests/WeeklyQuestProgress.cs +++ b/src/Persistence/WeeklyQuests/WeeklyQuestProgress.cs @@ -18,6 +18,14 @@ public class WeeklyQuestProgress /// 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. /// diff --git a/src/Web/AdminPanel/API/WebsiteController.cs b/src/Web/AdminPanel/API/WebsiteController.cs index d5d8a5c8d..c5f340df7 100644 --- a/src/Web/AdminPanel/API/WebsiteController.cs +++ b/src/Web/AdminPanel/API/WebsiteController.cs @@ -4,10 +4,16 @@ 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; /// @@ -22,18 +28,26 @@ namespace MUnique.OpenMU.Web.AdminPanel.API; [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. - public WebsiteController(IDictionary gameServers, ILoginServer loginServer) + /// The persistence context provider. + public WebsiteController(IDictionary gameServers, ILoginServer loginServer, IPersistenceContextProvider contextProvider) { this._gameServers = gameServers; this._loginServer = loginServer; + this._contextProvider = contextProvider; } /// @@ -101,6 +115,86 @@ public async Task DisconnectAccountAsync(string login) 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 periodStart = WeeklyPeriod.GetPeriodStartUtc(DateTime.UtcNow, configuration.ResetDay, configuration.ResetTime, context.ServerTimeZone); + var progressPerCharacter = new Dictionary>(); + foreach (var character in account.Characters) + { + var id = character.GetId(); + var loaded = await repository.LoadAsync(id, periodStart).ConfigureAwait(false); + progressPerCharacter[id] = loaded.ToDictionary(p => p.QuestId); + } + + 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, periodStart, info, progressPerCharacter[id], rewardedByOthers) + .Select(e => new + { + id = e.Quest.Id, + name = e.Quest.Name, + description = e.Quest.Description, + count = e.Count, + required = e.Quest.RequiredCount, + completed = e.IsCompleted, + rewarded = e.IsRewarded, + rewards = e.Quest.GetRewardsText(WebsiteCulture), + isBonus = e.Quest.Id == WeeklyQuestSelector.AllCompletedBonusId, + }) + .ToList(); + return new { name = character.Name, quests }; + }) + .ToList(); + + var nextResetUtc = WeeklyPeriod.GetNextPeriodStartUtc(periodStart, context.ServerTimeZone); + return this.Ok(new { enabled = true, nextResetUtc, characters }); + } + private async Task<(bool InGame, string? Character)> FindPlayerAsync(string login) { foreach (var server in this._gameServers.Values.OfType()) diff --git a/src/Web/Shared/Components/Modal/ModalContainer.razor b/src/Web/Shared/Components/Modal/ModalContainer.razor index b5f8c2865..2007c7d65 100644 --- a/src/Web/Shared/Components/Modal/ModalContainer.razor +++ b/src/Web/Shared/Components/Modal/ModalContainer.razor @@ -4,8 +4,12 @@ @ChildContent -@if (this.ModalService.Current is { } state) +@{ + var modals = this.ModalService.Modals; +} +@for (var i = 0; i < modals.Count; i++) { + var state = modals[i]; var sizeClass = state.Options?.Size switch { ModalSize.Small => "modal-sm", @@ -14,32 +18,29 @@ _ => string.Empty, }; - @* backdrop before modal so modal stacks on top by default *@ - -