From 21acabd095444da2ed374d6c40981dc1641aa012 Mon Sep 17 00:00:00 2001 From: Carlos Valdes Date: Tue, 11 Aug 2026 12:48:55 -0400 Subject: [PATCH 1/2] feat(admin): add batch and merchant cockpits --- .../Configuration/GameServerDefinition.cs | 6 + src/GameLogic/GameContext.cs | 3 + src/GameLogic/IGameContext.cs | 5 + src/GameLogic/MoneyDistribution.cs | 5 +- src/GameServer/GameServerContext.cs | 3 + .../20260811103000_AddGameServerMoneyRate.cs | 40 + .../Components/Layout/ConfigNavMenu.razor | 6 + .../AdminPanel/Pages/BatchOperations.razor | 385 ++++++ .../AdminPanel/Pages/BatchOperations.razor.cs | 1040 +++++++++++++++++ .../Pages/CreateGameServerConfig.razor.cs | 8 + .../AdminPanel/Pages/MerchantCockpit.razor | 346 ++++++ .../AdminPanel/Pages/MerchantCockpit.razor.cs | 890 ++++++++++++++ 12 files changed, 2736 insertions(+), 1 deletion(-) create mode 100644 src/Persistence/EntityFramework/Migrations/20260811103000_AddGameServerMoneyRate.cs create mode 100644 src/Web/AdminPanel/Pages/BatchOperations.razor create mode 100644 src/Web/AdminPanel/Pages/BatchOperations.razor.cs create mode 100644 src/Web/AdminPanel/Pages/MerchantCockpit.razor create mode 100644 src/Web/AdminPanel/Pages/MerchantCockpit.razor.cs diff --git a/src/DataModel/Configuration/GameServerDefinition.cs b/src/DataModel/Configuration/GameServerDefinition.cs index e27671fb2..b775450ed 100644 --- a/src/DataModel/Configuration/GameServerDefinition.cs +++ b/src/DataModel/Configuration/GameServerDefinition.cs @@ -30,6 +30,12 @@ public partial class GameServerDefinition /// public float ExperienceRate { get; set; } + /// + /// Gets or sets the Zen/money multiplier for the specific server. + /// This is applied in addition to the character/global MoneyAmountRate. + /// + public float MoneyRate { get; set; } = 1.0f; + /// /// Gets or sets a value indicating whether PVP is enabled on this server. /// diff --git a/src/GameLogic/GameContext.cs b/src/GameLogic/GameContext.cs index a15327f84..5a162d74d 100644 --- a/src/GameLogic/GameContext.cs +++ b/src/GameLogic/GameContext.cs @@ -113,6 +113,9 @@ public GameContext(GameConfiguration configuration, IPersistenceContextProvider /// public virtual float ExperienceRate => this.Configuration.ExperienceRate; + /// + public virtual float MoneyRate => 1.0f; + /// public virtual float MasterExperienceRate => this.Configuration.MasterExperienceRate; diff --git a/src/GameLogic/IGameContext.cs b/src/GameLogic/IGameContext.cs index 57283e4c5..c546c2449 100644 --- a/src/GameLogic/IGameContext.cs +++ b/src/GameLogic/IGameContext.cs @@ -32,6 +32,11 @@ public interface IGameContext /// float ExperienceRate { get; } + /// + /// Gets the effective Zen/money multiplier of this game server. + /// + float MoneyRate { get; } + /// /// Gets the global master experience rate. /// diff --git a/src/GameLogic/MoneyDistribution.cs b/src/GameLogic/MoneyDistribution.cs index 0dbe24c41..faba37848 100644 --- a/src/GameLogic/MoneyDistribution.cs +++ b/src/GameLogic/MoneyDistribution.cs @@ -139,7 +139,10 @@ public static bool TryPay(Player player, uint amount) // The rate is applied in double precision: a float multiplication would round money amounts // above the ~16.7M the float mantissa can represent exactly, before the cast to long. - var scaled = (long)(amount * (double)(player.Attributes?[Stats.MoneyAmountRate] ?? 1.0f)); + var scaled = (long)( + amount + * (double)(player.Attributes?[Stats.MoneyAmountRate] ?? 1.0f) + * (double)(player.GameContext?.MoneyRate ?? 1.0f)); if (scaled <= 0) { return false; diff --git a/src/GameServer/GameServerContext.cs b/src/GameServer/GameServerContext.cs index 281447e70..d7e781b4a 100644 --- a/src/GameServer/GameServerContext.cs +++ b/src/GameServer/GameServerContext.cs @@ -105,6 +105,9 @@ public GameServerContext( /// public override float ExperienceRate => base.ExperienceRate * this._gameServerDefinition.ExperienceRate; + /// + public override float MoneyRate => base.MoneyRate * this._gameServerDefinition.MoneyRate; + /// public override float MasterExperienceRate => base.MasterExperienceRate * this._gameServerDefinition.ExperienceRate; diff --git a/src/Persistence/EntityFramework/Migrations/20260811103000_AddGameServerMoneyRate.cs b/src/Persistence/EntityFramework/Migrations/20260811103000_AddGameServerMoneyRate.cs new file mode 100644 index 000000000..e303751c1 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20260811103000_AddGameServerMoneyRate.cs @@ -0,0 +1,40 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations +{ + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Migrations; + + /// + /// Adds a per-game-server Zen multiplier for MU Nueva Era. + /// + [DbContext(typeof(EntityDataContext))] + [Migration("20260811103000_AddGameServerMoneyRate")] + public partial class AddGameServerMoneyRate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MoneyRate", + schema: "config", + table: "GameServerDefinition", + type: "real", + nullable: false, + defaultValue: 1f); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MoneyRate", + schema: "config", + table: "GameServerDefinition"); + } + } +} diff --git a/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor b/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor index 492ce0c87..f178658f2 100644 --- a/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor +++ b/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor @@ -13,10 +13,16 @@ @Resources.General @Resources.Monsters @Resources.MerchantStores + + Merchant Cockpit + @Resources.CharacterClasses @Resources.Skills @Resources.Items @Resources.DropItemGroups + + Operaciones por lote + @Resources.GameMaps @Resources.MiniGames @Resources.WarpList diff --git a/src/Web/AdminPanel/Pages/BatchOperations.razor b/src/Web/AdminPanel/Pages/BatchOperations.razor new file mode 100644 index 000000000..fb0e590fe --- /dev/null +++ b/src/Web/AdminPanel/Pages/BatchOperations.razor @@ -0,0 +1,385 @@ +@page "/batch-operations" + +@using MUnique.OpenMU.DataModel.Configuration +@using MUnique.OpenMU.Persistence + +OpenMU: Operaciones por lote + + +

Operaciones por lote

+

+ Cockpit de MU Nueva Era para modificar configuración nativa de OpenMU por reglas. + Todas las operaciones muestran una vista previa antes de guardar y la última operación + aplicada puede revertirse mientras permanezcas en esta pantalla. +

+ +@if (this._isLoading) +{ +
Cargando configuración de OpenMU…
+ return; +} + +
+ Seguridad: haz un respaldo de PostgreSQL antes de una sesión de balance. + Las operaciones trabajan sobre la configuración real del servidor, no sobre una copia paralela. +
+ +
+
+ @this._previewTitle + @if (this._undoAction is not null) + { + + } +
+
+

@this._previewSummary

+ @if (this._previewLines.Count > 0) + { +
+
    + @foreach (var line in this._previewLines) + { +
  • @line
  • + } +
+
+ } +
+
+ +
+
+ 1. Economía global y canales +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
Canales / realms
+
+ + + + + + + + + + + @foreach (var server in this._serverEdits) + { + + + + + + + } + +
CanalEXPZenPvP
+ #@server.ServerId + @server.Description +
+
+ +
Drops comunes
+
+ @foreach (var drop in this._dropChanceEdits) + { +
+ +
+ + % +
+
+ } +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+ 2. Drops masivos +
+
+

+ Asigna o quita un Drop Item Group completo a mapas o a un conjunto de monstruos filtrados. + Esto evita configurar cientos de criaturas una por una. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
Mapas
+
+ + +
+
+
+ @foreach (var map in this._maps) + { + var id = map.GetId(); +
+ + +
+ } +
+
+ +
+
Filtro de monstruos
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+ +
+ + +
+
+
+ +
+
+ 3. Monstruos masivos +
+
+

+ Usa el mismo filtro de monstruos de la sección anterior. Deja un campo vacío para no modificarlo. +

+
+
+ + +
+
+ + +
+
+ Filtro actual: @this.GetMatchingMonsters().Count() monstruos +
+
+ +
+ + +
+
+
+ +
+
+ 4. Items masivos +
+
+

+ Filtra el catálogo y modifica niveles de drop o habilitación de drop desde monstruos en una sola operación. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
Vacío = no cambiar.
+
+
+
+ + +
+ +
Vacío + casilla activa = sin límite superior.
+
+
+
+ + +
+ +
+
+ +
Filtro actual: @this.GetMatchingItems().Count() items.
+ +
+ + +
+
+
+ +
+
+ 5. Plantillas de tiendas + Abrir Merchant Cockpit +
+
+
+ Aquí puedes clonar tiendas completas por lote. Para editar merchants cómodamente —buscar items, + agregar/quitar, configurar nivel, skill, sockets, Luck/opciones, ordenar slots y copiar en modo + reemplazar o agregar— usa el Merchant Cockpit. +
+ +
+
+ + +
+
+ +
+ @foreach (var merchant in this._merchants) + { + var id = merchant.GetId(); +
+ + +
+ } +
+
+
+ +
+ + +
+
+
diff --git a/src/Web/AdminPanel/Pages/BatchOperations.razor.cs b/src/Web/AdminPanel/Pages/BatchOperations.razor.cs new file mode 100644 index 000000000..cb0ae9382 --- /dev/null +++ b/src/Web/AdminPanel/Pages/BatchOperations.razor.cs @@ -0,0 +1,1040 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Pages; + +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Web.Shared.Components.Toast; +using MUnique.OpenMU.Web.Shared.Services; + +/// +/// MU Nueva Era batch configuration cockpit. +/// Provides preview-first bulk operations over the native OpenMU configuration graph. +/// +public partial class BatchOperations : ComponentBase, IAsyncDisposable +{ + private readonly HashSet _selectedMapIds = []; + private readonly HashSet _targetMerchantIds = []; + + private GameConfiguration? _gameConfiguration; + private IContext? _gameContext; + private IContext? _serverContext; + + private List _servers = []; + private List _serverEdits = []; + private List _maps = []; + private List _monsters = []; + private List _items = []; + private List _dropGroups = []; + private List _dropChanceEdits = []; + private List _merchants = []; + + private EconomyEdit _economy = new(); + + private string _dropScope = "maps"; + private string _dropAction = "add"; + private Guid? _selectedDropGroupId; + private string _monsterFilter = string.Empty; + private int? _monsterMinLevel; + private int? _monsterMaxLevel; + private bool _monsterOnlySelectedMaps; + + private int? _batchMaximumItemDrops; + private double? _batchRespawnSeconds; + + private string _itemFilter = string.Empty; + private int? _itemGroup; + private int? _itemCurrentMinDropLevel; + private int? _itemCurrentMaxDropLevel; + private int? _newItemDropLevel; + private bool _setItemMaximumDropLevel; + private int? _newItemMaximumDropLevel; + private bool _setDropsFromMonsters; + private bool _newDropsFromMonsters = true; + + private Guid? _sourceMerchantId; + + private bool _restartAllAfterEconomyApply = true; + private bool _isLoading = true; + private bool _isApplying; + + private string _previewTitle = "Sin vista previa"; + private string _previewSummary = "Configura una operación y pulsa Vista previa."; + private List _previewLines = []; + private Func? _undoAction; + + /// + /// Gets or sets the game configuration data source. + /// + [Inject] + public IDataSource DataSource { get; set; } = null!; + + /// + /// Gets or sets the persistence context provider. + /// + [Inject] + public IPersistenceContextProvider ContextProvider { get; set; } = null!; + + /// + /// Gets or sets the game server instance manager. + /// + [Inject] + public IGameServerInstanceManager ServerInstanceManager { get; set; } = null!; + + /// + /// Gets or sets the toast service. + /// + [Inject] + public IToastService ToastService { get; set; } = null!; + + /// + /// Gets or sets the loading overlay service. + /// + [Inject] + public LoadingOverlayService LoadingService { get; set; } = null!; + + /// + /// Gets or sets the logger. + /// + [Inject] + public ILogger Logger { get; set; } = null!; + + /// + protected override async Task OnInitializedAsync() + { + using var loading = this.LoadingService.ShowLoadingIndicator(); + try + { + this._gameContext = await this.DataSource.GetContextAsync().ConfigureAwait(true); + this._gameConfiguration = await this.DataSource.GetOwnerAsync().ConfigureAwait(true); + + this._maps = this.DataSource.GetAll() + .OrderBy(m => m.Number) + .ThenBy(m => m.Name.ToString()) + .ToList(); + + this._monsters = this.DataSource.GetAll() + .Where(m => m.ObjectKind == NpcObjectKind.Monster) + .OrderBy(GetMonsterLevel) + .ThenBy(m => m.Designation.ToString()) + .ToList(); + + this._items = this.DataSource.GetAll() + .OrderBy(i => i.Group) + .ThenBy(i => i.Number) + .ToList(); + + this._dropGroups = this.DataSource.GetAll() + .OrderBy(g => g.ItemType) + .ThenBy(g => g.Description.ToString()) + .ToList(); + + this._merchants = this.DataSource.GetAll() + .Where(m => m is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore: not null }) + .OrderBy(m => m.Designation.ToString()) + .ToList(); + + this._economy = new EconomyEdit + { + ExperienceRate = this._gameConfiguration.ExperienceRate, + MasterExperienceRate = this._gameConfiguration.MasterExperienceRate, + MaximumInventoryMoney = this._gameConfiguration.MaximumInventoryMoney, + MaximumVaultMoney = this._gameConfiguration.MaximumVaultMoney, + ShouldDropMoney = this._gameConfiguration.ShouldDropMoney, + ItemDropDurationSeconds = this._gameConfiguration.ItemDropDuration.TotalSeconds, + }; + + this._dropChanceEdits = this._dropGroups + .Where(IsCommonEconomyDropGroup) + .Select(group => new DropChanceEdit(group)) + .ToList(); + + this._serverContext = this.ContextProvider.CreateNewTypedContext( + typeof(GameServerDefinition), + true, + this._gameConfiguration); + + this._servers = (await this._serverContext.GetAsync().ConfigureAwait(true)) + .OrderBy(s => s.ServerID) + .ToList(); + + this._serverEdits = this._servers.Select(server => new ServerRateEdit(server)).ToList(); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to load batch cockpit."); + this.ToastService.ShowError($"No se pudo cargar Operaciones por lote: {ex.Message}"); + } + finally + { + this._isLoading = false; + } + } + + /// + public ValueTask DisposeAsync() + { + this._serverContext?.Dispose(); + this._serverContext = null; + return ValueTask.CompletedTask; + } + + private void SelectAllMaps(bool selected) + { + this._selectedMapIds.Clear(); + if (selected) + { + foreach (var map in this._maps) + { + this._selectedMapIds.Add(map.GetId()); + } + } + } + + private void ToggleMap(Guid id, bool selected) + { + if (selected) + { + this._selectedMapIds.Add(id); + } + else + { + this._selectedMapIds.Remove(id); + } + } + + private void ToggleMerchant(Guid id, bool selected) + { + if (selected) + { + this._targetMerchantIds.Add(id); + } + else + { + this._targetMerchantIds.Remove(id); + } + } + + private void PreviewEconomy() + { + if (this._gameConfiguration is null) + { + return; + } + + var lines = new List(); + AddDiff(lines, "EXP global", this._gameConfiguration.ExperienceRate, this._economy.ExperienceRate); + AddDiff(lines, "Master EXP global", this._gameConfiguration.MasterExperienceRate, this._economy.MasterExperienceRate); + AddDiff(lines, "Zen máximo inventario", this._gameConfiguration.MaximumInventoryMoney, this._economy.MaximumInventoryMoney); + AddDiff(lines, "Zen máximo baúl", this._gameConfiguration.MaximumVaultMoney, this._economy.MaximumVaultMoney); + AddDiff(lines, "Zen cae al suelo", this._gameConfiguration.ShouldDropMoney, this._economy.ShouldDropMoney); + AddDiff(lines, "Duración drops (s)", this._gameConfiguration.ItemDropDuration.TotalSeconds, this._economy.ItemDropDurationSeconds); + + foreach (var edit in this._serverEdits) + { + var server = this._servers.First(s => s.GetId() == edit.Id); + AddDiff(lines, $"Canal #{server.ServerID} {server.Description} · EXP", server.ExperienceRate, edit.ExperienceRate); + AddDiff(lines, $"Canal #{server.ServerID} {server.Description} · Zen", server.MoneyRate, edit.MoneyRate); + AddDiff(lines, $"Canal #{server.ServerID} {server.Description} · PvP", server.PvpEnabled, edit.PvpEnabled); + } + + foreach (var edit in this._dropChanceEdits) + { + var group = this._dropGroups.First(g => g.GetId() == edit.Id); + AddDiff(lines, $"Drop {group.Description}", group.Chance * 100.0, edit.Percent, "%"); + } + + this.SetPreview( + "Economía y canales", + lines, + lines.Count == 0 ? "No hay cambios pendientes." : $"{lines.Count} cambios listos para aplicar."); + } + + private async Task ApplyEconomyAsync() + { + if (this._gameConfiguration is null || this._gameContext is null || this._serverContext is null) + { + return; + } + + this.PreviewEconomy(); + if (this._previewLines.Count == 0) + { + this.ToastService.ShowSuccess("No hay cambios de economía para guardar."); + return; + } + + if (this._economy.ExperienceRate < 0 + || this._economy.MasterExperienceRate < 0 + || this._economy.ItemDropDurationSeconds < 0 + || this._serverEdits.Any(e => e.ExperienceRate < 0 || e.MoneyRate < 0) + || this._dropChanceEdits.Any(e => e.Percent is < 0 or > 100)) + { + this.ToastService.ShowError("Hay valores fuera de rango. EXP/Zen no pueden ser negativos y los drops deben estar entre 0% y 100%."); + return; + } + + var gameSnapshot = new EconomyEdit + { + ExperienceRate = this._gameConfiguration.ExperienceRate, + MasterExperienceRate = this._gameConfiguration.MasterExperienceRate, + MaximumInventoryMoney = this._gameConfiguration.MaximumInventoryMoney, + MaximumVaultMoney = this._gameConfiguration.MaximumVaultMoney, + ShouldDropMoney = this._gameConfiguration.ShouldDropMoney, + ItemDropDurationSeconds = this._gameConfiguration.ItemDropDuration.TotalSeconds, + }; + + var serverSnapshot = this._servers.ToDictionary( + s => s.GetId(), + s => new ServerSnapshot(s.ExperienceRate, s.MoneyRate, s.PvpEnabled)); + + var dropSnapshot = this._dropChanceEdits.ToDictionary( + e => e.Id, + e => this._dropGroups.First(g => g.GetId() == e.Id).Chance); + + await this.RunApplyAsync(async () => + { + this._gameConfiguration.ExperienceRate = this._economy.ExperienceRate; + this._gameConfiguration.MasterExperienceRate = this._economy.MasterExperienceRate; + this._gameConfiguration.MaximumInventoryMoney = this._economy.MaximumInventoryMoney; + this._gameConfiguration.MaximumVaultMoney = this._economy.MaximumVaultMoney; + this._gameConfiguration.ShouldDropMoney = this._economy.ShouldDropMoney; + this._gameConfiguration.ItemDropDuration = TimeSpan.FromSeconds(this._economy.ItemDropDurationSeconds); + + foreach (var edit in this._serverEdits) + { + var server = this._servers.First(s => s.GetId() == edit.Id); + server.ExperienceRate = edit.ExperienceRate; + server.MoneyRate = edit.MoneyRate; + server.PvpEnabled = edit.PvpEnabled; + } + + foreach (var edit in this._dropChanceEdits) + { + var group = this._dropGroups.First(g => g.GetId() == edit.Id); + group.Chance = edit.Percent / 100.0; + } + + await this.SaveContextsAsync().ConfigureAwait(true); + + this._undoAction = async () => + { + this._gameConfiguration.ExperienceRate = gameSnapshot.ExperienceRate; + this._gameConfiguration.MasterExperienceRate = gameSnapshot.MasterExperienceRate; + this._gameConfiguration.MaximumInventoryMoney = gameSnapshot.MaximumInventoryMoney; + this._gameConfiguration.MaximumVaultMoney = gameSnapshot.MaximumVaultMoney; + this._gameConfiguration.ShouldDropMoney = gameSnapshot.ShouldDropMoney; + this._gameConfiguration.ItemDropDuration = TimeSpan.FromSeconds(gameSnapshot.ItemDropDurationSeconds); + + foreach (var server in this._servers) + { + var snapshot = serverSnapshot[server.GetId()]; + server.ExperienceRate = snapshot.ExperienceRate; + server.MoneyRate = snapshot.MoneyRate; + server.PvpEnabled = snapshot.PvpEnabled; + } + + foreach (var pair in dropSnapshot) + { + this._dropGroups.First(g => g.GetId() == pair.Key).Chance = pair.Value; + } + + await this.SaveContextsAsync().ConfigureAwait(true); + }; + + if (this._restartAllAfterEconomyApply) + { + await this.ServerInstanceManager.RestartAllAsync(false).ConfigureAwait(true); + } + }, "Economía y canales guardados.").ConfigureAwait(true); + } + + private void PreviewDropAssignment() + { + var group = this.GetSelectedDropGroup(); + if (group is null) + { + this.SetPreview("Drops masivos", [], "Selecciona un grupo de drop."); + return; + } + + var lines = new List(); + if (this._dropScope == "maps") + { + foreach (var map in this.GetSelectedMaps()) + { + var contains = map.DropItemGroups.Contains(group); + if ((this._dropAction == "add" && !contains) || (this._dropAction == "remove" && contains)) + { + lines.Add($"{(this._dropAction == "add" ? "Agregar" : "Quitar")} '{group.Description}' {(this._dropAction == "add" ? "a" : "de")} mapa {map.Number} - {map.Name}"); + } + } + } + else + { + foreach (var monster in this.GetMatchingMonsters()) + { + var contains = monster.DropItemGroups.Contains(group); + if ((this._dropAction == "add" && !contains) || (this._dropAction == "remove" && contains)) + { + lines.Add($"{(this._dropAction == "add" ? "Agregar" : "Quitar")} '{group.Description}' {(this._dropAction == "add" ? "a" : "de")} {monster.Designation} (lvl {GetMonsterLevel(monster)})"); + } + } + } + + this.SetPreview("Drops masivos", lines, $"{lines.Count} relaciones cambiarán."); + } + + private async Task ApplyDropAssignmentAsync() + { + if (this._gameContext is null) + { + return; + } + + var group = this.GetSelectedDropGroup(); + if (group is null) + { + this.ToastService.ShowError("Selecciona un grupo de drop."); + return; + } + + var snapshots = new List(); + if (this._dropScope == "maps") + { + foreach (var map in this.GetSelectedMaps()) + { + var had = map.DropItemGroups.Contains(group); + if ((this._dropAction == "add" && !had) || (this._dropAction == "remove" && had)) + { + snapshots.Add(new DropMembershipSnapshot(map, null, group, had)); + } + } + } + else + { + foreach (var monster in this.GetMatchingMonsters()) + { + var had = monster.DropItemGroups.Contains(group); + if ((this._dropAction == "add" && !had) || (this._dropAction == "remove" && had)) + { + snapshots.Add(new DropMembershipSnapshot(null, monster, group, had)); + } + } + } + + if (snapshots.Count == 0) + { + this.ToastService.ShowSuccess("No hay relaciones de drop para modificar."); + return; + } + + await this.RunApplyAsync(async () => + { + foreach (var snapshot in snapshots) + { + SetDropMembership(snapshot.Map, snapshot.Monster, group, this._dropAction == "add"); + } + + await this.SaveGameContextAsync().ConfigureAwait(true); + + this._undoAction = async () => + { + foreach (var snapshot in snapshots) + { + SetDropMembership(snapshot.Map, snapshot.Monster, snapshot.Group, snapshot.HadGroup); + } + + await this.SaveGameContextAsync().ConfigureAwait(true); + }; + }, $"{snapshots.Count} relaciones de drop actualizadas.").ConfigureAwait(true); + } + + private void PreviewMonsterBatch() + { + var targets = this.GetMatchingMonsters().ToList(); + var lines = new List(); + + foreach (var monster in targets) + { + var changes = new List(); + if (this._batchMaximumItemDrops.HasValue && monster.NumberOfMaximumItemDrops != this._batchMaximumItemDrops.Value) + { + changes.Add($"max drops {monster.NumberOfMaximumItemDrops} → {this._batchMaximumItemDrops.Value}"); + } + + if (this._batchRespawnSeconds.HasValue + && Math.Abs(monster.RespawnDelay.TotalSeconds - this._batchRespawnSeconds.Value) > 0.001) + { + changes.Add($"respawn {monster.RespawnDelay.TotalSeconds:0.##}s → {this._batchRespawnSeconds.Value:0.##}s"); + } + + if (changes.Count > 0) + { + lines.Add($"{monster.Designation} (lvl {GetMonsterLevel(monster)}): {string.Join(", ", changes)}"); + } + } + + this.SetPreview("Monstruos por lote", lines, $"{lines.Count} monstruos cambiarán."); + } + + private async Task ApplyMonsterBatchAsync() + { + if (this._gameContext is null) + { + return; + } + + if (!this._batchMaximumItemDrops.HasValue && !this._batchRespawnSeconds.HasValue) + { + this.ToastService.ShowError("Define al menos un valor a modificar."); + return; + } + + if (this._batchMaximumItemDrops is < 0 || this._batchRespawnSeconds is < 0) + { + this.ToastService.ShowError("Max drops y respawn no pueden ser negativos."); + return; + } + + var targets = this.GetMatchingMonsters().ToList(); + var snapshots = targets.ToDictionary( + m => m.GetId(), + m => new MonsterBatchSnapshot(m.NumberOfMaximumItemDrops, m.RespawnDelay)); + + await this.RunApplyAsync(async () => + { + foreach (var monster in targets) + { + if (this._batchMaximumItemDrops.HasValue) + { + monster.NumberOfMaximumItemDrops = this._batchMaximumItemDrops.Value; + } + + if (this._batchRespawnSeconds.HasValue) + { + monster.RespawnDelay = TimeSpan.FromSeconds(this._batchRespawnSeconds.Value); + } + } + + await this.SaveGameContextAsync().ConfigureAwait(true); + + this._undoAction = async () => + { + foreach (var monster in targets) + { + var snapshot = snapshots[monster.GetId()]; + monster.NumberOfMaximumItemDrops = snapshot.MaximumItemDrops; + monster.RespawnDelay = snapshot.RespawnDelay; + } + + await this.SaveGameContextAsync().ConfigureAwait(true); + }; + }, $"{targets.Count} monstruos procesados.").ConfigureAwait(true); + } + + private void PreviewItemBatch() + { + var targets = this.GetMatchingItems().ToList(); + var lines = new List(); + foreach (var item in targets) + { + var changes = new List(); + if (this._newItemDropLevel.HasValue && item.DropLevel != this._newItemDropLevel.Value) + { + changes.Add($"drop lvl {item.DropLevel} → {this._newItemDropLevel.Value}"); + } + + if (this._setItemMaximumDropLevel) + { + var newMax = this._newItemMaximumDropLevel.HasValue ? this._newItemMaximumDropLevel.Value.ToString(CultureInfo.InvariantCulture) : "sin límite"; + var oldMax = item.MaximumDropLevel?.ToString(CultureInfo.InvariantCulture) ?? "sin límite"; + if (oldMax != newMax) + { + changes.Add($"max drop lvl {oldMax} → {newMax}"); + } + } + + if (this._setDropsFromMonsters && item.DropsFromMonsters != this._newDropsFromMonsters) + { + changes.Add($"drop monstruos {item.DropsFromMonsters} → {this._newDropsFromMonsters}"); + } + + if (changes.Count > 0) + { + lines.Add($"[{item.Group},{item.Number}] {item.Name}: {string.Join(", ", changes)}"); + } + } + + this.SetPreview("Items por lote", lines, $"{lines.Count} items cambiarán."); + } + + private async Task ApplyItemBatchAsync() + { + if (this._gameContext is null) + { + return; + } + + if (!this._newItemDropLevel.HasValue && !this._setItemMaximumDropLevel && !this._setDropsFromMonsters) + { + this.ToastService.ShowError("Define al menos un campo de item a modificar."); + return; + } + + if (!IsByteOrNull(this._newItemDropLevel) || !IsByteOrNull(this._newItemMaximumDropLevel)) + { + this.ToastService.ShowError("Los niveles de drop deben estar entre 0 y 255."); + return; + } + + var targets = this.GetMatchingItems().ToList(); + var snapshots = targets.ToDictionary( + i => i.GetId(), + i => new ItemBatchSnapshot(i.DropLevel, i.MaximumDropLevel, i.DropsFromMonsters)); + + await this.RunApplyAsync(async () => + { + foreach (var item in targets) + { + if (this._newItemDropLevel.HasValue) + { + item.DropLevel = (byte)this._newItemDropLevel.Value; + } + + if (this._setItemMaximumDropLevel) + { + item.MaximumDropLevel = this._newItemMaximumDropLevel.HasValue + ? (byte)this._newItemMaximumDropLevel.Value + : null; + } + + if (this._setDropsFromMonsters) + { + item.DropsFromMonsters = this._newDropsFromMonsters; + } + } + + await this.SaveGameContextAsync().ConfigureAwait(true); + + this._undoAction = async () => + { + foreach (var item in targets) + { + var snapshot = snapshots[item.GetId()]; + item.DropLevel = snapshot.DropLevel; + item.MaximumDropLevel = snapshot.MaximumDropLevel; + item.DropsFromMonsters = snapshot.DropsFromMonsters; + } + + await this.SaveGameContextAsync().ConfigureAwait(true); + }; + }, $"{targets.Count} items procesados.").ConfigureAwait(true); + } + + private void PreviewMerchantClone() + { + var source = this.GetSourceMerchant(); + if (source?.MerchantStore is null) + { + this.SetPreview("Plantilla de tienda", [], "Selecciona una tienda origen."); + return; + } + + var targets = this.GetTargetMerchants(source.GetId()).ToList(); + var lines = targets + .Select(target => $"{target.Designation}: {target.MerchantStore!.Items.Count} items → {source.MerchantStore.Items.Count} items") + .ToList(); + + this.SetPreview("Plantilla de tienda", lines, $"{lines.Count} tiendas serán reemplazadas por una copia de {source.Designation}."); + } + + private async Task ApplyMerchantCloneAsync() + { + if (this._gameContext is null) + { + return; + } + + var source = this.GetSourceMerchant(); + if (source?.MerchantStore is null) + { + this.ToastService.ShowError("Selecciona una tienda origen."); + return; + } + + var targets = this.GetTargetMerchants(source.GetId()).ToList(); + if (targets.Count == 0) + { + this.ToastService.ShowError("Selecciona al menos una tienda destino."); + return; + } + + var snapshots = targets.ToDictionary( + m => m.GetId(), + m => m.MerchantStore!.Items.Select(CreateShopItemSnapshot).ToList()); + + var sourceSnapshots = source.MerchantStore.Items.Select(CreateShopItemSnapshot).ToList(); + + await this.RunApplyAsync(async () => + { + foreach (var target in targets) + { + await this.ReplaceMerchantItemsAsync(target, sourceSnapshots).ConfigureAwait(true); + } + + await this.SaveGameContextAsync().ConfigureAwait(true); + + this._undoAction = async () => + { + foreach (var target in targets) + { + await this.ReplaceMerchantItemsAsync(target, snapshots[target.GetId()]).ConfigureAwait(true); + } + + await this.SaveGameContextAsync().ConfigureAwait(true); + }; + }, $"{targets.Count} tiendas clonadas. Puedes deshacer la última operación mientras permanezcas en esta pantalla.").ConfigureAwait(true); + } + + private async Task UndoLastAsync() + { + if (this._undoAction is null) + { + return; + } + + await this.RunApplyAsync(async () => + { + var undo = this._undoAction; + this._undoAction = null; + await undo().ConfigureAwait(true); + }, "Última operación revertida.").ConfigureAwait(true); + } + + private async Task ReplaceMerchantItemsAsync(MonsterDefinition merchant, IReadOnlyList snapshots) + { + if (this._gameContext is null || merchant.MerchantStore is null) + { + return; + } + + foreach (var oldItem in merchant.MerchantStore.Items.ToList()) + { + merchant.MerchantStore.Items.Remove(oldItem); + await this._gameContext.DeleteAsync(oldItem).ConfigureAwait(true); + } + + foreach (var snapshot in snapshots) + { + merchant.MerchantStore.Items.Add(CreateItemFromSnapshot(this._gameContext, snapshot)); + } + } + + private async Task RunApplyAsync(Func action, string successMessage) + { + if (this._isApplying) + { + return; + } + + this._isApplying = true; + using var loading = this.LoadingService.ShowLoadingIndicator(); + try + { + await action().ConfigureAwait(true); + this.ToastService.ShowSuccess(successMessage); + this._previewTitle = "Aplicado"; + this._previewSummary = successMessage; + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Batch operation failed."); + this.ToastService.ShowError($"La operación falló: {ex.Message}"); + } + finally + { + this._isApplying = false; + } + } + + private async Task SaveContextsAsync() + { + await this.SaveGameContextAsync().ConfigureAwait(true); + if (this._serverContext?.HasChanges is true) + { + await this._serverContext.SaveChangesAsync().ConfigureAwait(true); + } + } + + private async Task SaveGameContextAsync() + { + if (this._gameContext?.HasChanges is true) + { + await this._gameContext.SaveChangesAsync().ConfigureAwait(true); + } + } + + private DropItemGroup? GetSelectedDropGroup() + => this._selectedDropGroupId.HasValue + ? this._dropGroups.FirstOrDefault(g => g.GetId() == this._selectedDropGroupId.Value) + : null; + + private IEnumerable GetSelectedMaps() + => this._maps.Where(m => this._selectedMapIds.Contains(m.GetId())); + + private IEnumerable GetMatchingMonsters() + { + IEnumerable result = this._monsters; + + if (!string.IsNullOrWhiteSpace(this._monsterFilter)) + { + result = result.Where(m => (m.Designation.ToString() ?? string.Empty).Contains(this._monsterFilter, StringComparison.OrdinalIgnoreCase)); + } + + if (this._monsterMinLevel.HasValue) + { + result = result.Where(m => GetMonsterLevel(m) >= this._monsterMinLevel.Value); + } + + if (this._monsterMaxLevel.HasValue) + { + result = result.Where(m => GetMonsterLevel(m) <= this._monsterMaxLevel.Value); + } + + if (this._monsterOnlySelectedMaps && this._selectedMapIds.Count > 0) + { + var ids = this.GetSelectedMaps() + .SelectMany(m => m.MonsterSpawns) + .Where(s => s.MonsterDefinition is not null) + .Select(s => s.MonsterDefinition!.GetId()) + .ToHashSet(); + + result = result.Where(m => ids.Contains(m.GetId())); + } + + return result; + } + + private IEnumerable GetMatchingItems() + { + IEnumerable result = this._items; + + if (!string.IsNullOrWhiteSpace(this._itemFilter)) + { + result = result.Where(i => (i.Name.ToString() ?? string.Empty).Contains(this._itemFilter, StringComparison.OrdinalIgnoreCase)); + } + + if (this._itemGroup.HasValue) + { + result = result.Where(i => i.Group == this._itemGroup.Value); + } + + if (this._itemCurrentMinDropLevel.HasValue) + { + result = result.Where(i => i.DropLevel >= this._itemCurrentMinDropLevel.Value); + } + + if (this._itemCurrentMaxDropLevel.HasValue) + { + result = result.Where(i => i.DropLevel <= this._itemCurrentMaxDropLevel.Value); + } + + return result; + } + + private MonsterDefinition? GetSourceMerchant() + => this._sourceMerchantId.HasValue + ? this._merchants.FirstOrDefault(m => m.GetId() == this._sourceMerchantId.Value) + : null; + + private IEnumerable GetTargetMerchants(Guid sourceId) + => this._merchants.Where(m => m.GetId() != sourceId && this._targetMerchantIds.Contains(m.GetId())); + + private void SetPreview(string title, IReadOnlyList lines, string summary) + { + this._previewTitle = title; + this._previewSummary = summary; + this._previewLines = lines.Take(250).ToList(); + if (lines.Count > 250) + { + this._previewLines.Add($"… y {lines.Count - 250} cambios adicionales."); + } + } + + private static void SetDropMembership(GameMapDefinition? map, MonsterDefinition? monster, DropItemGroup group, bool shouldContain) + { + var collection = map?.DropItemGroups ?? monster?.DropItemGroups; + if (collection is null) + { + return; + } + + var contains = collection.Contains(group); + if (shouldContain && !contains) + { + collection.Add(group); + } + else if (!shouldContain && contains) + { + collection.Remove(group); + } + } + + private static bool IsCommonEconomyDropGroup(DropItemGroup group) + => group.Monster is null + && !group.MinimumMonsterLevel.HasValue + && !group.MaximumMonsterLevel.HasValue + && group.ItemType is SpecialItemType.Money + or SpecialItemType.RandomItem + or SpecialItemType.Excellent + or SpecialItemType.Jewel; + + private static int GetMonsterLevel(MonsterDefinition monster) + => (int)(monster.Attributes.FirstOrDefault(a => a.AttributeDefinition == Stats.Level)?.Value ?? 0); + + private static bool IsByteOrNull(int? value) + => !value.HasValue || value is >= byte.MinValue and <= byte.MaxValue; + + private static void AddDiff(ICollection lines, string label, T oldValue, T newValue, string suffix = "") + { + if (EqualityComparer.Default.Equals(oldValue, newValue)) + { + return; + } + + lines.Add($"{label}: {oldValue}{suffix} → {newValue}{suffix}"); + } + + private static ShopItemSnapshot CreateShopItemSnapshot(Item item) + => new( + item.ItemSlot, + item.Definition, + item.Durability, + item.Level, + item.HasSkill, + item.SocketCount, + item.StorePrice, + item.PetExperience, + item.ItemOptions.Select(option => new ShopOptionSnapshot(option.ItemOption, option.Level, option.Index)).ToList(), + item.ItemSetGroups.ToList()); + + private static Item CreateItemFromSnapshot(IContext context, ShopItemSnapshot snapshot) + { + var item = context.CreateNew(); + item.ItemSlot = snapshot.ItemSlot; + item.Definition = snapshot.Definition; + item.Durability = snapshot.Durability; + item.Level = snapshot.Level; + item.HasSkill = snapshot.HasSkill; + item.SocketCount = snapshot.SocketCount; + item.StorePrice = snapshot.StorePrice; + item.PetExperience = snapshot.PetExperience; + + foreach (var optionSnapshot in snapshot.Options) + { + var option = context.CreateNew(); + option.ItemOption = optionSnapshot.ItemOption; + option.Level = optionSnapshot.Level; + option.Index = optionSnapshot.Index; + item.ItemOptions.Add(option); + } + + foreach (var setGroup in snapshot.ItemSetGroups) + { + item.ItemSetGroups.Add(setGroup); + } + + return item; + } + + private sealed class EconomyEdit + { + public float ExperienceRate { get; set; } + + public float MasterExperienceRate { get; set; } + + public int MaximumInventoryMoney { get; set; } + + public int MaximumVaultMoney { get; set; } + + public bool ShouldDropMoney { get; set; } + + public double ItemDropDurationSeconds { get; set; } + } + + private sealed class ServerRateEdit + { + public ServerRateEdit(GameServerDefinition server) + { + this.Id = server.GetId(); + this.ServerId = server.ServerID; + this.Description = server.Description; + this.ExperienceRate = server.ExperienceRate; + this.MoneyRate = server.MoneyRate; + this.PvpEnabled = server.PvpEnabled; + } + + public Guid Id { get; } + + public byte ServerId { get; } + + public string Description { get; } + + public float ExperienceRate { get; set; } + + public float MoneyRate { get; set; } + + public bool PvpEnabled { get; set; } + } + + private sealed class DropChanceEdit + { + public DropChanceEdit(DropItemGroup group) + { + this.Id = group.GetId(); + this.Description = group.Description.ToString() ?? string.Empty; + this.Type = group.ItemType; + this.Percent = group.Chance * 100.0; + } + + public Guid Id { get; } + + public string Description { get; } + + public SpecialItemType Type { get; } + + public double Percent { get; set; } + } + + private sealed record ServerSnapshot(float ExperienceRate, float MoneyRate, bool PvpEnabled); + + private sealed record DropMembershipSnapshot(GameMapDefinition? Map, MonsterDefinition? Monster, DropItemGroup Group, bool HadGroup); + + private sealed record MonsterBatchSnapshot(int MaximumItemDrops, TimeSpan RespawnDelay); + + private sealed record ItemBatchSnapshot(byte DropLevel, byte? MaximumDropLevel, bool DropsFromMonsters); + + private sealed record ShopOptionSnapshot(IncreasableItemOption? ItemOption, int Level, int Index); + + private sealed record ShopItemSnapshot( + byte ItemSlot, + ItemDefinition? Definition, + double Durability, + byte Level, + bool HasSkill, + int SocketCount, + int? StorePrice, + int PetExperience, + IReadOnlyList Options, + IReadOnlyList ItemSetGroups); +} diff --git a/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs b/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs index 87ee740ec..8e1da0f8b 100644 --- a/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs +++ b/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs @@ -123,6 +123,7 @@ private async Task LoadDataAsync(CancellationToken cancellationToken) ServerConfiguration = serverConfigs.FirstOrDefault(), ServerId = (byte)nextServerId, ExperienceRate = 1.0f, + MoneyRate = 1.0f, PvpEnabled = true, NetworkPort = networkPort, Client = clients.FirstOrDefault(), @@ -143,6 +144,7 @@ private async ValueTask CreateDefinitionByViewModelAsync(I result.Description = this._viewModel.Description; result.PvpEnabled = this._viewModel.PvpEnabled; result.ExperienceRate = this._viewModel.ExperienceRate; + result.MoneyRate = this._viewModel.MoneyRate; result.GameConfiguration = await this.DataSource.GetOwnerAsync().ConfigureAwait(false); result.ServerConfiguration = this._viewModel.ServerConfiguration!; @@ -222,6 +224,12 @@ public class GameServerViewModel [Range(0, float.MaxValue)] public float ExperienceRate { get; set; } + /// + /// Gets or sets the Zen/money multiplier of the server. + /// + [Range(0, float.MaxValue)] + public float MoneyRate { get; set; } + /// /// Gets or sets a value indicating whether PVP is enabled on this server. /// diff --git a/src/Web/AdminPanel/Pages/MerchantCockpit.razor b/src/Web/AdminPanel/Pages/MerchantCockpit.razor new file mode 100644 index 000000000..14ed1421e --- /dev/null +++ b/src/Web/AdminPanel/Pages/MerchantCockpit.razor @@ -0,0 +1,346 @@ +@page "/merchant-cockpit" + +@using MUnique.OpenMU.DataModel.Configuration +@using MUnique.OpenMU.DataModel.Configuration.Items +@using MUnique.OpenMU.DataModel.Entities +@using MUnique.OpenMU.Persistence + +OpenMU: Merchant Cockpit + + +

Merchant Cockpit

+

+ Editor rápido de NPC vendedores para MU Nueva Era. Los cambios se preparan en memoria: + revisa la tienda y pulsa Guardar cambios cuando estés conforme. +

+ +@if (this._isLoading) +{ +
Cargando merchants e items…
+ return; +} + +
+ + + + Operaciones por lote + + @if (this.HasPendingChanges) + { + Cambios sin guardar + } +
+ +@if (!string.IsNullOrWhiteSpace(this._message)) +{ +
@this._message
+} + +
+
+
+
+ 1. Merchant +
+
+ + + +
+ @foreach (var merchant in this.FilteredMerchants) + { + var id = merchant.GetId(); + var active = this._selectedMerchantId == id; + + } +
+
+
+
+ +
+
+
+ 2. Tienda actual + @if (this.SelectedMerchant is { } selectedMerchant) + { + @selectedMerchant.Designation · @selectedMerchant.MerchantStore!.Items.Count items + } +
+
+ @if (this.SelectedMerchant is null) + { +
Selecciona un merchant a la izquierda.
+ } + else + { +
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + @foreach (var item in this.FilteredShopItems) + { + var id = item.GetId(); + var selected = this._selectedShopItemId == id; + + + + + + + + } + +
SlotItemNivelPrecio compra
@item.ItemSlot + +
+ G@(item.Definition?.Group)/#@(item.Definition?.Number) + · @item.Definition?.Width×@item.Definition?.Height +
+
+@item.Level@this.FormatZen(this.GetBuyingPrice(item)) + +
+
+ + @if (this.SelectedShopItem is { } editItem) + { +
+
Edición rápida · @editItem.Definition?.Name
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+ + +
+ +
+ + @if (!editItem.ItemOptions.Any()) + { +
Sin opciones adicionales.
+ } + else + { +
+ @foreach (var option in editItem.ItemOptions.ToList()) + { + + } +
+ } +
+ + @if (this.AvailableOptionsForSelectedItem.Any()) + { +
+
+ +
+
+ +
+
+ +
+
+ } + +
+ Precio mostrado: cálculo nativo de OpenMU para compra desde NPC. + No es un precio exclusivo de este merchant. +
+ } + } +
+
+
+ +
+
+
+ 3. Catálogo de items +
+
+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+ + + +
+ +
+ @foreach (var definition in this.FilteredCatalog) + { + var id = definition.GetId(); +
+ + +
+ } +
+
+
+ +
+
+ 4. Copiar tienda a otros NPC +
+
+
+
+ + +
+
+
+ + +
+
+
+ +
+ @foreach (var merchant in this.FilteredCloneTargets) + { + var id = merchant.GetId(); +
+ + +
+ } +
+ +
+ + +
+ + @if (this._clonePreviewLines.Count > 0) + { +
+ @foreach (var line in this._clonePreviewLines) + { +
@line
+ } +
+ } +
+
+
+
diff --git a/src/Web/AdminPanel/Pages/MerchantCockpit.razor.cs b/src/Web/AdminPanel/Pages/MerchantCockpit.razor.cs new file mode 100644 index 000000000..49fc8cf99 --- /dev/null +++ b/src/Web/AdminPanel/Pages/MerchantCockpit.razor.cs @@ -0,0 +1,890 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Pages; + +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Web.Shared.Components.Toast; +using MUnique.OpenMU.Web.Shared.Services; + +/// +/// Practical merchant editor for MU Nueva Era. +/// +public partial class MerchantCockpit : ComponentBase +{ + private const int MerchantGridWidth = 8; + private const int MerchantGridHeight = 15; + private const int MerchantGridSize = MerchantGridWidth * MerchantGridHeight; + + private readonly ItemPriceCalculator _priceCalculator = new(); + private readonly HashSet _selectedCatalogIds = []; + private readonly HashSet _cloneTargetIds = []; + + private IContext? _context; + private GameConfiguration? _gameConfiguration; + private List _merchants = []; + private List _items = []; + private List _maps = []; + + private Guid? _selectedMerchantId; + private Guid? _selectedShopItemId; + private string _merchantFilter = string.Empty; + private string _shopFilter = string.Empty; + private string _catalogFilter = string.Empty; + private int? _catalogGroup; + + private int _newItemLevel; + private int _newItemSocketCount; + private bool _newItemHasSkill; + + private int _editLevel; + private double _editDurability; + private int _editSocketCount; + private bool _editHasSkill; + + private Guid? _optionToAddId; + private int _optionLevel; + + private string _cloneMode = "replace"; + private bool _skipCloneDuplicates = true; + private List _clonePreviewLines = []; + + private bool _isLoading = true; + private bool _isSaving; + private string _message = string.Empty; + private string _messageCss = "alert-info"; + + /// + /// Gets or sets the game configuration data source. + /// + [Inject] + public IDataSource DataSource { get; set; } = null!; + + /// + /// Gets or sets the toast service. + /// + [Inject] + public IToastService ToastService { get; set; } = null!; + + /// + /// Gets or sets the loading overlay. + /// + [Inject] + public LoadingOverlayService LoadingService { get; set; } = null!; + + /// + /// Gets or sets the logger. + /// + [Inject] + public ILogger Logger { get; set; } = null!; + + private bool HasPendingChanges => this._context?.HasChanges is true; + + private MonsterDefinition? SelectedMerchant + => this._selectedMerchantId.HasValue + ? this._merchants.FirstOrDefault(m => m.GetId() == this._selectedMerchantId.Value) + : null; + + private Item? SelectedShopItem + => this.SelectedMerchant?.MerchantStore?.Items.FirstOrDefault(i => i.GetId() == this._selectedShopItemId); + + private IEnumerable FilteredMerchants + => this._merchants + .Where(m => string.IsNullOrWhiteSpace(this._merchantFilter) + || (m.Designation.ToString() ?? string.Empty).Contains(this._merchantFilter, StringComparison.OrdinalIgnoreCase)); + + private IEnumerable FilteredShopItems + => (this.SelectedMerchant?.MerchantStore?.Items ?? []) + .Where(i => string.IsNullOrWhiteSpace(this._shopFilter) + || (i.Definition?.Name.ToString() ?? string.Empty).Contains(this._shopFilter, StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => i.ItemSlot) + .ThenBy(i => i.Definition?.Group) + .ThenBy(i => i.Definition?.Number); + + private IEnumerable FilteredCatalog + => this._items + .Where(i => string.IsNullOrWhiteSpace(this._catalogFilter) + || (i.Name.ToString() ?? string.Empty).Contains(this._catalogFilter, StringComparison.OrdinalIgnoreCase)) + .Where(i => !this._catalogGroup.HasValue || i.Group == this._catalogGroup.Value) + .Take(300); + + private IEnumerable FilteredCloneTargets + => this._merchants.Where(m => m.GetId() != this._selectedMerchantId); + + private IEnumerable AvailableOptionsForSelectedItem + { + get + { + var item = this.SelectedShopItem; + if (item?.Definition is null) + { + return []; + } + + var existing = item.ItemOptions + .Where(o => o.ItemOption is not null) + .Select(o => o.ItemOption!.GetId()) + .ToHashSet(); + + return item.Definition.PossibleItemOptions + .SelectMany(o => o.PossibleOptions) + .Where(o => !existing.Contains(o.GetId())) + .OrderBy(o => o.OptionType?.ToString()) + .ThenBy(o => o.ToString()); + } + } + + private bool SelectedItemHasLuck + => this.SelectedShopItem?.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Luck) is true; + + /// + protected override async Task OnInitializedAsync() + { + await this.LoadAsync().ConfigureAwait(true); + } + + private async Task LoadAsync() + { + using var loading = this.LoadingService.ShowLoadingIndicator(); + this._isLoading = true; + + try + { + this._context = await this.DataSource.GetContextAsync().ConfigureAwait(true); + this._gameConfiguration = await this.DataSource.GetOwnerAsync().ConfigureAwait(true); + + this._merchants = this.DataSource.GetAll() + .Where(m => m is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore: not null }) + .OrderBy(m => m.Designation.ToString()) + .ToList(); + + this._items = this.DataSource.GetAll() + .OrderBy(i => i.Group) + .ThenBy(i => i.Number) + .ToList(); + + this._maps = this.DataSource.GetAll() + .OrderBy(m => m.Number) + .ToList(); + + if (!this._selectedMerchantId.HasValue + || this._merchants.All(m => m.GetId() != this._selectedMerchantId.Value)) + { + this._selectedMerchantId = this._merchants.FirstOrDefault()?.GetId(); + } + + this._selectedShopItemId = null; + this._selectedCatalogIds.Clear(); + this._cloneTargetIds.Clear(); + this._clonePreviewLines.Clear(); + this.ClearMessage(); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to load Merchant Cockpit."); + this.SetMessage($"No se pudo cargar Merchant Cockpit: {ex.Message}", "alert-danger"); + } + finally + { + this._isLoading = false; + } + } + + private void SelectMerchant(Guid id) + { + this._selectedMerchantId = id; + this._selectedShopItemId = null; + this._cloneTargetIds.Remove(id); + this._clonePreviewLines.Clear(); + this.ClearMessage(); + } + + private void SelectShopItem(Guid id) + { + this._selectedShopItemId = id; + if (this.SelectedShopItem is { } item) + { + this._editLevel = item.Level; + this._editDurability = item.Durability; + this._editSocketCount = item.SocketCount; + this._editHasSkill = item.HasSkill; + this._optionToAddId = null; + this._optionLevel = 0; + } + } + + private void ToggleCatalog(Guid id, bool selected) + { + if (selected) + { + this._selectedCatalogIds.Add(id); + } + else + { + this._selectedCatalogIds.Remove(id); + } + } + + private void SelectAllVisibleCatalog(bool selected) + { + if (!selected) + { + this._selectedCatalogIds.Clear(); + return; + } + + foreach (var definition in this.FilteredCatalog) + { + this._selectedCatalogIds.Add(definition.GetId()); + } + } + + private void ToggleCloneTarget(Guid id, bool selected) + { + if (selected) + { + this._cloneTargetIds.Add(id); + } + else + { + this._cloneTargetIds.Remove(id); + } + } + + private void AddSelectedCatalogItems() + { + var merchant = this.SelectedMerchant; + if (merchant?.MerchantStore is null || this._context is null) + { + return; + } + + var definitions = this._items + .Where(i => this._selectedCatalogIds.Contains(i.GetId())) + .ToList(); + + if (definitions.Count == 0) + { + return; + } + + var added = 0; + foreach (var definition in definitions) + { + var freeSlot = FindFirstFreeSlot(merchant.MerchantStore.Items, definition); + if (!freeSlot.HasValue) + { + this.SetMessage( + $"La tienda se quedó sin espacio después de agregar {added} item(s). " + + $"{definition.Name} y los siguientes no fueron agregados.", + "alert-warning"); + break; + } + + var item = this.CreateMerchantItem(definition, freeSlot.Value); + merchant.MerchantStore.Items.Add(item); + added++; + } + + if (added > 0) + { + this.SetMessage( + $"{added} item(s) agregados a {merchant.Designation}. Revisa la tienda y guarda cuando estés conforme.", + "alert-success"); + } + + this._selectedCatalogIds.Clear(); + } + + private Item CreateMerchantItem(ItemDefinition definition, byte slot) + { + if (this._context is null) + { + throw new InvalidOperationException("Persistence context not initialized."); + } + + var item = this._context.CreateNew(); + item.Definition = definition; + item.ItemSlot = slot; + item.Level = (byte)Math.Clamp(this._newItemLevel, 0, definition.MaximumItemLevel); + item.Durability = definition.Durability; + item.HasSkill = this._newItemHasSkill && definition.Skill is not null; + item.SocketCount = Math.Clamp(this._newItemSocketCount, 0, definition.MaximumSockets); + return item; + } + + private async Task RemoveShopItemAsync(Item item) + { + var merchant = this.SelectedMerchant; + if (merchant?.MerchantStore is null || this._context is null) + { + return; + } + + merchant.MerchantStore.Items.Remove(item); + await this._context.DeleteAsync(item).ConfigureAwait(true); + + if (this._selectedShopItemId == item.GetId()) + { + this._selectedShopItemId = null; + } + + this.SetMessage($"{item.Definition?.Name} quitado de {merchant.Designation}. Cambio aún sin guardar.", "alert-warning"); + } + + private async Task ClearCurrentMerchantAsync() + { + var merchant = this.SelectedMerchant; + if (merchant?.MerchantStore is null || this._context is null) + { + return; + } + + var count = merchant.MerchantStore.Items.Count; + foreach (var item in merchant.MerchantStore.Items.ToList()) + { + merchant.MerchantStore.Items.Remove(item); + await this._context.DeleteAsync(item).ConfigureAwait(true); + } + + this._selectedShopItemId = null; + this.SetMessage($"{merchant.Designation}: {count} item(s) preparados para eliminación. Pulsa Guardar para confirmar.", "alert-warning"); + } + + private void ApplyQuickItemEdit() + { + var item = this.SelectedShopItem; + if (item?.Definition is null) + { + return; + } + + item.Level = (byte)Math.Clamp(this._editLevel, 0, item.Definition.MaximumItemLevel); + item.Durability = Math.Max(0, this._editDurability); + item.SocketCount = Math.Clamp(this._editSocketCount, 0, item.Definition.MaximumSockets); + item.HasSkill = this._editHasSkill && item.Definition.Skill is not null; + + this._editLevel = item.Level; + this._editDurability = item.Durability; + this._editSocketCount = item.SocketCount; + this._editHasSkill = item.HasSkill; + + this.SetMessage( + $"{item.Definition.Name} actualizado. Precio calculado actual: {this.FormatZen(this.GetBuyingPrice(item))}.", + "alert-success"); + } + + private async Task ToggleLuckAsync() + { + var item = this.SelectedShopItem; + if (item?.Definition is null || this._context is null) + { + return; + } + + var existing = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.Luck); + if (existing is not null) + { + item.ItemOptions.Remove(existing); + await this._context.DeleteAsync(existing).ConfigureAwait(true); + this.SetMessage($"Luck quitado de {item.Definition.Name}.", "alert-warning"); + return; + } + + var luck = item.Definition.PossibleItemOptions + .SelectMany(o => o.PossibleOptions) + .FirstOrDefault(o => o.OptionType == ItemOptionTypes.Luck); + + if (luck is null) + { + this.SetMessage($"{item.Definition.Name} no tiene una opción Luck válida en su definición.", "alert-warning"); + return; + } + + var link = this._context.CreateNew(); + link.ItemOption = luck; + link.Level = 0; + link.Index = this.GetNextOptionIndex(item); + item.ItemOptions.Add(link); + this.SetMessage($"Luck agregado a {item.Definition.Name}.", "alert-success"); + } + + private async Task RemoveOptionAsync(ItemOptionLink option) + { + var item = this.SelectedShopItem; + if (item is null || this._context is null) + { + return; + } + + item.ItemOptions.Remove(option); + await this._context.DeleteAsync(option).ConfigureAwait(true); + this.SetMessage("Opción quitada. Cambio aún sin guardar.", "alert-warning"); + } + + private void AddSelectedOption() + { + var item = this.SelectedShopItem; + if (item?.Definition is null || this._context is null || !this._optionToAddId.HasValue) + { + return; + } + + var option = item.Definition.PossibleItemOptions + .SelectMany(o => o.PossibleOptions) + .FirstOrDefault(o => o.GetId() == this._optionToAddId.Value); + + if (option is null) + { + this.SetMessage("La opción seleccionada ya no está disponible para este item.", "alert-warning"); + return; + } + + if (item.ItemOptions.Any(o => o.ItemOption?.GetId() == option.GetId())) + { + this.SetMessage("Ese tipo de opción ya está aplicado al item.", "alert-warning"); + return; + } + + var link = this._context.CreateNew(); + link.ItemOption = option; + link.Level = Math.Max(0, this._optionLevel); + link.Index = this.GetNextOptionIndex(item); + item.ItemOptions.Add(link); + + this._optionToAddId = null; + this._optionLevel = 0; + this.SetMessage($"Opción agregada a {item.Definition.Name}.", "alert-success"); + } + + private int GetNextOptionIndex(Item item) + => item.ItemOptions.Count == 0 ? 0 : item.ItemOptions.Max(o => o.Index) + 1; + + private void AutoArrangeCurrentMerchant() + { + var merchant = this.SelectedMerchant; + if (merchant?.MerchantStore is null) + { + return; + } + + var items = merchant.MerchantStore.Items + .OrderBy(i => i.Definition?.Group) + .ThenBy(i => i.Definition?.Number) + .ThenBy(i => i.Level) + .ToList(); + + if (!TryPack(items, out var placements)) + { + this.SetMessage( + $"No es posible ordenar {merchant.Designation}: los items no caben en la grilla {MerchantGridWidth}×{MerchantGridHeight}.", + "alert-danger"); + return; + } + + foreach (var pair in placements) + { + pair.Key.ItemSlot = pair.Value; + } + + this.SetMessage($"{merchant.Designation}: slots reordenados automáticamente.", "alert-success"); + } + + private void PreviewClone() + { + var source = this.SelectedMerchant; + if (source?.MerchantStore is null) + { + this._clonePreviewLines = []; + return; + } + + var sourceSnapshots = source.MerchantStore.Items.Select(CreateSnapshot).ToList(); + var lines = new List + { + $"Origen: {source.Designation} · {sourceSnapshots.Count} items · modo {this._cloneMode}.", + }; + + foreach (var target in this.GetCloneTargets()) + { + if (this._cloneMode == "replace") + { + lines.Add($"{target.Designation}: {target.MerchantStore!.Items.Count} → {sourceSnapshots.Count} items."); + continue; + } + + var append = this.GetSnapshotsToAppend(target, sourceSnapshots).ToList(); + var canFit = CanAppend(target.MerchantStore!.Items, append); + lines.Add( + $"{target.Designation}: {target.MerchantStore.Items.Count} + {append.Count} " + + $"→ {(canFit ? target.MerchantStore.Items.Count + append.Count : "SIN ESPACIO")}."); + } + + this._clonePreviewLines = lines; + } + + private async Task StageCloneAsync() + { + var source = this.SelectedMerchant; + if (source?.MerchantStore is null || this._context is null) + { + return; + } + + var targets = this.GetCloneTargets().ToList(); + if (targets.Count == 0) + { + return; + } + + var sourceSnapshots = source.MerchantStore.Items.Select(CreateSnapshot).ToList(); + + if (this._cloneMode == "append") + { + foreach (var target in targets) + { + var append = this.GetSnapshotsToAppend(target, sourceSnapshots).ToList(); + if (!CanAppend(target.MerchantStore!.Items, append)) + { + this.SetMessage( + $"No se preparó la copia: {target.Designation} no tiene espacio suficiente. " + + "Usa Reemplazar, reduce la plantilla o quita items.", + "alert-danger"); + return; + } + } + } + + foreach (var target in targets) + { + if (this._cloneMode == "replace") + { + foreach (var oldItem in target.MerchantStore!.Items.ToList()) + { + target.MerchantStore.Items.Remove(oldItem); + await this._context.DeleteAsync(oldItem).ConfigureAwait(true); + } + + foreach (var snapshot in sourceSnapshots) + { + target.MerchantStore.Items.Add(this.CreateFromSnapshot(snapshot, snapshot.ItemSlot)); + } + + continue; + } + + foreach (var snapshot in this.GetSnapshotsToAppend(target, sourceSnapshots)) + { + var slot = FindFirstFreeSlot(target.MerchantStore!.Items, snapshot.Definition); + if (!slot.HasValue) + { + throw new InvalidOperationException($"Unexpected shop capacity failure on {target.Designation}."); + } + + target.MerchantStore.Items.Add(this.CreateFromSnapshot(snapshot, slot.Value)); + } + } + + this.SetMessage( + $"Copia preparada para {targets.Count} merchant(s). Revisa los destinos y pulsa Guardar cambios para persistir.", + "alert-success"); + this.PreviewClone(); + } + + private IEnumerable GetCloneTargets() + => this._merchants + .Where(m => m.GetId() != this._selectedMerchantId && this._cloneTargetIds.Contains(m.GetId())); + + private IEnumerable GetSnapshotsToAppend( + MonsterDefinition target, + IReadOnlyList sourceSnapshots) + { + if (!this._skipCloneDuplicates) + { + return sourceSnapshots; + } + + return sourceSnapshots.Where(snapshot => + !target.MerchantStore!.Items.Any(item => + item.Definition?.GetId() == snapshot.Definition.GetId() + && item.Level == snapshot.Level)); + } + + private async Task SaveAsync() + { + if (this._context is null || !this.HasPendingChanges) + { + return; + } + + this._isSaving = true; + try + { + var saved = await this._context.SaveChangesAsync().ConfigureAwait(true); + if (!saved) + { + this.ToastService.ShowError("OpenMU no informó cambios guardados."); + return; + } + + this.ToastService.ShowSuccess("Merchant configuration guardada."); + var selectedMerchantId = this._selectedMerchantId; + await this.DataSource.ForceDiscardChangesAsync().ConfigureAwait(true); + this._selectedMerchantId = selectedMerchantId; + await this.LoadAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Failed to save Merchant Cockpit changes."); + this.SetMessage($"Error al guardar merchants: {ex.Message}", "alert-danger"); + } + finally + { + this._isSaving = false; + } + } + + private async Task DiscardAsync() + { + var selectedMerchantId = this._selectedMerchantId; + await this.DataSource.DiscardChangesAsync().ConfigureAwait(true); + this._selectedMerchantId = selectedMerchantId; + await this.LoadAsync().ConfigureAwait(true); + this.ToastService.ShowSuccess("Cambios de merchants descartados."); + } + + private string GetMerchantMapsText(MonsterDefinition merchant) + { + var names = this._maps + .Where(map => map.MonsterSpawns.Any(spawn => spawn.MonsterDefinition?.GetId() == merchant.GetId())) + .Select(map => map.Name.ToString()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(3) + .ToList(); + + return names.Count == 0 ? "sin mapa detectado" : string.Join(", ", names); + } + + private long GetBuyingPrice(Item item) + { + try + { + return this._priceCalculator.CalculateFinalBuyingPrice(item); + } + catch + { + return 0; + } + } + + private string FormatZen(long amount) + => $"{amount:N0} Zen"; + + private static byte? FindFirstFreeSlot(IEnumerable existingItems, ItemDefinition definition) + { + var occupied = BuildOccupancy(existingItems); + return FindFirstFreeSlot(occupied, definition); + } + + private static bool[] BuildOccupancy(IEnumerable items) + { + var occupied = new bool[MerchantGridSize]; + foreach (var item in items) + { + if (item.Definition is null) + { + continue; + } + + MarkOccupied(occupied, item.ItemSlot, item.Definition, true); + } + + return occupied; + } + + private static byte? FindFirstFreeSlot(bool[] occupied, ItemDefinition definition) + { + var width = Math.Max(1, (int)definition.Width); + var height = Math.Max(1, (int)definition.Height); + + if (width > MerchantGridWidth || height > MerchantGridHeight) + { + return null; + } + + for (var row = 0; row <= MerchantGridHeight - height; row++) + { + for (var col = 0; col <= MerchantGridWidth - width; col++) + { + var fits = true; + for (var y = 0; y < height && fits; y++) + { + for (var x = 0; x < width; x++) + { + var slot = ((row + y) * MerchantGridWidth) + col + x; + if (occupied[slot]) + { + fits = false; + break; + } + } + } + + if (!fits) + { + continue; + } + + var firstSlot = (row * MerchantGridWidth) + col; + MarkOccupied(occupied, (byte)firstSlot, definition, true); + return (byte)firstSlot; + } + } + + return null; + } + + private static void MarkOccupied(bool[] occupied, byte itemSlot, ItemDefinition definition, bool value) + { + var startRow = itemSlot / MerchantGridWidth; + var startCol = itemSlot % MerchantGridWidth; + var width = Math.Max(1, (int)definition.Width); + var height = Math.Max(1, (int)definition.Height); + + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var row = startRow + y; + var col = startCol + x; + if (row >= MerchantGridHeight || col >= MerchantGridWidth) + { + continue; + } + + occupied[(row * MerchantGridWidth) + col] = value; + } + } + } + + private static bool TryPack(IReadOnlyList items, out Dictionary placements) + { + placements = []; + var occupied = new bool[MerchantGridSize]; + + foreach (var item in items) + { + if (item.Definition is null) + { + return false; + } + + var slot = FindFirstFreeSlot(occupied, item.Definition); + if (!slot.HasValue) + { + return false; + } + + placements[item] = slot.Value; + } + + return true; + } + + private static bool CanAppend(IEnumerable currentItems, IReadOnlyList snapshots) + { + var occupied = BuildOccupancy(currentItems); + foreach (var snapshot in snapshots) + { + if (!FindFirstFreeSlot(occupied, snapshot.Definition).HasValue) + { + return false; + } + } + + return true; + } + + private static ShopItemSnapshot CreateSnapshot(Item item) + => new( + item.ItemSlot, + item.Definition ?? throw new InvalidOperationException("Shop item without definition."), + item.Durability, + item.Level, + item.HasSkill, + item.SocketCount, + item.StorePrice, + item.PetExperience, + item.ItemOptions + .Where(o => o.ItemOption is not null) + .Select(o => new ShopOptionSnapshot(o.ItemOption!, o.Level, o.Index)) + .ToList(), + item.ItemSetGroups.ToList()); + + private Item CreateFromSnapshot(ShopItemSnapshot snapshot, byte slot) + { + if (this._context is null) + { + throw new InvalidOperationException("Persistence context not initialized."); + } + + var item = this._context.CreateNew(); + item.ItemSlot = slot; + item.Definition = snapshot.Definition; + item.Durability = snapshot.Durability; + item.Level = snapshot.Level; + item.HasSkill = snapshot.HasSkill; + item.SocketCount = snapshot.SocketCount; + item.StorePrice = snapshot.StorePrice; + item.PetExperience = snapshot.PetExperience; + + foreach (var optionSnapshot in snapshot.Options) + { + var link = this._context.CreateNew(); + link.ItemOption = optionSnapshot.ItemOption; + link.Level = optionSnapshot.Level; + link.Index = optionSnapshot.Index; + item.ItemOptions.Add(link); + } + + foreach (var setGroup in snapshot.ItemSetGroups) + { + item.ItemSetGroups.Add(setGroup); + } + + return item; + } + + private void SetMessage(string text, string css) + { + this._message = text; + this._messageCss = css; + } + + private void ClearMessage() + { + this._message = string.Empty; + this._messageCss = "alert-info"; + } + + private sealed record ShopOptionSnapshot(IncreasableItemOption ItemOption, int Level, int Index); + + private sealed record ShopItemSnapshot( + byte ItemSlot, + ItemDefinition Definition, + double Durability, + byte Level, + bool HasSkill, + int SocketCount, + int? StorePrice, + int PetExperience, + IReadOnlyList Options, + IReadOnlyList ItemSetGroups); +} From 5a2257444f4ced6ea606e7ad0ebe1868f3de061b Mon Sep 17 00:00:00 2001 From: Carlos Valdes Date: Tue, 11 Aug 2026 18:08:40 -0400 Subject: [PATCH 2/2] feat(admin): add direct drop content editor --- .../AdminPanel/Pages/BatchOperations.razor | 95 ++++++++++++- .../AdminPanel/Pages/BatchOperations.razor.cs | 118 ++++++++++++++++ .../AdminPanel/Pages/DropGroupItemSelector.cs | 129 ++++++++++++++++++ .../DropGroupItemSelectorTests.cs | 47 +++++++ 4 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 src/Web/AdminPanel/Pages/DropGroupItemSelector.cs create mode 100644 tests/MUnique.OpenMU.Web.Tests/DropGroupItemSelectorTests.cs diff --git a/src/Web/AdminPanel/Pages/BatchOperations.razor b/src/Web/AdminPanel/Pages/BatchOperations.razor index fb0e590fe..ae222f97b 100644 --- a/src/Web/AdminPanel/Pages/BatchOperations.razor +++ b/src/Web/AdminPanel/Pages/BatchOperations.razor @@ -8,9 +8,8 @@

Operaciones por lote

- Cockpit de MU Nueva Era para modificar configuración nativa de OpenMU por reglas. - Todas las operaciones muestran una vista previa antes de guardar y la última operación - aplicada puede revertirse mientras permanezcas en esta pantalla. + Cockpit de MU Nueva Era para modificar configuración nativa de OpenMU por reglas. Las operaciones por lote muestran una vista previa antes de guardar. + El editor directo de cajas aplica al confirmar y permite deshacer la última operación mientras permanezcas en esta pantalla.

@if (this._isLoading) @@ -335,9 +334,97 @@ +
+
+ 5. Editor directo de cajas y grupos de drop + sin vista previa +
+
+

+ Agrega, quita o reemplaza el contenido de una caja usando filtros por categoria, set, 380, + Excellent, Ancient, sockets, alas o armas. La edicion se guarda al pulsar el boton y la ultima + operacion se puede deshacer desde el panel superior. +

+
+ Usa Agregar para ampliar una caja de forma segura. Reemplazar + elimina primero todos sus items actuales. +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ @{ var matchingContentItems = this.GetMatchingContentItems(); } +
+
+ @matchingContentItems.Count coincidencias + Se aplicara directamente sobre la caja elegida. +
+ @if (matchingContentItems.Count > 0) + { +
@string.Join(" · ", matchingContentItems.Take(18).Select(item => $"{item.Name} G{item.Group}/#{item.Number}"))
+ } +
+
+ + +
+
+ +
+
+
+
- 5. Plantillas de tiendas + 6. Plantillas de tiendas Abrir Merchant Cockpit
diff --git a/src/Web/AdminPanel/Pages/BatchOperations.razor.cs b/src/Web/AdminPanel/Pages/BatchOperations.razor.cs index cb0ae9382..beccc3f12 100644 --- a/src/Web/AdminPanel/Pages/BatchOperations.razor.cs +++ b/src/Web/AdminPanel/Pages/BatchOperations.razor.cs @@ -35,6 +35,8 @@ public partial class BatchOperations : ComponentBase, IAsyncDisposable private List _monsters = []; private List _items = []; private List _dropGroups = []; + private List _dropContentGroups = []; + private List _itemSets = []; private List _dropChanceEdits = []; private List _merchants = []; @@ -61,6 +63,14 @@ public partial class BatchOperations : ComponentBase, IAsyncDisposable private bool _setDropsFromMonsters; private bool _newDropsFromMonsters = true; + private Guid? _contentDropGroupId; + private string _contentAction = "add"; + private string _contentFilter = string.Empty; + private int? _contentGroup; + private Guid? _contentSetId; + private DropGroupItemCategory _contentCategory = DropGroupItemCategory.Any; + private bool _contentConfirmed; + private Guid? _sourceMerchantId; private bool _restartAllAfterEconomyApply = true; @@ -138,6 +148,14 @@ protected override async Task OnInitializedAsync() .ThenBy(g => g.Description.ToString()) .ToList(); + this._dropContentGroups = this.DataSource.GetAll() + .OrderBy(g => g.Description.ToString()) + .ToList(); + + this._itemSets = this.DataSource.GetAll() + .OrderBy(set => set.Name.ToString()) + .ToList(); + this._merchants = this.DataSource.GetAll() .Where(m => m is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore: not null }) .OrderBy(m => m.Designation.ToString()) @@ -641,6 +659,106 @@ await this.RunApplyAsync(async () => }, $"{targets.Count} items procesados.").ConfigureAwait(true); } + private ItemDropItemGroup? GetSelectedContentDropGroup() + => this._contentDropGroupId.HasValue + ? this._dropContentGroups.FirstOrDefault(group => group.GetId() == this._contentDropGroupId.Value) + : null; + + private IReadOnlyList GetMatchingContentItems() + => DropGroupItemSelector.Filter( + this._items, + this._contentFilter, + this._contentGroup, + this._contentSetId, + this._contentCategory); + + private async Task ApplyDropContentBatchAsync() + { + if (this._gameContext is null) + { + return; + } + + var group = this.GetSelectedContentDropGroup(); + if (group is null) + { + this.ToastService.ShowError("Selecciona una caja o grupo de drop de items."); + return; + } + + if (!this._contentConfirmed) + { + this.ToastService.ShowError("Marca la confirmación para aplicar la edición directa."); + return; + } + + var targets = this.GetMatchingContentItems(); + if (targets.Count == 0) + { + this.ToastService.ShowError("Los filtros no encontraron items."); + return; + } + + if (this._contentAction is not ("add" or "remove" or "replace")) + { + this.ToastService.ShowError("La acción seleccionada no es válida."); + return; + } + + var original = group.PossibleItems.ToList(); + await this.RunApplyAsync(async () => + { + if (this._contentAction == "replace") + { + foreach (var item in group.PossibleItems.ToList()) + { + group.PossibleItems.Remove(item); + } + } + + var targetIds = targets.Select(item => item.GetId()).ToHashSet(); + if (this._contentAction is "add" or "replace") + { + var existingIds = group.PossibleItems.Select(item => item.GetId()).ToHashSet(); + foreach (var item in targets.Where(item => !existingIds.Contains(item.GetId()))) + { + group.PossibleItems.Add(item); + } + } + else + { + foreach (var item in group.PossibleItems.Where(item => targetIds.Contains(item.GetId())).ToList()) + { + group.PossibleItems.Remove(item); + } + } + + await this.SaveGameContextAsync().ConfigureAwait(true); + this._contentConfirmed = false; + var action = this._contentAction switch + { + "add" => "agregados", + "remove" => "quitados", + _ => "reemplazados", + }; + var message = $"{targets.Count} items {action} en {group.Description}."; + this.SetPreview("Edición directa aplicada", [], message); + this._undoAction = async () => + { + foreach (var item in group.PossibleItems.ToList()) + { + group.PossibleItems.Remove(item); + } + + foreach (var item in original) + { + group.PossibleItems.Add(item); + } + + await this.SaveGameContextAsync().ConfigureAwait(true); + }; + }, $"{targets.Count} items modificados en {group.Description}. Puedes deshacer la última operación.").ConfigureAwait(true); + } private void PreviewMerchantClone() { var source = this.GetSourceMerchant(); diff --git a/src/Web/AdminPanel/Pages/DropGroupItemSelector.cs b/src/Web/AdminPanel/Pages/DropGroupItemSelector.cs new file mode 100644 index 000000000..036c4156e --- /dev/null +++ b/src/Web/AdminPanel/Pages/DropGroupItemSelector.cs @@ -0,0 +1,129 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Pages; + +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.Persistence; + +/// +/// Categories understood by the drop-group editor. +/// +public enum DropGroupItemCategory +{ + /// Every item matching the text/group filters. + Any, + + /// Items belonging to the selected set group. + Set, + + /// Items which can receive the level 380 Guardian option. + Guardian380, + + /// Items which can receive Excellent options. + Excellent, + + /// Items which belong to an ancient set. + Ancient, + + /// Items with socket support. + Socket, + + /// Items equipped in either hand. + Weapons, + + /// Items equipped in an armor slot. + Armor, + + /// Wing definitions. + Wings, +} + +/// +/// Pure item selection rules shared by the Batch Operations page and tests. +/// +public static class DropGroupItemSelector +{ + /// + /// Filters item definitions using the same rules displayed by the drop-group editor. + /// + public static IReadOnlyList Filter( + IEnumerable items, + string? nameFilter, + int? group, + Guid? setId, + DropGroupItemCategory category) + { + ArgumentNullException.ThrowIfNull(items); + + IEnumerable result = items; + if (!string.IsNullOrWhiteSpace(nameFilter)) + { + result = result.Where(item => (item.Name.ToString() ?? string.Empty).Contains(nameFilter, StringComparison.OrdinalIgnoreCase)); + } + + if (group.HasValue) + { + result = result.Where(item => item.Group == group.Value); + } + + if (category == DropGroupItemCategory.Set) + { + result = setId.HasValue + ? result.Where(item => item.PossibleItemSetGroups.Any(set => set.GetId() == setId.Value)) + : []; + } + + result = category switch + { + DropGroupItemCategory.Guardian380 => result.Where(HasOptionType(ItemOptionTypes.GuardianOption)), + DropGroupItemCategory.Excellent => result.Where(HasOptionType(ItemOptionTypes.Excellent)), + DropGroupItemCategory.Ancient => result.Where(IsAncientItem), + DropGroupItemCategory.Socket => result.Where(item => item.MaximumSockets > 0), + DropGroupItemCategory.Weapons => result.Where(IsWeapon), + DropGroupItemCategory.Armor => result.Where(IsArmor), + DropGroupItemCategory.Wings => result.Where(item => item.IsWing()), + _ => result, + }; + + return result + .OrderBy(item => item.Group) + .ThenBy(item => item.Number) + .ToList(); + } + + /// + /// Determines whether a definition can receive a specific option type. + /// + public static bool HasOptionType(ItemDefinition item, ItemOptionType optionType) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(optionType); + return item.PossibleItemOptions + .SelectMany(option => option.PossibleOptions) + .Any(option => option.OptionType == optionType); + } + + /// + /// Determines whether a definition is linked to an ancient set entry. + /// + public static bool IsAncientItem(ItemDefinition item) + { + ArgumentNullException.ThrowIfNull(item); + var id = item.GetId(); + return item.PossibleItemSetGroups + .SelectMany(set => set.Items) + .Any(entry => entry.AncientSetDiscriminator > 0 && entry.ItemDefinition?.GetId() == id); + } + + private static bool IsWeapon(ItemDefinition item) + => item.ItemSlot?.ItemSlots.Any(slot => slot is 0 or 1) is true; + + private static bool IsArmor(ItemDefinition item) + => item.ItemSlot?.ItemSlots.Any(slot => slot is >= 2 and <= 6) is true; + + private static Func HasOptionType(ItemOptionType optionType) + => item => HasOptionType(item, optionType); +} diff --git a/tests/MUnique.OpenMU.Web.Tests/DropGroupItemSelectorTests.cs b/tests/MUnique.OpenMU.Web.Tests/DropGroupItemSelectorTests.cs new file mode 100644 index 000000000..c96746ddb --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/DropGroupItemSelectorTests.cs @@ -0,0 +1,47 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests; + +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.Interfaces; +using Moq; +using MUnique.OpenMU.Web.AdminPanel.Pages; + +/// +/// Tests the category selectors used by the direct drop-group editor. +/// +[TestFixture] +public class DropGroupItemSelectorTests +{ + /// + /// A 380-capable item is selected by its Guardian option and the normal group filter. + /// + [Test] + public void FindsGuardian380ItemsWithinAGroup() + { + var guardianOption = new IncreasableItemOption { OptionType = ItemOptionTypes.GuardianOption }; + var guardianDefinition = new Mock(); + guardianDefinition.SetupGet(definition => definition.PossibleOptions).Returns([guardianOption]); + + var guardianItem = new Mock { CallBase = true }; + guardianItem.Object.Name = new LocalizedString("Dragon Armor"); + guardianItem.Object.Group = 14; + guardianItem.SetupGet(item => item.PossibleItemOptions).Returns([guardianDefinition.Object]); + + var otherItem = new Mock { CallBase = true }; + otherItem.Object.Name = new LocalizedString("Dragon Helm"); + otherItem.Object.Group = 14; + otherItem.SetupGet(item => item.PossibleItemOptions).Returns([]); + + var result = DropGroupItemSelector.Filter( + [guardianItem.Object, otherItem.Object], + "Dragon", + 14, + null, + DropGroupItemCategory.Guardian380); + + Assert.That(result, Is.EqualTo(new[] { guardianItem.Object })); + } +} \ No newline at end of file