baseline: OpenMU upstream b5a0961 (fresh source)

This commit is contained in:
Acentech Dev
2026-07-14 19:00:35 +03:00
parent 450402e47d
commit 36fc125d5c
11968 changed files with 748705 additions and 0 deletions

View File

@@ -0,0 +1,525 @@
// <copyright file="BaseInvasionPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Properties;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Base class for invasion plugins.
/// </summary>
/// <typeparam name="TConfiguration">The concrete configuration type.</typeparam>
public abstract class BaseInvasionPlugIn<TConfiguration> : PeriodicTaskBasePlugIn<TConfiguration, InvasionGameServerState>, IPeriodicTaskPlugIn, IObjectAddedToMapPlugIn, ISupportCustomConfiguration<TConfiguration>
where TConfiguration : PeriodicInvasionConfiguration
{
/// <summary>
/// Gets the map-event type used for UI state broadcasts.
/// Override to enable map-event state updates for a specific event type.
/// When <c>null</c>, map-event state updates are disabled.
/// </summary>
protected virtual MapEventType? EventType => null;
/// <summary>
/// Gets the list of map IDs from which the event display map is randomly selected.
/// When non-empty, <see cref="InvasionGameServerState.MapId"/> is chosen from this
/// list and map-event UI is shown only on that single map.
/// When <c>null</c>, <see cref="InvasionGameServerState.MapId"/> falls back to the
/// minimum of <see cref="InvasionGameServerState.MapIds"/>.
/// </summary>
protected virtual IReadOnlyList<ushort>? EventDisplayMapIds => null;
/// <summary>
/// Gets the monster ID of the featured monster whose actual spawn map(s) are named in the
/// start/end broadcast (e.g. the Golden Dragon for the Golden Invasion). When set, the
/// announcement always points at the map(s) where this monster really spawned this run -
/// the single chosen map for <see cref="SpawnMapStrategy.RandomMap"/>, or all its maps for
/// <see cref="SpawnMapStrategy.AllMaps"/>. When <c>null</c>, the display map falls back to
/// <see cref="EventDisplayMapIds"/>.
/// </summary>
protected virtual ushort? AnnouncedMonsterId => null;
/// <inheritdoc />
public virtual async ValueTask ObjectAddedToMapAsync(GameMap map, ILocateable addedObject)
{
if (this.EventType is not null && addedObject is Player player)
{
var state = this.GetStateByGameContext(player.GameContext);
await this.TrySendMapEventStateUpdateAsync(player, state).ConfigureAwait(false);
}
}
/// <summary>
/// Spawns <paramref name="quantity"/> instances of <paramref name="monsterDefinition"/> on
/// <paramref name="gameMap"/>, each placed at a random walkable coordinate (or a fixed
/// coordinate when <paramref name="x"/> and <paramref name="y"/> are provided).
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="logger">The logger.</param>
/// <param name="gameMap">The game map.</param>
/// <param name="monsterDefinition">The monster definition.</param>
/// <param name="quantity">The quantity.</param>
/// <param name="announceDeath">If set, a death-broadcast handler is attached to each spawned monster.</param>
/// <param name="x">The optional fixed X coordinate.</param>
/// <param name="y">The optional fixed Y coordinate.</param>
protected async ValueTask CreateMonstersAsync(IGameContext gameContext, ILogger logger, GameMap gameMap, MonsterDefinition monsterDefinition, ushort quantity, bool announceDeath = false, byte? x = null, byte? y = null)
{
for (var i = 0; i < quantity; i++)
{
Point? spawnPoint = (x.HasValue && y.HasValue)
? new Point(x.Value, y.Value)
: gameMap.Terrain.RandomWalkableCoordinate;
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();
var state = this.GetStateByGameContext(gameContext);
state.AddMonster(monster);
if (announceDeath)
{
this.AttachDeathBroadcast(monster, state);
}
}
}
/// <summary>
/// Spawns all configured mobs for the given <paramref name="mapId"/>.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="mapId">The map id.</param>
/// <param name="spawns">The spawn configurations.</param>
protected async ValueTask SpawnMobsAsync(IGameContext gameContext, ushort mapId, IEnumerable<InvasionSpawnConfiguration> spawns)
{
var gameMap = await gameContext.GetMapAsync(mapId).ConfigureAwait(false);
if (gameMap is null)
{
return;
}
var logger = gameContext.LoggerFactory.CreateLogger(this.GetType());
foreach (var spawn in spawns)
{
if (gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.MonsterId) is { } monsterDefinition)
{
await this.CreateMonstersAsync(gameContext, logger, gameMap, monsterDefinition, spawn.Count, spawn.AnnounceDeath, spawn.X, spawn.Y).ConfigureAwait(false);
}
else
{
logger.LogDebug("Skipping monster {MobId}: definition not found.", spawn.MonsterId);
}
}
}
/// <inheritdoc />
protected override async ValueTask OnPrepareEventAsync(InvasionGameServerState state)
{
var config = this.Configuration;
if (config?.Mobs is not { Count: > 0 } mobs)
{
return;
}
if (config.ForceSingleMap)
{
this.SelectSingleMap(state, mobs);
return;
}
this.SelectSpawnMaps(mobs, state);
this.SelectDisplayMap(state);
}
/// <inheritdoc />
protected override InvasionGameServerState CreateState(IGameContext gameContext)
=> new(gameContext);
/// <summary>
/// Sends the invasion start message to a single player.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="state">The server state.</param>
protected async Task TrySendStartMessageAsync(Player player, InvasionGameServerState state)
{
var configuration = this.Configuration;
if (configuration is null || state.MapIds.Count == 0)
{
return;
}
var mapName = BuildAnnouncedMapNames(state, player);
var message = (configuration.StartMessage.GetTranslation(player.Culture)
?? PlugInResources.BaseInvasionPlugIn_DefaultStartMessage)
.Replace("{mapName}", mapName, StringComparison.InvariantCulture);
try
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogDebug(ex, "Unexpected error sending invasion start message.");
}
}
/// <summary>
/// Sends the invasion end message to a single player.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="state">The server state.</param>
protected async Task TrySendEndMessageAsync(Player player, InvasionGameServerState state)
{
var configuration = this.Configuration;
if (configuration is null || state.MapIds.Count == 0 || string.IsNullOrWhiteSpace(configuration.EndMessage))
{
return;
}
var mapName = BuildAnnouncedMapNames(state, player);
var message = (configuration.EndMessage.GetTranslation(player.Culture) ?? string.Empty)
.Replace("{mapName}", mapName, StringComparison.InvariantCulture);
if (string.IsNullOrWhiteSpace(message))
{
return;
}
try
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogDebug(ex, "Unexpected error sending invasion end message.");
}
}
/// <inheritdoc />
protected override async ValueTask OnPreparedAsync(InvasionGameServerState state)
{
await state.Context.ForEachPlayerAsync(p => this.TrySendStartMessageAsync(p, state)).ConfigureAwait(false);
if (this.EventType is not null)
{
await state.Context.ForEachPlayerAsync(p => this.TrySendMapEventStateUpdateAsync(p, state)).ConfigureAwait(false);
}
}
/// <inheritdoc />
protected override async ValueTask OnStartedAsync(InvasionGameServerState state)
{
await this.SpawnMobsOnMapsAsync(state).ConfigureAwait(false);
}
/// <inheritdoc />
protected override async ValueTask OnFinishedAsync(InvasionGameServerState state)
{
await state.Context.ForEachPlayerAsync(p => this.TrySendEndMessageAsync(p, state)).ConfigureAwait(false);
if (this.EventType is not null)
{
await state.Context.ForEachPlayerAsync(p => this.TrySendMapEventStateUpdateAsync(p, state)).ConfigureAwait(false);
}
await state.CleanUpMonstersAsync().ConfigureAwait(false);
state.Reset();
}
/// <summary>
/// Spawns mobs on the maps that were selected during <see cref="OnPrepareEventAsync"/>.
/// </summary>
/// <param name="state">The state.</param>
protected virtual async ValueTask SpawnMobsOnMapsAsync(InvasionGameServerState state)
{
var config = this.Configuration;
if (config?.Mobs is not { Count: > 0 } spawns)
{
return;
}
var gameContext = state.Context;
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.
}
}
}
/// <summary>
/// Builds the comma-separated, localized list of map names announced in the broadcast.
/// </summary>
/// <param name="state">The current invasion state.</param>
/// <param name="player">The player whose culture is used for translation.</param>
private static string BuildAnnouncedMapNames(InvasionGameServerState state, Player player)
{
var names = state.AnnouncedMapIds
.Select(id => state.Context.Configuration.Maps
.FirstOrDefault(m => m.Number == id)
?.Name.GetTranslation(player.Culture))
.Where(name => !string.IsNullOrEmpty(name));
return string.Join(", ", names);
}
/// <summary>
/// Selects a single map from the first mob's configuration and registers all mobs on it.
/// Used when <see cref="PeriodicInvasionConfiguration.ForceSingleMap"/> is <c>true</c>.
/// </summary>
/// <param name="state">The invasion state.</param>
/// <param name="mobs">The mob spawn configurations.</param>
private void SelectSingleMap(InvasionGameServerState state, IList<InvasionSpawnConfiguration> mobs)
{
var source = this.AnnouncedMonsterId is { } announcedId
? mobs.FirstOrDefault(m => m.MonsterId == announcedId)
: null;
source ??= mobs[0];
if (source.MapIds.Count == 0)
{
return;
}
var chosenMap = source.MapIds.Count == 1
? source.MapIds[0]
: source.MapIds[Rand.NextInt(0, source.MapIds.Count)];
foreach (var mob in mobs)
{
state.RegisterMap(chosenMap, mob.MonsterId);
}
state.SetAnnouncedMaps([chosenMap]);
}
/// <summary>
/// Iterates the mob configurations and registers the selected spawn maps into the state.
/// For mobs configured with <see cref="SpawnMapStrategy.AllMaps"/>, all map IDs are registered.
/// For mobs configured with <see cref="SpawnMapStrategy.RandomMap"/>, a single map is picked at random.
/// </summary>
/// <param name="mobs">The mob spawn configurations.</param>
/// <param name="state">The current invasion state.</param>
private void SelectSpawnMaps(IList<InvasionSpawnConfiguration> mobs, InvasionGameServerState state)
{
foreach (var mob in mobs)
{
if (mob.MapIds.Count == 0)
{
continue;
}
if (mob.IsSpawnOnAllMaps)
{
foreach (var mapId in mob.MapIds)
{
state.RegisterMap(mapId);
}
}
else
{
var randomMapId = mob.MapIds.Count == 1
? mob.MapIds[0]
: mob.MapIds[Rand.NextInt(0, mob.MapIds.Count)];
state.RegisterMap(randomMapId, mob.MonsterId);
}
}
}
/// <summary>
/// Selects the single map used for UI event display and message broadcast from
/// the maps that were actually selected for spawning during <see cref="SelectSpawnMaps"/>.
/// When <see cref="EventDisplayMapIds"/> is configured, the display map is restricted
/// to the intersection of <see cref="EventDisplayMapIds"/> 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.
/// </summary>
/// <param name="state">The current invasion state.</param>
private void SelectDisplayMap(InvasionGameServerState state)
{
if (state.MapIds.Count == 0)
{
return;
}
var announcedMaps = this.GetAnnouncedMaps(state);
if (announcedMaps.Count > 0)
{
state.SetAnnouncedMaps(announcedMaps);
return;
}
if (this.EventDisplayMapIds is { Count: > 0 } displayMaps)
{
var eligible = state.MapIds
.Where(displayMaps.Contains)
.ToList();
var mapId = eligible.Count switch
{
0 => state.MapIds.Min(),
1 => eligible[0],
_ => eligible[Rand.NextInt(0, eligible.Count)],
};
state.SetAnnouncedMaps([mapId]);
}
else
{
state.SetAnnouncedMaps([state.MapIds.Min()]);
}
}
/// <summary>
/// Gets the maps where the featured <see cref="AnnouncedMonsterId"/> actually spawns this run,
/// so the broadcast names a map that really contains it. Returns an empty list when no announced
/// monster is configured or it did not spawn, in which case the caller falls back to
/// <see cref="EventDisplayMapIds"/>.
/// </summary>
/// <param name="state">The current invasion state.</param>
private IReadOnlyList<ushort> GetAnnouncedMaps(InvasionGameServerState state)
{
if (this.AnnouncedMonsterId is not { } monsterId)
{
return [];
}
// RandomMap: the single chosen map was recorded in SelectedMaps during SelectSpawnMaps.
if (state.SelectedMaps.TryGetValue(monsterId, out var selectedMapId))
{
return [selectedMapId];
}
// AllMaps: the monster spawns on every configured map, so name them all.
var mob = this.Configuration?.Mobs.FirstOrDefault(m => m.MonsterId == monsterId);
return mob is { IsSpawnOnAllMaps: true, MapIds.Count: > 0 }
? mob.MapIds.ToArray()
: [];
}
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, InvasionGameServerState state)
{
if (this.EventType is null || !this.IsPlayerOnRelevantMap(player, state))
{
return;
}
try
{
var enabled = state.State != PeriodicTaskState.NotStarted;
await player.InvokeViewPlugInAsync<IMapEventStateUpdatePlugIn>(p => p.UpdateStateAsync(enabled, this.EventType.Value)).ConfigureAwait(false);
}
catch (Exception ex)
{
player.Logger.LogDebug(
ex,
"Unexpected error sending map event state update, event type: {MapEventType}",
this.EventType);
}
}
private void AttachDeathBroadcast(Monster monster, InvasionGameServerState state)
{
var context = state.Context;
void Handler(object? sender, DeathInformation e)
{
if (sender is Monster m)
{
m.Died -= Handler;
var mapDefinition = m.CurrentMap?.Definition;
_ = Task.Run(() => BroadcastMonsterDeathAsync(m, mapDefinition, e, context));
}
}
monster.Died += Handler;
}
private static async Task BroadcastMonsterDeathAsync(Monster monster, GameMapDefinition? mapDefinition, DeathInformation e, IGameContext gameContext)
{
if (mapDefinition is null)
{
return;
}
try
{
var mapName = mapDefinition.Name;
await gameContext.ForEachPlayerAsync(async player =>
{
var translatedMapName = mapName.GetTranslation(player.Culture);
var monsterName = monster.Definition.Designation.GetTranslation(player.Culture);
try
{
await player.ShowLocalizedGoldenMessageAsync(nameof(PlayerMessage.InvasionMonsterDefeated), e.KillerName, translatedMapName, monsterName).ConfigureAwait(false);
}
catch
{
// Ignore view invocation errors
}
}).ConfigureAwait(false);
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger(typeof(BaseInvasionPlugIn<TConfiguration>)).LogError(ex, "Error during invasion monster death broadcast.");
}
}
}

View File

@@ -0,0 +1,34 @@
// <copyright file="GoldenInvasionPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Enables the Golden Invasion feature.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.GoldenInvasionPlugIn_Name), Description = nameof(PlugInResources.GoldenInvasionPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("06D18A9E-2919-4C17-9DBC-6E4F7756495C")]
public sealed class GoldenInvasionPlugIn : SimpleInvasionPlugIn
{
/// <summary>
/// Initializes a new instance of the <see cref="GoldenInvasionPlugIn"/> class.
/// </summary>
public GoldenInvasionPlugIn()
: base(() => InvasionConfigurationDefaults.Golden)
{
}
/// <inheritdoc />
protected override MapEventType? EventType => MapEventType.GoldenDragonInvasion;
/// <inheritdoc />
protected override ushort? AnnouncedMonsterId => InvasionMonsters.GoldenDragon;
/// <inheritdoc />
protected override IReadOnlyList<ushort>? EventDisplayMapIds => [InvasionMaps.Lorencia, InvasionMaps.Noria, InvasionMaps.Devias];
}

View File

@@ -0,0 +1,72 @@
// <copyright file="InvasionConfigurationDefaults.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
/// <summary>
/// Provides ready-to-use default configurations for the built-in invasion types.
/// </summary>
internal static class InvasionConfigurationDefaults
{
/// <summary>
/// Gets the default configuration for the Golden Invasion event.
/// </summary>
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], SpawnMapStrategy.RandomMap),
],
};
/// <summary>
/// Gets the default configuration for the Red Dragon Invasion event.
/// </summary>
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),
],
};
/// <summary>
/// Gets the default configuration for the White Wizard Invasion event.
/// </summary>
public static PeriodicInvasionConfiguration WhiteWizard => new()
{
TaskDuration = TimeSpan.FromMinutes(30),
PreStartMessageDelay = TimeSpan.FromSeconds(3),
StartMessage = "[{mapName}] White Wizard corps invasion!",
EndMessage = "[{mapName}] White Wizard corps invasion has ended.",
ForceSingleMap = true,
Timetable = PeriodicTaskConfiguration.GenerateTimeSequence(TimeSpan.FromHours(2), new TimeOnly(12, 0), new TimeOnly(23, 0)).ToList(),
Mobs =
[
new(InvasionMonsters.WhiteWizard, 1, [InvasionMaps.Lorencia, InvasionMaps.Noria, InvasionMaps.Devias], SpawnMapStrategy.RandomMap, announceDeath: true),
new(InvasionMonsters.DestructiveOgreSoldier, 15, [], SpawnMapStrategy.RandomMap),
new(InvasionMonsters.DestructiveOgreArcher, 10, [], SpawnMapStrategy.RandomMap),
],
};
}

View File

@@ -0,0 +1,142 @@
// <copyright file="InvasionGameServerState.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
using System.Collections.Concurrent;
using System.Linq;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
/// <summary>
/// Invasion state that is created for every periodic invasion run.
/// </summary>
public class InvasionGameServerState : PeriodicTaskGameServerState
{
private readonly HashSet<ushort> _mapIds = [];
private readonly Dictionary<ushort, ushort> _selectedMaps = [];
private readonly List<ushort> _announcedMapIds = [];
private readonly ConcurrentDictionary<Monster, byte> _monsters = new();
/// <summary>
/// Initializes a new instance of the <see cref="InvasionGameServerState"/> class.
/// </summary>
/// <param name="context">The game context.</param>
public InvasionGameServerState(IGameContext context)
: base(context)
{
}
/// <summary>
/// Gets or sets the map identifier used for UI display / map-event state broadcasts.
/// <c>null</c> means no event is active or the display map has not been selected yet.
/// </summary>
public ushort? MapId { get; set; }
/// <summary>
/// Gets the set of map identifiers on which monsters will spawn this run.
/// </summary>
public IReadOnlySet<ushort> MapIds => this._mapIds;
/// <summary>
/// Gets the read-only mapping of monster ID to selected map identifier.
/// Populated for spawns whose <see cref="SpawnMapStrategy"/> is <see cref="SpawnMapStrategy.RandomMap"/>.
/// </summary>
public IReadOnlyDictionary<ushort, ushort> SelectedMaps => this._selectedMaps;
/// <summary>
/// Gets the list of map identifiers that are named in the invasion start/end broadcast.
/// These are the maps on which the announced (featured) monster actually spawns this run,
/// so the message always points players at a map that really contains it.
/// </summary>
public IReadOnlyList<ushort> AnnouncedMapIds => this._announcedMapIds;
/// <summary>
/// Sets the maps named in the broadcast message and the single <see cref="MapId"/> used
/// for the map-event UI state.
/// </summary>
/// <param name="mapIds">The maps on which the announced monster spawns this run.</param>
internal void SetAnnouncedMaps(IReadOnlyCollection<ushort> mapIds)
{
this._announcedMapIds.Clear();
this._announcedMapIds.AddRange(mapIds);
this.MapId = this._announcedMapIds.Count > 0 ? this._announcedMapIds[0] : null;
}
/// <summary>
/// Registers a map as active for this run and optionally records which map was
/// randomly chosen for a particular monster type.
/// </summary>
/// <param name="mapId">The map identifier to register.</param>
/// <param name="monsterId">
/// When provided, records the <paramref name="mapId"/> as the chosen map for this monster.
/// Pass <c>null</c> for "spawn-on-all-maps" entries.
/// </param>
internal void RegisterMap(ushort mapId, ushort? monsterId = null)
{
this._mapIds.Add(mapId);
if (monsterId.HasValue)
{
this._selectedMaps[monsterId.Value] = mapId;
}
}
/// <summary>
/// Clears all state accumulated from a previous run so the object can be reused.
/// </summary>
internal void Reset()
{
this.MapId = null;
this._mapIds.Clear();
this._selectedMaps.Clear();
this._announcedMapIds.Clear();
}
/// <summary>
/// Tracks a monster spawned by this invasion and handles its cleanup on death.
/// </summary>
/// <param name="monster">The monster to track.</param>
internal void AddMonster(Monster monster)
{
if (this._monsters.TryAdd(monster, 0))
{
monster.Died += this.OnMonsterDied;
}
}
/// <summary>
/// Despawns and disposes all active monsters tracked by this invasion state.
/// </summary>
internal async ValueTask CleanUpMonstersAsync()
{
if (this._monsters.Count == 0)
{
return;
}
var monsters = this._monsters.Keys.ToArray();
var tasks = monsters.Select(async monster =>
{
this._monsters.TryRemove(monster, out _);
monster.Died -= this.OnMonsterDied;
if (!monster.IsDisposed)
{
await monster.CurrentMap.RemoveAsync(monster).ConfigureAwait(false);
monster.Dispose();
}
});
await Task.WhenAll(tasks).ConfigureAwait(false);
}
private void OnMonsterDied(object? sender, DeathInformation e)
{
if (sender is Monster monster)
{
this._monsters.TryRemove(monster, out _);
monster.Died -= this.OnMonsterDied;
}
}
}

View File

@@ -0,0 +1,36 @@
// <copyright file="InvasionMaps.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
/// <summary>
/// Well-known map identifiers used across invasion plugins.
/// </summary>
internal static class InvasionMaps
{
/// <summary>
/// The Lorencia map.
/// </summary>
public const ushort Lorencia = 0;
/// <summary>
/// The Devias map.
/// </summary>
public const ushort Devias = 2;
/// <summary>
/// The Noria map.
/// </summary>
public const ushort Noria = 3;
/// <summary>
/// The Atlans map.
/// </summary>
public const ushort Atlans = 7;
/// <summary>
/// The Tarkan map.
/// </summary>
public const ushort Tarkan = 8;
}

View File

@@ -0,0 +1,76 @@
// <copyright file="InvasionMonsters.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
/// <summary>
/// Monster identifiers used across invasion plugins.
/// </summary>
internal static class InvasionMonsters
{
/// <summary>
/// The Golden Budge Dragon monster.
/// </summary>
public const ushort GoldenBudgeDragon = 43;
/// <summary>
/// The Golden Soldier monster.
/// </summary>
public const ushort GoldenSoldier = 54;
/// <summary>
/// The Golden Titan monster.
/// </summary>
public const ushort GoldenTitan = 53;
/// <summary>
/// The Golden Goblin monster.
/// </summary>
public const ushort GoldenGoblin = 78;
/// <summary>
/// The Golden Dragon monster.
/// </summary>
public const ushort GoldenDragon = 79;
/// <summary>
/// The Golden Lizard King monster.
/// </summary>
public const ushort GoldenLizardKing = 80;
/// <summary>
/// The Golden Vepar monster.
/// </summary>
public const ushort GoldenVepar = 81;
/// <summary>
/// The Golden Tantallos monster.
/// </summary>
public const ushort GoldenTantallos = 82;
/// <summary>
/// The Golden Wheel monster.
/// </summary>
public const ushort GoldenWheel = 83;
/// <summary>
/// The Red Dragon monster.
/// </summary>
public const ushort RedDragon = 44;
/// <summary>
/// The White Wizard monster.
/// </summary>
public const ushort WhiteWizard = 135;
/// <summary>
/// The Destructive Ogre Soldier monster.
/// </summary>
public const ushort DestructiveOgreSoldier = 136;
/// <summary>
/// The Destructive Ogre Archer monster.
/// </summary>
public const ushort DestructiveOgreArcher = 137;
}

View File

@@ -0,0 +1,128 @@
// <copyright file="InvasionSpawnConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
using System.ComponentModel.DataAnnotations;
/// <summary>
/// Represents a spawn configuration for an invasion event monster.
/// </summary>
public class InvasionSpawnConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="InvasionSpawnConfiguration"/> class.
/// Required for serialization and UI binding.
/// </summary>
public InvasionSpawnConfiguration()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="InvasionSpawnConfiguration"/> class.
/// </summary>
/// <param name="monsterId">The monster ID to spawn.</param>
/// <param name="count">The number of monsters to spawn (1-254).</param>
/// <param name="mapIds">The list of map IDs where the monster can spawn.</param>
/// <param name="mapStrategy">Controls whether to spawn on a random map or all maps.</param>
/// <param name="x">The optional fixed X coordinate.</param>
/// <param name="y">The optional fixed Y coordinate.</param>
/// <param name="announceDeath">Indicates whether the death of this monster type should be announced globally.</param>
public InvasionSpawnConfiguration(
ushort monsterId,
ushort count,
IList<ushort> mapIds,
SpawnMapStrategy mapStrategy,
byte? x = null,
byte? y = null,
bool announceDeath = false)
{
this.MonsterId = monsterId;
this.Count = count;
this.MapIds = mapIds;
this.MapStrategy = mapStrategy;
this.X = x;
this.Y = y;
this.AnnounceDeath = announceDeath;
}
/// <summary>
/// Gets or sets the monster ID to spawn.
/// </summary>
[Required]
public ushort MonsterId { get; set; }
/// <summary>
/// Gets or sets the number of monsters to spawn (1-254).
/// </summary>
[Range(1, 254)]
public ushort Count { get; set; }
/// <summary>
/// Gets or sets the list of map IDs where the monster can spawn.
/// </summary>
[Required]
[MinLength(1)]
public IList<ushort> MapIds { get; set; } = [];
/// <summary>
/// Gets or sets the strategy used to select a map when spawning.
/// </summary>
public SpawnMapStrategy MapStrategy { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the death of this monster type
/// should be announced with a global broadcast message.
/// </summary>
public bool AnnounceDeath { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the monster spawns on all maps in <see cref="MapIds"/>.
/// When set to <c>true</c>, <see cref="MapStrategy"/> is changed to <see cref="SpawnMapStrategy.AllMaps"/>.
/// When set to <c>false</c>, <see cref="MapStrategy"/> is changed to <see cref="SpawnMapStrategy.RandomMap"/>.
/// This property exists for UI binding and serialization compatibility.
/// </summary>
public bool IsSpawnOnAllMaps
{
get => this.MapStrategy == SpawnMapStrategy.AllMaps;
set => this.MapStrategy = value ? SpawnMapStrategy.AllMaps : SpawnMapStrategy.RandomMap;
}
/// <summary>
/// Gets or sets the fixed X coordinate.
/// If <c>null</c>, a random walkable coordinate is used.
/// </summary>
[Range(0, 255)]
public byte? X { get; set; }
/// <summary>
/// Gets or sets the fixed Y coordinate.
/// If <c>null</c>, a random walkable coordinate is used.
/// </summary>
[Range(0, 255)]
public byte? Y { get; set; }
/// <inheritdoc />
/// <remarks>
/// Equality is based solely on <see cref="MonsterId"/>, as each monster type
/// may only have one spawn configuration per invasion event.
/// </remarks>
public override bool Equals(object? obj)
{
if (obj is not InvasionSpawnConfiguration other)
{
return false;
}
return this.MonsterId == other.MonsterId;
}
/// <inheritdoc />
/// <remarks>
/// The hash code is based solely on <see cref="MonsterId"/>, consistent with <see cref="Equals"/>.
/// This means the object can be safely mutated (maps, count, strategy, coordinates)
/// while held in a hash-based collection without becoming unreachable.
/// </remarks>
public override int GetHashCode() => this.MonsterId.GetHashCode();
}

View File

@@ -0,0 +1,40 @@
// <copyright file="PeriodicInvasionConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
using System.ComponentModel.DataAnnotations;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
/// <summary>
/// Configuration data for a periodic invasion event.
/// Responsible only for describing the shape of the configuration (SRP).
/// Default values live in <see cref="InvasionConfigurationDefaults"/>.
/// </summary>
public class PeriodicInvasionConfiguration : PeriodicTaskConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PeriodicInvasionConfiguration"/> class.
/// </summary>
public PeriodicInvasionConfiguration()
{
this.StartMessage = "Invasion has started!";
this.EndMessage = "Invasion has ended!";
}
/// <summary>
/// Gets or sets a value indicating whether all mobs should spawn on a single map,
/// determined from the first mob's <see cref="InvasionSpawnConfiguration.MapIds"/>.
/// When <c>true</c>, every mob in the invasion is placed on the same randomly-selected map
/// rather than distributing across maps per their individual configurations.
/// </summary>
[Display(Name = "Force Single Map", Order = 6)]
public bool ForceSingleMap { get; set; }
/// <summary>
/// Gets or sets the monster spawns for this invasion.
/// </summary>
[Display(Name = "Monster Spawns", Order = 7)]
public IList<InvasionSpawnConfiguration> Mobs { get; set; } = [];
}

View File

@@ -0,0 +1,34 @@
// <copyright file="RedDragonInvasionPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Enables the Red Dragon Invasion feature.
/// </summary>
[PlugIn]
[Display(Name = nameof(PlugInResources.RedDragonInvasionPlugIn_Name), Description = nameof(PlugInResources.RedDragonInvasionPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("548A76CC-242C-441C-BC9D-6C22745A2D72")]
public sealed class RedDragonInvasionPlugIn : SimpleInvasionPlugIn
{
/// <summary>
/// Initializes a new instance of the <see cref="RedDragonInvasionPlugIn"/> class.
/// </summary>
public RedDragonInvasionPlugIn()
: base(() => InvasionConfigurationDefaults.RedDragon)
{
}
/// <inheritdoc />
protected override MapEventType? EventType => MapEventType.RedDragonInvasion;
/// <inheritdoc />
protected override ushort? AnnouncedMonsterId => InvasionMonsters.RedDragon;
/// <inheritdoc />
protected override IReadOnlyList<ushort>? EventDisplayMapIds => [InvasionMaps.Lorencia, InvasionMaps.Noria, InvasionMaps.Devias];
}

View File

@@ -0,0 +1,28 @@
// <copyright file="SimpleInvasionPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Convenience base for invasion plugins that need no logic beyond returning a default configuration.
/// </summary>
public abstract class SimpleInvasionPlugIn
: BaseInvasionPlugIn<PeriodicInvasionConfiguration>, ISupportDefaultCustomConfiguration
{
private readonly Func<PeriodicInvasionConfiguration> _defaultConfigFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SimpleInvasionPlugIn"/> class.
/// </summary>
/// <param name="defaultConfigFactory">Factory that returns the default configuration.</param>
protected SimpleInvasionPlugIn(Func<PeriodicInvasionConfiguration> defaultConfigFactory)
{
this._defaultConfigFactory = defaultConfigFactory;
}
/// <inheritdoc />
public object CreateDefaultConfig() => this._defaultConfigFactory();
}

View File

@@ -0,0 +1,21 @@
// <copyright file="SpawnMapStrategy.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
/// <summary>
/// Defines how the spawn map is selected when multiple map IDs are configured.
/// </summary>
public enum SpawnMapStrategy
{
/// <summary>
/// A single map is picked at random from the configured list.
/// </summary>
RandomMap,
/// <summary>
/// The monster spawns on every map in the configured list.
/// </summary>
AllMaps,
}

View File

@@ -0,0 +1,28 @@
// <copyright file="WhiteWizardInvasionPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.InvasionEvents;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Enables the White Wizard Invasion feature.
/// </summary>
[PlugIn]
[Display(Name = "White Wizard Invasion", Description = "Enables the White Wizard Invasion feature.")]
[Guid("4B5D0F55-5B26-4447-B9C0-C272E5D0A141")]
public sealed class WhiteWizardInvasionPlugIn : SimpleInvasionPlugIn
{
/// <summary>
/// Initializes a new instance of the <see cref="WhiteWizardInvasionPlugIn"/> class.
/// </summary>
public WhiteWizardInvasionPlugIn()
: base(() => InvasionConfigurationDefaults.WhiteWizard)
{
}
/// <inheritdoc />
protected override ushort? AnnouncedMonsterId => InvasionMonsters.WhiteWizard;
}