diff --git a/src/Dapr/GameServer.Host/ConfigurationChangeController.cs b/src/Dapr/GameServer.Host/ConfigurationChangeController.cs index 03cc71691..253367bf9 100644 --- a/src/Dapr/GameServer.Host/ConfigurationChangeController.cs +++ b/src/Dapr/GameServer.Host/ConfigurationChangeController.cs @@ -9,6 +9,7 @@ namespace MUnique.OpenMU.GameServer.Host; using MUnique.OpenMU.Dapr.Common; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Persistence.EntityFramework; +using MUnique.OpenMU.PlugIns; /// /// The API controller which handles the calls of the @@ -21,14 +22,17 @@ namespace MUnique.OpenMU.GameServer.Host; public class ConfigurationChangeController : ControllerBase { private readonly IConfigurationChangeListener _changeListener; + private readonly PlugInManager _plugInManager; /// /// Initializes a new instance of the class. /// /// The change listener. - public ConfigurationChangeController(IConfigurationChangeListener changeListener) + /// The plugin manager. + public ConfigurationChangeController(IConfigurationChangeListener changeListener, PlugInManager plugInManager) { this._changeListener = changeListener; + this._plugInManager = plugInManager; } /// @@ -50,6 +54,7 @@ public ValueTask ConfigurationAddedAsync([FromBody] ConfigurationChangeArguments [Topic("pubsub", nameof(IConfigurationChangePublisher.ConfigurationChangedAsync))] public ValueTask ConfigurationChangedAsync([FromBody] ConfigurationChangeArguments arguments) { + this._plugInManager.ApplyChangedConfiguration(arguments.Type, arguments.Id, arguments.Configuration); return this._changeListener.ConfigurationChangedAsync(arguments.Type, arguments.Id, arguments.Configuration!, null); } @@ -61,6 +66,7 @@ public ValueTask ConfigurationChangedAsync([FromBody] ConfigurationChangeArgumen [Topic("pubsub", nameof(IConfigurationChangePublisher.ConfigurationRemovedAsync))] public ValueTask ConfigurationRemovedAsync([FromBody] ConfigurationChangeArguments arguments) { + this._plugInManager.ApplyRemovedConfiguration(arguments.Type, arguments.Id); return this._changeListener.ConfigurationRemovedAsync(arguments.Type, arguments.Id, null, null); } -} \ No newline at end of file +} diff --git a/src/GameLogic/GameMapTerrain.cs b/src/GameLogic/GameMapTerrain.cs index 44d559f38..b0bb2e248 100644 --- a/src/GameLogic/GameMapTerrain.cs +++ b/src/GameLogic/GameMapTerrain.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -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,42 @@ 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. + /// + public Point? RandomWalkableCoordinate + { + get + { + 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 +141,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..773413619 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/BaseInvasionPlugIn.cs @@ -1,71 +1,47 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; +using System.Collections.Concurrent; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Pathfinding; using MUnique.OpenMU.PlugIns; /// /// Base class for invasion plugins. /// -/// Configuration. +/// The concrete configuration type. 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; + private readonly ConcurrentDictionary _cleanupHandlers = new(); /// - /// Initializes a new instance of the class. + /// 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) + /// + /// 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; - this._mobs = mobs ?? []; - this._mobsOnSelectedMap = mobsOnSelectedMap ?? []; } /// - /// Occurs when the event has finished. - /// - public event EventHandler? Finished; - - /// - /// Gets possible maps for the event. + /// Gets the list of map IDs from which the event display map is randomly selected. + /// 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 ushort[] PossibleMaps { get; } = { LorenciaId, NoriaId, DeviasId }; + protected virtual IReadOnlyList? EventDisplayMapIds => null; /// public virtual async ValueTask ObjectAddedToMapAsync(GameMap map, ILocateable addedObject) @@ -79,227 +55,334 @@ 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); } } /// - /// Create a new monster. + /// 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 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, ILogger logger, GameMap gameMap, MonsterDefinition monsterDefinition, ushort quantity, byte? x = null, byte? y = null) { - var area = new MonsterSpawnArea + for (var i = 0; i < quantity; i++) { - GameMap = gameMap.Definition, - MonsterDefinition = monsterDefinition, - SpawnTrigger = SpawnTrigger.OnceAtEventStart, - Quantity = 1, - X1 = x1, - X2 = x2, - Y1 = y1, - Y2 = y2, - }; - - while (quantity-- > 0) - { - var intelligence = new BasicMonsterIntelligence(); + Point? spawnPoint = (x.HasValue && y.HasValue) + ? new Point(x.Value, y.Value) + : gameMap.Terrain.RandomWalkableCoordinate; - var monster = new Monster(area, monsterDefinition, gameMap, gameContext.DropGenerator, intelligence, gameContext.PlugInManager, gameContext.PathFinderPool); + if (spawnPoint is null) + { + logger.LogDebug("Skipping one {Monster} on {Map}: no walkable cell found.", 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 - { - try - { - this.Finished -= CleanUpOnFinish; - if (monster is not null && !monster.IsDisposed) - { - await monster.CurrentMap.RemoveAsync(monster).ConfigureAwait(false); - monster.Dispose(); - } - } - catch - { - // must be catched in async void method - } - } + var handler = new MonsterCleanupHandler(this, monster); + this._cleanupHandlers.TryAdd(handler, 0); + monster.Died += (_, _) => this._cleanupHandlers.TryRemove(handler, out _); } } /// - /// Spawn mobs on the map. + /// Spawns all configured mobs for the given . /// /// 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); - if (gameMap is null) { return; } 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, 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.", mob.MonsterId); + logger.LogDebug("Skipping monster {MobId}: definition not 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)]; + state.Reset(); + + var config = this.Configuration; + if (config?.Mobs is not { Count: > 0 } mobs) + { + return; + } + + this.SelectSpawnMaps(mobs, state); + this.SelectDisplayMap(state); } /// protected override InvasionGameServerState CreateState(IGameContext gameContext) - { - return new InvasionGameServerState(gameContext); - } + => new(gameContext); /// - /// Returns true if the player stays on the map. + /// Sends the invasion start message to a single player. /// /// The player. - /// True, if need to check current event map. - protected bool IsPlayerOnMap(Player player, bool checkForCurrentMap = false) + /// The server state. + protected async Task TrySendStartMessageAsync(Player player, InvasionGameServerState state) { - var state = this.GetStateByGameContext(player.GameContext); + var configuration = this.Configuration; + if (configuration is null || state.MapIds.Count == 0) + { + return; + } + + var mapName = state.Context.Configuration.Maps + .FirstOrDefault(m => m.Number == state.MapId) + ?.Name.GetTranslation(player.Culture) + ?? string.Empty; + + var message = (configuration.StartMessage.GetTranslation(player.Culture) + ?? PlugInResources.BaseInvasionPlugIn_DefaultStartMessage) + .Replace("{mapName}", mapName, StringComparison.InvariantCulture); - return player.CurrentMap is { } map - && !player.PlayerState.CurrentState.IsDisconnectedOrFinished() - && (!checkForCurrentMap || map.MapId == state.MapId); + try + { + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogDebug(ex, "Unexpected error sending invasion start message."); + } } /// - /// Send a golden message to player's client. + /// Sends the invasion end message to a single player. /// /// The player. - /// The map name. - protected async Task TrySendStartMessageAsync(Player player, LocalizedString mapName) + /// The server state. + protected async Task TrySendEndMessageAsync(Player player, InvasionGameServerState state) { var configuration = this.Configuration; - - if (configuration is null) + if (configuration is null || state.MapIds.Count == 0 || string.IsNullOrWhiteSpace(configuration.EndMessage)) { 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.EndMessage.GetTranslation(player.Culture) ?? string.Empty) + .Replace("{mapName}", mapName, StringComparison.InvariantCulture); + + if (string.IsNullOrWhiteSpace(message)) { - 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."); - } + 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."); } } - /// - /// 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) { - await state.Context.ForEachPlayerAsync(p => this.TrySendMapEventStateUpdateAsync(p, true)).ConfigureAwait(false); + await state.Context.ForEachPlayerAsync(p => this.TrySendMapEventStateUpdateAsync(p, true, state)).ConfigureAwait(false); } } - /// - /// 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) { + 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)).ConfigureAwait(false); + await state.Context.ForEachPlayerAsync(p => this.TrySendMapEventStateUpdateAsync(p, false, state)).ConfigureAwait(false); } - this.Finished?.Invoke(this, EventArgs.Empty); + if (this._cleanupHandlers.Count > 0) + { + var handlers = this._cleanupHandlers.Keys.ToArray(); + if (handlers.Length > 0) + { + await Task.WhenAll(handlers.Select(h => h.CleanUpAsync())).ConfigureAwait(false); + } + } } /// - /// Spawn mobs on the selected map. + /// Spawns mobs on the maps that were selected during . /// /// The state. - protected virtual async ValueTask SpawnMobsOnSelectedMapAsync(InvasionGameServerState state) + protected virtual async ValueTask SpawnMobsOnMapsAsync(InvasionGameServerState state) { + var config = this.Configuration; + if (config?.Mobs is not { Count: > 0 } spawns) + { + return; + } + var gameContext = state.Context; - await this.SpawnMobsAsync(gameContext, state.MapId, this._mobsOnSelectedMap).ConfigureAwait(false); + + foreach (var spawn in spawns) + { + 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); + } + else + { + // This indicates an unexpected state or configuration mismatch. + } + } } /// - /// Spawn mobs on the map. + /// 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 state. - protected virtual async ValueTask SpawnMobsOnMapsAsync(InvasionGameServerState state) + /// The mob spawn configurations. + /// The current invasion state. + private void SelectSpawnMaps(IList mobs, InvasionGameServerState state) { - var gameContext = state.Context; + 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); + } + } + } - foreach (var group in this._mobs.GroupBy(mob => mob.MapId!.Value)) + /// + /// 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 (state.MapIds.Count == 0) { - var mapId = group.Key; - var mobs = group; + return; + } - await this.SpawnMobsAsync(gameContext, mapId, mobs).ConfigureAwait(false); + 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 async Task TrySendMapEventStateUpdateAsync(Player player, bool enabled) + 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.IsPlayerOnMap(player, true)) + if (this._mapEventType is null || !this.IsPlayerOnRelevantMap(player, state)) { return; } @@ -310,7 +393,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 {this._mapEventType}."); + player.Logger.LogDebug( + ex, + "Unexpected error sending map event state update, event type: {MapEventType}", + this._mapEventType); + } + } + + /// + /// 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 + { + 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; + } + + /// + /// 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.TryRemove(this, out _); + 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 84bd64e32..be99bb858 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. // @@ -8,46 +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 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 static readonly IReadOnlyList DisplayMaps = + [ + InvasionMaps.Lorencia, + InvasionMaps.Noria, + InvasionMaps.Devias, + ]; /// /// 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, DisplayMaps, () => InvasionConfigurationDefaults.Golden) { } - - /// - 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..f071126df --- /dev/null +++ b/src/GameLogic/PlugIns/InvasionEvents/InvasionConfigurationDefaults.cs @@ -0,0 +1,53 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.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(30), + PreStartMessageDelay = TimeSpan.FromSeconds(3), + StartMessage = "[{mapName}] Golden invasion!", + EndMessage = "[{mapName}] Golden invasion has ended.", + 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(30), + PreStartMessageDelay = TimeSpan.FromSeconds(3), + StartMessage = "[{mapName}] Red Dragon invasion!", + EndMessage = "[{mapName}] Red Dragon invasion has ended.", + 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 750774c72..6d738c5e7 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. // @@ -8,37 +8,65 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents; using MUnique.OpenMU.Interfaces; /// -/// Game server state per event. +/// Invasion state that is created 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 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; } + public ushort? MapId { get; set; } /// - /// Gets the map. + /// Gets the set of map identifiers on which monsters will spawn this run. /// - public GameMapDefinition Map => this.Context.Configuration.Maps.First(m => m.Number == this.MapId); + public IReadOnlySet MapIds => this._mapIds; /// - /// Gets the name of the map. + /// Gets the read-only mapping of monster ID to selected map identifier. + /// Populated for spawns whose is . /// - public LocalizedString MapName => this.Map.Name; + public IReadOnlyDictionary SelectedMaps => this._selectedMaps; - /// - public override string? ToString() + /// + /// Registers a map as active for this run and optionally records which map was + /// randomly chosen for a particular monster type. + /// + /// 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() { - return this.MapName.ToString(); + 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/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/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 new file mode 100644 index 000000000..d306cfcde --- /dev/null +++ b/src/GameLogic/PlugIns/InvasionEvents/InvasionSpawnConfiguration.cs @@ -0,0 +1,119 @@ +// +// 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. + /// Required for serialization and UI binding. + /// + 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. + /// 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, + SpawnMapStrategy mapStrategy, + byte? x = null, + byte? y = null) + { + this.MonsterId = monsterId; + this.Count = count; + this.MapIds = mapIds; + this.MapStrategy = mapStrategy; + 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; } = []; + + /// + /// Gets or sets the strategy used to select a map when spawning. + /// + 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 walkable coordinate is used. + /// + [Range(0, 255)] + public byte? X { get; set; } + + /// + /// Gets or sets the fixed Y coordinate. + /// If null, a random walkable coordinate is used. + /// + [Range(0, 255)] + public byte? Y { get; set; } + + /// + /// + /// 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) + { + return false; + } + + return this.MonsterId == other.MonsterId; + } + + /// + /// + /// 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 f50444f0c..95a23f6b4 100644 --- a/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs +++ b/src/GameLogic/PlugIns/InvasionEvents/PeriodicInvasionConfiguration.cs @@ -1,13 +1,16 @@ -// +// // 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; /// -/// 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 { @@ -16,28 +19,13 @@ public class PeriodicInvasionConfiguration : PeriodicTaskConfiguration /// public PeriodicInvasionConfiguration() { - this.Message = "Invasion's been started!"; + this.StartMessage = "Invasion has started!"; + this.EndMessage = "Invasion has ended!"; } /// - /// Gets the default configuration. + /// Gets or sets the monster spawns for this 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 - }; - - /// - /// 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 - }; + [Display(Name = "Monster Spawns", Order = 6)] + 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 2c089c6e3..d703a5ec3 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. // @@ -8,23 +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 RedDragonId = 44; + private static readonly IReadOnlyList DisplayMaps = + [ + InvasionMaps.Lorencia, + InvasionMaps.Noria, + InvasionMaps.Devias, + ]; /// /// Initializes a new instance of the class. /// public RedDragonInvasionPlugIn() - : base(MapEventType.RedDragonInvasion, null, [new(RedDragonId, 5)]) + : base(MapEventType.RedDragonInvasion, DisplayMaps, () => InvasionConfigurationDefaults.RedDragon) { } - - /// - 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/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/PeriodicTaskBasePlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskBasePlugIn.cs index 9dd7cc2e4..a29eaaeba 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskBasePlugIn.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskBasePlugIn.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -71,6 +71,12 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) return; } + if (this.IsPreviousEventStillRunning(state)) + { + this._isStartForced = false; + return; + } + this._isStartForced = false; state.NextRunUtc = DateTime.UtcNow.Add(configuration.PreStartMessageDelay); await this.OnPrepareEventAsync(state).ConfigureAwait(false); @@ -89,6 +95,7 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) { state.NextRunUtc = DateTime.UtcNow.Add(configuration.TaskDuration); state.State = PeriodicTaskState.Started; + state.LastRunUtc = DateTime.UtcNow; await this.OnStartedAsync(state).ConfigureAwait(false); @@ -131,6 +138,15 @@ protected virtual bool IsItTimeToStart(IGameContext gameContext) return this._isStartForced || (this.Configuration?.IsItTimeToStart() ?? false); } + /// + /// 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 task state. + /// true if the previous event duration has not elapsed yet; otherwise false. + protected virtual bool IsPreviousEventStillRunning(TState state) + => state.LastRunUtc != DateTime.MinValue && state.LastRunUtc.Add(this.Configuration?.TaskDuration ?? TimeSpan.Zero) > DateTime.UtcNow; + /// /// Called when the task should be prepared before starting it. /// diff --git a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskConfiguration.cs index 39454581d..dd64c860c 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. // @@ -11,29 +11,35 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; /// public class PeriodicTaskConfiguration { - /// - /// Gets or sets a timetable for the event. - /// - [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_Timetable_Name))] - 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. + /// 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))] - 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; } + + /// + /// Gets or sets a timetable for the event. + /// + [Display(ResourceType = typeof(PlugInResources), Name = nameof(PlugInResources.PeriodicTaskConfiguration_Timetable_Name), Order = 5)] + public IList Timetable { get; set; } = new List(); /// /// Generate a sequence of time points like [00:00, 00:01, ...]. diff --git a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskGameServerState.cs b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskGameServerState.cs index 56b08fd8a..8e145ea54 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskGameServerState.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/PeriodicTaskGameServerState.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -28,6 +28,12 @@ public PeriodicTaskGameServerState(IGameContext context) /// public DateTime NextRunUtc { get; set; } = DateTime.UtcNow; + /// + /// 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; } = DateTime.MinValue; + /// /// Gets or sets the state. /// diff --git a/src/GameLogic/Properties/PlugInResources.Designer.cs b/src/GameLogic/Properties/PlugInResources.Designer.cs index 6e19694f5..2da89378c 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 @@ -1645,20 +1645,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 @@ - +