diff --git a/src/GameLogic/Attributes/MonsterAttributeHolder.cs b/src/GameLogic/Attributes/MonsterAttributeHolder.cs
index ddc49d48b..d31fc8bea 100644
--- a/src/GameLogic/Attributes/MonsterAttributeHolder.cs
+++ b/src/GameLogic/Attributes/MonsterAttributeHolder.cs
@@ -165,9 +165,28 @@ public void AddAttributeRelationship(AttributeRelationship combination, IAttribu
}
///
+ ///
+ /// This is required when a monster attacks with a skill which has attribute relationships, e.g. a skill
+ /// damage multiplier which depends on the of the monster.
+ /// The stats of a monster are fixed, so they're returned as constant. Attributes which got elements added,
+ /// e.g. by a magic effect, are returned as they are, so that they stay up to date.
+ /// Note that the constant is a snapshot: a relationship which is created from it doesn't see elements
+ /// which are added to the same attribute later. So it shouldn't be used for stats which change at runtime.
+ ///
public IElement GetOrCreateAttribute(AttributeDefinition attributeDefinition)
{
- throw new NotImplementedException();
+ IDictionary? attributes;
+ lock (this._attributesLock)
+ {
+ attributes = this._attributes;
+ }
+
+ if (attributes is not null && attributes.TryGetValue(attributeDefinition, out var attribute))
+ {
+ return attribute;
+ }
+
+ return new ConstantElement(this.GetValueOfAttribute(attributeDefinition));
}
///
diff --git a/src/GameLogic/PlayerActions/WarpGateAction.cs b/src/GameLogic/PlayerActions/WarpGateAction.cs
index f073c4cb2..6044f277f 100644
--- a/src/GameLogic/PlayerActions/WarpGateAction.cs
+++ b/src/GameLogic/PlayerActions/WarpGateAction.cs
@@ -4,7 +4,9 @@
namespace MUnique.OpenMU.GameLogic.PlayerActions;
+using System.ComponentModel;
using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
@@ -22,6 +24,20 @@ public async ValueTask EnterGateAsync(Player player, EnterGate gate)
{
if (await this.IsWarpLegitAsync(player, gate).ConfigureAwait(false))
{
+ var eventArgs = new CancelEventArgs();
+ if (player.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint)
+ {
+ await plugInPoint.WarpGateEnteringAsync(player, gate.TargetGate!, eventArgs).ConfigureAwait(false);
+ }
+
+ if (eventArgs.Cancel)
+ {
+ // Like the original game, the player is warped to its current position.
+ // The game client shows its loading screen until a map change is completed.
+ await this.WarpToCurrentPositionAsync(player).ConfigureAwait(false);
+ return;
+ }
+
await player.WarpToAsync(gate.TargetGate!).ConfigureAwait(false);
}
else
@@ -67,6 +83,22 @@ private async ValueTask IsWarpLegitAsync(Player player, EnterGate? enterGa
return true;
}
+ private async ValueTask WarpToCurrentPositionAsync(Player player)
+ {
+ var position = player.IsWalking ? player.WalkTarget : player.Position;
+ var currentPosition = new ExitGate
+ {
+ Map = player.CurrentMap!.Definition,
+ X1 = position.X,
+ X2 = position.X,
+ Y1 = position.Y,
+ Y2 = position.Y,
+ Direction = player.Rotation,
+ };
+
+ await player.WarpToAsync(currentPosition).ConfigureAwait(false);
+ }
+
private bool IsXInRange(Point currentPosition, Gate gate, byte inaccuracy) => currentPosition.X >= gate.X1 - inaccuracy
&& currentPosition.X <= gate.X2 + inaccuracy;
diff --git a/src/GameLogic/PlugIns/IWarpGateEnteringPlugIn.cs b/src/GameLogic/PlugIns/IWarpGateEnteringPlugIn.cs
new file mode 100644
index 000000000..b421f5466
--- /dev/null
+++ b/src/GameLogic/PlugIns/IWarpGateEnteringPlugIn.cs
@@ -0,0 +1,26 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.PlugIns;
+
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// A plugin interface which is called when a player is about to enter a warp gate.
+///
+[Guid("3F6C2B1E-9A47-4D8B-B5E2-7C0D1A9E4F36")]
+[PlugInPoint("Warp gate entering", "Plugins which are called when a player is about to enter a warp gate. They can deny the entrance, e.g. while an event map is closed.")]
+public interface IWarpGateEnteringPlugIn
+{
+ ///
+ /// Is called when a player is about to enter a warp gate.
+ ///
+ /// The player.
+ /// The target gate.
+ /// The instance containing the event data. The entrance can be denied by setting to true.
+ /// The plugin should then show the reason to the player. The player stays at its current position.
+ ValueTask WarpGateEnteringAsync(Player player, ExitGate targetGate, CancelEventArgs eventArgs);
+}
diff --git a/src/GameLogic/Raklion/RaklionContext.cs b/src/GameLogic/Raklion/RaklionContext.cs
index 49dd14fb0..0f3c0cce2 100644
--- a/src/GameLogic/Raklion/RaklionContext.cs
+++ b/src/GameLogic/Raklion/RaklionContext.cs
@@ -230,6 +230,24 @@ public void Dispose()
}
}
+ ///
+ /// Checks whether the player may enter the map through a gate.
+ /// Like in the original game, the hatchery can't be entered while it's closed, and the player stays where it is.
+ ///
+ /// The player.
+ /// The map of the target gate.
+ /// true, if the player may enter the map; otherwise, false.
+ internal async ValueTask CanEnterThroughGateAsync(Player player, GameMapDefinition map)
+ {
+ if (map.Number != this._definition.HatcheryMapNumber || this.CanEnterHatchery)
+ {
+ return true;
+ }
+
+ await this.ShowHatcheryIsClosedAsync(player).ConfigureAwait(false);
+ return false;
+ }
+
///
/// Changes the state of Selupan and shows it to the players.
///
@@ -507,12 +525,7 @@ private async ValueTask OnObjectAddedToHatcheryAsync((GameMap Map, ILocateable O
else if (!this._battlePlayers.ContainsKey(player))
{
// The player is moved out of the hatchery by the next tick.
- await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.RaklionHatcheryIsClosed)).ConfigureAwait(false);
- if (this.State == RaklionState.Notify4)
- {
- await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.RaklionHatcheryOpensIn), this.GetOpeningTime(player)).ConfigureAwait(false);
- }
-
+ await this.ShowHatcheryIsClosedAsync(player).ConfigureAwait(false);
return;
}
else
@@ -533,6 +546,15 @@ private ValueTask OnObjectRemovedFromHatcheryAsync((GameMap Map, ILocateable Obj
return ValueTask.CompletedTask;
}
+ private async ValueTask ShowHatcheryIsClosedAsync(Player player)
+ {
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.RaklionHatcheryIsClosed)).ConfigureAwait(false);
+ if (this.State == RaklionState.Notify4)
+ {
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.RaklionHatcheryOpensIn), this.GetOpeningTime(player)).ConfigureAwait(false);
+ }
+ }
+
private async ValueTask ShowCurrentStateAsync(Player player)
{
await player.InvokeViewPlugInAsync(p => p.ShowCurrentStateAsync(this.State)).ConfigureAwait(false);
diff --git a/src/GameLogic/Raklion/RaklionEventDefinition.cs b/src/GameLogic/Raklion/RaklionEventDefinition.cs
index abf8adbe0..80a4a9756 100644
--- a/src/GameLogic/Raklion/RaklionEventDefinition.cs
+++ b/src/GameLogic/Raklion/RaklionEventDefinition.cs
@@ -136,6 +136,34 @@ public class RaklionEventDefinition
///
public int AreaSkillRadius { get; set; } = 6;
+ ///
+ /// Gets or sets the radius around Selupan, in which its fall hits players when it appears.
+ /// Only players which are closer than this radius are hit.
+ ///
+ public int FallRadius { get; set; } = 4;
+
+ ///
+ /// Gets or sets the number of fields, by which the fall of Selupan pushes the hit players away.
+ ///
+ public int FallPushDistance { get; set; } = 4;
+
+ ///
+ /// Gets or sets the number of fields, by which the ice strike (frost shock) of Selupan pushes the hit players away.
+ ///
+ public int IceStrikePushDistance { get; set; } = 10;
+
+ ///
+ /// Gets or sets the maximum number of fields, by which the skills of Selupan push the players
+ /// of the character classes which aren't contained in .
+ ///
+ public int LimitedPushDistance { get; set; } = 2;
+
+ ///
+ /// Gets or sets the numbers of the character classes, which are pushed by the full distance of the skills of Selupan.
+ /// Like in the original game, these are the dark knights, magic gladiators and dark lords.
+ ///
+ public IList FullyPushedCharacterClassNumbers { get; set; } = new List { 4, 6, 7, 12, 13, 16, 17 };
+
///
/// Gets the pattern (1 to 7) of Selupan by its remaining health.
///
@@ -172,6 +200,19 @@ public int GetBerserkLevel(int pattern)
return this.PatternBerserkLevels[Math.Clamp(pattern, 1, this.PatternBerserkLevels.Count) - 1];
}
+ ///
+ /// Gets the number of fields, by which a player of the character class is pushed by a skill of Selupan.
+ ///
+ /// The number of the character class of the player.
+ /// The push distance of the skill.
+ /// The distance, limited by for the classes which aren't pushed by the full distance.
+ public int GetPushDistance(byte? characterClassNumber, int distance)
+ {
+ return characterClassNumber is { } number && this.FullyPushedCharacterClassNumbers.Contains(number)
+ ? distance
+ : Math.Min(distance, this.LimitedPushDistance);
+ }
+
///
/// Gets the skills which Selupan may use in the pattern, except the .
///
diff --git a/src/GameLogic/Raklion/RaklionPlugIn.cs b/src/GameLogic/Raklion/RaklionPlugIn.cs
index 7b98db221..93c053466 100644
--- a/src/GameLogic/Raklion/RaklionPlugIn.cs
+++ b/src/GameLogic/Raklion/RaklionPlugIn.cs
@@ -5,6 +5,7 @@
namespace MUnique.OpenMU.GameLogic.Raklion;
using System.Collections.Concurrent;
+using System.ComponentModel;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.PlugIns;
@@ -19,7 +20,7 @@ namespace MUnique.OpenMU.GameLogic.Raklion;
[PlugIn]
[Display(Name = nameof(PlugInResources.RaklionPlugIn_Name), Description = nameof(PlugInResources.RaklionPlugIn_Description), ResourceType = typeof(PlugInResources))]
[Guid("C5A1E7D3-8B24-4F69-9E0A-6D3B2F7C4E18")]
-public sealed class RaklionPlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisposable
+public sealed class RaklionPlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, IWarpGateEnteringPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisposable
{
private readonly ConcurrentDictionary _contexts = new();
private readonly ConcurrentDictionary _runningTicks = new();
@@ -82,6 +83,17 @@ public void ForceStart()
}
}
+ ///
+ public async ValueTask WarpGateEnteringAsync(Player player, ExitGate targetGate, CancelEventArgs eventArgs)
+ {
+ if (targetGate.Map is { } map
+ && this._contexts.TryGetValue(player.GameContext, out var context)
+ && !await context.CanEnterThroughGateAsync(player, map).ConfigureAwait(false))
+ {
+ eventArgs.Cancel = true;
+ }
+ }
+
///
public object CreateDefaultConfig()
{
diff --git a/src/GameLogic/Raklion/SelupanIntelligence.cs b/src/GameLogic/Raklion/SelupanIntelligence.cs
index 279286836..45c597705 100644
--- a/src/GameLogic/Raklion/SelupanIntelligence.cs
+++ b/src/GameLogic/Raklion/SelupanIntelligence.cs
@@ -27,6 +27,7 @@ public sealed class SelupanIntelligence : INpcIntelligence, IDisposable
private const short PoisonSkillNumber = 250;
private const short IceStormSkillNumber = 251;
private const short IceStrikeSkillNumber = 252;
+ private const short FallSkillNumber = 253;
private static readonly TimeSpan TeleportVanishDuration = TimeSpan.FromMilliseconds(500);
private readonly RaklionContext _context;
@@ -116,6 +117,50 @@ public void Dispose()
this._timer = null;
}
+ ///
+ /// Pushes a player away from Selupan, field by field, until the distance is reached or
+ /// the next field isn't walkable or part of a safezone.
+ /// Stunned and frozen players aren't pushed.
+ ///
+ /// The monster of Selupan.
+ /// The player which is pushed.
+ /// The maximum number of fields the player is pushed.
+ internal static async ValueTask PushAwayAsync(Monster monster, Player player, int distance)
+ {
+ if (distance <= 0
+ || !player.IsAlive
+ || player.Attributes is not { } attributes
+ || attributes[Stats.IsStunned] > 0
+ || attributes[Stats.IsFrozen] > 0)
+ {
+ return;
+ }
+
+ var direction = monster.Position.GetDirectionTo(player.Position);
+ if (direction == Direction.Undefined)
+ {
+ direction = (Direction)Rand.NextInt(1, 9);
+ }
+
+ var terrain = monster.CurrentMap.Terrain;
+ var target = player.Position;
+ for (var i = 0; i < distance; i++)
+ {
+ var next = target.CalculateTargetPoint(direction);
+ if (!terrain.WalkMap[next.X, next.Y] || terrain.SafezoneMap[next.X, next.Y])
+ {
+ break;
+ }
+
+ target = next;
+ }
+
+ if (target != player.Position)
+ {
+ await player.MoveAsync(target).ConfigureAwait(false);
+ }
+ }
+
///
/// Executes one step of the intelligence. It's called periodically by a timer.
///
@@ -128,8 +173,14 @@ internal async ValueTask TickAsync()
if (this._pattern == 0)
{
- // Selupan falls from the sky when it appears.
+ // Selupan falls from the sky when it appears, and hits and pushes away the players around it.
await this.ShowSkillAsync(monster, null, SelupanSkill.Fall).ConfigureAwait(false);
+ foreach (var player in this.GetFallTargets(monster))
+ {
+ await player.AttackByAsync(monster, this.GetSkillEntry(SelupanSkill.Fall, player), false).ConfigureAwait(false);
+ await PushAwayAsync(monster, player, this.GetPushDistance(player, this._definition.FallPushDistance)).ConfigureAwait(false);
+ }
+
await this.UpdatePatternAsync(monster, 1).ConfigureAwait(false);
return;
}
@@ -157,6 +208,20 @@ internal async ValueTask TickAsync()
await this.ExecuteSkillAsync(monster, target, skill).ConfigureAwait(false);
}
+ ///
+ /// Gets the players which are hit by the fall of Selupan.
+ ///
+ /// The monster of Selupan.
+ /// The players which are closer to Selupan than the .
+ internal IReadOnlyList GetFallTargets(Monster monster)
+ {
+ var radius = this._definition.FallRadius;
+ return monster.CurrentMap.GetAttackablesInRange(monster.Position, radius)
+ .OfType()
+ .Where(player => IsValidTarget(monster, player) && player.GetDistanceTo(monster) < radius)
+ .ToList();
+ }
+
private static double GetHealthPercentage(Monster monster)
{
var maximumHealth = monster.Attributes[Stats.MaximumHealth];
@@ -238,6 +303,11 @@ private async ValueTask UpdatePatternAsync(Monster monster, int pattern)
await this._context.ChangeSelupanStateAsync((SelupanState)(pattern + (int)SelupanState.Standby)).ConfigureAwait(false);
}
+ private int GetPushDistance(Player player, int distance)
+ {
+ return this._definition.GetPushDistance(player.SelectedCharacter?.CharacterClass?.Number, distance);
+ }
+
private void UpdateInvincibility(Monster monster)
{
if (this._invincibleUntil != DateTime.MinValue && DateTime.UtcNow >= this._invincibleUntil)
@@ -297,9 +367,17 @@ private async ValueTask ExecuteSkillAsync(Monster monster, IAttackable target, S
{
case SelupanSkill.Poison:
case SelupanSkill.IceStorm:
- case SelupanSkill.IceStrike:
await this.ShowSkillAsync(monster, target, skill).ConfigureAwait(false);
await this.AttackAreaAsync(monster, target, skill).ConfigureAwait(false);
+ break;
+ case SelupanSkill.IceStrike:
+ // The frost shock also pushes the hit players away.
+ await this.ShowSkillAsync(monster, target, skill).ConfigureAwait(false);
+ foreach (var hitPlayer in await this.AttackAreaAsync(monster, target, skill).ConfigureAwait(false))
+ {
+ await PushAwayAsync(monster, hitPlayer, this.GetPushDistance(hitPlayer, this._definition.IceStrikePushDistance)).ConfigureAwait(false);
+ }
+
break;
case SelupanSkill.Freeze:
await this.ShowSkillAsync(monster, target, skill).ConfigureAwait(false);
@@ -333,7 +411,7 @@ private async ValueTask ExecuteSkillAsync(Monster monster, IAttackable target, S
}
}
- private async ValueTask AttackAreaAsync(Monster monster, IAttackable target, SelupanSkill skill)
+ private async ValueTask> AttackAreaAsync(Monster monster, IAttackable target, SelupanSkill skill)
{
var targets = monster.CurrentMap.GetAttackablesInRange(target.Position, this._definition.AreaSkillRadius)
.OfType()
@@ -343,6 +421,8 @@ private async ValueTask AttackAreaAsync(Monster monster, IAttackable target, Sel
{
await player.AttackByAsync(monster, this.GetSkillEntry(skill, player), false).ConfigureAwait(false);
}
+
+ return targets;
}
///
@@ -364,6 +444,7 @@ private async ValueTask AttackAreaAsync(Monster monster, IAttackable target, Sel
SelupanSkill.Poison => PoisonSkillNumber,
SelupanSkill.IceStorm => IceStormSkillNumber,
SelupanSkill.IceStrike => IceStrikeSkillNumber,
+ SelupanSkill.Fall => FallSkillNumber,
_ => default(short?),
};
diff --git a/src/Persistence/Initialization/Skills/SkillNumber.cs b/src/Persistence/Initialization/Skills/SkillNumber.cs
index f4267be07..6bb113209 100644
--- a/src/Persistence/Initialization/Skills/SkillNumber.cs
+++ b/src/Persistence/Initialization/Skills/SkillNumber.cs
@@ -118,6 +118,7 @@ internal enum SkillNumber
SelupanPoison = 250,
SelupanIceStorm = 251,
SelupanIceStrike = 252,
+ SelupanFall = 253,
KillingBlow = 260,
BeastUppercut = 261,
ChainDrive = 262,
diff --git a/src/Persistence/Initialization/Updates/AddRaklionEventUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddRaklionEventUpdatePlugIn.cs
index d1e43b468..a738376ac 100644
--- a/src/Persistence/Initialization/Updates/AddRaklionEventUpdatePlugIn.cs
+++ b/src/Persistence/Initialization/Updates/AddRaklionEventUpdatePlugIn.cs
@@ -59,6 +59,40 @@ public class AddRaklionEventUpdatePlugIn : UpdatePlugInBase
///
public override DateTime CreatedAt => new(2026, 09, 24, 0, 0, 0, DateTimeKind.Utc);
+ ///
+ /// Creates an attack skill of Selupan with its damage multiplier, if it doesn't exist yet.
+ ///
+ /// The context.
+ /// The game configuration.
+ /// The number of the skill.
+ /// The name of the skill.
+ /// The damage multiplier of the skill.
+ internal static void CreateSkill(IContext context, GameConfiguration gameConfiguration, SkillNumber number, string name, float damageMultiplier)
+ {
+ if (gameConfiguration.Skills.Any(skill => skill.Number == (short)number))
+ {
+ return;
+ }
+
+ var skill = context.CreateNew();
+ gameConfiguration.Skills.Add(skill);
+ skill.Number = (short)number;
+ skill.Name = name;
+ skill.DamageType = DamageType.Physical;
+ skill.Range = 10;
+ skill.SkillType = SkillType.AreaSkillExplicitTarget;
+ skill.Target = SkillTarget.Explicit;
+ skill.SetGuid(skill.Number);
+
+ skill.AttributeRelationships.Add(CharacterClassHelper.CreateAttributeRelationship(
+ context,
+ gameConfiguration,
+ Stats.SkillFinalMultiplier,
+ damageMultiplier,
+ Stats.SkillMultiplier,
+ InputOperator.Maximum));
+ }
+
///
protected override ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
{
@@ -84,35 +118,9 @@ protected override ValueTask ApplyAsync(IContext context, GameConfiguration game
private static void AddSelupanSkills(IContext context, GameConfiguration gameConfiguration)
{
- CreateSkill(SkillNumber.SelupanPoison, "Selupan Poison", 2.0f);
- CreateSkill(SkillNumber.SelupanIceStorm, "Selupan Ice Storm", 2.2f);
- CreateSkill(SkillNumber.SelupanIceStrike, "Selupan Ice Strike", 2.3f);
-
- void CreateSkill(SkillNumber number, string name, float damageMultiplier)
- {
- if (gameConfiguration.Skills.Any(skill => skill.Number == (short)number))
- {
- return;
- }
-
- var skill = context.CreateNew();
- gameConfiguration.Skills.Add(skill);
- skill.Number = (short)number;
- skill.Name = name;
- skill.DamageType = DamageType.Physical;
- skill.Range = 10;
- skill.SkillType = SkillType.AreaSkillExplicitTarget;
- skill.Target = SkillTarget.Explicit;
- skill.SetGuid(skill.Number);
-
- skill.AttributeRelationships.Add(CharacterClassHelper.CreateAttributeRelationship(
- context,
- gameConfiguration,
- Stats.SkillFinalMultiplier,
- damageMultiplier,
- Stats.SkillMultiplier,
- InputOperator.Maximum));
- }
+ CreateSkill(context, gameConfiguration, SkillNumber.SelupanPoison, "Selupan Poison", 2.0f);
+ CreateSkill(context, gameConfiguration, SkillNumber.SelupanIceStorm, "Selupan Ice Storm", 2.2f);
+ CreateSkill(context, gameConfiguration, SkillNumber.SelupanIceStrike, "Selupan Ice Strike", 2.3f);
}
private static byte? GetWaveNumber(short? monsterNumber)
diff --git a/src/Persistence/Initialization/Updates/AddSelupanFallSkillUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddSelupanFallSkillUpdatePlugIn.cs
new file mode 100644
index 000000000..58dcb94b9
--- /dev/null
+++ b/src/Persistence/Initialization/Updates/AddSelupanFallSkillUpdatePlugIn.cs
@@ -0,0 +1,54 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence.Initialization.Updates;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.Persistence.Initialization.Skills;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Adds the skill of the fall of Selupan, with which it hits the players around it when it appears.
+///
+[PlugIn]
+[Display(Name = PlugInName, Description = PlugInDescription)]
+[Guid("8F3C6A2D-1E74-4B95-9D08-5A7E2C4B1F93")]
+public class AddSelupanFallSkillUpdatePlugIn : UpdatePlugInBase
+{
+ ///
+ /// The plug-in name.
+ ///
+ internal const string PlugInName = "Add Selupan Fall skill";
+
+ ///
+ /// The plug-in description.
+ ///
+ internal const string PlugInDescription = "This update adds the skill of the fall of Selupan, with which it hits the players around it when it appears.";
+
+ ///
+ public override string Name => PlugInName;
+
+ ///
+ public override string Description => PlugInDescription;
+
+ ///
+ public override UpdateVersion Version => UpdateVersion.AddSelupanFallSkill;
+
+ ///
+ public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id;
+
+ ///
+ public override bool IsMandatory => true;
+
+ ///
+ public override DateTime CreatedAt => new(2026, 09, 27, 0, 0, 0, DateTimeKind.Utc);
+
+ ///
+ protected override ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration)
+ {
+ AddRaklionEventUpdatePlugIn.CreateSkill(context, gameConfiguration, SkillNumber.SelupanFall, "Selupan Fall", 2.5f);
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/src/Persistence/Initialization/Updates/UpdateVersion.cs b/src/Persistence/Initialization/Updates/UpdateVersion.cs
index 3285e94f4..0256b82f5 100644
--- a/src/Persistence/Initialization/Updates/UpdateVersion.cs
+++ b/src/Persistence/Initialization/Updates/UpdateVersion.cs
@@ -599,4 +599,9 @@ public enum UpdateVersion
/// The version of the .
///
AddRaklionEvent = 118,
+
+ ///
+ /// The version of the .
+ ///
+ AddSelupanFallSkill = 120,
}
diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/RaklionBoss.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/RaklionBoss.cs
index e5081d3b7..45395dd2f 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/Maps/RaklionBoss.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/RaklionBoss.cs
@@ -131,6 +131,7 @@ protected override void CreateMonsters()
};
monster.AddAttributes(attributes, this.Context, this.GameConfiguration);
monster.SetGuid(monster.Number);
+
// The attack skills of Selupan are used by its intelligence of the raklion event,
// which passes them to the damage calculation so that their multipliers apply.
}
diff --git a/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs
index ddb483272..5d4b06ff2 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs
@@ -255,6 +255,7 @@ public override void Initialize()
this.CreateSkill(SkillNumber.SelupanPoison, "Selupan Poison", damageType: DamageType.Physical, distance: 10, skillType: SkillType.AreaSkillExplicitTarget);
this.CreateSkill(SkillNumber.SelupanIceStorm, "Selupan Ice Storm", damageType: DamageType.Physical, distance: 10, skillType: SkillType.AreaSkillExplicitTarget);
this.CreateSkill(SkillNumber.SelupanIceStrike, "Selupan Ice Strike", damageType: DamageType.Physical, distance: 10, skillType: SkillType.AreaSkillExplicitTarget);
+ this.CreateSkill(SkillNumber.SelupanFall, "Selupan Fall", damageType: DamageType.Physical, distance: 10, skillType: SkillType.AreaSkillExplicitTarget);
// Master skills:
// Common:
@@ -650,6 +651,7 @@ private void InitializeSkillAttributes()
this.AddAttributeRelationship(SkillNumber.SelupanPoison, Stats.SkillFinalMultiplier, 2.0f, Stats.SkillMultiplier, InputOperator.Maximum);
this.AddAttributeRelationship(SkillNumber.SelupanIceStorm, Stats.SkillFinalMultiplier, 2.2f, Stats.SkillMultiplier, InputOperator.Maximum);
this.AddAttributeRelationship(SkillNumber.SelupanIceStrike, Stats.SkillFinalMultiplier, 2.3f, Stats.SkillMultiplier, InputOperator.Maximum);
+ this.AddAttributeRelationship(SkillNumber.SelupanFall, Stats.SkillFinalMultiplier, 2.5f, Stats.SkillMultiplier, InputOperator.Maximum);
this.AddAttributeRelationship(SkillNumber.Explosion223, Stats.SkillFinalDamageBonus, 1.0f, Stats.ExplosionBonusDmg);
this.AddAttributeRelationship(SkillNumber.Requiem, Stats.SkillFinalDamageBonus, 1.0f, Stats.RequiemBonusDmg);
diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/RaklionDataTest.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/RaklionDataTest.cs
index ac046ef78..8e16866b1 100644
--- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/RaklionDataTest.cs
+++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/RaklionDataTest.cs
@@ -22,6 +22,7 @@ internal class RaklionDataTest
private const short PoisonSkillNumber = 250;
private const short IceStormSkillNumber = 251;
private const short IceStrikeSkillNumber = 252;
+ private const short FallSkillNumber = 253;
///
/// Tests that the monsters of the hatchery of a new database are spawned by the event.
@@ -37,8 +38,8 @@ public async Task NewDatabaseContainsEventSpawnsAsync()
}
///
- /// Tests that the update changes the automatic spawns of the hatchery of an existing database to the waves of the event,
- /// and that applying it twice doesn't change anything.
+ /// Tests that the updates change the automatic spawns of the hatchery of an existing database to the waves of the event
+ /// and add the skills of Selupan, and that applying them twice doesn't change anything.
///
[Test]
public async Task UpdateChangesSpawnsOfExistingDatabaseAsync()
@@ -56,9 +57,13 @@ public async Task UpdateChangesSpawnsOfExistingDatabaseAsync()
gameConfiguration.Skills.Remove(skill);
}
- var update = new AddRaklionEventUpdatePlugIn();
- await update.ApplyUpdateAsync(contextProvider.CreateNewContext(), gameConfiguration).ConfigureAwait(false);
- await update.ApplyUpdateAsync(contextProvider.CreateNewContext(), gameConfiguration).ConfigureAwait(false);
+ var raklionUpdate = new AddRaklionEventUpdatePlugIn();
+ var fallUpdate = new AddSelupanFallSkillUpdatePlugIn();
+ for (var i = 0; i < 2; i++)
+ {
+ await raklionUpdate.ApplyUpdateAsync(contextProvider.CreateNewContext(), gameConfiguration).ConfigureAwait(false);
+ await fallUpdate.ApplyUpdateAsync(contextProvider.CreateNewContext(), gameConfiguration).ConfigureAwait(false);
+ }
AssertEventSpawns(gameConfiguration);
AssertSelupanSkills(gameConfiguration);
@@ -79,7 +84,7 @@ private static GameMapDefinition GetHatchery(GameConfiguration gameConfiguration
private static bool IsSelupanSkill(Skill skill)
{
- return skill.Number is PoisonSkillNumber or IceStormSkillNumber or IceStrikeSkillNumber;
+ return skill.Number is PoisonSkillNumber or IceStormSkillNumber or IceStrikeSkillNumber or FallSkillNumber;
}
///
@@ -89,11 +94,12 @@ private static bool IsSelupanSkill(Skill skill)
/// The game configuration.
private static void AssertSelupanSkills(GameConfiguration gameConfiguration)
{
- Assert.That(gameConfiguration.Skills.Where(IsSelupanSkill).ToList(), Has.Count.EqualTo(3));
+ Assert.That(gameConfiguration.Skills.Where(IsSelupanSkill).ToList(), Has.Count.EqualTo(4));
AssertMultiplier(PoisonSkillNumber, 2.0f);
AssertMultiplier(IceStormSkillNumber, 2.2f);
AssertMultiplier(IceStrikeSkillNumber, 2.3f);
+ AssertMultiplier(FallSkillNumber, 2.5f);
void AssertMultiplier(short number, float expected)
{
diff --git a/tests/MUnique.OpenMU.Tests/RaklionEventDefinitionTest.cs b/tests/MUnique.OpenMU.Tests/RaklionEventDefinitionTest.cs
index c59134397..0d7def5d3 100644
--- a/tests/MUnique.OpenMU.Tests/RaklionEventDefinitionTest.cs
+++ b/tests/MUnique.OpenMU.Tests/RaklionEventDefinitionTest.cs
@@ -61,4 +61,26 @@ public void SkillsByPattern()
Assert.That(definition.GetSkills(7), Does.Contain(SelupanSkill.Invincibility));
Assert.That(definition.GetSkills(7), Does.Not.Contain(SelupanSkill.Fall), "The fall is only used when Selupan appears.");
}
+
+ ///
+ /// Tests that only the dark knights, magic gladiators and dark lords are pushed by the full distance,
+ /// and the other classes by two fields at most.
+ ///
+ /// The number of the character class.
+ /// The expected push distance for a skill which pushes by ten fields.
+ [TestCase((byte)0, 2)]
+ [TestCase((byte)4, 10)]
+ [TestCase((byte)7, 10)]
+ [TestCase((byte)8, 2)]
+ [TestCase((byte)12, 10)]
+ [TestCase((byte)17, 10)]
+ [TestCase((byte)20, 2)]
+ [TestCase((byte)24, 2)]
+ public void PushDistanceByCharacterClass(byte characterClassNumber, int expectedDistance)
+ {
+ var definition = new RaklionEventDefinition();
+
+ Assert.That(definition.GetPushDistance(characterClassNumber, 10), Is.EqualTo(expectedDistance));
+ Assert.That(definition.GetPushDistance(characterClassNumber, 1), Is.EqualTo(1), "A shorter distance is never increased.");
+ }
}
diff --git a/tests/MUnique.OpenMU.Tests/RaklionHatcheryGateTest.cs b/tests/MUnique.OpenMU.Tests/RaklionHatcheryGateTest.cs
new file mode 100644
index 000000000..2efc9d709
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/RaklionHatcheryGateTest.cs
@@ -0,0 +1,69 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using System.ComponentModel;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Raklion;
+
+///
+/// Tests the entrance of the hatchery of the raklion event through its gate.
+///
+[TestFixture]
+public class RaklionHatcheryGateTest
+{
+ ///
+ /// Tests that the hatchery can't be entered through a gate while it's closed,
+ /// like in the original game, where the player stays where it is.
+ ///
+ [Test]
+ public async Task ClosedHatcheryCantBeEnteredAsync()
+ {
+ var (context, player, definition) = await CreateAsync().ConfigureAwait(false);
+ Assume.That(context.CanEnterHatchery, Is.False, "The event starts closed, until its first tick.");
+
+ var hatchery = new GameMapDefinition { Number = definition.HatcheryMapNumber };
+
+ Assert.That(await context.CanEnterThroughGateAsync(player, hatchery).ConfigureAwait(false), Is.False);
+ }
+
+ ///
+ /// Tests that other maps can still be entered while the hatchery is closed.
+ ///
+ [Test]
+ public async Task OtherMapsCanBeEnteredAsync()
+ {
+ var (context, player, _) = await CreateAsync().ConfigureAwait(false);
+ var raklion = new GameMapDefinition { Number = 57 };
+
+ Assert.That(await context.CanEnterThroughGateAsync(player, raklion).ConfigureAwait(false), Is.True);
+ }
+
+ ///
+ /// Tests that the plugin doesn't deny any gate while the raklion event isn't running on the game context.
+ ///
+ [Test]
+ public async Task GatesAreNotDeniedWithoutRunningEventAsync()
+ {
+ var (_, player, definition) = await CreateAsync().ConfigureAwait(false);
+ var plugIn = new RaklionPlugIn();
+ var eventArgs = new CancelEventArgs();
+ var hatcheryGate = new ExitGate { Map = new GameMapDefinition { Number = definition.HatcheryMapNumber } };
+
+ await plugIn.WarpGateEnteringAsync(player, hatcheryGate, eventArgs).ConfigureAwait(false);
+
+ Assert.That(eventArgs.Cancel, Is.False);
+ }
+
+ private static async ValueTask<(RaklionContext Context, Player Player, RaklionEventDefinition Definition)> CreateAsync()
+ {
+ var gameContext = (GameContext)GameContextTestHelper.CreateGameContext();
+ var definition = new RaklionEventDefinition();
+ var context = new RaklionContext(gameContext, definition);
+ var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ return (context, player, definition);
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs b/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs
new file mode 100644
index 000000000..1bdc32cb8
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs
@@ -0,0 +1,133 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using Microsoft.Extensions.Logging.Abstractions;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.GameLogic.Raklion;
+using MUnique.OpenMU.Pathfinding;
+using AttributeRelationship = MUnique.OpenMU.Persistence.BasicModel.AttributeRelationship;
+using MonsterAttribute = MUnique.OpenMU.Persistence.BasicModel.MonsterAttribute;
+using MonsterDefinition = MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition;
+using Skill = MUnique.OpenMU.Persistence.BasicModel.Skill;
+
+///
+/// Tests for the .
+///
+[TestFixture]
+public class SelupanIntelligenceTest
+{
+ private static readonly Point SelupanPosition = new(100, 100);
+
+ ///
+ /// Tests that the fall of Selupan hits the players which are closer to it than the fall radius,
+ /// but not the ones further away.
+ ///
+ [Test]
+ public async Task FallHitsOnlyPlayersAroundSelupanAsync()
+ {
+ var gameContext = (GameContext)GameContextTestHelper.CreateGameContext();
+ var definition = new RaklionEventDefinition();
+ var (monster, intelligence) = await CreateSelupanAsync(gameContext, definition).ConfigureAwait(false);
+
+ var near = await CreatePlayerAsync(gameContext, new Point(102, 102)).ConfigureAwait(false);
+ var atRadius = await CreatePlayerAsync(gameContext, new Point((byte)(SelupanPosition.X + definition.FallRadius), SelupanPosition.Y)).ConfigureAwait(false);
+ var far = await CreatePlayerAsync(gameContext, new Point(108, 100)).ConfigureAwait(false);
+
+ var targets = intelligence.GetFallTargets(monster);
+
+ Assert.That(targets, Is.EquivalentTo(new[] { near }));
+ Assert.That(targets, Does.Not.Contain(atRadius).And.Not.Contain(far));
+ }
+
+ ///
+ /// Tests that the fall pushes a player away from Selupan by the push distance.
+ ///
+ [Test]
+ public async Task FallPushesPlayerAwayFromSelupanAsync()
+ {
+ var gameContext = (GameContext)GameContextTestHelper.CreateGameContext();
+ var (monster, _) = await CreateSelupanAsync(gameContext, new RaklionEventDefinition()).ConfigureAwait(false);
+ var player = await CreatePlayerAsync(gameContext, new Point(102, 100)).ConfigureAwait(false);
+
+ await SelupanIntelligence.PushAwayAsync(monster, player, 4).ConfigureAwait(false);
+
+ Assert.That(player.Position, Is.EqualTo(new Point(106, 100)));
+ }
+
+ ///
+ /// Tests that the push stops in front of a field which isn't walkable.
+ ///
+ [Test]
+ public async Task PushStopsAtUnwalkableFieldAsync()
+ {
+ var gameContext = (GameContext)GameContextTestHelper.CreateGameContext();
+ var (monster, _) = await CreateSelupanAsync(gameContext, new RaklionEventDefinition()).ConfigureAwait(false);
+ var player = await CreatePlayerAsync(gameContext, new Point(102, 100)).ConfigureAwait(false);
+ monster.CurrentMap.Terrain.WalkMap[104, 100] = false;
+
+ await SelupanIntelligence.PushAwayAsync(monster, player, 4).ConfigureAwait(false);
+
+ Assert.That(player.Position, Is.EqualTo(new Point(103, 100)));
+ }
+
+ ///
+ /// Tests that the damage multiplier of a skill of Selupan can be calculated with the attributes of the monster.
+ /// The attribute system of a monster didn't support attribute relationships, so the first attack with each
+ /// skill threw an exception, and the multiplier was missing afterwards.
+ ///
+ [Test]
+ public async Task SkillMultiplierIsCalculatedWithMonsterAttributesAsync()
+ {
+ var gameContext = (GameContext)GameContextTestHelper.CreateGameContext();
+ var (monster, _) = await CreateSelupanAsync(gameContext, new RaklionEventDefinition()).ConfigureAwait(false);
+ var skill = new Skill { Number = 253, Name = "Selupan Fall" };
+ skill.AttributeRelationships.Add(new AttributeRelationship(Stats.SkillFinalMultiplier, 2.5f, Stats.SkillMultiplier, AggregateType.AddRaw) { InputOperator = InputOperator.Maximum });
+ var skillEntry = new SkillEntry { Skill = skill };
+
+ var skillAttributes = skillEntry.EnsureSkillAttributes(monster.Attributes);
+
+ Assert.That(skillAttributes?[Stats.SkillFinalMultiplier], Is.EqualTo(2.5f).Within(0.001f));
+ }
+
+ private static async ValueTask<(Monster Monster, SelupanIntelligence Intelligence)> CreateSelupanAsync(GameContext gameContext, RaklionEventDefinition definition)
+ {
+ var map = await gameContext.GetMapAsync(0).ConfigureAwait(false);
+ var monsterDefinition = new MonsterDefinition { Id = Guid.NewGuid(), Number = 459, ObjectKind = NpcObjectKind.Monster, AttackRange = 10 };
+ monsterDefinition.Attributes.Add(new MonsterAttribute { AttributeDefinition = Stats.MaximumHealth, Value = 100 });
+ monsterDefinition.Attributes.Add(new MonsterAttribute { AttributeDefinition = Stats.SkillMultiplier, Value = 2 });
+ var spawnArea = new MonsterSpawnArea
+ {
+ MonsterDefinition = monsterDefinition,
+ GameMap = map!.Definition,
+ X1 = SelupanPosition.X,
+ Y1 = SelupanPosition.Y,
+ X2 = SelupanPosition.X,
+ Y2 = SelupanPosition.Y,
+ Quantity = 1,
+ };
+
+ var intelligence = new SelupanIntelligence(new RaklionContext(gameContext, definition), definition, NullLogger.Instance);
+ var monster = new Monster(spawnArea, monsterDefinition, map, NullDropGenerator.Instance, intelligence, gameContext.PlugInManager, gameContext.PathFinderPool);
+ monster.Initialize();
+ await map.AddAsync(monster).ConfigureAwait(false);
+ return (monster, intelligence);
+ }
+
+ private static async ValueTask CreatePlayerAsync(IGameContext gameContext, Point position)
+ {
+ var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false);
+ await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false);
+ player.IsAlive = true;
+ player.Position = position;
+ await player.CurrentMap!.AddAsync(player).ConfigureAwait(false);
+ return player;
+ }
+}