namespace Aeshnidae.Bank; /// /// Where radiance and resonance come from, and the buffer that keeps earning them /// from costing anything on the world thread. /// /// The problem this solves. Awards happen on a landblock thread, in the middle of a /// tick: every experience grant for radiance, a quest stamp for resonance. BankDb.TryAdjust /// takes a process-wide lock and does a MySQL round trip inside a transaction, which /// is entirely reasonable for a player typing /bank deposit and entirely unreasonable /// on every monster death on the shard. Doing it inline would put database latency /// inside the tick loop and serialise every landblock behind one lock. /// /// So awards land in memory and are written out by a background flush. The player is /// told immediately and reads from balance + pending everywhere, so nothing looks lost /// in between - see PendingFor, which BankService folds into every balance it reports. /// /// What this costs: a hard crash loses at most FlushSeconds of unflushed earnings. /// That is the deliberate trade, it is bounded, and a clean shutdown flushes. If you /// would rather have durability than tick latency, set FlushSeconds to 0 and awards /// are written synchronously instead. /// public static class Earning { /// /// (account, currency) -> earned but not yet written. Read by PendingFor on the /// command threads and mutated by the award paths on landblock threads, hence the /// concurrent dictionary and the atomic AddOrUpdate rather than a read-modify-write. /// private static readonly ConcurrentDictionary<(uint Account, CurrencyKind Kind), long> _pending = new(); /// /// player guid -> radiance earned since their last chat line. /// /// Separate from _pending, which is keyed by ACCOUNT: the balance belongs to the /// account but the sentence belongs to the character who swung, and on a shared /// account those are not the same thing. /// private static readonly ConcurrentDictionary _unannounced = new(); private static Timer? _flushTimer; private static Timer? _announceTimer; public static void Start() { var flush = Mod.Settings.FlushSeconds; if (flush > 0) { var period = TimeSpan.FromSeconds(flush); _flushTimer = new Timer(_ => Flush(), null, period, period); } else { // Synchronous mode writes each award as it happens, so there is nothing to // flush - but History.Prune rides this tick, and without it the earning // ledgers of everyone who ever logged in would be kept for the life of the // process. A slow maintenance timer covers it. var period = TimeSpan.FromMinutes(5); _flushTimer = new Timer(_ => History.Prune(), null, period, period); } var rollup = Mod.Settings.AnnounceRollupSeconds; if (rollup > 0) { var period = TimeSpan.FromSeconds(rollup); _announceTimer = new Timer(_ => DrainAnnouncements(), null, period, period); } } /// Flushes and stops. Called on unload so a hot reload does not drop earnings. public static void Stop() { _flushTimer?.Dispose(); _flushTimer = null; _announceTimer?.Dispose(); _announceTimer = null; // Unsent chat lines are dropped rather than delivered. The currency itself is // in _pending and is about to be written; the sentence about it is worth // nothing during a shutdown or reload. _unannounced.Clear(); Flush(); } // --------------------------------------------------------------- announcing /// /// Queues, or immediately sends, the chat line for radiance earned. /// /// Called from a landblock thread, so it does no work beyond an atomic add when /// rollup is on. /// public static void AnnounceRadiance(Player player, long amount) { if (!Mod.Settings.AnnounceRadiance || amount <= 0) return; if (Mod.Settings.AnnounceRollupSeconds <= 0) { player.SendMessage($"You gain {amount:N0} Radiance."); return; } _unannounced.AddOrUpdate(player.Guid.Full, amount, (_, current) => current + amount); } /// /// Sends one line per player for everything they have earned since the last pass. /// /// Entries are removed before the message is built, so radiance earned during this /// pass accumulates into the next line rather than being announced twice or lost. /// A player who logged out in between is simply dropped - they already have the /// currency, and there is nobody to tell. /// private static void DrainAnnouncements() { foreach (var guid in _unannounced.Keys.ToList()) { if (!_unannounced.TryRemove(guid, out var amount) || amount <= 0) continue; try { PlayerManager.GetOnlinePlayer(guid)?.SendMessage($"You gain {amount:N0} Radiance."); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not announce radiance to {guid:X8}: {ex.Message}", ModManager.LogLevel.Warn); } } } /// Earned but not yet written, for one account and currency. public static long PendingFor(uint accountId, CurrencyKind kind) => _pending.TryGetValue((accountId, kind), out var amount) ? amount : 0; /// Everything pending for an account, so a statement can be complete. public static Dictionary PendingFor(uint accountId) { var result = new Dictionary(); foreach (var (key, amount) in _pending) { if (key.Account == accountId && amount != 0) result[key.Kind] = amount; } return result; } /// /// Credits an account. Safe to call from any thread, including a landblock tick. /// /// Returns silently for a non-positive amount rather than treating it as a debit - /// every caller here is an award, and a rounding-down to zero is the normal case /// for a cheap kill rather than something to report. /// public static void Award(uint accountId, CurrencyKind kind, long amount) { if (amount <= 0) return; if (Mod.Settings.FlushSeconds <= 0) { BankDb.TryAdjust(accountId, kind, amount, out _); return; } _pending.AddOrUpdate((accountId, kind), amount, (_, current) => current + amount); } /// /// Writes every pending balance out. /// /// Each entry is REMOVED before it is written, so a concurrent award during the /// flush accumulates into a fresh entry rather than being overwritten by this one. /// If the write then fails, the amount is added back rather than dropped - losing /// earned currency silently is the one outcome worth extra code to avoid. /// public static void Flush() { // Cheap, and this is the only recurring tick the mod has, so the earning // history is swept from here rather than given a timer of its own. History.Prune(); if (!BankDb.Ready) return; foreach (var key in _pending.Keys.ToList()) { if (!_pending.TryRemove(key, out var amount) || amount == 0) continue; try { if (!BankDb.TryAdjust(key.Account, key.Kind, amount, out _)) throw new InvalidOperationException("the balance update matched no rows"); } catch (Exception ex) { _pending.AddOrUpdate(key, amount, (_, current) => current + amount); ModManager.Log($"[{Mod.Name}] could not bank {amount:N0} {key.Kind} for account " + $"{key.Account}, holding it for the next flush: {ex.Message}", ModManager.LogLevel.Warn); } } } // ----------------------------------------------------------------- resonance /// /// Resonance for completing a quest. /// /// Flat per completion, because a quest has no equivalent of XpOverride to scale /// from - there is no field in the registry saying what a quest was worth. Per-quest /// values belong in ResonanceOverrides once you know which quests deserve them. /// public static long ResonanceFor(string questName) { var overrides = Mod.Settings.ResonanceOverrides; if (overrides is not null && overrides.TryGetValue(questName, out var specific)) return specific; return Mod.Settings.ResonancePerQuest; } }