diff --git a/src/GameLogic/DroppedItem.cs b/src/GameLogic/DroppedItem.cs index 7e8ce8f21..4c00f0f1e 100644 --- a/src/GameLogic/DroppedItem.cs +++ b/src/GameLogic/DroppedItem.cs @@ -250,6 +250,11 @@ private async ValueTask TryStackOnItemAsync(Player player, Item stackTarge player.Logger.LogInformation("Item '{0}' got picked up by player '{1}'. Durability of available stack {2} increased to {3}", this, player, stackTarget, stackTarget.Durability); this.DisposeAndDelete(null); + if (player.GameContext.PlugInManager.GetPlugInPoint() is { } itemStackedPlugIn) + { + await itemStackedPlugIn.ItemStackedAsync(player, this.Item, stackTarget).ConfigureAwait(false); + } + return true; } diff --git a/src/GameLogic/LocateableExtensions.cs b/src/GameLogic/LocateableExtensions.cs index d316e894d..bcfa8a86b 100644 --- a/src/GameLogic/LocateableExtensions.cs +++ b/src/GameLogic/LocateableExtensions.cs @@ -68,6 +68,15 @@ public static double GetDistanceTo(this ILocateable objectFrom, Point objectToPo /// True, if the specified coordinate is in the specified range of the object; Otherwise, false. public static bool IsInRange(this ILocateable obj, Point point, int range) => obj.IsInRange(point.X, point.Y, range); + /// + /// Determines whether the specified coordinates are in the specified range of the object. + /// + /// The object. + /// The second object. + /// The maximum range. + /// True, if the specified coordinate is in the specified range of the object; Otherwise, false. + public static bool IsInRange(this ILocateable obj, ILocateable obj2, int range) => obj.IsInRange(obj2.Position, range); + /// /// Determines whether the specified coordinate is in the specified range of the object. /// diff --git a/src/GameLogic/NPC/GateNpc.cs b/src/GameLogic/NPC/GateNpc.cs new file mode 100644 index 000000000..4bdb6498d --- /dev/null +++ b/src/GameLogic/NPC/GateNpc.cs @@ -0,0 +1,130 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.NPC; + +using System.Threading; + +/// +/// Represents a which is a gate to another map. +/// When a player gets close to the gate, it will warp the player to the target gate, +/// if the player is in the same party as the summoner. +/// +public class GateNpc : NonPlayerCharacter, ISummonable +{ + private const int Range = 2; + private readonly ILogger _logger; + private readonly Task _warpTask; + private readonly CancellationTokenSource _cts; + private int _enterCount; + + /// + /// Initializes a new instance of the class. + /// + /// The spawn information. + /// The stats. + /// The map. + /// The summoned by. + /// The target gate. + /// The lifespan. + public GateNpc(MonsterSpawnArea spawnInfo, MonsterDefinition stats, GameMap map, Player summonedBy, ExitGate targetGate, TimeSpan lifespan) + : base(spawnInfo, stats, map) + { + this.SummonedBy = summonedBy; + this.TargetGate = targetGate; + this._logger = summonedBy.GameContext.LoggerFactory.CreateLogger(); + this._cts = new CancellationTokenSource(lifespan); + this._warpTask = Task.Run(this.RunTaskAsync); + } + + /// + public Player SummonedBy { get; } + + /// + /// Gets the target gate. + /// + public ExitGate TargetGate { get; } + + /// + protected override void Dispose(bool disposing) + { + this._cts.Cancel(); + this._cts.Dispose(); + base.Dispose(disposing); + } + + /// + protected override async ValueTask DisposeAsyncCore() + { + await this._cts.CancelAsync().ConfigureAwait(false); + this._cts.Dispose(); + + await this._warpTask.ConfigureAwait(false); + await base.DisposeAsyncCore().ConfigureAwait(false); + } + + private async Task RunTaskAsync() + { + try + { + var cancellationToken = this._cts.Token; + var playersInRange = new List(); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); + + if (!this.SummonedBy.IsAlive) + { + this._logger.LogInformation("Closing the gate, player is dead or offline"); + return; + } + + playersInRange.Clear(); + if (this.SummonedBy.Party is { } party) + { + playersInRange.AddRange(party.PartyList.OfType().Where(this.IsPlayerInRange)); + } + else if (this.IsPlayerInRange(this.SummonedBy)) + { + playersInRange.Add(this.SummonedBy); + } + else + { + // do nothing. + } + + foreach (var player in playersInRange) + { + this._logger.LogInformation("Player {player} passes the gate ({enterCount})", player, this._enterCount); + await player.WarpToAsync(this.TargetGate).ConfigureAwait(false); + this._enterCount++; + if (this._enterCount >= this.SummonedBy.GameContext.Configuration.MaximumPartySize) + { + this._logger.LogInformation("Closing the gate, maximum entrances reached ({enterCount})", this._enterCount); + return; + } + } + } + } + catch (OperationCanceledException) + { + // ignored + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error in GateNpc task."); + } + finally + { + await this.DisposeAsync().ConfigureAwait(false); + } + } + + private bool IsPlayerInRange(Player player) + { + return player.IsActive() && player.IsInRange(this, Range) && this.CurrentMap == player.CurrentMap; + } +} \ No newline at end of file diff --git a/src/GameLogic/NPC/ISummonable.cs b/src/GameLogic/NPC/ISummonable.cs new file mode 100644 index 000000000..72d9d5581 --- /dev/null +++ b/src/GameLogic/NPC/ISummonable.cs @@ -0,0 +1,21 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.NPC; + +/// +/// An interface for a class which can (but not must) be summoned by a player. +/// +public interface ISummonable : IIdentifiable, ILocateable, IRotatable +{ + /// + /// Gets the player which summoned this instance. + /// + Player? SummonedBy { get; } + + /// + /// Gets the definition for this instance. + /// + MonsterDefinition Definition { get; } +} \ No newline at end of file diff --git a/src/GameLogic/NPC/Monster.cs b/src/GameLogic/NPC/Monster.cs index f1dbe4a81..d13f7444d 100644 --- a/src/GameLogic/NPC/Monster.cs +++ b/src/GameLogic/NPC/Monster.cs @@ -15,7 +15,7 @@ namespace MUnique.OpenMU.GameLogic.NPC; /// /// The implementation of a monster, which can attack players. /// -public sealed class Monster : AttackableNpcBase, IAttackable, IAttacker, ISupportWalk, IMovable +public sealed class Monster : AttackableNpcBase, IAttackable, IAttacker, ISupportWalk, IMovable, ISummonable { private readonly AsyncLock _moveLock = new(); private readonly INpcIntelligence _intelligence; diff --git a/src/GameLogic/PlayerActions/Items/DropItemAction.cs b/src/GameLogic/PlayerActions/Items/DropItemAction.cs index 6987d6ca8..6d3e14e51 100644 --- a/src/GameLogic/PlayerActions/Items/DropItemAction.cs +++ b/src/GameLogic/PlayerActions/Items/DropItemAction.cs @@ -4,9 +4,10 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Items; -using MUnique.OpenMU.GameLogic.Views.Character; using MUnique.OpenMU.GameLogic.Views.Inventory; using MUnique.OpenMU.Pathfinding; +using MUnique.OpenMU.GameLogic.PlugIns; +using static MUnique.OpenMU.GameLogic.PlugIns.IItemDropPlugIn; /// /// Action to drop an item from the inventory to the floor. @@ -23,70 +24,36 @@ public async ValueTask DropItemAsync(Player player, byte slot, Point target) { var item = player.Inventory?.GetItem(slot); - if (item is not null && (player.CurrentMap?.Terrain.WalkMap[target.X, target.Y] ?? false)) - { - if (item.Definition!.DropItems.Count > 0 - && item.Definition!.DropItems.Where(di => di.SourceItemLevel == item.Level) is { } itemDropGroups) - { - await this.DropRandomItemAsync(item, player, itemDropGroups).ConfigureAwait(false); - } - else - { - await this.DropItemAsync(player, item, target).ConfigureAwait(false); - } - } - else + if (item is null + || !(player.CurrentMap?.Terrain.WalkMap[target.X, target.Y] ?? false)) { await player.InvokeViewPlugInAsync(p => p.ItemDropResultAsync(slot, false)).ConfigureAwait(false); - } - } - - /// - /// Drops a random item of the given at the players coordinates. - /// - /// The source item. - /// The player. - /// The from which the random item is generated. - private async ValueTask DropRandomItemAsync(Item sourceItem, Player player, IEnumerable dropItemGroups) - { - if (dropItemGroups.Any(g => g.RequiredCharacterLevel > player.Level)) - { - await player.InvokeViewPlugInAsync(p => p.ItemDropResultAsync(sourceItem.ItemSlot, false)).ConfigureAwait(false); return; } - var (item, droppedMoneyAmount, dropEffect) = player.GameContext.DropGenerator.GenerateItemDrop(dropItemGroups); - if (droppedMoneyAmount is not null) + if (player.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint) { - var droppedMoney = new DroppedMoney(droppedMoneyAmount.Value, player.Position, player.CurrentMap!); - await player.CurrentMap!.AddAsync(droppedMoney).ConfigureAwait(false); - } - - if (item is not null) - { - var droppedItem = new DroppedItem(item, player.Position, player.CurrentMap!, player); - await player.CurrentMap!.AddAsync(droppedItem).ConfigureAwait(false); - } + var dropArguments = new ItemDropArguments(); + await plugInPoint.HandleItemDropAsync(player, item, target, dropArguments).ConfigureAwait(false); + if (dropArguments.Cancel) + { + // If we're here, that means that a plugin has handled the drop request. + // Now, we have to decide if we should remove the item from the inventory or not. + if (dropArguments.Success) + { + await this.RemoveItemFromInventoryAsync(player, item).ConfigureAwait(false); + await player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); + } + else + { + await player.InvokeViewPlugInAsync(p => p.ItemDropResultAsync(slot, false)).ConfigureAwait(false); + } - if (dropEffect is not ItemDropEffect.Undefined) - { - await this.ShowDropEffectAsync(player, dropEffect).ConfigureAwait(false); + return; + } } - await this.RemoveItemFromInventoryAsync(player, sourceItem).ConfigureAwait(false); - await player.PersistenceContext.DeleteAsync(sourceItem).ConfigureAwait(false); - } - - private async ValueTask ShowDropEffectAsync(Player player, ItemDropEffect dropEffect) - { - if (dropEffect == ItemDropEffect.Swirl) - { - await player.InvokeViewPlugInAsync(p => p.ShowEffectAsync(player, IShowEffectPlugIn.EffectType.Swirl)).ConfigureAwait(false); - } - else - { - await player.InvokeViewPlugInAsync(p => p.ShowEffectAsync(dropEffect, player.Position)).ConfigureAwait(false); - } + await this.DropItemAsync(player, item, target).ConfigureAwait(false); } private async ValueTask DropItemAsync(Player player, Item item, Point target) diff --git a/src/GameLogic/PlayerActions/Items/ItemBoxDroppedPlugIn.cs b/src/GameLogic/PlayerActions/Items/ItemBoxDroppedPlugIn.cs new file mode 100644 index 000000000..c985bc067 --- /dev/null +++ b/src/GameLogic/PlayerActions/Items/ItemBoxDroppedPlugIn.cs @@ -0,0 +1,68 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlayerActions.Items; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.Views.Character; +using MUnique.OpenMU.Pathfinding; +using MUnique.OpenMU.PlugIns; +using static MUnique.OpenMU.GameLogic.PlugIns.IItemDropPlugIn; + +/// +/// This plugin handles the drop of an item box, e.g. box of kundun. +/// +[PlugIn(nameof(ItemBoxDroppedPlugIn), "This plugin handles the drop of an item box, e.g. box of kundun.")] +[Guid("3D15D55D-EEFE-4B5F-89B1-6934AB3F0BEE")] +public sealed class ItemBoxDroppedPlugIn : IItemDropPlugIn +{ + /// + public async ValueTask HandleItemDropAsync(Player player, Item sourceItem, Point target, ItemDropArguments cancelArgs) + { + if (sourceItem.Definition!.DropItems.Count <= 0 + || sourceItem.Definition!.DropItems.Where(di => di.SourceItemLevel == sourceItem.Level) is not { } itemDropGroups) + { + return; + } + + cancelArgs.WasHandled = true; + if (itemDropGroups.Any(g => g.RequiredCharacterLevel > player.Level)) + { + cancelArgs.Success = false; + return; + } + + cancelArgs.Success = true; + var (item, droppedMoneyAmount, dropEffect) = player.GameContext.DropGenerator.GenerateItemDrop(itemDropGroups); + if (droppedMoneyAmount is not null) + { + var droppedMoney = new DroppedMoney(droppedMoneyAmount.Value, player.Position, player.CurrentMap!); + await player.CurrentMap!.AddAsync(droppedMoney).ConfigureAwait(false); + } + + if (item is not null) + { + var droppedItem = new DroppedItem(item, player.Position, player.CurrentMap!, player); + await player.CurrentMap!.AddAsync(droppedItem).ConfigureAwait(false); + } + + if (dropEffect is not ItemDropEffect.Undefined) + { + await this.ShowDropEffectAsync(player, dropEffect).ConfigureAwait(false); + } + } + + private async ValueTask ShowDropEffectAsync(Player player, ItemDropEffect dropEffect) + { + if (dropEffect == ItemDropEffect.Swirl) + { + await player.InvokeViewPlugInAsync(p => p.ShowEffectAsync(player, IShowEffectPlugIn.EffectType.Swirl)).ConfigureAwait(false); + } + else + { + await player.InvokeViewPlugInAsync(p => p.ShowEffectAsync(dropEffect, player.Position)).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/GameLogic/PlayerActions/Items/LostMapDroppedPlugIn.cs b/src/GameLogic/PlayerActions/Items/LostMapDroppedPlugIn.cs new file mode 100644 index 000000000..32dc37d6d --- /dev/null +++ b/src/GameLogic/PlayerActions/Items/LostMapDroppedPlugIn.cs @@ -0,0 +1,93 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlayerActions.Items; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; +using MUnique.OpenMU.Pathfinding; +using MUnique.OpenMU.PlugIns; +using MonsterSpawnArea = MUnique.OpenMU.Persistence.BasicModel.MonsterSpawnArea; + +/// +/// This plugin transforms a stack of symbol of kundun into a lost map. +/// todo: implement plugin configuration to resolve magic numbers. +/// +[PlugIn(nameof(LostMapDroppedPlugIn), "This plugin handles the drop of the lost map item. It creates the gate to the kalima map.")] +[Guid("F6DB10E0-AE7F-4BC6-914F-B858763C5CF7")] +public sealed class LostMapDroppedPlugIn : IItemDropPlugIn +{ + private static readonly int[] KalimaMapNumbers = [24, 25, 26, 27, 28, 29, 36]; + + private const byte GateNpcStartNumber = 152; + + /// + public async ValueTask HandleItemDropAsync(Player player, Item item, Point target, IItemDropPlugIn.ItemDropArguments cancelArgs) + { + if (!item.IsLostMap()) + { + return; + } + + cancelArgs.WasHandled = true; + var currentMap = player.CurrentMap; + if (currentMap is null) + { + return; + } + + if (item.Level is < 1 or > 7) + { + await player.ShowMessageAsync("The lost map is not valid.").ConfigureAwait(false); + return; + } + + if (player.CurrentMiniGame is not null) + { + await player.ShowMessageAsync("Cannot create kalima gate on event map.").ConfigureAwait(false); + return; + } + + var gatePosition = target; + if (player.IsAtSafezone() || player.CurrentMap?.Terrain.SafezoneMap[gatePosition.X, gatePosition.Y] is true) + { + await player.ShowMessageAsync("Cannot create kalima gate in safe zone.").ConfigureAwait(false); + return; + } + + var gateNpcNumber = GateNpcStartNumber + item.Level; + var gateNpcDef = player.GameContext.Configuration.Monsters.FirstOrDefault(def => def.Number == gateNpcNumber); + if (gateNpcDef is null) + { + await player.ShowMessageAsync("The gate npc is not defined.").ConfigureAwait(false); + return; + } + + var spawnArea = new MonsterSpawnArea + { + Direction = Direction.West, + Quantity = 1, + MonsterDefinition = gateNpcDef, + SpawnTrigger = SpawnTrigger.ManuallyForEvent, + X1 = target.X, + X2 = target.X, + Y1 = target.Y, + Y2 = target.Y, + }; + + var targetGate = player.GameContext.Configuration.Maps.FirstOrDefault(g => g.Number == KalimaMapNumbers[item.Level - 1])?.ExitGates.FirstOrDefault(); + if (targetGate is null) + { + await player.ShowMessageAsync("The kalima entrance wasn't found.").ConfigureAwait(false); + return; + } + + var gate = new GateNpc(spawnArea, gateNpcDef, currentMap, player, targetGate, TimeSpan.FromMinutes(1)); + gate.Initialize(); + await currentMap.AddAsync(gate).ConfigureAwait(false); + cancelArgs.Success = true; + } +} \ No newline at end of file diff --git a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs index cf9b94a1f..9dc0a14a3 100644 --- a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs +++ b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs @@ -80,11 +80,18 @@ public async ValueTask MoveItemAsync(Player player, byte fromSlot, Storages from break; } - if (movement != Movement.None + if (movement is not Movement.None && player.GameContext.PlugInManager.GetPlugInPoint() is { } itemMovedPlugIn) { await itemMovedPlugIn.ItemMovedAsync(player, item).ConfigureAwait(false); } + + if (movement is not (Movement.None or Movement.Normal) + && toItemStorage?.GetItem(toSlot) is { } target + && player.GameContext.PlugInManager.GetPlugInPoint() is { } itemStackedPlugIn) + { + await itemStackedPlugIn.ItemStackedAsync(player, item, target).ConfigureAwait(false); + } } private async ValueTask FullStackAsync(Player player, Item sourceItem, Item targetItem) diff --git a/src/GameLogic/PlugIns/ChatCommands/ItemChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/ItemChatCommandPlugIn.cs index e0a68bd6d..27e88a72b 100644 --- a/src/GameLogic/PlugIns/ChatCommands/ItemChatCommandPlugIn.cs +++ b/src/GameLogic/PlugIns/ChatCommands/ItemChatCommandPlugIn.cs @@ -43,7 +43,7 @@ private static Item CreateItem(Player gameMaster, ItemChatCommandArgs arguments) { var item = new TemporaryItem(); item.Definition = GetItemDefination(gameMaster, arguments); - item.Durability = item.Definition.Durability; + item.Durability = item.IsStackable() ? 1 : item.Definition.Durability; item.HasSkill = item.Definition.Skill != null && arguments.Skill; item.Level = GetItemLevel(item.Definition, arguments); item.SocketCount = item.Definition.MaximumSockets; diff --git a/src/GameLogic/PlugIns/IItemDropPlugIn.cs b/src/GameLogic/PlugIns/IItemDropPlugIn.cs new file mode 100644 index 000000000..1c760aad4 --- /dev/null +++ b/src/GameLogic/PlugIns/IItemDropPlugIn.cs @@ -0,0 +1,56 @@ +// +// 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.Pathfinding; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin interface which is called when an item has been dropped to the ground by the player. +/// +[Guid("EDB62A52-96BA-4ACA-9355-244834D53437")] +[PlugInPoint("Item dropping", "Plugins which are called when an item has been dropped to the ground by the player.")] +public interface IItemDropPlugIn +{ + /// + /// Is called when an item has been dropped to the ground by the player. + /// + /// The player. + /// The item. + /// The target point on the ground. + /// The instance containing the event data. + /// A plugin can define what's happening after the plugin has been executed. + /// + /// true, when the item should be removed and deleted from the inventory; otherwise, false. + /// + ValueTask HandleItemDropAsync(Player player, Item item, Point target, ItemDropArguments dropArgs); + + /// + /// Arguments for handling the item drop. + /// + public class ItemDropArguments : CancelEventArgs + { + /// + /// Gets or sets a value indicating whether the item drop request was handled. + /// In this case is set to true to prevent further plugins to execute. + /// + public bool WasHandled + { + get => this.Cancel; + set => this.Cancel = value; + } + + /// + /// Gets or sets a value indicating whether the item should be removed and deleted from the inventory + /// when also is set to true. + /// + /// + /// true if [remove item]; otherwise, false. + /// + public bool Success { get; set; } + } +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/IItemStackedPlugIn.cs b/src/GameLogic/PlugIns/IItemStackedPlugIn.cs new file mode 100644 index 000000000..51c273268 --- /dev/null +++ b/src/GameLogic/PlugIns/IItemStackedPlugIn.cs @@ -0,0 +1,24 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin interface which is called when an item got moved by the player. +/// +[Guid("FCDE60E9-6183-4833-BF59-CE907F9EECCD")] +[PlugInPoint("Item stacked", "Plugins which are called when an item has been stacked by the player.")] +public interface IItemStackedPlugIn +{ + /// + /// Is called when an item has been moved by the player. + /// + /// The player. + /// The source item. + /// The target item. + ValueTask ItemStackedAsync(Player player, Item sourceItem, Item targetItem); +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/KalimaConstants.cs b/src/GameLogic/PlugIns/KalimaConstants.cs new file mode 100644 index 000000000..90b6e9954 --- /dev/null +++ b/src/GameLogic/PlugIns/KalimaConstants.cs @@ -0,0 +1,69 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using MUnique.OpenMU.DataModel.Configuration.Items; + +/// +/// Constants for the Kalima map. +/// +public static class KalimaConstants +{ + /// + /// The symbol of kundun item group. + /// + internal const byte SymbolOfKundunGroup = 14; + + /// + /// The symbol of kundun item number. + /// + internal const byte SymbolOfKundunNumber = 29; + + /// + /// The lost map item group. + /// + internal const byte LostMapGroup = 14; + + /// + /// The lost map item number. + /// + internal const byte LostMapNumber = 28; + + /// + /// Determines whether the specified item is a lost map. + /// + /// The item. + /// + /// true if the specified item is a lost map; otherwise, false. + /// + public static bool IsLostMap(this Item item) + { + return item.Definition.IsLostMap(); + } + + /// + /// Determines whether the specified item is a lost map. + /// + /// The item definition. + /// + /// true if the specified item is a lost map; otherwise, false. + /// + public static bool IsLostMap(this ItemDefinition? itemDefinition) + { + return itemDefinition is { Group: LostMapGroup, Number: LostMapNumber }; + } + + /// + /// Determines whether the specified item is a symbol of kundun. + /// + /// The item. + /// + /// true if the specified item is a symbol of kundun; otherwise, false. + /// + public static bool IsSymbolOfKundun(this Item item) + { + return item.Definition is { Group: SymbolOfKundunGroup, Number: SymbolOfKundunNumber }; + } +} \ No newline at end of file diff --git a/src/GameLogic/PlugIns/SymbolOfKundunStackedPlugIn.cs b/src/GameLogic/PlugIns/SymbolOfKundunStackedPlugIn.cs new file mode 100644 index 000000000..7b10836fd --- /dev/null +++ b/src/GameLogic/PlugIns/SymbolOfKundunStackedPlugIn.cs @@ -0,0 +1,42 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.PlugIns; + +/// +/// This plugin transforms a stack of symbol of kundun into a lost map. +/// +[PlugIn(nameof(SymbolOfKundunStackedPlugIn), "This plugin transforms a stack of symbol of kundun into a lost map.")] +[Guid("F07A9CED-F43E-4824-9587-F5C3C3187A13")] +public sealed class SymbolOfKundunStackedPlugIn : IItemStackedPlugIn +{ + /// + public async ValueTask ItemStackedAsync(Player player, Item sourceItem, Item targetItem) + { + if (!targetItem.IsSymbolOfKundun()) + { + return; + } + + if (targetItem.Durability() < targetItem.Definition?.Durability) + { + return; + } + + var lostMap = player.GameContext.Configuration.Items.FirstOrDefault(item => item.IsLostMap()); + if (lostMap is null) + { + player.Logger.LogWarning("Lost map definition not found."); + return; + } + + await player.InvokeViewPlugInAsync(p => p.RemoveItemAsync(targetItem.ItemSlot)).ConfigureAwait(false); + targetItem.Definition = lostMap; + targetItem.Durability = 1; + await player.InvokeViewPlugInAsync(p => p.ItemAppearAsync(targetItem)).ConfigureAwait(false); + } +} diff --git a/src/GameServer/RemoteView/World/NewNpcsInScopePlugIn.cs b/src/GameServer/RemoteView/World/NewNpcsInScopePlugIn.cs index a7db6c307..0563da197 100644 --- a/src/GameServer/RemoteView/World/NewNpcsInScopePlugIn.cs +++ b/src/GameServer/RemoteView/World/NewNpcsInScopePlugIn.cs @@ -38,8 +38,8 @@ public async ValueTask NewNpcsInScopeAsync(IEnumerable newOb return; } - var summons = newObjects.OfType().Where(m => m.SummonedBy is { }).ToList(); - var npcs = newObjects.Except(summons).ToList(); + var summons = newObjects.OfType().Where(m => m.SummonedBy is { }).ToList(); + var npcs = newObjects.Except(summons.OfType()).ToList(); if (npcs.Any()) { @@ -100,7 +100,7 @@ int Write() await connection.SendAsync(Write).ConfigureAwait(false); } - private static async ValueTask SummonedMonstersInScopeAsync(bool isSpawned, IConnection connection, ICollection summons) + private static async ValueTask SummonedMonstersInScopeAsync(bool isSpawned, IConnection connection, ICollection summons) { int Write() { @@ -127,10 +127,10 @@ int Write() block.CurrentPositionX = summon.Position.X; block.CurrentPositionY = summon.Position.Y; - if (summon.IsWalking) + if (summon is ISupportWalk walker && walker.IsWalking) { - block.TargetPositionX = summon.WalkTarget.X; - block.TargetPositionY = summon.WalkTarget.Y; + block.TargetPositionX = walker.WalkTarget.X; + block.TargetPositionY = walker.WalkTarget.Y; } else { @@ -141,12 +141,19 @@ int Write() block.Rotation = summon.Rotation.ToPacketByte(); block.OwnerCharacterName = summon.SummonedBy?.Name ?? string.Empty; - var activeEffects = summon.MagicEffectList.VisibleEffects; - block.EffectCount = (byte)activeEffects.Count; - for (int e = block.EffectCount - 1; e >= 0; e--) + if (summon is IAttackable attackable) { - var effectBlock = block[e]; - effectBlock.Id = (byte)activeEffects[e].Id; + var activeEffects = attackable.MagicEffectList.VisibleEffects; + block.EffectCount = (byte)activeEffects.Count; + for (int e = block.EffectCount - 1; e >= 0; e--) + { + var effectBlock = block[e]; + effectBlock.Id = (byte)activeEffects[e].Id; + } + } + else + { + block.EffectCount = 0; } i++; diff --git a/src/Persistence/Initialization/Updates/AddKalimaPlugIn.cs b/src/Persistence/Initialization/Updates/AddKalimaPlugIn.cs new file mode 100644 index 000000000..b28a2ba41 --- /dev/null +++ b/src/Persistence/Initialization/Updates/AddKalimaPlugIn.cs @@ -0,0 +1,112 @@ +// +// 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.DataModel.Configuration.Items; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.PlugIns; + +/// +/// This adds the items required to enter the kalima map. +/// +[PlugIn(PlugInName, PlugInDescription)] +[Guid("0C99155F-1289-4E73-97F0-47CB67C3716F")] +public class AddKalimaPlugIn : UpdatePlugInBase +{ + /// + /// The plug in name. + /// + internal const string PlugInName = "Add Kalima"; + + /// + /// The plug in description. + /// + internal const string PlugInDescription = "This adds the items required to enter the kalima map."; + + /// + public override UpdateVersion Version => UpdateVersion.AddKalima; + + /// + public override string DataInitializationKey => VersionSeasonSix.DataInitialization.Id; + + /// + public override string Name => PlugInName; + + /// + public override string Description => PlugInDescription; + + /// + public override bool IsMandatory => true; + + /// + public override DateTime CreatedAt => new(2024, 06, 09, 18, 0, 0, DateTimeKind.Utc); + + /// +#pragma warning disable CS1998 + protected override async ValueTask ApplyAsync(IContext context, GameConfiguration gameConfiguration) +#pragma warning restore CS1998 + { + this.CreateLostMap(context, gameConfiguration); + this.CreateSymbolOfKundun(context, gameConfiguration); + + // copy potion girl items to oracle layla: + var potionGirl = gameConfiguration.Monsters.First(m => m.Number == 253); + var oracleLayla = gameConfiguration.Monsters.First(m => m.Number == 259); + if (oracleLayla.NpcWindow is not NpcWindow.Merchant && oracleLayla.MerchantStore is null) + { + oracleLayla.NpcWindow = NpcWindow.Merchant; + oracleLayla.MerchantStore = potionGirl.MerchantStore!.Clone(gameConfiguration); + oracleLayla.MerchantStore.SetGuid(oracleLayla.Number); + } + } + + private void CreateLostMap(IContext context, GameConfiguration gameConfiguration) + { + var itemDefinition = context.CreateNew(); + itemDefinition.Name = "Lost Map"; + itemDefinition.Number = 28; + itemDefinition.Group = 14; + itemDefinition.DropsFromMonsters = false; + itemDefinition.Durability = 1; + itemDefinition.Width = 1; + itemDefinition.Height = 1; + itemDefinition.MaximumItemLevel = 7; + itemDefinition.SetGuid(itemDefinition.Group, itemDefinition.Number); + gameConfiguration.Items.Add(itemDefinition); + } + + private void CreateSymbolOfKundun(IContext context, GameConfiguration gameConfiguration) + { + var itemDefinition = context.CreateNew(); + itemDefinition.Name = "Symbol of Kundun"; + itemDefinition.Number = 29; + itemDefinition.Group = 14; + itemDefinition.DropLevel = 0; + itemDefinition.DropsFromMonsters = true; + itemDefinition.Durability = 5; + itemDefinition.Width = 1; + itemDefinition.Height = 1; + itemDefinition.MaximumItemLevel = 7; + itemDefinition.SetGuid(itemDefinition.Group, itemDefinition.Number); + gameConfiguration.Items.Add(itemDefinition); + + (byte, byte)[] dropLevels = [(25, 46), (47, 65), (66, 77), (78, 84), (85, 91), (92, 107), (108, 255)]; + for (byte level = 1; level <= dropLevels.Length; level++) + { + var dropItemGroup = context.CreateNew(); + dropItemGroup.SetGuid(14, 29, level); + dropItemGroup.ItemLevel = level; + dropItemGroup.PossibleItems.Add(itemDefinition); + dropItemGroup.Chance = 0.003; // 0.3 Percent + dropItemGroup.Description = $"The drop item group for Symbol of Kundun (Level {level})"; + (dropItemGroup.MinimumMonsterLevel, dropItemGroup.MaximumMonsterLevel) = dropLevels[level - 1]; + + gameConfiguration.DropItemGroups.Add(dropItemGroup); + gameConfiguration.Maps.ForEach(map => map.DropItemGroups.Add(dropItemGroup)); + } + } +} \ No newline at end of file diff --git a/src/Persistence/Initialization/Updates/UpdateVersion.cs b/src/Persistence/Initialization/Updates/UpdateVersion.cs index 4e7a53394..fea089606 100644 --- a/src/Persistence/Initialization/Updates/UpdateVersion.cs +++ b/src/Persistence/Initialization/Updates/UpdateVersion.cs @@ -88,5 +88,10 @@ public enum UpdateVersion /// /// The version of the . /// - AddPointsPerResetByClassAttribute = 16 + AddPointsPerResetByClassAttribute = 16, + + /// + /// The version of the . + /// + AddKalima = 17, } \ No newline at end of file diff --git a/src/Persistence/Initialization/VersionSeasonSix/Items/Misc.cs b/src/Persistence/Initialization/VersionSeasonSix/Items/Misc.cs index ea97df119..677db1920 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Items/Misc.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Items/Misc.cs @@ -27,6 +27,63 @@ public override void Initialize() { this.CreateLifeStone(); this.CreateGoldenCherryBlossomBranch(); + this.CreateLostMap(); + this.CreateSymbolOfKundun(); + } + + private void CreateLostMap() + { + var itemDefinition = this.Context.CreateNew(); + itemDefinition.Name = "Lost Map"; + itemDefinition.Number = 28; + itemDefinition.Group = 14; + itemDefinition.DropsFromMonsters = false; + itemDefinition.Durability = 1; + itemDefinition.Width = 1; + itemDefinition.Height = 1; + itemDefinition.MaximumItemLevel = 7; + itemDefinition.SetGuid(itemDefinition.Group, itemDefinition.Number); + this.GameConfiguration.Items.Add(itemDefinition); + } + + private void CreateSymbolOfKundun() + { + var itemDefinition = this.Context.CreateNew(); + itemDefinition.Name = "Symbol of Kundun"; + itemDefinition.Number = 29; + itemDefinition.Group = 14; + itemDefinition.DropLevel = 0; + itemDefinition.DropsFromMonsters = true; + itemDefinition.Durability = 5; + itemDefinition.Width = 1; + itemDefinition.Height = 1; + itemDefinition.MaximumItemLevel = 7; + itemDefinition.SetGuid(itemDefinition.Group, itemDefinition.Number); + this.GameConfiguration.Items.Add(itemDefinition); + + /* Drop Levels: + Symbol +1: Monster Level 25 ~ 46 + Symbol +2: Monster Level 47 ~ 65 + Symbol +3: Monster Level 66 ~ 77 + Symbol +4: Monster Level 78 ~ 84 + Symbol +5: Monster Level 85 ~ 91 + Symbol +6: Monster Level 92 ~ 107 + Symbol +7: Monster Level 108+ + */ + (byte, byte)[] dropLevels = [(25, 46), (47, 65), (66, 77), (78, 84), (85, 91), (92, 107), (108, 255)]; + for (byte level = 1; level <= dropLevels.Length; level++) + { + var dropItemGroup = this.Context.CreateNew(); + dropItemGroup.SetGuid(14, 29, level); + dropItemGroup.ItemLevel = level; + dropItemGroup.PossibleItems.Add(itemDefinition); + dropItemGroup.Chance = 0.003; // 0.3 Percent + dropItemGroup.Description = $"The drop item group for Symbol of Kundun (Level {level})"; + (dropItemGroup.MinimumMonsterLevel, dropItemGroup.MaximumMonsterLevel) = dropLevels[level - 1]; + + this.GameConfiguration.DropItemGroups.Add(dropItemGroup); + BaseMapInitializer.RegisterDefaultDropItemGroup(dropItemGroup); + } } private void CreateLifeStone() diff --git a/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs b/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs index 0d34049c2..af82a718a 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/NpcInitialization.cs @@ -124,6 +124,7 @@ public override void Initialize() def.Number = 259; def.Designation = "Oracle Layla"; def.ObjectKind = NpcObjectKind.PassiveNpc; + def.MerchantStore = this.CreatePotionGirlItemStorage(def.Number); this.GameConfiguration.Monsters.Add(def); def.SetGuid(def.Number); }