// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.NPC; using System.Diagnostics; using System.Threading; using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.GameLogic.Views.World; using MUnique.OpenMU.Pathfinding; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.PlugIns; /// /// An abstract base class for an . /// public abstract class AttackableNpcBase : NonPlayerCharacter, IAttackable { private const byte MaximumDropDistance = 2; private readonly IEventStateProvider? _eventStateProvider; private readonly IDropGenerator _dropGenerator; private readonly PlugInManager _plugInManager; private readonly List _registrations = new(); private int _health; /// /// Initializes a new instance of the class. /// /// The spawn information. /// The stats. /// The map. /// The event state provider. /// The drop generator. /// The plug in manager. protected AttackableNpcBase(MonsterSpawnArea spawnInfo, MonsterDefinition stats, GameMap map, IEventStateProvider? eventStateProvider, IDropGenerator dropGenerator, PlugInManager plugInManager) : base(spawnInfo, stats, map) { this._eventStateProvider = eventStateProvider; this._dropGenerator = dropGenerator; this._plugInManager = plugInManager; this.MagicEffectList = new MagicEffectsList(this); this.Attributes = new MonsterAttributeHolder(this); } /// /// Occurs when this instance died. /// public event EventHandler? Died; /// public IAttributeSystem Attributes { get; } /// public MagicEffectsList MagicEffectList { get; } /// public bool IsAlive { get; private set; } /// /// Gets a value indicating whether this is currently teleporting and can't be directly targeted. /// It can still receive damage, if the teleport target coordinates are within an target skill area for area attacks. /// /// /// true if teleporting; otherwise, false. /// /// Teleporting for monsters or npcs is not implemented yet. public bool IsTeleporting => false; /// public DeathInformation? LastDeath { get; protected set; } /// public override Point Position { get => base.Position; set { if (base.Position != value) { base.Position = value; this._plugInManager?.GetPlugInPoint()?.AttackableMoved(this); } } } /// /// Gets or sets the current health. /// public int Health { get => Math.Max(this._health, 0); set => this._health = value; } private bool ShouldRespawn => this.SpawnArea.SpawnTrigger == SpawnTrigger.Automatic || (this.SpawnArea.SpawnTrigger == SpawnTrigger.AutomaticDuringEvent && (this._eventStateProvider?.IsEventRunning ?? false)) || (this.SpawnArea.SpawnTrigger == SpawnTrigger.AutomaticDuringWave && (this._eventStateProvider?.IsSpawnWaveActive(this.SpawnArea.WaveNumber) ?? false)); /// public async ValueTask AttackByAsync(IAttacker attacker, SkillEntry? skill, bool isCombo, double damageFactor = 1.0, bool? isFinalStreakHit = null) { if (this.Definition.ObjectKind == NpcObjectKind.Guard || this.IsAttackBlockedBySafezone(attacker)) { return null; } // ADAMU-CUSTOM: Heykel Savasi -> a statue is immune to damage from its own team (only the enemy may // break it) AND while any of its 3 guard mobs are still alive (guards must be cleared first). if (attacker is Player heykelStatueAttacker && heykelStatueAttacker.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelStatueGame && heykelStatueGame.IsStatueAttackBlocked(this, heykelStatueAttacker)) { return null; } var hitInfo = await attacker.CalculateDamageAsync(this, skill, isCombo, damageFactor).ConfigureAwait(false); if (skill?.Skill is not { } attackSkill || attackSkill.DamageType != DamageType.Fenrir) { attacker.ApplyAmmunitionConsumption(hitInfo); } await this.HitAsync(hitInfo, attacker, skill?.Skill, isFinalStreakHit).ConfigureAwait(false); if (hitInfo.HealthDamage > 0) { if (this.Attributes[Stats.IsAsleep] > 0) { await this.MagicEffectList.ClearAllEffectsProducingSpecificStatAsync(Stats.IsAsleep).ConfigureAwait(false); } if (attacker is Player player) { await player.AfterHitTargetAsync().ConfigureAwait(false); if (this.IsAlive && Rand.NextRandomBool(player.Attributes![Stats.MaceMasteryStunChance])) { await player.ApplyMaceMasteryStunEffectAsync(this).ConfigureAwait(false); } // ADAMU-CUSTOM: TvT Event -> record per-character statue damage for the event scoreboard. if (this.Definition.ObjectKind == NpcObjectKind.Destructible && player.CurrentMiniGame is MiniGames.HeykelSavasiContext heykelDamageGame) { heykelDamageGame.RecordStatueDamage(player, this, (uint)hitInfo.HealthDamage); } } if (attacker as IPlayerSurrogate is { } playerSurrogate) { await playerSurrogate.Owner.AfterHitTargetAsync().ConfigureAwait(false); } } return hitInfo; } /// public abstract ValueTask ReflectDamageAsync(IAttacker reflector, uint damage); /// public abstract ValueTask ApplyPoisonDamageAsync(IAttacker initialAttacker, uint damage); /// public abstract ValueTask ApplyBleedingDamageAsync(IAttacker initialAttacker, uint damage); /// public ValueTask KillInstantlyAsync() { throw new NotImplementedException(); } /// public override void Initialize() { base.Initialize(); this.Health = this.SpawnArea.MaximumHealthOverride ?? (int)this.Attributes[Stats.MaximumHealth]; this.IsAlive = true; } /// /// Reloads the attributes from the , so that changes /// to the monster definition take effect on this already spawned instance. /// public void ReloadAttributes() { (this.Attributes as MonsterAttributeHolder)?.ApplyChanges(); } /// /// Registers a disposable (e.g. a configuration change registration) to be disposed /// together with this instance. /// /// The disposable. public void RegisterDisposable(IDisposable disposable) { this._registrations.Add(disposable); } /// protected override void Dispose(bool managed) { if (managed) { this.Died = null; this.IsAlive = false; foreach (var registration in this._registrations) { registration.Dispose(); } this._registrations.Clear(); } base.Dispose(managed); } /// /// Called when the object is removed from the map. /// protected virtual void OnRemoveFromMap() { // can be overwritten to do additional stuff. } /// /// Hits this instance with the specified hit information. /// /// The hit information. /// The attacker. /// The skill. /// /// Not null when it's a rage fighter multiple hit skill: /// true, if it's the final hit; /// false, for other hits. /// protected async ValueTask HitAsync(HitInfo hitInfo, IAttacker attacker, Skill? skill, bool? isFinalStreakHit = null) { if (!this.IsAlive) { return; } var killed = this.TryHit(hitInfo.HealthDamage + hitInfo.ShieldDamage, attacker); var player = this.GetHitNotificationTarget(attacker); if (player is not null) { if (isFinalStreakHit.HasValue) { hitInfo.Attributes |= DamageAttributes.RageFighterStreakHit; if (isFinalStreakHit.Value || killed) { hitInfo.Attributes |= DamageAttributes.RageFighterStreakFinalHit; } } await player.InvokeViewPlugInAsync(p => p.ShowHitAsync(this, hitInfo)).ConfigureAwait(false); player.GameContext.PlugInManager.GetPlugInPoint()?.AttackableGotHit(this, attacker, hitInfo); } if (killed) { this.LastDeath = new DeathInformation(attacker.Id, attacker.GetName(), hitInfo, skill?.Number ?? 0); await this.OnDeathAsync(attacker).ConfigureAwait(false); this.Died?.Invoke(this, this.LastDeath); if (!this.ShouldRespawn) { await this.RemoveFromMapAndDisposeAsync().ConfigureAwait(false); } } } /// /// Gets the target of a hit notification. /// /// The attacker. /// The target player of a hit notification. protected virtual Player? GetHitNotificationTarget(IAttacker attacker) { return attacker as Player ?? (attacker as IPlayerSurrogate)?.Owner; } /// /// Registers the hit. /// /// The attacker. protected virtual void RegisterHit(IAttacker attacker) { // can be overwritten } /// /// Called when this instance died. /// /// The attacker which killed this instance. protected virtual async ValueTask OnDeathAsync(IAttacker attacker) { if (this.ShouldRespawn) { _ = Task.Run(async () => { try { await Task.Delay(this.Definition.RespawnDelay).ConfigureAwait(false); await this.RespawnAsync().ConfigureAwait(false); } catch (Exception ex) { Debug.Fail($"Unexpected error during respawning the attackable npc {this}: {ex}", ex.StackTrace); } }); } await this.ForEachWorldObserverAsync(p => p.ObjectGotKilledAsync(this, attacker), true).ConfigureAwait(false); var player = this.GetHitNotificationTarget(attacker); if (player is { }) { var experienceShares = player.Party is { } party ? await party.DistributeExperienceAfterKillAsync(this, player).ConfigureAwait(false) : [new ExperienceShare(player, await player.AddExpAfterKillAsync(this).ConfigureAwait(false))]; if (attacker == player) { await player.AfterKilledMonsterAsync().ConfigureAwait(false); } if (player.GameContext.PlugInManager.GetPlugInPoint() is { } plugInPoint) { await plugInPoint.AttackableGotKilledAsync(this, attacker).ConfigureAwait(false); } if (!this.IsSummonedMonster && player.SelectedCharacter is { } selectedCharacter) { if (selectedCharacter.State > HeroState.Normal) { selectedCharacter.StateRemainingSeconds -= (int)this.Attributes[Stats.Level]; } _ = this.DropItemDelayedAsync(player, experienceShares); // don't wait for completion. } } } private async ValueTask RemoveFromMapAndDisposeAsync() { await this.CurrentMap.RemoveAsync(this).ConfigureAwait(false); this.Dispose(); this.OnRemoveFromMap(); } /// /// Respawns this instance on the map. /// private async ValueTask RespawnAsync() { try { if (!this.ShouldRespawn) { await this.RemoveFromMapAndDisposeAsync().ConfigureAwait(false); return; } this.Initialize(); await this.CurrentMap.RespawnAsync(this).ConfigureAwait(false); this.OnSpawn(); } catch (Exception ex) { Debug.Fail(ex.Message, ex.StackTrace); } } private bool TryHit(uint damage, IAttacker attacker) { if (damage > 0) { this.RegisterHit(attacker); } if (damage >= this.Health) { this.IsAlive = false; this.Health = 0; return true; } try { Interlocked.Add(ref this._health, -(int)damage); return false; } catch { return false; } } private async ValueTask HandleMoneyDropAsync(uint amount, Player killer, IReadOnlyList experienceShares) { // Each player gets the part of the money which matches the experience they gained from the kill, // so that money follows the same distribution as the experience it is derived from. var shares = MoneyDistribution.CreateShares(amount, experienceShares); // We don't drop money in Devil Square, etc. var shouldDropMoney = killer.GameContext.Configuration.ShouldDropMoney && killer.CurrentMiniGame is null; if (!shouldDropMoney) { if (killer.Party is { } party) { await party.DistributeMoneyAfterKillAsync(this, killer, shares).ConfigureAwait(false); } else { _ = MoneyDistribution.TryPay(killer, amount); } return; } var droppedMoney = new DroppedMoney(amount, this.Position, this.CurrentMap, shares); await this.CurrentMap.AddAsync(droppedMoney).ConfigureAwait(false); } private async ValueTask DropItemAsync(IReadOnlyList experienceShares, Player killer) { var exp = 0; foreach (var share in experienceShares) { exp += share.Experience; } var (generatedItems, droppedMoney) = await this._dropGenerator.GenerateItemDropsAsync(this.Definition, exp, killer).ConfigureAwait(false); if (droppedMoney > 0) { await this.HandleMoneyDropAsync(droppedMoney.Value, killer, experienceShares).ConfigureAwait(false); } var firstItem = !droppedMoney.HasValue; foreach (var item in generatedItems) { Point dropCoordinates; if (firstItem) { dropCoordinates = this.Position; firstItem = false; } else { dropCoordinates = this.CurrentMap.Terrain.GetRandomCoordinate(this.Position, MaximumDropDistance); } var owners = killer.Party?.PartyList.AsEnumerable() ?? killer.GetAsEnumerable(); var droppedItem = new DroppedItem(item, dropCoordinates, this.CurrentMap, null, owners); await this.CurrentMap.AddAsync(droppedItem).ConfigureAwait(false); } } private async ValueTask DropItemDelayedAsync(Player player, IReadOnlyList experienceShares) { try { await Task.Delay(1000).ConfigureAwait(false); await this.DropItemAsync(experienceShares, player).ConfigureAwait(false); } catch (Exception ex) { player.Logger.LogDebug(ex, "Dropping an item failed after killing '{this}': {ex}", this, ex); } } }