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 @@
-
+