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