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; + } +}