namespace Aeshnidae.AllegianceBounty; /// /// The bounty itself: what each allegiance has active, how an entry is gained, and what /// the bonus works out to for a player about to receive kill experience. /// /// The active entries live in memory (monarch guid -> quest name -> entry) with the /// database behind them. GrantXP runs on landblock threads for every kill on the /// server, so the read side is a dictionary lookup and a sum, nothing more; the write /// side (a completion) is rare and may touch the database. /// public static class Bounty { private static readonly ConcurrentDictionary> _active = new(); /// Serialises the gain path so two members finishing the same quest in the same second record it once. private static readonly object _gainLock = new(); private static DateTime _lastPrune = DateTime.MinValue; public static int ActiveEntries => _active.Values.Sum(d => d.Count); public static int ActiveAllegiances => _active.Count(kv => kv.Value.Count > 0); /// Fills the cache from the table. Called on enable; a reload keeps entries gained meanwhile because they are in the table too. public static void Load() { _active.Clear(); foreach (var e in BountyDb.LoadActive()) Dict(e.MonarchId)[Key(e.QuestName)] = e; try { BountyDb.PruneExpired(); } catch { /* cosmetic */ } _lastPrune = DateTime.UtcNow; } private static ConcurrentDictionary Dict(uint monarch) => _active.GetOrAdd(monarch, _ => new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase)); private static string Key(string questName) => questName.Trim().ToLowerInvariant(); /// The allegiance unit: the monarch's guid, or null for the unsworn. public static uint? MonarchOf(Player player) { var allegiance = player?.Allegiance; if (allegiance is null) return null; return allegiance.MonarchId ?? player!.MonarchId; } // --------------------------------------------------------------- gaining /// /// A quest flag has just gone up on a player. If it is a listed completion and the /// player is sworn, the allegiance gains the entry - unless it already has it. /// public static void OnStamp(Player player, string flag) { if (!Mod.Settings.Enabled || !BountyDb.Ready) return; var entry = QuestList.ByFlag(flag); if (entry is null) return; var monarch = MonarchOf(player); if (monarch is null) return; Gain(player, monarch.Value, entry, flag, "completed"); } /// /// Records the entry for the allegiance and tells the members. Returns false when the /// allegiance already had the quest active (counts once per allegiance per day) - which /// is also what stops an NPC that stamps the same flag twice, or two listed flags, from /// counting a single turn-in twice. /// public static bool Gain(Player player, uint monarch, QuestEntry quest, string flag, string verb) { var now = DateTime.UtcNow; var s = Mod.Settings; BountyEntry entry; lock (_gainLock) { var dict = Dict(monarch); var key = Key(quest.Name); if (dict.TryGetValue(key, out var existing) && !existing.Expired(now)) return false; entry = new BountyEntry { MonarchId = monarch, QuestName = quest.Name, Flag = flag, Percent = quest.EffectivePercent(s), CompletedBy = player.Name, CompletedById = player.Guid.Full, CompletedAt = now, ExpiresAt = now.AddHours(Math.Max(0.01, s.HoursActive)), }; dict[key] = entry; try { BountyDb.Upsert(entry); if (now - _lastPrune > TimeSpan.FromHours(1)) { _lastPrune = now; BountyDb.PruneExpired(); } } catch (Exception ex) { // The entry stays in memory for the day either way; only the restart-survival is lost. ModManager.Log($"[{Mod.Name}] could not store the bounty entry '{quest.Name}' for allegiance {monarch:X8}: {ex.Message}", ModManager.LogLevel.Error); } } var summary = Summary(player); ModManager.Log($"[{Mod.Name}] {player.Name} {verb} '{quest.Name}' ({flag}, +{entry.Percent:0.##}%) for allegiance {monarch:X8}: " + $"raw {summary.Raw:0.##}% x{summary.Multiplier:0.##} ({summary.Members} members) = +{summary.Effective:0.##}%, {summary.Count} active"); if (s.AnnounceToAllegiance) { var line = $"{player.Name} completed {quest.Name} (+{entry.Percent:0.##}%): the allegiance's bounty is now +{summary.Effective:0.##}% for 24 hours."; Announce(player, line); } return true; } private static void Announce(Player player, string line) { try { var members = player.Allegiance?.OnlinePlayers; if (members is null || members.Count == 0) { player.SendMessage(line); return; } foreach (var member in members) member.SendMessage(line); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not announce to the allegiance: {ex.Message}", ModManager.LogLevel.Warn); } } // --------------------------------------------------------------- reading public readonly record struct BountySummary(double Raw, int Count, int Members, double Multiplier, string Band, double Effective); /// The live entries of an allegiance, expired ones dropped on the way. public static List EntriesOf(uint monarch) { if (!_active.TryGetValue(monarch, out var dict)) return new List(); var now = DateTime.UtcNow; var list = new List(dict.Count); foreach (var kv in dict) { if (kv.Value.Expired(now)) dict.TryRemove(kv.Key, out _); else list.Add(kv.Value); } list.Sort((a, b) => a.ExpiresAt.CompareTo(b.ExpiresAt)); return list; } /// The raw total, the size band, and the effective percent, for a player's allegiance. public static BountySummary Summary(Player player) { var allegiance = player?.Allegiance; var monarch = MonarchOf(player!); if (allegiance is null || monarch is null) return new BountySummary(0, 0, 0, 1, "unsworn", 0); var s = Mod.Settings; var entries = EntriesOf(monarch.Value); var raw = entries.Sum(e => e.Percent); if (s.MaxPercent > 0 && raw > s.MaxPercent) raw = s.MaxPercent; var members = Math.Max(1, allegiance.TotalMembers); var mult = s.SizeMultiplier(members); return new BountySummary(raw, entries.Count, members, mult, s.SizeBandLabel(members), raw * mult); } /// /// The percent to add to a kill for this player right now. The hot path: no database, /// no allocation beyond the entry list of one allegiance. /// public static double EffectivePercent(Player player) { var allegiance = player.Allegiance; if (allegiance is null) return 0; var monarch = allegiance.MonarchId ?? player.MonarchId; if (monarch is null || !_active.TryGetValue(monarch.Value, out var dict) || dict.IsEmpty) return 0; var now = DateTime.UtcNow; double raw = 0; foreach (var kv in dict) { if (kv.Value.Expired(now)) dict.TryRemove(kv.Key, out _); else raw += kv.Value.Percent; } if (raw <= 0) return 0; var s = Mod.Settings; if (s.MaxPercent > 0 && raw > s.MaxPercent) raw = s.MaxPercent; return raw * s.SizeMultiplier(Math.Max(1, allegiance.TotalMembers)); } }