From ca39a445c39748ef5682129d11e74cb11ad34325 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:29:45 -0300 Subject: [PATCH 01/11] refactor invasion events to allow dynamic spawn table --- src/GameLogic/GameMapTerrain.cs | 59 ++- .../InvasionEvents/BaseInvasionPlugIn.cs | 301 ++++++++-------- .../InvasionEvents/GoldenInvasionPlugIn.cs | 34 +- .../InvasionEvents/InvasionGameServerState.cs | 17 +- .../InvasionEvents/InvasionMobSpawn.cs | 25 -- .../InvasionSpawnConfiguration.cs | 113 ++++++ .../PeriodicInvasionConfiguration.cs | 54 ++- .../InvasionEvents/RedDragonInvasionPlugIn.cs | 13 +- .../PeriodicTaskConfiguration.cs | 10 +- .../InvasionSpawnTableFieldBuilder.cs | 25 ++ src/Web/Shared/Components/Form/AutoFields.cs | 3 +- .../Components/Form/InvasionSpawnTable.razor | 72 ++++ .../Form/InvasionSpawnTable.razor.cs | 303 ++++++++++++++++ .../Components/Form/MultiLookupField.razor.cs | 2 +- .../Shared/Properties/Resources.Designer.cs | 340 +++++++++++++----- src/Web/Shared/Properties/Resources.resx | 21 ++ src/Web/Shared/Services/PlugInController.cs | 18 +- src/Web/Shared/Styles/Site.scss | 7 +- src/Web/Shared/wwwroot/css/shared.css | 34 -- 19 files changed, 1083 insertions(+), 368 deletions(-) delete mode 100644 src/GameLogic/PlugIns/InvasionEvents/InvasionMobSpawn.cs create mode 100644 src/GameLogic/PlugIns/InvasionEvents/InvasionSpawnConfiguration.cs create mode 100644 src/Web/Shared/ComponentBuilders/InvasionSpawnTableFieldBuilder.cs create mode 100644 src/Web/Shared/Components/Form/InvasionSpawnTable.razor create mode 100644 src/Web/Shared/Components/Form/InvasionSpawnTable.razor.cs diff --git a/src/GameLogic/GameMapTerrain.cs b/src/GameLogic/GameMapTerrain.cs index 44d559f38..275b7be57 100644 --- a/src/GameLogic/GameMapTerrain.cs +++ b/src/GameLogic/GameMapTerrain.cs @@ -12,11 +12,22 @@ namespace MUnique.OpenMU.GameLogic; /// public class GameMapTerrain { + /// + /// The size of the map in each dimension (byte range: 0–255). + /// + private const int MapSize = 256; + /// /// The default terrain where all coordinates are walkable and not a safezone. /// private static readonly byte[] DefaultTerrain = Enumerable.Repeat(0, short.MaxValue).ToArray(); + /// + /// Pre-computed array of walkable, non-safezone points. + /// Built once during construction for O(1) random spawn lookups. + /// + private readonly Point[] _spawnPoints; + /// /// Initializes a new instance of the class. /// @@ -40,22 +51,41 @@ public GameMapTerrain(byte[]? terrainData) { this.ReadTerrainData(DefaultTerrain); } + + this._spawnPoints = this.BuildSpawnPoints(); } /// /// Gets a grid of all safezone coordinates. /// - public bool[,] SafezoneMap { get; } = new bool[256, 256]; + public bool[,] SafezoneMap { get; } = new bool[MapSize, MapSize]; /// /// Gets a grid of all walkable coordinates. /// - public bool[,] WalkMap { get; } = new bool[256, 256]; + public bool[,] WalkMap { get; } = new bool[MapSize, MapSize]; /// /// Gets a grid of the walkable coordinates of monsters. /// - public byte[,] AIgrid { get; } = new byte[256, 256]; + public byte[,] AIgrid { get; } = new byte[MapSize, MapSize]; + + /// + /// Gets a random walkable, non-safezone point anywhere on the map. + /// Samples from a pre-computed array in O(1) per call. + /// + /// A valid spawn point, or null if none exists. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Point? GetRandomWalkableCoordinate() + { + var points = this._spawnPoints; + if (points.Length == 0) + { + return null; + } + + return points[Random.Shared.Next(points.Length)]; + } /// /// Gets a random drop coordinate at the specified point in the specified radius. @@ -110,4 +140,27 @@ private void ReadTerrainData(ReadOnlySpan data) this.UpdateAiGridValue(x, y); } } + + /// + /// Builds the array of valid spawn points. + /// A valid spawn point is walkable and not in a safezone. + /// + /// Array of valid spawn points. + private Point[] BuildSpawnPoints() + { + var result = new List(MapSize * MapSize); + + for (var x = 0; x < MapSize; x++) + { + for (var y = 0; y < MapSize; y++) + { + if (this.WalkMap[x, y] && !this.SafezoneMap[x, y]) + { + result.Add(new Point((byte)x, (byte)y)); + } + } + } + + return result.ToArray(); + } } \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs b/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs index 99c2e03e9..de8341b57 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Pathfinding; using MUnique.OpenMU.PlugIns; /// @@ -17,55 +18,29 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; public abstract class BaseInvasionPlugIn : PeriodicTaskBasePlugIn, IPeriodicTaskPlugIn, IObjectAddedToMapPlugIn, ISupportCustomConfiguration where TConfiguration : PeriodicInvasionConfiguration { - /// - /// Lorencia. - /// - protected const ushort LorenciaId = 0; - - /// - /// Devias. - /// - protected const ushort DeviasId = 2; - - /// - /// Noria. - /// - protected const ushort NoriaId = 3; - - /// - /// Gets mobs which spawn on event starting only on the selected map (MapId is null). - /// - private readonly InvasionMobSpawn[] _mobsOnSelectedMap; - - /// - /// Gets mobs which spawn on event starting on specific maps (MapId is not null). - /// - private readonly InvasionMobSpawn[] _mobs; - private readonly MapEventType? _mapEventType; /// /// Initializes a new instance of the class. /// - /// Type of the map event. - /// The mobs which spawn on specific maps (MapId is not null). - /// The mobs which always spawn on event starting on the selected map (MapId is null). - protected BaseInvasionPlugIn(MapEventType? mapEventType, InvasionMobSpawn[]? mobs, InvasionMobSpawn[]? mobsOnSelectedMap) + /// Type of the map event. If null, no map event state updates are sent. + protected BaseInvasionPlugIn(MapEventType? mapEventType = null) { this._mapEventType = mapEventType; - this._mobs = mobs ?? []; - this._mobsOnSelectedMap = mobsOnSelectedMap ?? []; } /// /// Occurs when the event has finished. /// - public event EventHandler? Finished; + public event Func? Finished; /// - /// Gets possible maps for the event. + /// Gets the list of map IDs from which the event display map is randomly selected. + /// When set, is chosen from this list + /// and the map event state UI is only shown on that single map. + /// If null, is chosen from . /// - protected virtual ushort[] PossibleMaps { get; } = { LorenciaId, NoriaId, DeviasId }; + protected virtual IReadOnlyList? EventDisplayMapIds => null; /// public virtual async ValueTask ObjectAddedToMapAsync(GameMap map, ILocateable addedObject) @@ -84,71 +59,82 @@ public virtual async ValueTask ObjectAddedToMapAsync(GameMap map, ILocateable ad } /// - /// Create a new monster. + /// Spawns the given quantity of a monster on the map. + /// For each monster, picks a random walkable coordinate via a spiral search — + /// no allocation, no exception on invalid terrain. /// /// The game context. /// The game map. /// The monster definition. - /// The x1. - /// The x2. - /// The y1. - /// The y2. /// The quantity. - protected async ValueTask CreateMonstersAsync(IGameContext gameContext, GameMap gameMap, MonsterDefinition monsterDefinition, byte x1, byte x2, byte y1, byte y2, ushort quantity) + /// The optional fixed X coordinate. + /// The optional fixed Y coordinate. + protected async ValueTask CreateMonstersAsync(IGameContext gameContext, GameMap gameMap, MonsterDefinition monsterDefinition, ushort quantity, byte? x = null, byte? y = null) { - var area = new MonsterSpawnArea - { - GameMap = gameMap.Definition, - MonsterDefinition = monsterDefinition, - SpawnTrigger = SpawnTrigger.OnceAtEventStart, - Quantity = 1, - X1 = x1, - X2 = x2, - Y1 = y1, - Y2 = y2, - }; + var logger = gameContext.LoggerFactory.CreateLogger(this.GetType()); while (quantity-- > 0) { - var intelligence = new BasicMonsterIntelligence(); + Point? spawnPoint; + if (x.HasValue && y.HasValue) + { + spawnPoint = new Point(x.Value, y.Value); + } + else + { + spawnPoint = gameMap.Terrain.GetRandomWalkableCoordinate(); + } + + if (spawnPoint is null) + { + logger.LogDebug( + "Skipping one {monster} on {map}: no walkable cell found in rolled area.", + monsterDefinition.Designation, + gameMap.Definition.Name); + continue; + } + + var area = new MonsterSpawnArea + { + GameMap = gameMap.Definition, + MonsterDefinition = monsterDefinition, + SpawnTrigger = SpawnTrigger.OnceAtEventStart, + Quantity = 1, + X1 = spawnPoint.Value.X, + X2 = spawnPoint.Value.X, + Y1 = spawnPoint.Value.Y, + Y2 = spawnPoint.Value.Y, + }; + var intelligence = new BasicMonsterIntelligence(); var monster = new Monster(area, monsterDefinition, gameMap, gameContext.DropGenerator, intelligence, gameContext.PlugInManager, gameContext.PathFinderPool); monster.Initialize(); await gameMap.AddAsync(monster).ConfigureAwait(false); monster.OnSpawn(); - this.Finished += CleanUpOnFinish; - monster.Died += (_, _) => this.Finished -= CleanUpOnFinish; - -#pragma warning disable VSTHRD100 - async void CleanUpOnFinish(object? sender, EventArgs e) -#pragma warning restore VSTHRD100 + async Task CleanUpOnFinishAsync() { - try - { - this.Finished -= CleanUpOnFinish; - if (monster is not null && !monster.IsDisposed) - { - await monster.CurrentMap.RemoveAsync(monster).ConfigureAwait(false); - monster.Dispose(); - } - } - catch + this.Finished -= CleanUpOnFinishAsync; + if (monster is not null && !monster.IsDisposed) { - // must be catched in async void method + await monster.CurrentMap.RemoveAsync(monster).ConfigureAwait(false); + monster.Dispose(); } } + + this.Finished += CleanUpOnFinishAsync; + monster.Died += (_, _) => this.Finished -= CleanUpOnFinishAsync; } } /// - /// Spawn mobs on the map. + /// Spawn mobs on a specific map. /// /// The game context. /// The map id. - /// The mobs. - protected async ValueTask SpawnMobsAsync(IGameContext gameContext, ushort mapId, IEnumerable mobs) + /// The spawn configurations. + protected async ValueTask SpawnMobsAsync(IGameContext gameContext, ushort mapId, IEnumerable spawns) { var gameMap = await gameContext.GetMapAsync(mapId).ConfigureAwait(false); @@ -158,29 +144,58 @@ protected async ValueTask SpawnMobsAsync(IGameContext gameContext, ushort mapId, } var logger = gameContext.LoggerFactory.CreateLogger(this.GetType()); - foreach (var mob in mobs) + foreach (var spawn in spawns) { - if (gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == mob.MonsterId) is { } monsterDefinition) + if (gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.MonsterId) is { } monsterDefinition) { - // Use custom coordinates if provided, otherwise use default (10, 240, 10, 240) - var spawnX1 = mob.X1 ?? 10; - var spawnX2 = mob.X2 ?? 240; - var spawnY1 = mob.Y1 ?? 10; - var spawnY2 = mob.Y2 ?? 240; - - await this.CreateMonstersAsync(gameContext, gameMap, monsterDefinition, spawnX1, spawnX2, spawnY1, spawnY2, mob.Count).ConfigureAwait(false); + await this.CreateMonstersAsync(gameContext, gameMap, monsterDefinition, spawn.Count, spawn.X, spawn.Y).ConfigureAwait(false); } else { - logger.LogDebug("Skipping spawning of monster with number {mobId}, because monster definition wasn't found.", mob.MonsterId); + logger.LogDebug("Skipping spawning of monster with number {mobId}, because monster definition wasn't found.", spawn.MonsterId); } } } /// - protected async override ValueTask OnPrepareEventAsync(InvasionGameServerState state) + protected override async ValueTask OnPrepareEventAsync(InvasionGameServerState state) { - state.MapId = this.PossibleMaps[Rand.NextInt(0, this.PossibleMaps.Length)]; + var config = this.Configuration; + if (config?.Mobs is null || config.Mobs.Count == 0) + { + return; + } + + foreach (var mob in config.Mobs) + { + if (mob.MapIds.Count == 0) + { + continue; + } + + if (mob.IsSpawnOnAllMaps) + { + foreach (var mapId in mob.MapIds) + { + state.MapIds.Add(mapId); + } + } + else + { + var randomMapId = mob.MapIds[Rand.NextInt(0, mob.MapIds.Count)]; + state.MapIds.Add(randomMapId); + state.SelectedMaps[mob.MonsterId] = randomMapId; + } + } + + if (this.EventDisplayMapIds is { Count: > 0 } displayMaps) + { + state.MapId = displayMaps[Rand.NextInt(0, displayMaps.Count)]; + } + else if (state.MapIds.Count > 0) + { + state.MapId = state.MapIds.Min(); + } } /// @@ -190,55 +205,41 @@ protected override InvasionGameServerState CreateState(IGameContext gameContext) } /// - /// Returns true if the player stays on the map. + /// Send a golden message to all online players. /// /// The player. - /// True, if need to check current event map. - protected bool IsPlayerOnMap(Player player, bool checkForCurrentMap = false) - { - var state = this.GetStateByGameContext(player.GameContext); - - return player.CurrentMap is { } map - && !player.PlayerState.CurrentState.IsDisconnectedOrFinished() - && (!checkForCurrentMap || map.MapId == state.MapId); - } - - /// - /// Send a golden message to player's client. - /// - /// The player. - /// The map name. - protected async Task TrySendStartMessageAsync(Player player, LocalizedString mapName) + /// The server state. + protected async Task TrySendStartMessageAsync(Player player, InvasionGameServerState state) { var configuration = this.Configuration; - if (configuration is null) + if (configuration is null || state.MapIds.Count == 0) { return; } - var message = (configuration.Message.GetTranslation(player.Culture) ?? PlugInResources.BaseInvasionPlugIn_DefaultStartMessage).Replace("{mapName}", mapName.GetTranslation(player.Culture), StringComparison.InvariantCulture); + var mapName = state.Context.Configuration.Maps + .FirstOrDefault(m => m.Number == state.MapId) + ?.Name.GetTranslation(player.Culture) + ?? string.Empty; - if (this.IsPlayerOnMap(player)) + var message = (configuration.Message.GetTranslation(player.Culture) + ?? PlugInResources.BaseInvasionPlugIn_DefaultStartMessage).Replace("{mapName}", mapName, StringComparison.InvariantCulture); + + try { - try - { - await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false); - } - catch (Exception ex) - { - player.Logger.LogDebug(ex, "Unexpected error sending start message."); - } + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogDebug(ex, "Unexpected error sending start message."); } } - /// - /// Calls after the state changed to Prepared. - /// - /// The state. + /// protected override async ValueTask OnPreparedAsync(InvasionGameServerState state) { - await state.Context.ForEachPlayerAsync(p => this.TrySendStartMessageAsync(p, state.MapName)).ConfigureAwait(false); + await state.Context.ForEachPlayerAsync(p => this.TrySendStartMessageAsync(p, state)).ConfigureAwait(false); if (this._mapEventType is not null) { @@ -246,20 +247,13 @@ protected override async ValueTask OnPreparedAsync(InvasionGameServerState state } } - /// - /// Calls after the state changed to Started. - /// - /// State. + /// protected override async ValueTask OnStartedAsync(InvasionGameServerState state) { - await this.SpawnMobsOnSelectedMapAsync(state).ConfigureAwait(false); await this.SpawnMobsOnMapsAsync(state).ConfigureAwait(false); } - /// - /// Calls after the state changed to Finished. - /// - /// State. + /// protected override async ValueTask OnFinishedAsync(InvasionGameServerState state) { if (this._mapEventType is not null) @@ -267,39 +261,45 @@ protected override async ValueTask OnFinishedAsync(InvasionGameServerState state await state.Context.ForEachPlayerAsync(p => this.TrySendMapEventStateUpdateAsync(p, false)).ConfigureAwait(false); } - this.Finished?.Invoke(this, EventArgs.Empty); - } - - /// - /// Spawn mobs on the selected map. - /// - /// The state. - protected virtual async ValueTask SpawnMobsOnSelectedMapAsync(InvasionGameServerState state) - { - var gameContext = state.Context; - await this.SpawnMobsAsync(gameContext, state.MapId, this._mobsOnSelectedMap).ConfigureAwait(false); + if (this.Finished is not null) + { + await this.Finished.Invoke().ConfigureAwait(false); + } } /// - /// Spawn mobs on the map. + /// Spawn mobs based on pre-selected maps in the state. /// /// The state. protected virtual async ValueTask SpawnMobsOnMapsAsync(InvasionGameServerState state) { + var config = this.Configuration; + if (config?.Mobs is not { } spawns || spawns.Count == 0) + { + return; + } + var gameContext = state.Context; - foreach (var group in this._mobs.GroupBy(mob => mob.MapId!.Value)) + foreach (var spawn in spawns) { - var mapId = group.Key; - var mobs = group; - - await this.SpawnMobsAsync(gameContext, mapId, mobs).ConfigureAwait(false); + if (spawn.IsSpawnOnAllMaps) + { + foreach (var mapId in spawn.MapIds) + { + await this.SpawnMobsAsync(gameContext, mapId, [spawn]).ConfigureAwait(false); + } + } + else if (state.SelectedMaps.TryGetValue(spawn.MonsterId, out var selectedMapId)) + { + await this.SpawnMobsAsync(gameContext, selectedMapId, [spawn]).ConfigureAwait(false); + } } } private async Task TrySendMapEventStateUpdateAsync(Player player, bool enabled) { - if (this._mapEventType is null || !this.IsPlayerOnMap(player, true)) + if (this._mapEventType is null || !this.IsPlayerOnMap(player)) { return; } @@ -310,7 +310,16 @@ private async Task TrySendMapEventStateUpdateAsync(Player player, bool enabled) } catch (Exception ex) { - player.Logger.LogDebug(ex, $"Unexpected error sending map event state update, event type {this._mapEventType}."); + player.Logger.LogDebug(ex, "Unexpected error sending map event state update, event type: {MapEventType}", this._mapEventType); } } + + private bool IsPlayerOnMap(Player player) + { + var state = this.GetStateByGameContext(player.GameContext); + + return player.CurrentMap is { } map + && !player.PlayerState.CurrentState.IsDisconnectedOrFinished() + && map.MapId == state.MapId; + } } \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/GoldenInvasionPlugIn.cs b/src/GameLogic/PlugIns/InvasionEvents/GoldenInvasionPlugIn.cs index 84bd64e32..a1830330f 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/GoldenInvasionPlugIn.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/GoldenInvasionPlugIn.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -15,39 +15,23 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; [Guid("06D18A9E-2919-4C17-9DBC-6E4F7756495C")] public class GoldenInvasionPlugIn : BaseInvasionPlugIn, ISupportDefaultCustomConfiguration { - private const ushort AtlansId = 7; - private const ushort TarkanId = 8; + private const ushort LorenciaId = 0; + private const ushort DeviasId = 2; + private const ushort NoriaId = 3; - private const ushort GoldenBudgeDragonId = 43; - private const ushort GoldenGoblinId = 78; - private const ushort GoldenSoldierId = 54; - private const ushort GoldenTitanId = 53; - private const ushort GoldenDragonId = 79; - private const ushort GoldenVeparId = 81; - private const ushort GoldenLizardKingId = 80; - private const ushort GoldenWheelId = 83; - private const ushort GoldenTantallosId = 82; + private static readonly IReadOnlyList DisplayMaps = new ushort[] { LorenciaId, NoriaId, DeviasId }; /// /// Initializes a new instance of the class. /// public GoldenInvasionPlugIn() - : base( - MapEventType.GoldenDragonInvasion, - [ - new(GoldenBudgeDragonId, 20, MapId: LorenciaId), - new(GoldenGoblinId, 20, MapId: NoriaId), - new(GoldenSoldierId, 20, MapId: DeviasId), - new(GoldenTitanId, 10, MapId: DeviasId), - new(GoldenVeparId, 20, MapId: AtlansId), - new(GoldenLizardKingId, 10, MapId: AtlansId), - new(GoldenWheelId, 20, MapId: TarkanId), - new(GoldenTantallosId, 10, MapId: TarkanId), - ], - [new(GoldenDragonId, 10)]) + : base(MapEventType.GoldenDragonInvasion) { } + /// + protected override IReadOnlyList EventDisplayMapIds => DisplayMaps; + /// public object CreateDefaultConfig() => PeriodicInvasionConfiguration.DefaultGoldenInvasion; } \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs b/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs index 750774c72..415932e52 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -27,18 +27,13 @@ public InvasionGameServerState(IGameContext context) public ushort MapId { get; set; } /// - /// Gets the map. + /// Gets the map identifiers where monsters will spawn. /// - public GameMapDefinition Map => this.Context.Configuration.Maps.First(m => m.Number == this.MapId); + public HashSet MapIds { get; } = new(); /// - /// Gets the name of the map. + /// Gets the mapping of monster ID to the selected map identifier. + /// Used when a random map is picked from the configuration's list. /// - public LocalizedString MapName => this.Map.Name; - - /// - public override string? ToString() - { - return this.MapName.ToString(); - } + public IDictionary SelectedMaps { get; } = new Dictionary(); } \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/InvasionMobSpawn.cs b/src/GameLogic/PlugIns/InvasionEvents/InvasionMobSpawn.cs deleted file mode 100644 index 99ba33df4..000000000 --- a/src/GameLogic/PlugIns/InvasionEvents/InvasionMobSpawn.cs +++ /dev/null @@ -1,25 +0,0 @@ -// -// Licensed under the MIT License. See LICENSE file in the project root for full license information. -// - -namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; - -/// -/// Represents a mob spawn configuration for invasions. -/// -/// The monster ID to spawn. -/// The number of monsters to spawn. -/// The map ID. If null, spawns on the selected map. -/// The minimum X coordinate. If null, uses default (10). -/// The maximum X coordinate. If null, uses default (240). -/// The minimum Y coordinate. If null, uses default (10). -/// The maximum Y coordinate. If null, uses default (240). -public record InvasionMobSpawn( - ushort MonsterId, - ushort Count, - ushort? MapId = null, - byte? X1 = null, - byte? Y1 = null, - byte? X2 = null, - byte? Y2 = null); - diff --git a/src/GameLogic/PlugIns/InvasionEvents/InvasionSpawnConfiguration.cs b/src/GameLogic/PlugIns/InvasionEvents/InvasionSpawnConfiguration.cs new file mode 100644 index 000000000..4f2b23fff --- /dev/null +++ b/src/GameLogic/PlugIns/InvasionEvents/InvasionSpawnConfiguration.cs @@ -0,0 +1,113 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; + +using System.ComponentModel.DataAnnotations; + +/// +/// Represents a spawn configuration for an invasion event monster. +/// +public class InvasionSpawnConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + public InvasionSpawnConfiguration() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The monster ID to spawn. + /// The number of monsters to spawn (1-254). + /// The list of map IDs where the monster can spawn. + /// If true, spawns on all maps in the list. If false, picks a random map. + /// The optional fixed X coordinate. + /// The optional fixed Y coordinate. + public InvasionSpawnConfiguration(ushort monsterId, ushort count, IList mapIds, bool isSpawnOnAllMaps, byte? x = null, byte? y = null) + { + this.MonsterId = monsterId; + this.Count = count; + this.MapIds = mapIds; + this.IsSpawnOnAllMaps = isSpawnOnAllMaps; + this.X = x; + this.Y = y; + } + + /// + /// Gets or sets the monster ID to spawn. + /// + [Required] + public ushort MonsterId { get; set; } + + /// + /// Gets or sets the number of monsters to spawn (1-254). + /// + [Range(1, 254)] + public ushort Count { get; set; } + + /// + /// Gets or sets the list of map IDs where the monster can spawn. + /// + [Required] + [MinLength(1)] + public IList MapIds { get; set; } = new List(); + + /// + /// Gets or sets a value indicating whether to spawn on all maps in the list. + /// If false, picks a random map. + /// + public bool IsSpawnOnAllMaps { get; set; } + + /// + /// Gets or sets the fixed X coordinate. + /// If null, a random coordinate is used. + /// + [Range(0, 255)] + public byte? X { get; set; } + + /// + /// Gets or sets the fixed Y coordinate. + /// If null, a random coordinate is used. + /// + [Range(0, 255)] + public byte? Y { get; set; } + + /// + /// Determines whether this instance is equal to another . + /// + /// The object to compare with. + /// True if the instances are equal; otherwise, false. + public override bool Equals(object? obj) + { + if (obj is not InvasionSpawnConfiguration other) + { + return false; + } + + return this.MonsterId == other.MonsterId + && this.Count == other.Count + && this.IsSpawnOnAllMaps == other.IsSpawnOnAllMaps + && this.X == other.X + && this.Y == other.Y + && this.MapIds.SequenceEqual(other.MapIds); + } + + /// + /// Returns a hash code for this instance based on , , , , , and . + /// + /// A hash code based on the configuration properties. + public override int GetHashCode() + { + var hash = HashCode.Combine(this.MonsterId, this.Count, this.IsSpawnOnAllMaps, this.X, this.Y); + foreach (var mapId in this.MapIds) + { + hash = HashCode.Combine(hash, mapId); + } + + return hash; + } +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs b/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs index f50444f0c..0bef7173a 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs @@ -1,16 +1,36 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; +using System.ComponentModel.DataAnnotations; using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.PlugIns; /// /// Abstract configuration for periodic invasions. /// public class PeriodicInvasionConfiguration : PeriodicTaskConfiguration { + private const ushort LorenciaId = 0; + private const ushort DeviasId = 2; + private const ushort NoriaId = 3; + private const ushort AtlansId = 7; + private const ushort TarkanId = 8; + + private const ushort GoldenBudgeDragonId = 43; + private const ushort GoldenGoblinId = 78; + private const ushort GoldenSoldierId = 54; + private const ushort GoldenTitanId = 53; + private const ushort GoldenDragonId = 79; + private const ushort GoldenVeparId = 81; + private const ushort GoldenLizardKingId = 80; + private const ushort GoldenWheelId = 83; + private const ushort GoldenTantallosId = 82; + + private const ushort RedDragonId = 44; + /// /// Initializes a new instance of the class. /// @@ -20,24 +40,46 @@ public PeriodicInvasionConfiguration() } /// - /// Gets the default configuration. + /// Gets the default configuration for Golden Invasion. /// public static PeriodicInvasionConfiguration DefaultGoldenInvasion => new() { TaskDuration = TimeSpan.FromMinutes(5), PreStartMessageDelay = TimeSpan.FromSeconds(3), Message = "[{mapName}] Golden Invasion!", - Timetable = GenerateTimeSequence(TimeSpan.FromHours(4)).ToList(), // Every 4 hours + Timetable = GenerateTimeSequence(TimeSpan.FromHours(4)).ToList(), + Mobs = new List + { + new(GoldenBudgeDragonId, 20, new List { LorenciaId }, false), + new(GoldenGoblinId, 20, new List { NoriaId }, false), + new(GoldenSoldierId, 20, new List { DeviasId }, false), + new(GoldenTitanId, 10, new List { DeviasId }, false), + new(GoldenVeparId, 20, new List { AtlansId }, false), + new(GoldenLizardKingId, 10, new List { AtlansId }, false), + new(GoldenWheelId, 20, new List { TarkanId }, false), + new(GoldenTantallosId, 10, new List { TarkanId }, false), + new(GoldenDragonId, 10, new List { LorenciaId, NoriaId, DeviasId, AtlansId, TarkanId }, false), + }, }; /// - /// Gets the default configuration for the red dragon invasion. + /// Gets the default configuration for the Red Dragon Invasion. /// public static PeriodicInvasionConfiguration DefaultRedDragonInvasion => new() { TaskDuration = TimeSpan.FromMinutes(10), PreStartMessageDelay = TimeSpan.FromSeconds(3), Message = "[{mapName}] Red Dragon Invasion!", - Timetable = GenerateTimeSequence(TimeSpan.FromHours(6), new TimeOnly(2, 0)).ToList(), // Every 6 hours, starting from 02:00 + Timetable = GenerateTimeSequence(TimeSpan.FromHours(6), new TimeOnly(2, 0)).ToList(), + Mobs = new List + { + new(RedDragonId, 5, new List { LorenciaId, NoriaId, DeviasId }, false), + }, }; -} \ No newline at end of file + + /// + /// Gets or sets the monster spawns. + /// + [Display(Name = "Monster Spawns", Order = 5)] + public IList Mobs { get; set; } = new List(); +} diff --git a/src/GameLogic/PlugIns/InvasionEvents/RedDragonInvasionPlugIn.cs b/src/GameLogic/PlugIns/InvasionEvents/RedDragonInvasionPlugIn.cs index 2c089c6e3..2fb94431c 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/RedDragonInvasionPlugIn.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/RedDragonInvasionPlugIn.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -15,16 +15,23 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; [Guid("548A76CC-242C-441C-BC9D-6C22745A2D72")] public class RedDragonInvasionPlugIn : BaseInvasionPlugIn, ISupportDefaultCustomConfiguration { - private const ushort RedDragonId = 44; + private const ushort LorenciaId = 0; + private const ushort DeviasId = 2; + private const ushort NoriaId = 3; + + private static readonly IReadOnlyList DisplayMaps = new ushort[] { LorenciaId, NoriaId, DeviasId }; /// /// Initializes a new instance of the class. /// public RedDragonInvasionPlugIn() - : base(MapEventType.RedDragonInvasion, null, [new(RedDragonId, 5)]) + : base(MapEventType.RedDragonInvasion) { } + /// + protected override IReadOnlyList EventDisplayMapIds => DisplayMaps; + /// public object CreateDefaultConfig() => PeriodicInvasionConfiguration.DefaultRedDragonInvasion; } \ No newline at end of file diff --git a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs index 39454581d..6367a6178 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -14,25 +14,25 @@ public class PeriodicTaskConfiguration /// /// Gets or sets a timetable for the event. /// - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_Timetable_Name))] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_Timetable_Name), Order = 4)] public IList Timetable { get; set; } = new List(); /// /// Gets or sets a time delay. /// - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_PreStartMessageDelay_Name))] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_PreStartMessageDelay_Name), Order = 1)] public TimeSpan PreStartMessageDelay { get; set; } = TimeSpan.FromSeconds(3); /// /// Gets or sets the minimum time of the task duration. /// - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_TaskDuration_Name))] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_TaskDuration_Name), Order = 2)] public TimeSpan TaskDuration { get; set; } = TimeSpan.FromMinutes(5); /// /// Gets or sets the text which prints as a golden message in the game. /// - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_Message_Name))] + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_Message_Name), Order = 3)] public LocalizedString Message { get; set; } /// diff --git a/src/Web/Shared/ComponentBuilders/InvasionSpawnTableFieldBuilder.cs b/src/Web/Shared/ComponentBuilders/InvasionSpawnTableFieldBuilder.cs new file mode 100644 index 000000000..5ff150cfa --- /dev/null +++ b/src/Web/Shared/ComponentBuilders/InvasionSpawnTableFieldBuilder.cs @@ -0,0 +1,25 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.ComponentBuilders; + +using System.Reflection; +using Microsoft.AspNetCore.Components.Rendering; +using MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; +using MUnique.OpenMU.Web.Shared.Components.Form; +using MUnique.OpenMU.Web.Shared.Services; + +/// +/// A for fields. +/// +public class InvasionSpawnTableFieldBuilder : BaseComponentBuilder, IComponentBuilder +{ + /// + public int BuildComponent(object model, PropertyInfo propertyInfo, RenderTreeBuilder builder, int currentIndex, IChangeNotificationService notificationService) + => this.BuildField>(model, typeof(InvasionSpawnTable), propertyInfo, builder, currentIndex, notificationService); + + /// + public bool CanBuildComponent(PropertyInfo propertyInfo) + => propertyInfo.PropertyType == typeof(IList); +} diff --git a/src/Web/Shared/Components/Form/AutoFields.cs b/src/Web/Shared/Components/Form/AutoFields.cs index 56c1497ed..0f7059ef9 100644 --- a/src/Web/Shared/Components/Form/AutoFields.cs +++ b/src/Web/Shared/Components/Form/AutoFields.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -53,6 +53,7 @@ static AutoFields() Builders.Add(new IntCollectionFieldBuilder()); Builders.Add(new ByteArrayFieldBuilder()); Builders.Add(new ValueListFieldBuilder()); + Builders.Add(new InvasionSpawnTableFieldBuilder()); } /// diff --git a/src/Web/Shared/Components/Form/InvasionSpawnTable.razor b/src/Web/Shared/Components/Form/InvasionSpawnTable.razor new file mode 100644 index 000000000..af2e69c9b --- /dev/null +++ b/src/Web/Shared/Components/Form/InvasionSpawnTable.razor @@ -0,0 +1,72 @@ +@using MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents +@using MUnique.OpenMU.DataModel.Configuration +@using MUnique.OpenMU.Web.Shared.Services +@using MUnique.OpenMU.Web.Shared.Properties + +@inherits Microsoft.AspNetCore.Components.Forms.InputBase> + +
+
+ +
+
+ @foreach (var spawn in this._viewModels) + { +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ } +
+ +
+
+
diff --git a/src/Web/Shared/Components/Form/InvasionSpawnTable.razor.cs b/src/Web/Shared/Components/Form/InvasionSpawnTable.razor.cs new file mode 100644 index 000000000..5a6d45c6e --- /dev/null +++ b/src/Web/Shared/Components/Form/InvasionSpawnTable.razor.cs @@ -0,0 +1,303 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.Form; + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; +using MUnique.OpenMU.Persistence; + +/// +/// A component which shows a collection of in a table. +/// +public partial class InvasionSpawnTable : InputBase> +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + private List _viewModels = new(); + + private IEnumerable? _allMonsters; + + private IEnumerable? _allMaps; + + private bool _parsing; + + /// + /// Gets or sets the label. + /// + [Parameter] + public string Label { get; set; } = string.Empty; + + /// + /// Gets or sets the persistence context. + /// + [CascadingParameter] + public IContext PersistenceContext { get; set; } = null!; + + [Inject] + private IDataSource GameConfigurationSource { get; set; } = null!; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync().ConfigureAwait(false); + if (this.Value is null) + { + this.Value = new List(); + } + + await this.GameConfigurationSource.GetOwnerAsync().ConfigureAwait(false); + this._allMonsters = this.GameConfigurationSource.GetAll(); + this._allMaps = this.GameConfigurationSource.GetAll(); + + this._viewModels = this.Value.Select(v => new SpawnViewModel(v, this)).ToList(); + } + + /// + protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out IList result, [NotNullWhen(false)] out string? validationErrorMessage) + { + if (this._parsing) + { + result = this.Value ?? new List(); + validationErrorMessage = null; + return true; + } + + this._parsing = true; + try + { + result = JsonSerializer.Deserialize>(value ?? "[]", JsonOptions) ?? new List(); + validationErrorMessage = null; + return true; + } + catch (Exception ex) + { + result = this.Value ?? new List(); + validationErrorMessage = ex.Message; + return true; + } + finally + { + this._parsing = false; + } + } + + /// + protected override string FormatValueAsString(IList? value) + { + return JsonSerializer.Serialize(value ?? new List(), JsonOptions); + } + + private void OnAddClick() + { + var newSpawn = new InvasionSpawnConfiguration(); + this.Value?.Add(newSpawn); + this._viewModels.Add(new SpawnViewModel(newSpawn, this)); + } + + private void OnRemoveClick(SpawnViewModel viewModel) + { + this.Value?.Remove(viewModel.Model); + this._viewModels.Remove(viewModel); + } + + private MonsterDefinition? GetMonster(ushort monsterId) + { + if (monsterId == 0) + { + return null; + } + + return this._allMonsters?.FirstOrDefault(m => m.Number == monsterId); + } + + private IList GetMaps(IList mapIds) + { + if (mapIds.Count == 0 || this._allMaps is null) + { + return new List(); + } + + return mapIds.Select(id => this._allMaps.FirstOrDefault(m => m.Number == (short)id)) + .Where(map => map is not null) + .Cast() + .ToList(); + } + + private void SetMaps(InvasionSpawnConfiguration spawn, IList maps) + { + spawn.MapIds = maps.Select(m => (ushort)m.Number).ToList(); + } + + /// + /// View model for a single invasion spawn configuration to facilitate better Blazor data binding. + /// + public class SpawnViewModel + { + private readonly InvasionSpawnConfiguration _model; + private readonly InvasionSpawnTable _parent; + private SyncedMapList _cachedMaps; + + /// + /// Initializes a new instance of the class. + /// + /// The model. + /// The parent component. + public SpawnViewModel(InvasionSpawnConfiguration model, InvasionSpawnTable parent) + { + this._model = model; + this._parent = parent; + var initialMaps = parent.GetMaps(model.MapIds); + this._cachedMaps = new SyncedMapList(initialMaps, this); + } + + /// + /// Gets the underlying model. + /// + public InvasionSpawnConfiguration Model => this._model; + + /// + /// Gets or sets the maps where the monster can spawn. + /// + public IList Maps + { + get => this._cachedMaps; + set + { + this._cachedMaps = new SyncedMapList(value, this); + this.SyncMapIdsToModel(); + } + } + + /// + /// Gets or sets the monster definition. + /// + public MonsterDefinition? Monster + { + get => this._parent.GetMonster(this._model.MonsterId); + set => this._model.MonsterId = (ushort)(value?.Number ?? 0); + } + + /// + /// Gets or sets the quantity of monsters to spawn. + /// + public int Count + { + get => this._model.Count; + set => this._model.Count = (byte)value; + } + + /// + /// Gets or sets a value indicating whether to spawn on all maps or just a random one. + /// + public bool IsSpawnOnAllMaps + { + get => this._model.IsSpawnOnAllMaps; + set => this._model.IsSpawnOnAllMaps = value; + } + + /// + /// Gets or sets the fixed X coordinate. + /// + public int? X + { + get => this._model.X; + set => this._model.X = (byte?)value; + } + + /// + /// Gets or sets the fixed Y coordinate. + /// + public int? Y + { + get => this._model.Y; + set => this._model.Y = (byte?)value; + } + + private void SyncMapIdsToModel() + { + this._model.MapIds = this._cachedMaps + .Select(m => (ushort)m.Number) + .ToList(); + } + + /// + /// A list wrapper that automatically syncs changes back to the model's MapIds. + /// + private class SyncedMapList : IList + { + private readonly List _inner; + private readonly SpawnViewModel _owner; + + public SyncedMapList(IEnumerable items, SpawnViewModel owner) + { + this._inner = new List(items); + this._owner = owner; + } + + public int Count => this._inner.Count; + + public bool IsReadOnly => false; + + public GameMapDefinition this[int index] + { + get => this._inner[index]; + set + { + this._inner[index] = value; + this._owner.SyncMapIdsToModel(); + } + } + + public void Add(GameMapDefinition item) + { + this._inner.Add(item); + this._owner.SyncMapIdsToModel(); + } + + public bool Remove(GameMapDefinition item) + { + var result = this._inner.Remove(item); + if (result) + { + this._owner.SyncMapIdsToModel(); + } + + return result; + } + + public void Clear() + { + this._inner.Clear(); + this._owner.SyncMapIdsToModel(); + } + + public void Insert(int index, GameMapDefinition item) + { + this._inner.Insert(index, item); + this._owner.SyncMapIdsToModel(); + } + + public void RemoveAt(int index) + { + this._inner.RemoveAt(index); + this._owner.SyncMapIdsToModel(); + } + + public bool Contains(GameMapDefinition item) => this._inner.Contains(item); + + public void CopyTo(GameMapDefinition[] array, int arrayIndex) => this._inner.CopyTo(array, arrayIndex); + + public int IndexOf(GameMapDefinition item) => this._inner.IndexOf(item); + + public IEnumerator GetEnumerator() => this._inner.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => this.GetEnumerator(); + } + } +} \ No newline at end of file diff --git a/src/Web/Shared/Components/Form/MultiLookupField.razor.cs b/src/Web/Shared/Components/Form/MultiLookupField.razor.cs index d5dc238e7..ba4b91fce 100644 --- a/src/Web/Shared/Components/Form/MultiLookupField.razor.cs +++ b/src/Web/Shared/Components/Form/MultiLookupField.razor.cs @@ -214,7 +214,7 @@ private void ToggleItem(TObject item) this.Value.Add(item); } - this.StateHasChanged(); + this.CurrentValue = this.Value; } /// diff --git a/src/Web/Shared/Properties/Resources.Designer.cs b/src/Web/Shared/Properties/Resources.Designer.cs index 3250e7d9c..840124a19 100644 --- a/src/Web/Shared/Properties/Resources.Designer.cs +++ b/src/Web/Shared/Properties/Resources.Designer.cs @@ -8,10 +8,11 @@ // //------------------------------------------------------------------------------ -namespace MUnique.OpenMU.Web.Shared.Properties { +namespace MUnique.OpenMU.Web.Shared.Properties +{ using System; - - + + /// /// A strongly-typed resource class, for looking up localized strings, etc. /// @@ -22,175 +23,213 @@ namespace MUnique.OpenMU.Web.Shared.Properties { [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - public class Resources { - + public class Resources + { + private static global::System.Resources.ResourceManager resourceMan; - + private static global::System.Globalization.CultureInfo resourceCulture; - + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - public Resources() { + public Resources() + { } - + /// /// Returns the cached ResourceManager instance used by this class. /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - public static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { + public static global::System.Resources.ResourceManager ResourceManager + { + get + { + if (object.ReferenceEquals(resourceMan, null)) + { global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MUnique.OpenMU.Web.Shared.Properties.Resources", typeof(Resources).Assembly); resourceMan = temp; } return resourceMan; } } - + /// /// Overrides the current thread's CurrentUICulture property for all /// resource lookups using this strongly typed resource class. /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - public static global::System.Globalization.CultureInfo Culture { - get { + public static global::System.Globalization.CultureInfo Culture + { + get + { return resourceCulture; } - set { + set + { resourceCulture = value; } } - + /// /// Looks up a localized string similar to E-Mail. /// - public static string AccountCreationParameters_EMail_Name { - get { + public static string AccountCreationParameters_EMail_Name + { + get + { return ResourceManager.GetString("AccountCreationParameters_EMail_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Login Name. /// - public static string AccountCreationParameters_LoginName_Name { - get { + public static string AccountCreationParameters_LoginName_Name + { + get + { return ResourceManager.GetString("AccountCreationParameters_LoginName_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Password. /// - public static string AccountCreationParameters_Password_Name { - get { + public static string AccountCreationParameters_Password_Name + { + get + { return ResourceManager.GetString("AccountCreationParameters_Password_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Security Code. /// - public static string AccountCreationParameters_SecurityCode_Name { - get { + public static string AccountCreationParameters_SecurityCode_Name + { + get + { return ResourceManager.GetString("AccountCreationParameters_SecurityCode_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Status. /// - public static string AccountCreationParameters_State_Name { - get { + public static string AccountCreationParameters_State_Name + { + get + { return ResourceManager.GetString("AccountCreationParameters_State_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to All. /// - public static string All { - get { + public static string All + { + get + { return ResourceManager.GetString("All", resourceCulture); } } - + /// /// Looks up a localized string similar to Ancient Option Level. /// - public static string AncientOptionLevel { - get { + public static string AncientOptionLevel + { + get + { return ResourceManager.GetString("AncientOptionLevel", resourceCulture); } } - + /// /// Looks up a localized string similar to Bonus. /// - public static string Bonus { - get { + public static string Bonus + { + get + { return ResourceManager.GetString("Bonus", resourceCulture); } } - + /// /// Looks up a localized string similar to Control Group. /// - public static string ControlGroup { - get { + public static string ControlGroup + { + get + { return ResourceManager.GetString("ControlGroup", resourceCulture); } } - + /// /// Looks up a localized string similar to Creation Group. /// - public static string CreationGroup { - get { + public static string CreationGroup + { + get + { return ResourceManager.GetString("CreationGroup", resourceCulture); } } - + /// /// Looks up a localized string similar to Has Guardian Option (Level 380 PvP). /// - public static string HasGuardianOption { - get { + public static string HasGuardianOption + { + get + { return ResourceManager.GetString("HasGuardianOption", resourceCulture); } } - + /// /// Looks up a localized string similar to Loading .... /// - public static string Loading { - get { + public static string Loading + { + get + { return ResourceManager.GetString("Loading", resourceCulture); } } - + /// /// Looks up a localized string similar to Map. /// - public static string Map { - get { + public static string Map + { + get + { return ResourceManager.GetString("Map", resourceCulture); } } - + /// /// Looks up a localized string similar to Option Level. /// - public static string OptionLevel { - get { + public static string OptionLevel + { + get + { return ResourceManager.GetString("OptionLevel", resourceCulture); } } - + /// /// Looks up a localized string similar to <Please select a definition>. /// - public static string PleaseSelectItemDefinition { - get { + public static string PleaseSelectItemDefinition + { + get + { return ResourceManager.GetString("PleaseSelectItemDefinition", resourceCulture); } } @@ -198,8 +237,10 @@ public static string PleaseSelectItemDefinition { /// /// Looks up a localized string similar to Duplicate. /// - public static string Duplicate { - get { + public static string Duplicate + { + get + { return ResourceManager.GetString("Duplicate", resourceCulture); } } @@ -207,8 +248,10 @@ public static string Duplicate { /// /// Looks up a localized string similar to Filter. /// - public static string Filter { - get { + public static string Filter + { + get + { return ResourceManager.GetString("Filter", resourceCulture); } } @@ -216,8 +259,10 @@ public static string Filter { /// /// Looks up a localized string similar to Search. /// - public static string Search { - get { + public static string Search + { + get + { return ResourceManager.GetString("Search", resourceCulture); } } @@ -225,44 +270,54 @@ public static string Search { /// /// Looks up a localized string similar to Remove. /// - public static string Remove { - get { + public static string Remove + { + get + { return ResourceManager.GetString("Remove", resourceCulture); } } - + /// /// Looks up a localized string similar to Selected Map. /// - public static string SelectedMap { - get { + public static string SelectedMap + { + get + { return ResourceManager.GetString("SelectedMap", resourceCulture); } } - + /// /// Looks up a localized string similar to Selected Object. /// - public static string SelectedObject { - get { + public static string SelectedObject + { + get + { return ResourceManager.GetString("SelectedObject", resourceCulture); } } - + /// /// Looks up a localized string similar to Socket {0}. /// - public static string SocketNumber { - get { + public static string SocketNumber + { + get + { return ResourceManager.GetString("SocketNumber", resourceCulture); } } - + /// /// Looks up a localized string similar to Target. /// - public static string Target { - get { + public static string Target + { + get + { return ResourceManager.GetString("Target", resourceCulture); } } @@ -270,55 +325,144 @@ public static string Target { /// /// Looks up a localized string similar to Target map. /// - public static string TargetMap { - get { + public static string TargetMap + { + get + { return ResourceManager.GetString("TargetMap", resourceCulture); } } - + /// /// Looks up a localized string similar to Terrain of {0}. /// - public static string TerrainOf { - get { + public static string TerrainOf + { + get + { return ResourceManager.GetString("TerrainOf", resourceCulture); } } - + /// /// Looks up a localized string similar to {0}: The value must not contain "{1}".. /// - public static string TheValueMustNotContain { - get { + public static string TheValueMustNotContain + { + get + { return ResourceManager.GetString("TheValueMustNotContain", resourceCulture); } } - + /// /// Looks up a localized string similar to Toolbar. /// - public static string Toolbar { - get { + public static string Toolbar + { + get + { return ResourceManager.GetString("Toolbar", resourceCulture); } } - + /// /// Looks up a localized string similar to Username. /// - public static string UserCreationParameters_LoginName_Name { - get { + public static string UserCreationParameters_LoginName_Name + { + get + { return ResourceManager.GetString("UserCreationParameters_LoginName_Name", resourceCulture); } } - + /// /// Looks up a localized string similar to Password. /// - public static string UserCreationParameters_Password_Name { - get { + public static string UserCreationParameters_Password_Name + { + get + { return ResourceManager.GetString("UserCreationParameters_Password_Name", resourceCulture); } } + + /// + /// Looks up a localized string similar to Monster. + /// + public static string Monster + { + get + { + return ResourceManager.GetString("Monster", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Target Maps. + /// + public static string TargetMaps + { + get + { + return ResourceManager.GetString("TargetMaps", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Quantity. + /// + public static string Quantity + { + get + { + return ResourceManager.GetString("Quantity", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Spawn Rules. + /// + public static string SpawnRules + { + get + { + return ResourceManager.GetString("SpawnRules", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Spawn on All Selected Maps. + /// + public static string SpawnOnAllSelectedMaps + { + get + { + return ResourceManager.GetString("SpawnOnAllSelectedMaps", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to If enabled spawn monster on all selected maps, if false, select a random map.. + /// + public static string SpawnOnAllSelectedMapsTooltip + { + get + { + return ResourceManager.GetString("SpawnOnAllSelectedMapsTooltip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Add Value. + /// + public static string AddValue + { + get + { + return ResourceManager.GetString("AddValue", resourceCulture); + } + } } } diff --git a/src/Web/Shared/Properties/Resources.resx b/src/Web/Shared/Properties/Resources.resx index f076f96f5..37bc97da7 100644 --- a/src/Web/Shared/Properties/Resources.resx +++ b/src/Web/Shared/Properties/Resources.resx @@ -207,4 +207,25 @@ No results found + + Monster + + + Target Maps + + + Quantity + + + Spawn Rules + + + Spawn on All Selected Maps + + + If enabled spawn monster on all selected maps, if disabled, it will select a random map. + + + Add Value + \ No newline at end of file diff --git a/src/Web/Shared/Services/PlugInController.cs b/src/Web/Shared/Services/PlugInController.cs index 070342f66..c014ab9cf 100644 --- a/src/Web/Shared/Services/PlugInController.cs +++ b/src/Web/Shared/Services/PlugInController.cs @@ -21,6 +21,7 @@ public class PlugInController : IDataService, ISupp { private readonly IDataSource _dataSource; private readonly IModalService _modalService; + private readonly PlugInManager? _plugInManager; private string _nameFilter = string.Empty; private Guid _pointFilter; private string _typeFilter = string.Empty; @@ -30,10 +31,12 @@ public class PlugInController : IDataService, ISupp /// /// The data source. /// The modal service. - public PlugInController(IDataSource dataSource, IModalService modalService) + /// The plug in manager. + public PlugInController(IDataSource dataSource, IModalService modalService, PlugInManager? plugInManager = null) { this._dataSource = dataSource; this._modalService = modalService; + this._plugInManager = plugInManager; } /// @@ -42,9 +45,6 @@ public PlugInController(IDataSource dataSource, IModalService /// /// Gets or sets the name filter. /// - /// - /// The name filter. - /// public string NameFilter { get => this._nameFilter; @@ -58,9 +58,6 @@ public string NameFilter /// /// Gets or sets the type filter. /// - /// - /// The type filter. - /// public string TypeFilter { get => this._typeFilter; @@ -130,7 +127,7 @@ public async Task> GetAsync(int offset, int co try { - var gameConfiguration = await this._dataSource.GetOwnerAsync(Guid.Empty); + var gameConfiguration = await this._dataSource.GetOwnerAsync(Guid.Empty).ConfigureAwait(true); var allPlugIns = GetPluginTypes().ToDictionary(t => t.GUID, t => t); var rest = count - result.Count; @@ -180,7 +177,7 @@ public async Task ShowPlugInConfigAsync(PlugInConfigurationViewItem item) ?? Activator.CreateInstance(item.ConfigurationType); var parameters = new ModalParameters(); parameters.Add(nameof(ModalCreateNew.Item), configuration!); - parameters.Add(nameof(ModalCreateNew.PersistenceContext), await this._dataSource.GetContextAsync()); + parameters.Add(nameof(ModalCreateNew.PersistenceContext), await this._dataSource.GetContextAsync().ConfigureAwait(true)); var options = new ModalOptions { DisableBackgroundCancel = true, @@ -198,6 +195,9 @@ public async Task ShowPlugInConfigAsync(PlugInConfigurationViewItem item) { item.Configuration.SetConfiguration(configuration!, referenceResolver); await (await this._dataSource.GetContextAsync().ConfigureAwait(false)).SaveChangesAsync().ConfigureAwait(false); + + this._plugInManager?.ConfigurePlugIn(item.Configuration.TypeId, item.Configuration); + this.DataChanged?.Invoke(this, EventArgs.Empty); } } diff --git a/src/Web/Shared/Styles/Site.scss b/src/Web/Shared/Styles/Site.scss index 9cf6b912c..7e92cdb81 100644 --- a/src/Web/Shared/Styles/Site.scss +++ b/src/Web/Shared/Styles/Site.scss @@ -1,4 +1,9 @@ -@import "bootstrap-4.6.1/scss/bootstrap"; +// Disable form validation icons and extra padding +$form-feedback-icon-valid: none; +$form-feedback-icon-invalid: none; +$enable-validation-icons: false; + +@import "bootstrap-4.6.1/scss/bootstrap"; @import "open-iconic/font/css/open-iconic-bootstrap.min.css"; @import "Navigation"; diff --git a/src/Web/Shared/wwwroot/css/shared.css b/src/Web/Shared/wwwroot/css/shared.css index e35ab4a06..8102f418c 100644 --- a/src/Web/Shared/wwwroot/css/shared.css +++ b/src/Web/Shared/wwwroot/css/shared.css @@ -2011,31 +2011,14 @@ textarea.form-control, textarea.valid.modified, .invalid textarea.valid { .was-validated .form-control:valid, .was-validated th input:valid, th .was-validated input:valid, .valid.modified .form-control:valid, .valid.modified th input:valid, th .valid.modified input:valid, .was-validated .invalid .valid:valid, .valid.modified .invalid .valid:valid, .invalid .was-validated .valid:valid, .invalid .valid.modified .valid:valid, .was-validated form input:valid, .valid.modified form input:valid, form .was-validated input:valid, form .valid.modified input:valid, .was-validated select:valid, .valid.modified select:valid, .form-control.is-valid, th input.is-valid, .valid.modified, .invalid .is-valid.valid, form input.is-valid, select.is-valid { border-color: #28a745; - padding-right: calc(1.5em + 0.75rem) !important; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right calc(0.375em + 0.1875rem) center; - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } .was-validated .form-control:valid:focus, .was-validated th input:valid:focus, th .was-validated input:valid:focus, .valid.modified .form-control:valid:focus, .valid.modified th input:valid:focus, th .valid.modified input:valid:focus, .was-validated .invalid .valid:valid:focus, .valid.modified .invalid .valid:valid:focus, .invalid .was-validated .valid:valid:focus, .invalid .valid.modified .valid:valid:focus, .was-validated form input:valid:focus, .valid.modified form input:valid:focus, form .was-validated input:valid:focus, form .valid.modified input:valid:focus, .was-validated select:valid:focus, .valid.modified select:valid:focus, .form-control.is-valid:focus, th input.is-valid:focus, .valid.modified:focus, .invalid .is-valid.valid:focus, form input.is-valid:focus, select.is-valid:focus { border-color: #28a745; box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25); } -.was-validated select.form-control:valid, .was-validated select:valid, .valid.modified select:valid, select.form-control.is-valid, .invalid select.is-valid.valid, select.is-valid, select.valid.modified { - padding-right: 3rem !important; - background-position: right 1.5rem center; -} - -.was-validated textarea.form-control:valid, .valid.modified textarea.form-control:valid, .was-validated .invalid textarea.valid:valid, .valid.modified .invalid textarea.valid:valid, .invalid .was-validated textarea.valid:valid, .invalid .valid.modified textarea.valid:valid, textarea.form-control.is-valid, textarea.valid.modified, .invalid textarea.is-valid.valid { - padding-right: calc(1.5em + 0.75rem); - background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); -} - .was-validated .custom-select:valid, .valid.modified .custom-select:valid, .custom-select.is-valid, .custom-select.valid.modified { border-color: #28a745; - padding-right: calc(0.75em + 2.3125rem) !important; - background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right 0.75rem center/8px 10px no-repeat, #fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem) no-repeat; } .was-validated .custom-select:valid:focus, .valid.modified .custom-select:valid:focus, .custom-select.is-valid:focus, .custom-select.valid.modified:focus { border-color: #28a745; @@ -2117,31 +2100,14 @@ textarea.form-control, textarea.valid.modified, .invalid textarea.valid { .was-validated .form-control:invalid, .was-validated th input:invalid, th .was-validated input:invalid, .was-validated .valid.modified:invalid, .valid.modified .form-control:invalid, .valid.modified th input:invalid, th .valid.modified input:invalid, .valid.modified .valid.modified:invalid, .was-validated .invalid .valid:invalid, .valid.modified .invalid .valid:invalid, .invalid .was-validated .valid:invalid, .invalid .valid.modified .valid:invalid, .was-validated form input:invalid, .valid.modified form input:invalid, form .was-validated input:invalid, form .valid.modified input:invalid, .was-validated select:invalid, .valid.modified select:invalid, .form-control.is-invalid, th input.is-invalid, .is-invalid.valid.modified, .form-control.invalid.modified, th input.invalid.modified, .invalid.modified.valid, .invalid .is-invalid.valid, form input.is-invalid, form input.invalid.modified, select.is-invalid, select.invalid.modified { border-color: #dc3545; - padding-right: calc(1.5em + 0.75rem) !important; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right calc(0.375em + 0.1875rem) center; - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); } .was-validated .form-control:invalid:focus, .was-validated th input:invalid:focus, th .was-validated input:invalid:focus, .was-validated .valid.modified:invalid:focus, .valid.modified .form-control:invalid:focus, .valid.modified th input:invalid:focus, th .valid.modified input:invalid:focus, .valid.modified .valid.modified:invalid:focus, .was-validated .invalid .valid:invalid:focus, .valid.modified .invalid .valid:invalid:focus, .invalid .was-validated .valid:invalid:focus, .invalid .valid.modified .valid:invalid:focus, .was-validated form input:invalid:focus, .valid.modified form input:invalid:focus, form .was-validated input:invalid:focus, form .valid.modified input:invalid:focus, .was-validated select:invalid:focus, .valid.modified select:invalid:focus, .form-control.is-invalid:focus, th input.is-invalid:focus, .is-invalid.valid.modified:focus, .form-control.invalid.modified:focus, th input.invalid.modified:focus, .invalid.modified.valid:focus, .invalid .is-invalid.valid:focus, form input.is-invalid:focus, form input.invalid.modified:focus, select.is-invalid:focus, select.invalid.modified:focus { border-color: #dc3545; box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25); } -.was-validated select.form-control:invalid, .was-validated select:invalid, .valid.modified select:invalid, select.form-control.is-invalid, select.is-invalid.valid.modified, .invalid select.is-invalid.valid, select.is-invalid, select.invalid.modified { - padding-right: 3rem !important; - background-position: right 1.5rem center; -} - -.was-validated textarea.form-control:invalid, .was-validated textarea.valid.modified:invalid, .valid.modified textarea.form-control:invalid, .valid.modified textarea.valid.modified:invalid, .was-validated .invalid textarea.valid:invalid, .valid.modified .invalid textarea.valid:invalid, .invalid .was-validated textarea.valid:invalid, .invalid .valid.modified textarea.valid:invalid, textarea.form-control.is-invalid, textarea.is-invalid.valid.modified, textarea.form-control.invalid.modified, textarea.invalid.modified.valid, .invalid textarea.is-invalid.valid { - padding-right: calc(1.5em + 0.75rem); - background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); -} - .was-validated .custom-select:invalid, .valid.modified .custom-select:invalid, .custom-select.is-invalid, .custom-select.invalid.modified { border-color: #dc3545; - padding-right: calc(0.75em + 2.3125rem) !important; - background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right 0.75rem center/8px 10px no-repeat, #fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") center right 1.75rem/calc(0.75em + 0.375rem) calc(0.75em + 0.375rem) no-repeat; } .was-validated .custom-select:invalid:focus, .valid.modified .custom-select:invalid:focus, .custom-select.is-invalid:focus, .custom-select.invalid.modified:focus { border-color: #dc3545; From 07d56d8a5f92537e771830b9ba0b250790f81a16 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:26:36 -0300 Subject: [PATCH 02/11] avoid the unnecessary Rand.NextInt --- src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs b/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs index de8341b57..80bb237de 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs @@ -182,7 +182,10 @@ protected override async ValueTask OnPrepareEventAsync(InvasionGameServerState s } else { - var randomMapId = mob.MapIds[Rand.NextInt(0, mob.MapIds.Count)]; + var randomMapId = mob.MapIds.Count == 1 + ? mob.MapIds[0] + : mob.MapIds[Rand.NextInt(0, mob.MapIds.Count)]; + state.MapIds.Add(randomMapId); state.SelectedMaps[mob.MonsterId] = randomMapId; } From 21b655087bd8195a44f6d20ab2d89a8225103e8d Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:33:31 -0300 Subject: [PATCH 03/11] fix: invasion remaining problems and code cleanup --- .../InvasionEvents/BaseInvasionPlugIn.cs | 288 +++++++++++------- .../InvasionEvents/GoldenInvasionPlugIn.cs | 23 +- .../InvasionConfigurationDefaults.cs | 51 ++++ .../InvasionEvents/InvasionGameServerState.cs | 56 +++- .../PlugIns/InvasionEvents/InvasionMaps.cs | 36 +++ .../InvasionEvents/InvasionMonsters.cs | 61 ++++ .../InvasionSpawnConfiguration.cs | 74 ++--- .../PeriodicInvasionConfiguration.cs | 69 +---- .../InvasionEvents/RedDragonInvasionPlugIn.cs | 23 +- .../InvasionEvents/SimpleInvasionPlugIn.cs | 37 +++ .../InvasionEvents/SpawnMapStrategy.cs | 21 ++ .../Form/InvasionSpawnTable.razor.cs | 23 +- 12 files changed, 522 insertions(+), 240 deletions(-) create mode 100644 src/GameLogic/PlugIns/InvasionEvents/InvasionConfigurationDefaults.cs create mode 100644 src/GameLogic/PlugIns/InvasionEvents/InvasionMaps.cs create mode 100644 src/GameLogic/PlugIns/InvasionEvents/InvasionMonsters.cs create mode 100644 src/GameLogic/PlugIns/InvasionEvents/SimpleInvasionPlugIn.cs create mode 100644 src/GameLogic/PlugIns/InvasionEvents/SpawnMapStrategy.cs diff --git a/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs b/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs index 80bb237de..825716494 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs @@ -14,31 +14,31 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; /// /// Base class for invasion plugins. /// -/// Configuration. +/// The concrete configuration type. public abstract class BaseInvasionPlugIn : PeriodicTaskBasePlugIn, IPeriodicTaskPlugIn, IObjectAddedToMapPlugIn, ISupportCustomConfiguration where TConfiguration : PeriodicInvasionConfiguration { private readonly MapEventType? _mapEventType; + private readonly List> _cleanupHandlers = []; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// Type of the map event. If null, no map event state updates are sent. + /// + /// The map-event type used for UI state broadcasts. + /// Pass null to disable map-event state updates. + /// protected BaseInvasionPlugIn(MapEventType? mapEventType = null) { this._mapEventType = mapEventType; } - /// - /// Occurs when the event has finished. - /// - public event Func? Finished; - /// /// Gets the list of map IDs from which the event display map is randomly selected. - /// When set, is chosen from this list - /// and the map event state UI is only shown on that single map. - /// If null, is chosen from . + /// When non-empty, is chosen from this + /// list and map-event UI is shown only on that single map. + /// When null, falls back to the + /// minimum of . /// protected virtual IReadOnlyList? EventDisplayMapIds => null; @@ -54,43 +54,33 @@ public virtual async ValueTask ObjectAddedToMapAsync(GameMap map, ILocateable ad { var state = this.GetStateByGameContext(player.GameContext); var isEnabled = state.State != PeriodicTaskState.NotStarted; - await this.TrySendMapEventStateUpdateAsync(player, isEnabled).ConfigureAwait(false); + await this.TrySendMapEventStateUpdateAsync(player, isEnabled, state).ConfigureAwait(false); } } /// - /// Spawns the given quantity of a monster on the map. - /// For each monster, picks a random walkable coordinate via a spiral search — - /// no allocation, no exception on invalid terrain. + /// Spawns instances of on + /// , each placed at a random walkable coordinate (or a fixed + /// coordinate when and are provided). /// /// The game context. + /// The logger. /// The game map. /// The monster definition. /// The quantity. /// The optional fixed X coordinate. /// The optional fixed Y coordinate. - protected async ValueTask CreateMonstersAsync(IGameContext gameContext, GameMap gameMap, MonsterDefinition monsterDefinition, ushort quantity, byte? x = null, byte? y = null) + protected async ValueTask CreateMonstersAsync(IGameContext gameContext, ILogger logger, GameMap gameMap, MonsterDefinition monsterDefinition, ushort quantity, byte? x = null, byte? y = null) { - var logger = gameContext.LoggerFactory.CreateLogger(this.GetType()); - - while (quantity-- > 0) + for (var i = 0; i < quantity; i++) { - Point? spawnPoint; - if (x.HasValue && y.HasValue) - { - spawnPoint = new Point(x.Value, y.Value); - } - else - { - spawnPoint = gameMap.Terrain.GetRandomWalkableCoordinate(); - } + Point? spawnPoint = (x.HasValue && y.HasValue) + ? new Point(x.Value, y.Value) + : gameMap.Terrain.GetRandomWalkableCoordinate(); if (spawnPoint is null) { - logger.LogDebug( - "Skipping one {monster} on {map}: no walkable cell found in rolled area.", - monsterDefinition.Designation, - gameMap.Definition.Name); + logger.LogDebug("Skipping one {Monster} on {Map}: no walkable cell found.", monsterDefinition.Designation, gameMap.Definition.Name); continue; } @@ -107,29 +97,27 @@ protected async ValueTask CreateMonstersAsync(IGameContext gameContext, GameMap }; var intelligence = new BasicMonsterIntelligence(); - var monster = new Monster(area, monsterDefinition, gameMap, gameContext.DropGenerator, intelligence, gameContext.PlugInManager, gameContext.PathFinderPool); + var monster = new Monster( + area, + monsterDefinition, + gameMap, + gameContext.DropGenerator, + intelligence, + gameContext.PlugInManager, + gameContext.PathFinderPool); monster.Initialize(); await gameMap.AddAsync(monster).ConfigureAwait(false); monster.OnSpawn(); - async Task CleanUpOnFinishAsync() - { - this.Finished -= CleanUpOnFinishAsync; - if (monster is not null && !monster.IsDisposed) - { - await monster.CurrentMap.RemoveAsync(monster).ConfigureAwait(false); - monster.Dispose(); - } - } - - this.Finished += CleanUpOnFinishAsync; - monster.Died += (_, _) => this.Finished -= CleanUpOnFinishAsync; + var handler = new MonsterCleanupHandler(this, monster); + this._cleanupHandlers.Add(handler.CleanUpAsync); + monster.Died += (_, _) => this._cleanupHandlers.Remove(handler.CleanUpAsync); } } /// - /// Spawn mobs on a specific map. + /// Spawns all configured mobs for the given . /// /// The game context. /// The map id. @@ -137,22 +125,22 @@ async Task CleanUpOnFinishAsync() protected async ValueTask SpawnMobsAsync(IGameContext gameContext, ushort mapId, IEnumerable spawns) { var gameMap = await gameContext.GetMapAsync(mapId).ConfigureAwait(false); - if (gameMap is null) { return; } var logger = gameContext.LoggerFactory.CreateLogger(this.GetType()); + foreach (var spawn in spawns) { if (gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.MonsterId) is { } monsterDefinition) { - await this.CreateMonstersAsync(gameContext, gameMap, monsterDefinition, spawn.Count, spawn.X, spawn.Y).ConfigureAwait(false); + await this.CreateMonstersAsync(gameContext, logger, gameMap, monsterDefinition, spawn.Count, spawn.X, spawn.Y).ConfigureAwait(false); } else { - logger.LogDebug("Skipping spawning of monster with number {mobId}, because monster definition wasn't found.", spawn.MonsterId); + logger.LogDebug("Skipping monster {MobId}: definition not found.", spawn.MonsterId); } } } @@ -160,62 +148,35 @@ protected async ValueTask SpawnMobsAsync(IGameContext gameContext, ushort mapId, /// protected override async ValueTask OnPrepareEventAsync(InvasionGameServerState state) { - var config = this.Configuration; - if (config?.Mobs is null || config.Mobs.Count == 0) + if (this.IsPreviousEventStillRunning(state)) { return; } - foreach (var mob in config.Mobs) - { - if (mob.MapIds.Count == 0) - { - continue; - } - - if (mob.IsSpawnOnAllMaps) - { - foreach (var mapId in mob.MapIds) - { - state.MapIds.Add(mapId); - } - } - else - { - var randomMapId = mob.MapIds.Count == 1 - ? mob.MapIds[0] - : mob.MapIds[Rand.NextInt(0, mob.MapIds.Count)]; - - state.MapIds.Add(randomMapId); - state.SelectedMaps[mob.MonsterId] = randomMapId; - } - } + state.Reset(); - if (this.EventDisplayMapIds is { Count: > 0 } displayMaps) - { - state.MapId = displayMaps[Rand.NextInt(0, displayMaps.Count)]; - } - else if (state.MapIds.Count > 0) + var config = this.Configuration; + if (config?.Mobs is not { Count: > 0 } mobs) { - state.MapId = state.MapIds.Min(); + return; } + + this.SelectSpawnMaps(mobs, state); + this.SelectDisplayMap(state); } /// protected override InvasionGameServerState CreateState(IGameContext gameContext) - { - return new InvasionGameServerState(gameContext); - } + => new(gameContext); /// - /// Send a golden message to all online players. + /// Sends the invasion start message to a single player. /// /// The player. /// The server state. protected async Task TrySendStartMessageAsync(Player player, InvasionGameServerState state) { var configuration = this.Configuration; - if (configuration is null || state.MapIds.Count == 0) { return; @@ -227,15 +188,16 @@ protected async Task TrySendStartMessageAsync(Player player, InvasionGameServerS ?? string.Empty; var message = (configuration.Message.GetTranslation(player.Culture) - ?? PlugInResources.BaseInvasionPlugIn_DefaultStartMessage).Replace("{mapName}", mapName, StringComparison.InvariantCulture); + ?? PlugInResources.BaseInvasionPlugIn_DefaultStartMessage) + .Replace("{mapName}", mapName, StringComparison.InvariantCulture); try { - await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).ConfigureAwait(false); } catch (Exception ex) { - player.Logger.LogDebug(ex, "Unexpected error sending start message."); + player.Logger.LogDebug(ex, "Unexpected error sending invasion start message."); } } @@ -246,13 +208,14 @@ protected override async ValueTask OnPreparedAsync(InvasionGameServerState state if (this._mapEventType is not null) { - await state.Context.ForEachPlayerAsync(p => this.TrySendMapEventStateUpdateAsync(p, true)).ConfigureAwait(false); + await state.Context.ForEachPlayerAsync(p => this.TrySendMapEventStateUpdateAsync(p, true, state)).ConfigureAwait(false); } } /// protected override async ValueTask OnStartedAsync(InvasionGameServerState state) { + state.LastRunUtc = DateTime.UtcNow; await this.SpawnMobsOnMapsAsync(state).ConfigureAwait(false); } @@ -261,23 +224,24 @@ protected override async ValueTask OnFinishedAsync(InvasionGameServerState state { if (this._mapEventType is not null) { - await state.Context.ForEachPlayerAsync(p => this.TrySendMapEventStateUpdateAsync(p, false)).ConfigureAwait(false); + await state.Context.ForEachPlayerAsync(p => this.TrySendMapEventStateUpdateAsync(p, false, state)).ConfigureAwait(false); } - if (this.Finished is not null) + if (this._cleanupHandlers.Count > 0) { - await this.Finished.Invoke().ConfigureAwait(false); + var handlers = this._cleanupHandlers.ToArray(); + await Task.WhenAll(handlers.Select(h => h())).ConfigureAwait(false); } } /// - /// Spawn mobs based on pre-selected maps in the state. + /// Spawns mobs on the maps that were selected during . /// /// The state. protected virtual async ValueTask SpawnMobsOnMapsAsync(InvasionGameServerState state) { var config = this.Configuration; - if (config?.Mobs is not { } spawns || spawns.Count == 0) + if (config?.Mobs is not { Count: > 0 } spawns) { return; } @@ -300,9 +264,95 @@ protected virtual async ValueTask SpawnMobsOnMapsAsync(InvasionGameServerState s } } - private async Task TrySendMapEventStateUpdateAsync(Player player, bool enabled) + /// + /// Determines whether the previous event run is still within its configured task duration. + /// Prevents a new event from starting before the previous one has fully elapsed. + /// + /// The current invasion state. + /// true if the previous event duration has not elapsed yet; otherwise false. + private bool IsPreviousEventStillRunning(InvasionGameServerState state) + => state.State != PeriodicTaskState.NotStarted + && state.LastRunUtc.Add(this.Configuration?.TaskDuration ?? TimeSpan.Zero) > DateTime.UtcNow; + + /// + /// Iterates the mob configurations and registers the selected spawn maps into the state. + /// For mobs configured with , all map IDs are registered. + /// For mobs configured with , a single map is picked at random. + /// + /// The mob spawn configurations. + /// The current invasion state. + private void SelectSpawnMaps(IList mobs, InvasionGameServerState state) + { + foreach (var mob in mobs) + { + if (mob.MapIds.Count == 0) + { + continue; + } + + if (mob.IsSpawnOnAllMaps) + { + foreach (var mapId in mob.MapIds) + { + state.RegisterMap(mapId); + } + } + else + { + var randomMapId = mob.MapIds.Count == 1 + ? mob.MapIds[0] + : mob.MapIds[Rand.NextInt(0, mob.MapIds.Count)]; + + state.RegisterMap(randomMapId, mob.MonsterId); + } + } + } + + /// + /// Selects the single map used for UI event display and message broadcast from + /// the maps that were actually selected for spawning during . + /// When is configured, the display map is restricted + /// to the intersection of and the selected spawn maps, + /// ensuring the announced map always has active monsters. + /// Falls back to the minimum map ID if no intersection exists or no display maps are configured. + /// + /// The current invasion state. + private void SelectDisplayMap(InvasionGameServerState state) { - if (this._mapEventType is null || !this.IsPlayerOnMap(player)) + if (state.MapIds.Count == 0) + { + return; + } + + if (this.EventDisplayMapIds is { Count: > 0 } displayMaps) + { + var eligible = state.MapIds + .Where(displayMaps.Contains) + .ToList(); + + state.MapId = eligible.Count switch + { + 0 => state.MapIds.Min(), + 1 => eligible[0], + _ => eligible[Rand.NextInt(0, eligible.Count)], + }; + } + else + { + state.MapId = state.MapIds.Min(); + } + } + + private bool IsPlayerOnRelevantMap(Player player, InvasionGameServerState state) + => state.MapId.HasValue + && player.CurrentMap is { } map + && !player.PlayerState.CurrentState.IsDisconnectedOrFinished() + && map.MapId == state.MapId.Value + && (this.EventDisplayMapIds is null || this.EventDisplayMapIds.Contains(state.MapId.Value)); + + private async Task TrySendMapEventStateUpdateAsync(Player player, bool enabled, InvasionGameServerState state) + { + if (this._mapEventType is null || !this.IsPlayerOnRelevantMap(player, state)) { return; } @@ -313,16 +363,46 @@ private async Task TrySendMapEventStateUpdateAsync(Player player, bool enabled) } catch (Exception ex) { - player.Logger.LogDebug(ex, "Unexpected error sending map event state update, event type: {MapEventType}", this._mapEventType); + player.Logger.LogDebug( + ex, + "Unexpected error sending map event state update, event type: {MapEventType}", + this._mapEventType); } } - private bool IsPlayerOnMap(Player player) + /// + /// Handles cleanup of a single spawned monster when the invasion event finishes. + /// Encapsulates the self-referencing delegate pattern needed to unsubscribe from + /// the cleanup handler list without relying on captured uninitialized variables. + /// + private sealed class MonsterCleanupHandler { - var state = this.GetStateByGameContext(player.GameContext); + private readonly BaseInvasionPlugIn _plugin; + private readonly Monster _monster; + + /// + /// Initializes a new instance of the class. + /// + /// The owning plugin instance. + /// The monster to clean up. + public MonsterCleanupHandler(BaseInvasionPlugIn plugin, Monster monster) + { + this._plugin = plugin; + this._monster = monster; + } - return player.CurrentMap is { } map - && !player.PlayerState.CurrentState.IsDisconnectedOrFinished() - && map.MapId == state.MapId; + /// + /// Removes the monster from the map and disposes it. + /// Unsubscribes itself from the plugin's cleanup handler list so it is not called again. + /// + public async Task CleanUpAsync() + { + this._plugin._cleanupHandlers.Remove(this.CleanUpAsync); + if (!this._monster.IsDisposed) + { + await this._monster.CurrentMap.RemoveAsync(this._monster).ConfigureAwait(false); + this._monster.Dispose(); + } + } } -} \ No newline at end of file +} diff --git a/src/GameLogic/PlugIns/InvasionEvents/GoldenInvasionPlugIn.cs b/src/GameLogic/PlugIns/InvasionEvents/GoldenInvasionPlugIn.cs index a1830330f..be99bb858 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/GoldenInvasionPlugIn.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/GoldenInvasionPlugIn.cs @@ -8,30 +8,25 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; using MUnique.OpenMU.PlugIns; /// -/// This plugin enables Golden Invasion feature. +/// Enables the Golden Invasion feature. /// [PlugIn] [Display(Name = nameof(PlugInResources.GoldenInvasionPlugIn_Name), Description = nameof(PlugInResources.GoldenInvasionPlugIn_Description), ResourceType = typeof(PlugInResources))] [Guid("06D18A9E-2919-4C17-9DBC-6E4F7756495C")] -public class GoldenInvasionPlugIn : BaseInvasionPlugIn, ISupportDefaultCustomConfiguration +public sealed class GoldenInvasionPlugIn : SimpleInvasionPlugIn { - private const ushort LorenciaId = 0; - private const ushort DeviasId = 2; - private const ushort NoriaId = 3; - - private static readonly IReadOnlyList DisplayMaps = new ushort[] { LorenciaId, NoriaId, DeviasId }; + private static readonly IReadOnlyList DisplayMaps = + [ + InvasionMaps.Lorencia, + InvasionMaps.Noria, + InvasionMaps.Devias, + ]; /// /// Initializes a new instance of the class. /// public GoldenInvasionPlugIn() - : base(MapEventType.GoldenDragonInvasion) + : base(MapEventType.GoldenDragonInvasion, DisplayMaps, () => InvasionConfigurationDefaults.Golden) { } - - /// - protected override IReadOnlyList EventDisplayMapIds => DisplayMaps; - - /// - public object CreateDefaultConfig() => PeriodicInvasionConfiguration.DefaultGoldenInvasion; } \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/InvasionConfigurationDefaults.cs b/src/GameLogic/PlugIns/InvasionEvents/InvasionConfigurationDefaults.cs new file mode 100644 index 000000000..81059b6e6 --- /dev/null +++ b/src/GameLogic/PlugIns/InvasionEvents/InvasionConfigurationDefaults.cs @@ -0,0 +1,51 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; + +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; + +/// +/// Provides ready-to-use default configurations for the built-in invasion types. +/// +internal static class InvasionConfigurationDefaults +{ + /// + /// Gets the default configuration for the Golden Invasion event. + /// + public static PeriodicInvasionConfiguration Golden => new() + { + TaskDuration = TimeSpan.FromMinutes(5), + PreStartMessageDelay = TimeSpan.FromSeconds(3), + Message = "[{mapName}] Golden Invasion!", + Timetable = PeriodicTaskConfiguration.GenerateTimeSequence(TimeSpan.FromHours(4)).ToList(), + Mobs = + [ + new(InvasionMonsters.GoldenBudgeDragon, 20, [InvasionMaps.Lorencia], SpawnMapStrategy.RandomMap), + new(InvasionMonsters.GoldenGoblin, 20, [InvasionMaps.Noria], SpawnMapStrategy.RandomMap), + new(InvasionMonsters.GoldenSoldier, 20, [InvasionMaps.Devias], SpawnMapStrategy.RandomMap), + new(InvasionMonsters.GoldenTitan, 10, [InvasionMaps.Devias], SpawnMapStrategy.RandomMap), + new(InvasionMonsters.GoldenVepar, 20, [InvasionMaps.Atlans], SpawnMapStrategy.RandomMap), + new(InvasionMonsters.GoldenLizardKing, 10, [InvasionMaps.Atlans], SpawnMapStrategy.RandomMap), + new(InvasionMonsters.GoldenWheel, 20, [InvasionMaps.Tarkan], SpawnMapStrategy.RandomMap), + new(InvasionMonsters.GoldenTantallos, 10, [InvasionMaps.Tarkan], SpawnMapStrategy.RandomMap), + new(InvasionMonsters.GoldenDragon, 10, [InvasionMaps.Lorencia, InvasionMaps.Noria, InvasionMaps.Devias, InvasionMaps.Atlans, InvasionMaps.Tarkan ], SpawnMapStrategy.RandomMap), + ], + }; + + /// + /// Gets the default configuration for the Red Dragon Invasion event. + /// + public static PeriodicInvasionConfiguration RedDragon => new() + { + TaskDuration = TimeSpan.FromMinutes(10), + PreStartMessageDelay = TimeSpan.FromSeconds(3), + Message = "[{mapName}] Red Dragon Invasion!", + Timetable = PeriodicTaskConfiguration.GenerateTimeSequence(TimeSpan.FromHours(6), new TimeOnly(2, 0)).ToList(), + Mobs = + [ + new(InvasionMonsters.RedDragon, 5, [InvasionMaps.Lorencia, InvasionMaps.Noria, InvasionMaps.Devias], SpawnMapStrategy.RandomMap), + ], + }; +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs b/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs index 415932e52..edcfe7d9b 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs @@ -8,32 +8,70 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; using MUnique.OpenMU.Interfaces; /// -/// Game server state per event. +/// Invasion state that is created fresh for every periodic invasion run. /// public class InvasionGameServerState : PeriodicTaskGameServerState { + private readonly HashSet _mapIds = []; + private readonly Dictionary _selectedMaps = []; + /// /// Initializes a new instance of the class. /// - /// The context. + /// The game context. public InvasionGameServerState(IGameContext context) : base(context) { } /// - /// Gets or sets the map identifier. + /// Gets or sets the UTC timestamp of when the last event run started. + /// Used to prevent a new event from starting before the previous task duration has elapsed. + /// + public DateTime LastRunUtc { get; set; } + + /// + /// Gets or sets the map identifier used for UI display / map-event state broadcasts. + /// null means no event is active or the display map has not been selected yet. + /// + public ushort? MapId { get; set; } + + /// + /// Gets the set of map identifiers on which monsters will spawn this run. /// - public ushort MapId { get; set; } + public IReadOnlySet MapIds => this._mapIds; /// - /// Gets the map identifiers where monsters will spawn. + /// Gets the read-only mapping of monster ID to selected map identifier. + /// Populated for spawns whose is . /// - public HashSet MapIds { get; } = new(); + public IReadOnlyDictionary SelectedMaps => this._selectedMaps; /// - /// Gets the mapping of monster ID to the selected map identifier. - /// Used when a random map is picked from the configuration's list. + /// Registers a map as active for this run and optionally records which map was + /// randomly chosen for a particular monster type. /// - public IDictionary SelectedMaps { get; } = new Dictionary(); + /// The map identifier to register. + /// + /// When provided, records the as the chosen map for this monster. + /// Pass null for "spawn-on-all-maps" entries. + /// + internal void RegisterMap(ushort mapId, ushort? monsterId = null) + { + this._mapIds.Add(mapId); + if (monsterId.HasValue) + { + this._selectedMaps[monsterId.Value] = mapId; + } + } + + /// + /// Clears all state accumulated from a previous run so the object can be reused. + /// + internal void Reset() + { + this.MapId = null; + this._mapIds.Clear(); + this._selectedMaps.Clear(); + } } \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/InvasionMaps.cs b/src/GameLogic/PlugIns/InvasionEvents/InvasionMaps.cs new file mode 100644 index 000000000..6fdaf23ac --- /dev/null +++ b/src/GameLogic/PlugIns/InvasionEvents/InvasionMaps.cs @@ -0,0 +1,36 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; + +/// +/// Well-known map identifiers used across invasion plugins. +/// +internal static class InvasionMaps +{ + /// + /// The Lorencia map. + /// + public const ushort Lorencia = 0; + + /// + /// The Devias map. + /// + public const ushort Devias = 2; + + /// + /// The Noria map. + /// + public const ushort Noria = 3; + + /// + /// The Atlans map. + /// + public const ushort Atlans = 7; + + /// + /// The Tarkan map. + /// + public const ushort Tarkan = 8; +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/InvasionMonsters.cs b/src/GameLogic/PlugIns/InvasionEvents/InvasionMonsters.cs new file mode 100644 index 000000000..69da4dbb3 --- /dev/null +++ b/src/GameLogic/PlugIns/InvasionEvents/InvasionMonsters.cs @@ -0,0 +1,61 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; + +/// +/// Monster identifiers used across invasion plugins. +/// +internal static class InvasionMonsters +{ + /// + /// The Golden Budge Dragon monster. + /// + public const ushort GoldenBudgeDragon = 43; + + /// + /// The Golden Soldier monster. + /// + public const ushort GoldenSoldier = 54; + + /// + /// The Golden Titan monster. + /// + public const ushort GoldenTitan = 53; + + /// + /// The Golden Goblin monster. + /// + public const ushort GoldenGoblin = 78; + + /// + /// The Golden Dragon monster. + /// + public const ushort GoldenDragon = 79; + + /// + /// The Golden Lizard King monster. + /// + public const ushort GoldenLizardKing = 80; + + /// + /// The Golden Vepar monster. + /// + public const ushort GoldenVepar = 81; + + /// + /// The Golden Tantallos monster. + /// + public const ushort GoldenTantallos = 82; + + /// + /// The Golden Wheel monster. + /// + public const ushort GoldenWheel = 83; + + /// + /// The Red Dragon monster. + /// + public const ushort RedDragon = 44; +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/InvasionSpawnConfiguration.cs b/src/GameLogic/PlugIns/InvasionEvents/InvasionSpawnConfiguration.cs index 4f2b23fff..d306cfcde 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/InvasionSpawnConfiguration.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/InvasionSpawnConfiguration.cs @@ -13,6 +13,7 @@ public class InvasionSpawnConfiguration { /// /// Initializes a new instance of the class. + /// Required for serialization and UI binding. /// public InvasionSpawnConfiguration() { @@ -24,15 +25,21 @@ public InvasionSpawnConfiguration() /// The monster ID to spawn. /// The number of monsters to spawn (1-254). /// The list of map IDs where the monster can spawn. - /// If true, spawns on all maps in the list. If false, picks a random map. + /// Controls whether to spawn on a random map or all maps. /// The optional fixed X coordinate. /// The optional fixed Y coordinate. - public InvasionSpawnConfiguration(ushort monsterId, ushort count, IList mapIds, bool isSpawnOnAllMaps, byte? x = null, byte? y = null) + public InvasionSpawnConfiguration( + ushort monsterId, + ushort count, + IList mapIds, + SpawnMapStrategy mapStrategy, + byte? x = null, + byte? y = null) { this.MonsterId = monsterId; this.Count = count; this.MapIds = mapIds; - this.IsSpawnOnAllMaps = isSpawnOnAllMaps; + this.MapStrategy = mapStrategy; this.X = x; this.Y = y; } @@ -54,33 +61,44 @@ public InvasionSpawnConfiguration(ushort monsterId, ushort count, IList /// [Required] [MinLength(1)] - public IList MapIds { get; set; } = new List(); + public IList MapIds { get; set; } = []; /// - /// Gets or sets a value indicating whether to spawn on all maps in the list. - /// If false, picks a random map. + /// Gets or sets the strategy used to select a map when spawning. /// - public bool IsSpawnOnAllMaps { get; set; } + public SpawnMapStrategy MapStrategy { get; set; } + + /// + /// Gets or sets a value indicating whether the monster spawns on all maps in . + /// When set to true, is changed to . + /// When set to false, is changed to . + /// This property exists for UI binding and serialization compatibility. + /// + public bool IsSpawnOnAllMaps + { + get => this.MapStrategy == SpawnMapStrategy.AllMaps; + set => this.MapStrategy = value ? SpawnMapStrategy.AllMaps : SpawnMapStrategy.RandomMap; + } /// /// Gets or sets the fixed X coordinate. - /// If null, a random coordinate is used. + /// If null, a random walkable coordinate is used. /// [Range(0, 255)] public byte? X { get; set; } /// /// Gets or sets the fixed Y coordinate. - /// If null, a random coordinate is used. + /// If null, a random walkable coordinate is used. /// [Range(0, 255)] public byte? Y { get; set; } - /// - /// Determines whether this instance is equal to another . - /// - /// The object to compare with. - /// True if the instances are equal; otherwise, false. + /// + /// + /// Equality is based solely on , as each monster type + /// may only have one spawn configuration per invasion event. + /// public override bool Equals(object? obj) { if (obj is not InvasionSpawnConfiguration other) @@ -88,26 +106,14 @@ public override bool Equals(object? obj) return false; } - return this.MonsterId == other.MonsterId - && this.Count == other.Count - && this.IsSpawnOnAllMaps == other.IsSpawnOnAllMaps - && this.X == other.X - && this.Y == other.Y - && this.MapIds.SequenceEqual(other.MapIds); + return this.MonsterId == other.MonsterId; } - /// - /// Returns a hash code for this instance based on , , , , , and . - /// - /// A hash code based on the configuration properties. - public override int GetHashCode() - { - var hash = HashCode.Combine(this.MonsterId, this.Count, this.IsSpawnOnAllMaps, this.X, this.Y); - foreach (var mapId in this.MapIds) - { - hash = HashCode.Combine(hash, mapId); - } - - return hash; - } + /// + /// + /// The hash code is based solely on , consistent with . + /// This means the object can be safely mutated (maps, count, strategy, coordinates) + /// while held in a hash-based collection without becoming unreachable. + /// + public override int GetHashCode() => this.MonsterId.GetHashCode(); } \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs b/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs index 0bef7173a..e4c4290c0 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs @@ -6,80 +6,25 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; using System.ComponentModel.DataAnnotations; using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; -using MUnique.OpenMU.PlugIns; /// -/// Abstract configuration for periodic invasions. +/// Configuration data for a periodic invasion event. +/// Responsible only for describing the shape of the configuration (SRP). +/// Default values live in . /// public class PeriodicInvasionConfiguration : PeriodicTaskConfiguration { - private const ushort LorenciaId = 0; - private const ushort DeviasId = 2; - private const ushort NoriaId = 3; - private const ushort AtlansId = 7; - private const ushort TarkanId = 8; - - private const ushort GoldenBudgeDragonId = 43; - private const ushort GoldenGoblinId = 78; - private const ushort GoldenSoldierId = 54; - private const ushort GoldenTitanId = 53; - private const ushort GoldenDragonId = 79; - private const ushort GoldenVeparId = 81; - private const ushort GoldenLizardKingId = 80; - private const ushort GoldenWheelId = 83; - private const ushort GoldenTantallosId = 82; - - private const ushort RedDragonId = 44; - /// /// Initializes a new instance of the class. /// public PeriodicInvasionConfiguration() { - this.Message = "Invasion's been started!"; + this.Message = "Invasion has started!"; } /// - /// Gets the default configuration for Golden Invasion. - /// - public static PeriodicInvasionConfiguration DefaultGoldenInvasion => new() - { - TaskDuration = TimeSpan.FromMinutes(5), - PreStartMessageDelay = TimeSpan.FromSeconds(3), - Message = "[{mapName}] Golden Invasion!", - Timetable = GenerateTimeSequence(TimeSpan.FromHours(4)).ToList(), - Mobs = new List - { - new(GoldenBudgeDragonId, 20, new List { LorenciaId }, false), - new(GoldenGoblinId, 20, new List { NoriaId }, false), - new(GoldenSoldierId, 20, new List { DeviasId }, false), - new(GoldenTitanId, 10, new List { DeviasId }, false), - new(GoldenVeparId, 20, new List { AtlansId }, false), - new(GoldenLizardKingId, 10, new List { AtlansId }, false), - new(GoldenWheelId, 20, new List { TarkanId }, false), - new(GoldenTantallosId, 10, new List { TarkanId }, false), - new(GoldenDragonId, 10, new List { LorenciaId, NoriaId, DeviasId, AtlansId, TarkanId }, false), - }, - }; - - /// - /// Gets the default configuration for the Red Dragon Invasion. - /// - public static PeriodicInvasionConfiguration DefaultRedDragonInvasion => new() - { - TaskDuration = TimeSpan.FromMinutes(10), - PreStartMessageDelay = TimeSpan.FromSeconds(3), - Message = "[{mapName}] Red Dragon Invasion!", - Timetable = GenerateTimeSequence(TimeSpan.FromHours(6), new TimeOnly(2, 0)).ToList(), - Mobs = new List - { - new(RedDragonId, 5, new List { LorenciaId, NoriaId, DeviasId }, false), - }, - }; - - /// - /// Gets or sets the monster spawns. + /// Gets or sets the monster spawns for this invasion. /// [Display(Name = "Monster Spawns", Order = 5)] - public IList Mobs { get; set; } = new List(); -} + public IList Mobs { get; set; } = []; +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/RedDragonInvasionPlugIn.cs b/src/GameLogic/PlugIns/InvasionEvents/RedDragonInvasionPlugIn.cs index 2fb94431c..d703a5ec3 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/RedDragonInvasionPlugIn.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/RedDragonInvasionPlugIn.cs @@ -8,30 +8,25 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; using MUnique.OpenMU.PlugIns; /// -/// This plugin enables Red Dragon Invasion feature. +/// Enables the Red Dragon Invasion feature. /// [PlugIn] [Display(Name = nameof(PlugInResources.RedDragonInvasionPlugIn_Name), Description = nameof(PlugInResources.RedDragonInvasionPlugIn_Description), ResourceType = typeof(PlugInResources))] [Guid("548A76CC-242C-441C-BC9D-6C22745A2D72")] -public class RedDragonInvasionPlugIn : BaseInvasionPlugIn, ISupportDefaultCustomConfiguration +public sealed class RedDragonInvasionPlugIn : SimpleInvasionPlugIn { - private const ushort LorenciaId = 0; - private const ushort DeviasId = 2; - private const ushort NoriaId = 3; - - private static readonly IReadOnlyList DisplayMaps = new ushort[] { LorenciaId, NoriaId, DeviasId }; + private static readonly IReadOnlyList DisplayMaps = + [ + InvasionMaps.Lorencia, + InvasionMaps.Noria, + InvasionMaps.Devias, + ]; /// /// Initializes a new instance of the class. /// public RedDragonInvasionPlugIn() - : base(MapEventType.RedDragonInvasion) + : base(MapEventType.RedDragonInvasion, DisplayMaps, () => InvasionConfigurationDefaults.RedDragon) { } - - /// - protected override IReadOnlyList EventDisplayMapIds => DisplayMaps; - - /// - public object CreateDefaultConfig() => PeriodicInvasionConfiguration.DefaultRedDragonInvasion; } \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/SimpleInvasionPlugIn.cs b/src/GameLogic/PlugIns/InvasionEvents/SimpleInvasionPlugIn.cs new file mode 100644 index 000000000..c0306d1a2 --- /dev/null +++ b/src/GameLogic/PlugIns/InvasionEvents/SimpleInvasionPlugIn.cs @@ -0,0 +1,37 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; + +using MUnique.OpenMU.PlugIns; + +/// +/// Convenience base for invasion plugins that need no logic beyond choosing a display +/// map and returning a default configuration. +/// +public abstract class SimpleInvasionPlugIn + : BaseInvasionPlugIn, ISupportDefaultCustomConfiguration +{ + private readonly IReadOnlyList _displayMaps; + private readonly Func _defaultConfigFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The map-event type for UI broadcasting. + /// Maps eligible to be shown in the event UI. + /// Factory that returns the default configuration. + protected SimpleInvasionPlugIn(MapEventType mapEventType, IReadOnlyList displayMaps, Func defaultConfigFactory) + : base(mapEventType) + { + this._displayMaps = displayMaps; + this._defaultConfigFactory = defaultConfigFactory; + } + + /// + protected override IReadOnlyList EventDisplayMapIds => this._displayMaps; + + /// + public object CreateDefaultConfig() => this._defaultConfigFactory(); +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/InvasionEvents/SpawnMapStrategy.cs b/src/GameLogic/PlugIns/InvasionEvents/SpawnMapStrategy.cs new file mode 100644 index 000000000..b1ef20dcd --- /dev/null +++ b/src/GameLogic/PlugIns/InvasionEvents/SpawnMapStrategy.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.InvasionEvents; + +/// +/// Defines how the spawn map is selected when multiple map IDs are configured. +/// +public enum SpawnMapStrategy +{ + /// + /// A single map is picked at random from the configured list. + /// + RandomMap, + + /// + /// The monster spawns on every map in the configured list. + /// + AllMaps, +} \ No newline at end of file diff --git a/src/Web/Shared/Components/Form/InvasionSpawnTable.razor.cs b/src/Web/Shared/Components/Form/InvasionSpawnTable.razor.cs index 5a6d45c6e..1c52027fa 100644 --- a/src/Web/Shared/Components/Form/InvasionSpawnTable.razor.cs +++ b/src/Web/Shared/Components/Form/InvasionSpawnTable.razor.cs @@ -20,11 +20,8 @@ public partial class InvasionSpawnTable : InputBase _viewModels = new(); - private IEnumerable? _allMonsters; - private IEnumerable? _allMaps; - private bool _parsing; /// @@ -194,6 +191,8 @@ public int Count /// /// Gets or sets a value indicating whether to spawn on all maps or just a random one. + /// Delegates to via the + /// bridge property. /// public bool IsSpawnOnAllMaps { @@ -234,16 +233,24 @@ private class SyncedMapList : IList private readonly List _inner; private readonly SpawnViewModel _owner; + /// + /// Initializes a new instance of the class. + /// + /// The initial map items. + /// The owning view model to sync back to. public SyncedMapList(IEnumerable items, SpawnViewModel owner) { this._inner = new List(items); this._owner = owner; } + /// public int Count => this._inner.Count; + /// public bool IsReadOnly => false; + /// public GameMapDefinition this[int index] { get => this._inner[index]; @@ -254,12 +261,14 @@ public GameMapDefinition this[int index] } } + /// public void Add(GameMapDefinition item) { this._inner.Add(item); this._owner.SyncMapIdsToModel(); } + /// public bool Remove(GameMapDefinition item) { var result = this._inner.Remove(item); @@ -271,32 +280,40 @@ public bool Remove(GameMapDefinition item) return result; } + /// public void Clear() { this._inner.Clear(); this._owner.SyncMapIdsToModel(); } + /// public void Insert(int index, GameMapDefinition item) { this._inner.Insert(index, item); this._owner.SyncMapIdsToModel(); } + /// public void RemoveAt(int index) { this._inner.RemoveAt(index); this._owner.SyncMapIdsToModel(); } + /// public bool Contains(GameMapDefinition item) => this._inner.Contains(item); + /// public void CopyTo(GameMapDefinition[] array, int arrayIndex) => this._inner.CopyTo(array, arrayIndex); + /// public int IndexOf(GameMapDefinition item) => this._inner.IndexOf(item); + /// public IEnumerator GetEnumerator() => this._inner.GetEnumerator(); + /// System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => this.GetEnumerator(); } } From 4601335aca9f9d0ec2dddb4c46020abc44f8fcd4 Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:20:19 -0300 Subject: [PATCH 04/11] feature: add periodic event end message --- .../InvasionEvents/BaseInvasionPlugIn.cs | 40 ++++++++++++++++++- .../InvasionConfigurationDefaults.cs | 12 +++--- .../InvasionEvents/InvasionGameServerState.cs | 2 +- .../PeriodicInvasionConfiguration.cs | 3 +- .../PeriodicTasks/HappyHourConfiguration.cs | 5 ++- .../PlugIns/PeriodicTasks/HappyHourPlugIn.cs | 4 +- .../PeriodicTaskConfiguration.cs | 12 ++++-- .../Properties/PlugInResources.Designer.cs | 32 +++++++++++---- src/GameLogic/Properties/PlugInResources.resx | 16 +++++--- 9 files changed, 99 insertions(+), 27 deletions(-) diff --git a/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs b/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs index 825716494..a6b65166e 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs @@ -187,7 +187,7 @@ protected async Task TrySendStartMessageAsync(Player player, InvasionGameServerS ?.Name.GetTranslation(player.Culture) ?? string.Empty; - var message = (configuration.Message.GetTranslation(player.Culture) + var message = (configuration.StartMessage.GetTranslation(player.Culture) ?? PlugInResources.BaseInvasionPlugIn_DefaultStartMessage) .Replace("{mapName}", mapName, StringComparison.InvariantCulture); @@ -201,6 +201,42 @@ protected async Task TrySendStartMessageAsync(Player player, InvasionGameServerS } } + /// + /// Sends the invasion end message to a single player. + /// + /// The player. + /// The server state. + protected async Task TrySendEndMessageAsync(Player player, InvasionGameServerState state) + { + var configuration = this.Configuration; + if (configuration is null || state.MapIds.Count == 0 || string.IsNullOrWhiteSpace(configuration.EndMessage)) + { + return; + } + + var mapName = state.Context.Configuration.Maps + .FirstOrDefault(m => m.Number == state.MapId) + ?.Name.GetTranslation(player.Culture) + ?? string.Empty; + + var message = (configuration.EndMessage.GetTranslation(player.Culture) ?? string.Empty) + .Replace("{mapName}", mapName, StringComparison.InvariantCulture); + + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + try + { + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogDebug(ex, "Unexpected error sending invasion end message."); + } + } + /// protected override async ValueTask OnPreparedAsync(InvasionGameServerState state) { @@ -222,6 +258,8 @@ protected override async ValueTask OnStartedAsync(InvasionGameServerState state) /// protected override async ValueTask OnFinishedAsync(InvasionGameServerState state) { + await state.Context.ForEachPlayerAsync(p => this.TrySendEndMessageAsync(p, state)).ConfigureAwait(false); + if (this._mapEventType is not null) { await state.Context.ForEachPlayerAsync(p => this.TrySendMapEventStateUpdateAsync(p, false, state)).ConfigureAwait(false); diff --git a/src/GameLogic/PlugIns/InvasionEvents/InvasionConfigurationDefaults.cs b/src/GameLogic/PlugIns/InvasionEvents/InvasionConfigurationDefaults.cs index 81059b6e6..f071126df 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/InvasionConfigurationDefaults.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/InvasionConfigurationDefaults.cs @@ -16,9 +16,10 @@ internal static class InvasionConfigurationDefaults /// public static PeriodicInvasionConfiguration Golden => new() { - TaskDuration = TimeSpan.FromMinutes(5), + TaskDuration = TimeSpan.FromMinutes(30), PreStartMessageDelay = TimeSpan.FromSeconds(3), - Message = "[{mapName}] Golden Invasion!", + StartMessage = "[{mapName}] Golden invasion!", + EndMessage = "[{mapName}] Golden invasion has ended.", Timetable = PeriodicTaskConfiguration.GenerateTimeSequence(TimeSpan.FromHours(4)).ToList(), Mobs = [ @@ -30,7 +31,7 @@ internal static class InvasionConfigurationDefaults new(InvasionMonsters.GoldenLizardKing, 10, [InvasionMaps.Atlans], SpawnMapStrategy.RandomMap), new(InvasionMonsters.GoldenWheel, 20, [InvasionMaps.Tarkan], SpawnMapStrategy.RandomMap), new(InvasionMonsters.GoldenTantallos, 10, [InvasionMaps.Tarkan], SpawnMapStrategy.RandomMap), - new(InvasionMonsters.GoldenDragon, 10, [InvasionMaps.Lorencia, InvasionMaps.Noria, InvasionMaps.Devias, InvasionMaps.Atlans, InvasionMaps.Tarkan ], SpawnMapStrategy.RandomMap), + new(InvasionMonsters.GoldenDragon, 10, [InvasionMaps.Lorencia, InvasionMaps.Noria, InvasionMaps.Devias, InvasionMaps.Atlans, InvasionMaps.Tarkan], SpawnMapStrategy.RandomMap), ], }; @@ -39,9 +40,10 @@ internal static class InvasionConfigurationDefaults /// public static PeriodicInvasionConfiguration RedDragon => new() { - TaskDuration = TimeSpan.FromMinutes(10), + TaskDuration = TimeSpan.FromMinutes(30), PreStartMessageDelay = TimeSpan.FromSeconds(3), - Message = "[{mapName}] Red Dragon Invasion!", + StartMessage = "[{mapName}] Red Dragon invasion!", + EndMessage = "[{mapName}] Red Dragon invasion has ended.", Timetable = PeriodicTaskConfiguration.GenerateTimeSequence(TimeSpan.FromHours(6), new TimeOnly(2, 0)).ToList(), Mobs = [ diff --git a/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs b/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs index edcfe7d9b..44442a3af 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/InvasionGameServerState.cs @@ -8,7 +8,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; using MUnique.OpenMU.Interfaces; /// -/// Invasion state that is created fresh for every periodic invasion run. +/// Invasion state that is created for every periodic invasion run. /// public class InvasionGameServerState : PeriodicTaskGameServerState { diff --git a/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs b/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs index e4c4290c0..51b83ab51 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs @@ -19,7 +19,8 @@ public class PeriodicInvasionConfiguration : PeriodicTaskConfiguration /// public PeriodicInvasionConfiguration() { - this.Message = "Invasion has started!"; + this.StartMessage = "Invasion has started!"; + this.EndMessage = "Invasion has ended!"; } /// diff --git a/src/GameLogic/PlugIns/PeriodicTasks/HappyHourConfiguration.cs b/src/GameLogic/PlugIns/PeriodicTasks/HappyHourConfiguration.cs index c31695d90..8593c4853 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/HappyHourConfiguration.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/HappyHourConfiguration.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -18,7 +18,8 @@ public class HappyHourConfiguration : PeriodicTaskConfiguration { TaskDuration = TimeSpan.FromHours(1), PreStartMessageDelay = TimeSpan.FromSeconds(0), - Message = "Happy Hour event has been started!", + StartMessage = "Happy Hour event has been started!", + EndMessage = "Happy Hour event has ended!", Timetable = GenerateTimeSequence(TimeSpan.FromHours(6), new TimeOnly(0, 5)).ToList(), // Every 6 hours, ExperienceMultiplier = 1.5f, }; diff --git a/src/GameLogic/PlugIns/PeriodicTasks/HappyHourPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/HappyHourPlugIn.cs index 18197f2e3..aa6706e6e 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/HappyHourPlugIn.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/HappyHourPlugIn.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -124,7 +124,7 @@ protected async Task TrySendStartMessageAsync(Player player) try { - var message = configuration.Message.GetTranslation(player.Culture) is { Length: > 0 } translation ? translation : player.GetLocalizedMessage(nameof(PlayerMessage.HappyHourEventHasBeenStarted)); + var message = configuration.StartMessage.GetTranslation(player.Culture) is { Length: > 0 } translation ? translation : player.GetLocalizedMessage(nameof(PlayerMessage.HappyHourEventHasBeenStarted)); await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, Interfaces.MessageType.GoldenCenter)).ConfigureAwait(false); } catch (Exception ex) diff --git a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs index 6367a6178..bfbadd6c1 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs @@ -30,10 +30,16 @@ public class PeriodicTaskConfiguration public TimeSpan TaskDuration { get; set; } = TimeSpan.FromMinutes(5); /// - /// Gets or sets the text which prints as a golden message in the game. + /// Gets or sets the text which prints as a golden message in the game when task starts. /// - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_Message_Name), Order = 3)] - public LocalizedString Message { get; set; } + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_StartMessage_Name), Order = 3)] + public LocalizedString StartMessage { get; set; } + + /// + /// Gets or sets the text which prints as a golden message in the game when task ends. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_EndMessage_Name), Order = 4)] + public LocalizedString EndMessage { get; set; } /// /// Generate a sequence of time points like [00:00, 00:01, ...]. diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index b5d5b231d..63a0369dd 100644 --- a/src/GameLogic/Properties/PlugInResources.Designer.cs +++ b/src/GameLogic/Properties/PlugInResources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // Runtime Version:4.0.30319.42000 @@ -1609,20 +1609,38 @@ public static string PeriodicSaveProgressPlugInConfiguration_Interval_Name { } /// - /// Looks up a localized string similar to The text which prints as a golden message in the game.. + /// Looks up a localized string similar to The text which prints as a golden message in the game when task starts.. /// - public static string PeriodicTaskConfiguration_Message_Description { + public static string PeriodicTaskConfiguration_StartMessage_Description { get { - return ResourceManager.GetString("PeriodicTaskConfiguration_Message_Description", resourceCulture); + return ResourceManager.GetString("PeriodicTaskConfiguration_StartMessage_Description", resourceCulture); } } /// - /// Looks up a localized string similar to Message. + /// Looks up a localized string similar to Start message. + /// + public static string PeriodicTaskConfiguration_StartMessage_Name { + get { + return ResourceManager.GetString("PeriodicTaskConfiguration_StartMessage_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The text which prints as a golden message in the game when task ends.. + /// + public static string PeriodicTaskConfiguration_EndMessage_Description { + get { + return ResourceManager.GetString("PeriodicTaskConfiguration_EndMessage_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to End message. /// - public static string PeriodicTaskConfiguration_Message_Name { + public static string PeriodicTaskConfiguration_EndMessage_Name { get { - return ResourceManager.GetString("PeriodicTaskConfiguration_Message_Name", resourceCulture); + return ResourceManager.GetString("PeriodicTaskConfiguration_EndMessage_Name", resourceCulture); } } diff --git a/src/GameLogic/Properties/PlugInResources.resx b/src/GameLogic/Properties/PlugInResources.resx index f31d436ba..6f488c63a 100644 --- a/src/GameLogic/Properties/PlugInResources.resx +++ b/src/GameLogic/Properties/PlugInResources.resx @@ -1,4 +1,4 @@ - +