The money amount of a monster drop is computed as "gained experience + 7", but in a party it was neither derived from, nor distributed like, that experience. DistributeExperienceAfterKillAsync returned a party total without the game rate and the personal experience rates, while AddExpAfterKillAsync returned a value which had them applied. Both landed in the same variable in OnDeathAsync, so the party number was orders of magnitude smaller. That mismatch was worked around by recalculating the killer's solo experience for money purposes, which pinned the party pool to a solo-sized amount: with the pool then split by member count, a party of three received about a third of the solo zen each, while its experience pool grows with the party size. AwardExperienceAsync already computes the per member experience with all rates applied, it just discarded it. It now returns that value, the distribution returns the per member breakdown, and the money is split proportionally to it. The workaround is gone, the pool follows the experience, and each member's zen matches their own level, their own rates and their own master/normal branch. The units lost to the integer division are handed to the shares which were cut the most, one each, so no member is systematically favoured over many kills. Two defects around the money rate are fixed on the way: - MoneyAmountRate was applied for the killer when the drop was created and again for the receiver when a party picked it up, so a rate of 3.0 paid a party 9x. On the "money straight into the inventory" path for a solo killer it was not applied at all. It is now applied exactly once, for the receiver. - ClampMoneyOnPickup was only honoured for solo pick ups. A party member at MaximumInventoryMoney silently lost their share, because the drop was consumed as soon as any other member could take one. The clamp now runs on the shared payout path, after the money rate, so it clamps the amount actually credited. Money without a per player breakdown, such as the fixed amount of an item box, keeps being split equally. Shares of players who are no longer eligible are redistributed among the remaining ones instead of being lost. (cherry picked from commit 53f25aca08e690255571f2bb6c796bb7c10934a2)
479 lines
17 KiB
C#
479 lines
17 KiB
C#
// <copyright file="AttackableNpcBase.cs" company="MUnique">
|
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
|
// </copyright>
|
|
|
|
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;
|
|
|
|
/// <summary>
|
|
/// An abstract base class for an <see cref="IAttackable"/> <see cref="NonPlayerCharacter"/>.
|
|
/// </summary>
|
|
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<IDisposable> _registrations = new();
|
|
|
|
private int _health;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="AttackableNpcBase" /> class.
|
|
/// </summary>
|
|
/// <param name="spawnInfo">The spawn information.</param>
|
|
/// <param name="stats">The stats.</param>
|
|
/// <param name="map">The map.</param>
|
|
/// <param name="eventStateProvider">The event state provider.</param>
|
|
/// <param name="dropGenerator">The drop generator.</param>
|
|
/// <param name="plugInManager">The plug in manager.</param>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Occurs when this instance died.
|
|
/// </summary>
|
|
public event EventHandler<DeathInformation>? Died;
|
|
|
|
/// <inheritdoc />
|
|
public IAttributeSystem Attributes { get; }
|
|
|
|
/// <inheritdoc />
|
|
public MagicEffectsList MagicEffectList { get; }
|
|
|
|
/// <inheritdoc />
|
|
public bool IsAlive { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether this <see cref="IAttackable" /> 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.
|
|
/// </summary>
|
|
/// <value>
|
|
/// <c>true</c> if teleporting; otherwise, <c>false</c>.
|
|
/// </value>
|
|
/// <remarks>Teleporting for monsters or npcs is not implemented yet.</remarks>
|
|
public bool IsTeleporting => false;
|
|
|
|
/// <inheritdoc />
|
|
public DeathInformation? LastDeath { get; protected set; }
|
|
|
|
/// <inheritdoc/>
|
|
public override Point Position
|
|
{
|
|
get => base.Position;
|
|
set
|
|
{
|
|
if (base.Position != value)
|
|
{
|
|
base.Position = value;
|
|
this._plugInManager?.GetPlugInPoint<IAttackableMovedPlugIn>()?.AttackableMoved(this);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the current health.
|
|
/// </summary>
|
|
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));
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask<HitInfo?> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public abstract ValueTask ReflectDamageAsync(IAttacker reflector, uint damage);
|
|
|
|
/// <inheritdoc />
|
|
public abstract ValueTask ApplyPoisonDamageAsync(IAttacker initialAttacker, uint damage);
|
|
|
|
/// <inheritdoc />
|
|
public abstract ValueTask ApplyBleedingDamageAsync(IAttacker initialAttacker, uint damage);
|
|
|
|
/// <inheritdoc/>
|
|
public ValueTask KillInstantlyAsync()
|
|
{
|
|
throw new NotImplementedException();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override void Initialize()
|
|
{
|
|
base.Initialize();
|
|
this.Health = this.SpawnArea.MaximumHealthOverride ?? (int)this.Attributes[Stats.MaximumHealth];
|
|
this.IsAlive = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reloads the attributes from the <see cref="NonPlayerCharacter.Definition"/>, so that changes
|
|
/// to the monster definition take effect on this already spawned instance.
|
|
/// </summary>
|
|
public void ReloadAttributes()
|
|
{
|
|
(this.Attributes as MonsterAttributeHolder)?.ApplyChanges();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers a disposable (e.g. a configuration change registration) to be disposed
|
|
/// together with this instance.
|
|
/// </summary>
|
|
/// <param name="disposable">The disposable.</param>
|
|
public void RegisterDisposable(IDisposable disposable)
|
|
{
|
|
this._registrations.Add(disposable);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when the object is removed from the map.
|
|
/// </summary>
|
|
protected virtual void OnRemoveFromMap()
|
|
{
|
|
// can be overwritten to do additional stuff.
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hits this instance with the specified hit information.
|
|
/// </summary>
|
|
/// <param name="hitInfo">The hit information.</param>
|
|
/// <param name="attacker">The attacker.</param>
|
|
/// <param name="skill">The skill.</param>
|
|
/// <param name="isFinalStreakHit">
|
|
/// Not <c>null</c> when it's a rage fighter multiple hit skill:
|
|
/// <c>true</c>, if it's the final hit;
|
|
/// <c>false</c>, for other hits.
|
|
/// </param>
|
|
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<IShowHitPlugIn>(p => p.ShowHitAsync(this, hitInfo)).ConfigureAwait(false);
|
|
player.GameContext.PlugInManager.GetPlugInPoint<IAttackableGotHitPlugIn>()?.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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the target of a hit notification.
|
|
/// </summary>
|
|
/// <param name="attacker">The attacker.</param>
|
|
/// <returns>The target player of a hit notification.</returns>
|
|
protected virtual Player? GetHitNotificationTarget(IAttacker attacker)
|
|
{
|
|
return attacker as Player ?? (attacker as IPlayerSurrogate)?.Owner;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers the hit.
|
|
/// </summary>
|
|
/// <param name="attacker">The attacker.</param>
|
|
protected virtual void RegisterHit(IAttacker attacker)
|
|
{
|
|
// can be overwritten
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when this instance died.
|
|
/// </summary>
|
|
/// <param name="attacker">The attacker which killed this instance.</param>
|
|
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<IObjectGotKilledPlugIn>(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<IAttackableGotKilledPlugIn>() 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Respawns this instance on the map.
|
|
/// </summary>
|
|
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<ExperienceShare> 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<ExperienceShare> 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<ExperienceShare> 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);
|
|
}
|
|
}
|
|
}
|