namespace Aeshnidae.AllegianceBounty;
///
/// The personal layer (decided 2026-09-20): a per-character kill-experience bonus of
/// +0.1% for every DISTINCT quest flag the character stamped today, capped at +20%,
/// resetting daily at a fixed server-local hour. Any quest flag counts - quest stamps
/// are abundant by design and the per-stamp value is small - but each flag counts once
/// per day, so re-stamping the same cheap flag is worth nothing: that is the guard
/// against looping one stamp to the cap. Added to the allegiance bounty in the same
/// GrantXP prefix; the same Kill-only and fellowship rules; Radiance follows.
///
/// The "day" is the reset-to-reset window: a stamp at 16:59 belongs to the day that
/// started at 17:00 yesterday. One table row per (character, day, flag); the character's
/// current day is cached in memory and read there on every kill.
///
public static class Personal
{
public const string TableName = "aeshnidae_personal_bounty";
private sealed class Day
{
public DateOnly Key;
public readonly HashSet Flags = new(StringComparer.OrdinalIgnoreCase);
public bool CapAnnounced;
}
private static readonly ConcurrentDictionary _days = new();
private static DateTime _lastPrune = DateTime.MinValue;
// ------------------------------------------------------------------ the day
/// The day key for a server-local instant: the date of the reset that started its window.
public static DateOnly DayOf(DateTime localNow) =>
DateOnly.FromDateTime(localNow.AddHours(-Mod.Settings.PersonalResetHour));
public static DateOnly Today => DayOf(DateTime.Now);
/// The next reset, server-local, for /bounty.
public static DateTime NextReset()
{
var hour = Math.Clamp(Mod.Settings.PersonalResetHour, 0, 23);
var today = DateTime.Today.AddHours(hour);
return DateTime.Now < today ? today : today.AddDays(1);
}
// ------------------------------------------------------------------ cache
private static Day DayFor(Player player)
{
var today = Today;
if (_days.TryGetValue(player.Guid.Full, out var day) && day.Key == today)
return day;
// First touch today (login, or the window rolled over while online): read what
// the table already has for this day, so a reload or a relog loses nothing.
var fresh = new Day { Key = today };
if (BountyDb.Ready)
{
try
{
foreach (var flag in LoadFlags(player.Guid.Full, today))
fresh.Flags.Add(flag);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not read {player.Name}'s personal bounty: {ex.Message}", ModManager.LogLevel.Warn);
}
}
fresh.CapAnnounced = PercentOf(fresh.Flags.Count) >= Mod.Settings.PersonalMaxPercent;
_days[player.Guid.Full] = fresh;
return fresh;
}
public static void Load(Player player)
{
try { DayFor(player); } catch { /* lazy path will retry */ }
}
public static void Forget(Player player) => _days.TryRemove(player.Guid.Full, out _);
public static void ForgetAll() => _days.Clear();
// ------------------------------------------------------------------ the stamp
/// A quest flag was written on the character. Counts it if it is the first time today.
public static void OnStamp(Player player, string flag)
{
var s = Mod.Settings;
if (!s.PersonalEnabled || string.IsNullOrWhiteSpace(flag))
return;
if (!s.PersonalAnyQuest && QuestList.ByFlag(flag) is null)
return;
var day = DayFor(player);
bool added;
lock (day)
{
added = day.Flags.Add(flag);
}
if (!added)
return;
if (BountyDb.Ready)
{
try
{
Insert(player.Guid.Full, day.Key, flag, DateTime.UtcNow);
if (DateTime.UtcNow - _lastPrune > TimeSpan.FromHours(1))
{
_lastPrune = DateTime.UtcNow;
Prune();
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not store {player.Name}'s personal stamp '{flag}': {ex.Message}", ModManager.LogLevel.Error);
}
}
// Nothing per stamp - too chatty. One line when the cap is reached.
if (!day.CapAnnounced && PercentOf(day.Flags.Count) >= s.PersonalMaxPercent && s.PersonalMaxPercent > 0)
{
day.CapAnnounced = true;
player.SendMessage($"Your personal bounty is at its cap for today: +{s.PersonalMaxPercent:0.##}% on your kills until it resets at {NextReset():HH:mm}.");
}
}
// ------------------------------------------------------------------ reading
public static double PercentOf(int distinctStamps)
{
var s = Mod.Settings;
var pct = distinctStamps * s.PersonalPercentPerStamp;
return s.PersonalMaxPercent > 0 ? Math.Min(pct, s.PersonalMaxPercent) : pct;
}
public static int StampsToday(Player player) => Mod.Settings.PersonalEnabled ? DayFor(player).Flags.Count : 0;
/// The percent to add to this player's kill right now. Hot path: a dictionary read.
public static double Percent(Player player)
{
if (!Mod.Settings.PersonalEnabled)
return 0;
var today = Today;
if (_days.TryGetValue(player.Guid.Full, out var day) && day.Key == today)
return PercentOf(day.Flags.Count);
return PercentOf(DayFor(player).Flags.Count);
}
// ------------------------------------------------------------------ the table
public static void CreateTable(MySqlConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
CREATE TABLE IF NOT EXISTS `{TableName}` (
`character_Id` INT UNSIGNED NOT NULL,
`day` DATE NOT NULL,
`flag` VARCHAR(255) NOT NULL,
`stamped_at` DATETIME NOT NULL,
UNIQUE KEY `character_day_flag` (`character_Id`, `day`, `flag`),
KEY `day` (`day`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
cmd.ExecuteNonQuery();
}
private static List LoadFlags(uint characterId, DateOnly day)
{
var list = new List();
using var conn = BountyDb.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT `flag` FROM `{TableName}` WHERE `character_Id` = @c AND `day` = @d;";
cmd.Parameters.AddWithValue("@c", characterId);
cmd.Parameters.AddWithValue("@d", day.ToDateTime(TimeOnly.MinValue));
using var reader = cmd.ExecuteReader();
while (reader.Read())
list.Add(reader.GetString(0));
return list;
}
private static void Insert(uint characterId, DateOnly day, string flag, DateTime stampedAtUtc)
{
using var conn = BountyDb.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"INSERT IGNORE INTO `{TableName}` (`character_Id`, `day`, `flag`, `stamped_at`) VALUES (@c, @d, @f, @t);";
cmd.Parameters.AddWithValue("@c", characterId);
cmd.Parameters.AddWithValue("@d", day.ToDateTime(TimeOnly.MinValue));
cmd.Parameters.AddWithValue("@f", flag);
cmd.Parameters.AddWithValue("@t", stampedAtUtc);
cmd.ExecuteNonQuery();
}
/// Drops days older than two: yesterday's window is kept for a look, nothing older is read.
public static int Prune()
{
using var conn = BountyDb.OpenConnection();
using var cmd = conn.CreateCommand();
cmd.CommandText = $"DELETE FROM `{TableName}` WHERE `day` < @d;";
cmd.Parameters.AddWithValue("@d", Today.AddDays(-2).ToDateTime(TimeOnly.MinValue));
return cmd.ExecuteNonQuery();
}
}