using System; using System.Collections.Generic; using System.Linq; using ACE.Entity; using ACE.Entity.Enum; using ACE.Server.WorldObjects; namespace ACE.Server.Entity { /// /// Tracks the recent damage sources for Players / Creatures /// public class DamageHistory { /// /// The player or creature this Damage History is tracking /// public readonly Creature Creature; /// /// A list of damage sources, amounts, and timestamps /// public readonly List Log = new List(); /// /// A lookup table of WorldObjects that have damaged this WorldObject, /// and the total amount of damage they have inflicted /// public readonly Dictionary TotalDamage = new Dictionary(); /// /// Returns the list of players or creatures who inflicted damage /// public List Damagers => TotalDamage.Values.ToList(); /// /// Returns the DamageHistoryInfo for the last damager /// public DamageHistoryInfo LastDamager { get { var lastDamager = Log.LastOrDefault(l => l.Amount < 0); if (lastDamager == null) return null; TotalDamage.TryGetValue(lastDamager.Attacker, out var info); return info; } } public float TotalHealth => TotalDamage.Values.Sum(i => i.TotalDamage); /// /// Returns the DamageHistoryInfo for the top damager /// for determining 'Killed by' corpse looting rights /// public DamageHistoryInfo TopDamager => GetTopDamager(); public DamageHistoryInfo GetTopDamager(bool includeSelf = true) { var sorted = TotalDamage.Values.Where(wo => includeSelf || wo.Guid != Creature.Guid).OrderByDescending(wo => wo.TotalDamage); return sorted.FirstOrDefault(); } /// /// Constructs a new DamageHistory for a Player / Creature /// public DamageHistory(Creature creature) { Creature = creature; } /// /// Logs a damaging event for this player or creature /// /// The attacker or source of damage /// The amount of damage hit for public void Add(WorldObject attacker, DamageType damageType, uint amount) { //Console.WriteLine($"{Creature.Name}.DamageHistory.Add({attacker.Name}, {damageType}, {amount})"); if (amount == 0) return; var entry = new DamageHistoryEntry(Creature, attacker.Guid, damageType, -(int)amount); Log.Add(entry); AddInternal(attacker, amount); Creature.OnHealthUpdate(); } /// /// Internally increments the total damage table /// private void AddInternal(WorldObject attacker, uint amount) { if (TotalDamage.TryGetValue(attacker.Guid, out var value)) value.TotalDamage += amount; else TotalDamage.Add(attacker.Guid, new DamageHistoryInfo(attacker, amount)); } /// /// Internally increments the total damage table /// private void AddInternal(ObjectGuid attacker, uint amount) { // todo: investigate, this shouldn't happen? // key 0 from BuildTotalDamage() if (TotalDamage.ContainsKey(attacker)) TotalDamage[attacker].TotalDamage += amount; } /// /// Called when this player or creature regains some health /// /// The amount of health restored public void OnHeal(uint healAmount) { //Console.WriteLine($"DamageHistory.OnHeal({Creature.Name}, {healAmount})"); Log.Add(new DamageHistoryEntry(Creature, ObjectGuid.Invalid, DamageType.Undef, (int)healAmount)); // calculate previous missingHealth OnHealInternal(healAmount, Creature.Health.Current, Creature.Health.MaxValue); Creature.OnHealthUpdate(); } /// /// Internally scales TotalDamage entries by a healing amount /// /// The amount of health restored /// The current health after healing /// The maximum health at the time of healing private void OnHealInternal(uint healAmount, uint currentHealth, uint maxHealth) { // on heal, scale the damage from each source by 1 - healAmount / previous missingHealth var missingHealth = maxHealth - (currentHealth - healAmount); if (healAmount == 0 || missingHealth == 0) return; var scalar = 1.0f - (float)healAmount / missingHealth; var attackers = TotalDamage.Keys.ToList(); foreach (var attacker in attackers) TotalDamage[attacker].TotalDamage *= scalar; } /// /// Resets the damage log (eg. on player death) /// public void Reset() { Log.Clear(); TotalDamage.Clear(); } /// /// The last time the log was pruned /// public DateTime LastPruneTime = DateTime.UtcNow; private static readonly TimeSpan minimumPruneInterval = TimeSpan.FromSeconds(30); private static readonly TimeSpan maximumTimeToRetain = TimeSpan.FromMinutes(3); /// /// Tries pruning the log according to the minimum pruning time /// public void TryPrune() { if (LastPruneTime + minimumPruneInterval < DateTime.UtcNow) Prune(); } /// /// Removes log entries older than HistoryMinutes /// public void Prune() { var entriesToRemove = 0; foreach (var entry in Log) { if (entry.Time + maximumTimeToRetain < DateTime.UtcNow) entriesToRemove++; else break; } if (entriesToRemove > 0) { Log.RemoveRange(0, entriesToRemove); BuildTotalDamage(); //Console.WriteLine($"DamageHistory.Prune() - {entriesToRemove} entries removed"); } LastPruneTime = DateTime.UtcNow; } /// /// Rebuilds TotalDamage from the current state of the history log /// public void BuildTotalDamage() { // This is a little bit hacky. // We don't want to clear our TotalDamage entries because we might lose references to WorldObjects // Instead, we remove entries that are no longer needed, and set all the values to 0. var guids = new HashSet(); foreach (var entry in Log) guids.Add(entry.Attacker); var keys = TotalDamage.Keys.ToList(); foreach (var key in keys) { if (guids.Contains(key)) TotalDamage[key].TotalDamage = 0; else TotalDamage.Remove(key); } // TotalDamage is now reset foreach (var entry in Log) { if (entry.Amount < 0) AddInternal(entry.Attacker, (uint)-entry.Amount); else OnHealInternal((uint)entry.Amount, entry.CurrentHealth, entry.MaxHealth); } } /// /// Returns TRUE if damage history contains wo as recent attacker /// /// If TRUE, attacker must have TotalDamage > 0 public bool HasDamager(WorldObject wo, bool nonZero = false) { if (!TotalDamage.TryGetValue(wo.Guid, out var totalDamage)) return false; if (nonZero) return totalDamage.TotalDamage > 0; else return true; } public override string ToString() { var table = ""; foreach (var attacker in TotalDamage.Values) table += $"{attacker.Name} ({attacker.Guid}) - {attacker.TotalDamage}\n"; return table; } } }