From fc120eb3344fc7fcdf27d2a8711b221dde6ee03e Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:44:19 -0300 Subject: [PATCH 1/9] style(raklion): address static code analysis findings of the selupan skills Co-Authored-By: Claude Opus 5.5 --- .../Updates/AddRaklionEventUpdatePlugIn.cs | 52 +++++++++---------- .../VersionSeasonSix/Maps/RaklionBoss.cs | 1 + 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/Persistence/Initialization/Updates/AddRaklionEventUpdatePlugIn.cs b/src/Persistence/Initialization/Updates/AddRaklionEventUpdatePlugIn.cs index d1e43b468..00979de40 100644 --- a/src/Persistence/Initialization/Updates/AddRaklionEventUpdatePlugIn.cs +++ b/src/Persistence/Initialization/Updates/AddRaklionEventUpdatePlugIn.cs @@ -84,35 +84,35 @@ 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); + 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); + } - void CreateSkill(SkillNumber number, string name, float damageMultiplier) + private static void CreateSkill(IContext context, GameConfiguration gameConfiguration, SkillNumber number, string name, float damageMultiplier) + { + if (gameConfiguration.Skills.Any(skill => skill.Number == (short)number)) { - 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)); + 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)); } private static byte? GetWaveNumber(short? monsterNumber) 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. } From 0adc53073507cd3d9e4dd2d4652d42aed4babf43 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:43:15 -0300 Subject: [PATCH 2/9] feat(raklion): let the fall of Selupan hit the players around it When Selupan appears, it falls from the sky and hits the players which are closer to it than four fields, like in the original game. The fall is a skill with a damage multiplier of 2.5, which is added to existing databases by a new update. Co-Authored-By: Claude Opus 5.5 --- .../Raklion/RaklionEventDefinition.cs | 6 ++ src/GameLogic/Raklion/SelupanIntelligence.cs | 23 +++++- .../Initialization/Skills/SkillNumber.cs | 1 + .../Updates/AddRaklionEventUpdatePlugIn.cs | 60 +++++++------- .../AddSelupanFallSkillUpdatePlugIn.cs | 54 +++++++++++++ .../Initialization/Updates/UpdateVersion.cs | 5 ++ .../VersionSeasonSix/SkillsInitializer.cs | 2 + .../RaklionDataTest.cs | 20 +++-- .../SelupanIntelligenceTest.cs | 78 +++++++++++++++++++ 9 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 src/Persistence/Initialization/Updates/AddSelupanFallSkillUpdatePlugIn.cs create mode 100644 tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs diff --git a/src/GameLogic/Raklion/RaklionEventDefinition.cs b/src/GameLogic/Raklion/RaklionEventDefinition.cs index abf8adbe0..311e98774 100644 --- a/src/GameLogic/Raklion/RaklionEventDefinition.cs +++ b/src/GameLogic/Raklion/RaklionEventDefinition.cs @@ -136,6 +136,12 @@ 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 the pattern (1 to 7) of Selupan by its remaining health. /// diff --git a/src/GameLogic/Raklion/SelupanIntelligence.cs b/src/GameLogic/Raklion/SelupanIntelligence.cs index 279286836..1790b6d38 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; @@ -128,8 +129,13 @@ 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 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 this.UpdatePatternAsync(monster, 1).ConfigureAwait(false); return; } @@ -157,6 +163,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]; @@ -364,6 +384,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 00979de40..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) { @@ -89,32 +123,6 @@ private static void AddSelupanSkills(IContext context, GameConfiguration gameCon CreateSkill(context, gameConfiguration, SkillNumber.SelupanIceStrike, "Selupan Ice Strike", 2.3f); } - private 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)); - } - private static byte? GetWaveNumber(short? monsterNumber) { return monsterNumber switch 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/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/SelupanIntelligenceTest.cs b/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs new file mode 100644 index 000000000..f50efe5aa --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs @@ -0,0 +1,78 @@ +// +// 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.DataModel.Configuration; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.Raklion; +using MUnique.OpenMU.Pathfinding; +using MonsterAttribute = MUnique.OpenMU.Persistence.BasicModel.MonsterAttribute; +using MonsterDefinition = MUnique.OpenMU.Persistence.BasicModel.MonsterDefinition; + +/// +/// 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)); + } + + 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 }); + 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; + } +} From 01452b8de72752b9f8a53012af7358e59b78d957 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:20:42 -0300 Subject: [PATCH 3/9] feat(raklion): let the fall of Selupan push the hit players away Like in the original game, the fall pushes the hit players four fields away from Selupan, until a field isn't walkable or part of a safezone. Stunned and frozen players aren't pushed. Co-Authored-By: Claude Opus 5.5 --- .../Raklion/RaklionEventDefinition.cs | 5 ++ src/GameLogic/Raklion/SelupanIntelligence.cs | 47 ++++++++++++++++++- .../SelupanIntelligenceTest.cs | 31 ++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/GameLogic/Raklion/RaklionEventDefinition.cs b/src/GameLogic/Raklion/RaklionEventDefinition.cs index 311e98774..e85ab261d 100644 --- a/src/GameLogic/Raklion/RaklionEventDefinition.cs +++ b/src/GameLogic/Raklion/RaklionEventDefinition.cs @@ -142,6 +142,11 @@ public class RaklionEventDefinition /// 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 the pattern (1 to 7) of Selupan by its remaining health. /// diff --git a/src/GameLogic/Raklion/SelupanIntelligence.cs b/src/GameLogic/Raklion/SelupanIntelligence.cs index 1790b6d38..336d0b6e6 100644 --- a/src/GameLogic/Raklion/SelupanIntelligence.cs +++ b/src/GameLogic/Raklion/SelupanIntelligence.cs @@ -117,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. /// @@ -129,11 +173,12 @@ internal async ValueTask TickAsync() if (this._pattern == 0) { - // Selupan falls from the sky when it appears, and hits the players around it. + // 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._definition.FallPushDistance).ConfigureAwait(false); } await this.UpdatePatternAsync(monster, 1).ConfigureAwait(false); diff --git a/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs b/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs index f50efe5aa..46552f1f6 100644 --- a/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs +++ b/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs @@ -43,6 +43,37 @@ public async Task FallHitsOnlyPlayersAroundSelupanAsync() 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))); + } + private static async ValueTask<(Monster Monster, SelupanIntelligence Intelligence)> CreateSelupanAsync(GameContext gameContext, RaklionEventDefinition definition) { var map = await gameContext.GetMapAsync(0).ConfigureAwait(false); From 93ecda5ba2d540f7d0b932747fda99572421ebe2 Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:03:57 -0300 Subject: [PATCH 4/9] feat(raklion): let the frost shock of Selupan push the hit players away Like in the original game, the ice strike (frost shock) of Selupan pushes the hit players ten fields away from Selupan. Co-Authored-By: Claude Opus 5.5 --- src/GameLogic/Raklion/RaklionEventDefinition.cs | 5 +++++ src/GameLogic/Raklion/SelupanIntelligence.cs | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/GameLogic/Raklion/RaklionEventDefinition.cs b/src/GameLogic/Raklion/RaklionEventDefinition.cs index e85ab261d..091bff2b4 100644 --- a/src/GameLogic/Raklion/RaklionEventDefinition.cs +++ b/src/GameLogic/Raklion/RaklionEventDefinition.cs @@ -147,6 +147,11 @@ public class RaklionEventDefinition /// 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 the pattern (1 to 7) of Selupan by its remaining health. /// diff --git a/src/GameLogic/Raklion/SelupanIntelligence.cs b/src/GameLogic/Raklion/SelupanIntelligence.cs index 336d0b6e6..4fdffbfb1 100644 --- a/src/GameLogic/Raklion/SelupanIntelligence.cs +++ b/src/GameLogic/Raklion/SelupanIntelligence.cs @@ -362,9 +362,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._definition.IceStrikePushDistance).ConfigureAwait(false); + } + break; case SelupanSkill.Freeze: await this.ShowSkillAsync(monster, target, skill).ConfigureAwait(false); @@ -398,7 +406,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() @@ -408,6 +416,8 @@ private async ValueTask AttackAreaAsync(Monster monster, IAttackable target, Sel { await player.AttackByAsync(monster, this.GetSkillEntry(skill, player), false).ConfigureAwait(false); } + + return targets; } /// From 96cb2ec92cc7a4c40a46729586215af3925e247e Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:57:01 -0300 Subject: [PATCH 5/9] feat(raklion): push most classes by two fields at most, like the original In the original game, only dark knights, magic gladiators and dark lords are pushed by the full distance of the skills of Selupan, the other classes by two fields at most. The classes and the limit are configurable. Co-Authored-By: Claude Opus 5.5 --- .../Raklion/RaklionEventDefinition.cs | 25 +++++++++++++++++++ src/GameLogic/Raklion/SelupanIntelligence.cs | 9 +++++-- .../RaklionEventDefinitionTest.cs | 22 ++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/GameLogic/Raklion/RaklionEventDefinition.cs b/src/GameLogic/Raklion/RaklionEventDefinition.cs index 091bff2b4..80a4a9756 100644 --- a/src/GameLogic/Raklion/RaklionEventDefinition.cs +++ b/src/GameLogic/Raklion/RaklionEventDefinition.cs @@ -152,6 +152,18 @@ public class RaklionEventDefinition /// 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. /// @@ -188,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/SelupanIntelligence.cs b/src/GameLogic/Raklion/SelupanIntelligence.cs index 4fdffbfb1..45c597705 100644 --- a/src/GameLogic/Raklion/SelupanIntelligence.cs +++ b/src/GameLogic/Raklion/SelupanIntelligence.cs @@ -178,7 +178,7 @@ internal async ValueTask TickAsync() foreach (var player in this.GetFallTargets(monster)) { await player.AttackByAsync(monster, this.GetSkillEntry(SelupanSkill.Fall, player), false).ConfigureAwait(false); - await PushAwayAsync(monster, player, this._definition.FallPushDistance).ConfigureAwait(false); + await PushAwayAsync(monster, player, this.GetPushDistance(player, this._definition.FallPushDistance)).ConfigureAwait(false); } await this.UpdatePatternAsync(monster, 1).ConfigureAwait(false); @@ -303,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) @@ -370,7 +375,7 @@ private async ValueTask ExecuteSkillAsync(Monster monster, IAttackable target, S 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._definition.IceStrikePushDistance).ConfigureAwait(false); + await PushAwayAsync(monster, hitPlayer, this.GetPushDistance(hitPlayer, this._definition.IceStrikePushDistance)).ConfigureAwait(false); } break; 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."); + } } From 196a3c7b34ad0f528a4305f8c143f1f5fddab61d Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:00:52 -0300 Subject: [PATCH 6/9] fix(raklion): keep players out of the closed hatchery at its gate Like in the original game, the gate to the hatchery doesn't move the player while the hatchery is closed, and shows that it's closed. Before, the player entered the hatchery and was moved to the start of raklion afterward. Co-Authored-By: Claude Opus 5.5 --- src/GameLogic/PlayerActions/WarpGateAction.cs | 7 +++ src/GameLogic/Raklion/RaklionContext.cs | 34 +++++++++--- .../RaklionHatcheryGateTest.cs | 52 +++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 tests/MUnique.OpenMU.Tests/RaklionHatcheryGateTest.cs diff --git a/src/GameLogic/PlayerActions/WarpGateAction.cs b/src/GameLogic/PlayerActions/WarpGateAction.cs index f073c4cb2..e6e98e2a8 100644 --- a/src/GameLogic/PlayerActions/WarpGateAction.cs +++ b/src/GameLogic/PlayerActions/WarpGateAction.cs @@ -5,6 +5,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions; using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Raklion; using MUnique.OpenMU.GameLogic.Views.World; using MUnique.OpenMU.Pathfinding; @@ -64,6 +65,12 @@ private async ValueTask IsWarpLegitAsync(Player player, EnterGate? enterGa return false; } + if (RaklionPlugIn.GetContext(player.GameContext) is { } raklion + && !await raklion.CanEnterThroughGateAsync(player, enterGate.TargetGate.Map).ConfigureAwait(false)) + { + return false; + } + return true; } 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/tests/MUnique.OpenMU.Tests/RaklionHatcheryGateTest.cs b/tests/MUnique.OpenMU.Tests/RaklionHatcheryGateTest.cs new file mode 100644 index 000000000..f11362e2f --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/RaklionHatcheryGateTest.cs @@ -0,0 +1,52 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +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); + } + + 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); + } +} From 7d697d5c0b93d17d6f88febc8bc24cf407630a2c Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:24:20 -0300 Subject: [PATCH 7/9] fix(raklion): let the skill multipliers of Selupan work with its attributes The attribute system of a monster didn't support attribute relationships. So the first attack of Selupan with each of its skills threw an exception, and the skill attributes were cached without the damage multiplier. The monster now returns its attributes for the relationships of a skill. Co-Authored-By: Claude Opus 5.5 --- .../Attributes/MonsterAttributeHolder.cs | 19 ++++++++++++++- .../SelupanIntelligenceTest.cs | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/GameLogic/Attributes/MonsterAttributeHolder.cs b/src/GameLogic/Attributes/MonsterAttributeHolder.cs index ddc49d48b..71e7365ff 100644 --- a/src/GameLogic/Attributes/MonsterAttributeHolder.cs +++ b/src/GameLogic/Attributes/MonsterAttributeHolder.cs @@ -165,9 +165,26 @@ 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. + /// 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/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs b/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs index 46552f1f6..1bdc32cb8 100644 --- a/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs +++ b/tests/MUnique.OpenMU.Tests/SelupanIntelligenceTest.cs @@ -5,14 +5,18 @@ 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 . @@ -74,11 +78,31 @@ public async Task PushStopsAtUnwalkableFieldAsync() 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, From 7da697d9fa46b3de3b6bdc3525208db2539d444b Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:31:50 -0300 Subject: [PATCH 8/9] fix(raklion): end the loading screen when the closed hatchery is denied The game client shows its loading screen after it entered a gate, until a map change is completed. A failed map change doesn't end it, so the client got stuck. Like the original game, the player is warped to its current position instead. Co-Authored-By: Claude Opus 5.5 --- src/GameLogic/PlayerActions/WarpGateAction.cs | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/GameLogic/PlayerActions/WarpGateAction.cs b/src/GameLogic/PlayerActions/WarpGateAction.cs index e6e98e2a8..0a2558276 100644 --- a/src/GameLogic/PlayerActions/WarpGateAction.cs +++ b/src/GameLogic/PlayerActions/WarpGateAction.cs @@ -23,6 +23,15 @@ public async ValueTask EnterGateAsync(Player player, EnterGate gate) { if (await this.IsWarpLegitAsync(player, gate).ConfigureAwait(false)) { + if (RaklionPlugIn.GetContext(player.GameContext) is { } raklion + && !await raklion.CanEnterThroughGateAsync(player, gate.TargetGate!.Map!).ConfigureAwait(false)) + { + // 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 @@ -65,13 +74,23 @@ private async ValueTask IsWarpLegitAsync(Player player, EnterGate? enterGa return false; } - if (RaklionPlugIn.GetContext(player.GameContext) is { } raklion - && !await raklion.CanEnterThroughGateAsync(player, enterGate.TargetGate.Map).ConfigureAwait(false)) + return true; + } + + private async ValueTask WarpToCurrentPositionAsync(Player player) + { + var position = player.IsWalking ? player.WalkTarget : player.Position; + var currentPosition = new ExitGate { - return false; - } + Map = player.CurrentMap!.Definition, + X1 = position.X, + X2 = position.X, + Y1 = position.Y, + Y2 = position.Y, + Direction = player.Rotation, + }; - return true; + await player.WarpToAsync(currentPosition).ConfigureAwait(false); } private bool IsXInRange(Point currentPosition, Gate gate, byte inaccuracy) => currentPosition.X >= gate.X1 - inaccuracy From 888475cb667debd5a4aa054f8382ee45457bd07f Mon Sep 17 00:00:00 2001 From: apraxico <80994201+apraxico@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:10:08 -0300 Subject: [PATCH 9/9] refactor(raklion): deny the closed hatchery through a warp gate plugin point WarpGateAction no longer depends on the raklion event. The new plugin point IWarpGateEnteringPlugIn lets any plugin deny the entrance through a gate by cancelling it; RaklionPlugIn implements it for the closed hatchery. Also documents that GetOrCreateAttribute of a monster returns a snapshot for attributes which have no elements. Co-Authored-By: Claude Opus 5.5 --- .../Attributes/MonsterAttributeHolder.cs | 2 ++ src/GameLogic/PlayerActions/WarpGateAction.cs | 12 ++++++--- .../PlugIns/IWarpGateEnteringPlugIn.cs | 26 +++++++++++++++++++ src/GameLogic/Raklion/RaklionPlugIn.cs | 14 +++++++++- .../RaklionHatcheryGateTest.cs | 17 ++++++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 src/GameLogic/PlugIns/IWarpGateEnteringPlugIn.cs diff --git a/src/GameLogic/Attributes/MonsterAttributeHolder.cs b/src/GameLogic/Attributes/MonsterAttributeHolder.cs index 71e7365ff..d31fc8bea 100644 --- a/src/GameLogic/Attributes/MonsterAttributeHolder.cs +++ b/src/GameLogic/Attributes/MonsterAttributeHolder.cs @@ -170,6 +170,8 @@ public void AddAttributeRelationship(AttributeRelationship combination, IAttribu /// 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) { diff --git a/src/GameLogic/PlayerActions/WarpGateAction.cs b/src/GameLogic/PlayerActions/WarpGateAction.cs index 0a2558276..6044f277f 100644 --- a/src/GameLogic/PlayerActions/WarpGateAction.cs +++ b/src/GameLogic/PlayerActions/WarpGateAction.cs @@ -4,8 +4,9 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions; +using System.ComponentModel; using MUnique.OpenMU.GameLogic.Attributes; -using MUnique.OpenMU.GameLogic.Raklion; +using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.Views.World; using MUnique.OpenMU.Pathfinding; @@ -23,8 +24,13 @@ public async ValueTask EnterGateAsync(Player player, EnterGate gate) { if (await this.IsWarpLegitAsync(player, gate).ConfigureAwait(false)) { - if (RaklionPlugIn.GetContext(player.GameContext) is { } raklion - && !await raklion.CanEnterThroughGateAsync(player, gate.TargetGate!.Map!).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. 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/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/tests/MUnique.OpenMU.Tests/RaklionHatcheryGateTest.cs b/tests/MUnique.OpenMU.Tests/RaklionHatcheryGateTest.cs index f11362e2f..2efc9d709 100644 --- a/tests/MUnique.OpenMU.Tests/RaklionHatcheryGateTest.cs +++ b/tests/MUnique.OpenMU.Tests/RaklionHatcheryGateTest.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.Tests; +using System.ComponentModel; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic.Raklion; @@ -41,6 +42,22 @@ public async Task OtherMapsCanBeEnteredAsync() 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();