namespace Aeshnidae.Bank; /// /// The per-character switch that sends earned luminance straight to the bank. /// /// Off by default and opted into per character, because it is a real change to how /// luminance behaves rather than a convenience: banked luminance is not subject to /// MaximumLuminance, so a flagged character never loses an award to the cap and never /// has to stop and deposit. That is the whole point, and it is also why it should be a /// deliberate choice - the cap is a designed constraint in retail, and a character that /// is meant to feel it should be able to. /// /// Cached in memory, and that is not an optimisation. The flag is read inside /// AddLuminance, which runs on a landblock thread in the middle of a tick, on every /// kill that pays luminance. A database read there would put MySQL latency in the world /// loop - the same reason Earning buffers its writes. So the flag is loaded once at /// login and written through on change, and the hot path only ever reads a dictionary. /// public static class AutoBank { /// The flag's name in the database. public const string Luminance = "AutoBankLuminance"; /// character guid -> flagged. Absent means not loaded, which reads as off. private static readonly ConcurrentDictionary _flags = new(); /// /// Reads a character's flag out of the database and caches it. Called at login. /// /// A failure here leaves the character unflagged rather than throwing, so a database /// hiccup costs the convenience and not the login. /// public static void Load(Player player) { if (player is null || !BankDb.Ready) return; try { _flags[player.Guid.Full] = BankDb.GetFlag(player.Guid.Full, Luminance); } catch (Exception ex) { _flags[player.Guid.Full] = false; ModManager.Log($"[{Mod.Name}] could not read the auto-bank flag for {player.Name}, " + $"treating it as off: {ex.Message}", ModManager.LogLevel.Warn); } } public static void Forget(Player player) { if (player is not null) _flags.TryRemove(player.Guid.Full, out _); } /// /// Whether this character banks its luminance. Safe on a landblock thread - a /// dictionary lookup and nothing else. /// public static bool IsOn(Player player) => Mod.Settings.AllowLuminanceAutoBank && player is not null && _flags.TryGetValue(player.Guid.Full, out var on) && on; /// /// Moves enough luminance out of the bank onto the character to cover /// , and reports whether they can now afford it. /// /// Topping the character up and letting ACE's own code spend it, rather than /// intercepting the spend itself, is what keeps this small: SpendLuminance still /// does the deducting and still sends the client update through its own private /// UpdateLuminance, so there is no second implementation of either to drift. /// /// Flushes first, because a player who has been killing things for the last few /// seconds has earnings still sitting in Earning's buffer, and refusing them a /// purchase they can demonstrably afford is worse than a rare database write on the /// world thread. Purchases are rare; kills are not. /// /// The character keeps anything topped up but not spent - if they walk away from /// the NPC without buying, that luminance is on them rather than in the bank until /// they deposit it. Untidy, but visible and never lost, which is the right way round. /// public static bool TopUp(Player player, long needed) { var onHand = player.AvailableLuminance ?? 0; if (onHand >= needed) return true; if (!BankDb.Ready || player.Account is null) return false; var shortfall = needed - onHand; try { Earning.Flush(); var banked = BankDb.GetBalance(player.Account.AccountId, CurrencyKind.Luminance); if (banked < shortfall) return false; if (!BankDb.TryAdjust(player.Account.AccountId, CurrencyKind.Luminance, -shortfall, out _)) return false; // Set directly rather than through GrantLuminance, which would clamp to // MaximumLuminance and destroy the difference. This is luminance on its way // out again a moment later, so the cap has no business in it. player.AvailableLuminance = onHand + shortfall; player.Session?.Network?.EnqueueSend( new GameMessagePrivateUpdatePropertyInt64(player, PropertyInt64.AvailableLuminance, player.AvailableLuminance ?? 0)); return true; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not draw {shortfall:N0} banked luminance for {player.Name}: {ex}", ModManager.LogLevel.Error); return false; } } /// /// Sets the flag and writes it through. Returns false if it could not be saved, in /// which case the cache is left alone rather than promising something that will not /// survive a relog. /// public static bool Set(Player player, bool enabled) { try { BankDb.SetFlag(player.Guid.Full, Luminance, enabled); _flags[player.Guid.Full] = enabled; return true; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not save the auto-bank flag for {player.Name}: {ex.Message}", ModManager.LogLevel.Error); return false; } } }