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));
}
}