namespace Aeshnidae.ResonanceAuras; /// /// One aura as the code knows it: the fixed key the database and the patches use, /// and what a rank does, in the player's terms. Names and maximums come from /// Settings; these do not change without a rebuild because a formula patch is /// written against them. /// public sealed record Aura(string Key, string PerRank, string Retail) { public const string SpellDuration = "spellduration"; public const string ItemMana = "itemmana"; public const string Imbue = "imbue"; public const string ManaStone = "manastone"; public const string Salvage = "salvage"; public const string Carry = "carry"; public const string Regen = "regen"; public const string Components = "components"; public const string Ammo = "ammo"; /// /// Every aura the code can honour. "Retail" is how the same number was sold on /// retail, by other means; those still work and add to ours. /// public static readonly IReadOnlyList All = new List { new(SpellDuration, "beneficial spell duration +20%", "Archmage's Endurance gem"), new(ItemMana, "item mana burn 5 rating slower (5 ranks is about -20%)", "Aura of Item Mana Usage, luminance"), new(Imbue, "imbue success +5% on the base 33%", "Charmed Smith gem"), new(ManaStone, "mana from stones +5 rating", "Aura of Item Mana Gain, luminance"), new(Salvage, "salvage units and value +25%", "Ciandra's Fortune gem"), new(Carry, "carrying capacity +20% of base", "Might of the Seventh Mule gem"), new(Regen, "vitals regenerate +100% faster while lying down", "Enhancement of the Blade Turner gem"), new(Components, "each burning spell component has a 10% chance to survive", "nothing on retail"), new(Ammo, "each arrow, bolt or dart has a 10% chance not to be spent", "nothing on retail"), }; public static Aura? ByKey(string key) => All.FirstOrDefault(a => a.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); /// What Settings say about this aura, or a disabled stand-in if the row is missing. public AuraSetting Setting => Mod.Settings.Auras.FirstOrDefault(s => s.Key.Equals(Key, StringComparison.OrdinalIgnoreCase)) ?? new AuraSetting { Key = Key, Name = Key, MaxRanks = 0 }; public string Name => Setting.Name; public int MaxRanks => Math.Max(0, Setting.MaxRanks); /// Rank n costs n times the aura's base price. public long PriceOfRank(int rank) { var basePrice = Setting.RankPriceBase > 0 ? Setting.RankPriceBase : Mod.Settings.RankPriceBase; return basePrice * rank; } public long PriceOfRanks(int from, int count) { long total = 0; for (var r = from + 1; r <= from + count; r++) total += PriceOfRank(r); return total; } /// /// The aura whose name starts with what was typed, if exactly one does. An exact /// name wins over prefixes, so "frugal" finds Frugal Mana even if another aura /// were later named "Frugal Something". /// public static bool TryParse(string text, out Aura aura, out string problem) { aura = null!; problem = ""; var wanted = (text ?? "").Trim(); if (wanted.Length == 0) { problem = "Which aura? /aura lists them."; return false; } var exact = All.FirstOrDefault(a => a.Name.Equals(wanted, StringComparison.OrdinalIgnoreCase) || a.Key.Equals(wanted, StringComparison.OrdinalIgnoreCase)); if (exact is not null) { aura = exact; return true; } var matches = All.Where(a => a.Name.StartsWith(wanted, StringComparison.OrdinalIgnoreCase) || a.Name.Split(' ').Any(w => w.StartsWith(wanted, StringComparison.OrdinalIgnoreCase))) .ToList(); if (matches.Count == 1) { aura = matches[0]; return true; } problem = matches.Count == 0 ? $"No aura called '{wanted}'. /aura lists them." : $"'{wanted}' could be {string.Join(" or ", matches.Select(m => m.Name))}."; return false; } } /// /// The ranks each online character holds, and the purchase itself. /// /// Ranks are cached per character at login and read on every formula patch, so the /// hot paths (a vital tick, a spell landing) never touch the database. The cache is /// keyed by character guid and entries are simply replaced on login; a character /// who is not online reads as zero everywhere, which is right - nothing is computed /// for them. /// public static class Auras { private static readonly ConcurrentDictionary> _cache = new(); public static void LoadInto(Player player) { if (player is null) return; _cache[player.Guid.Full] = AuraDb.Load(player.Guid.Full); } public static void Forget(Player player) { if (player is not null) _cache.TryRemove(player.Guid.Full, out _); } /// Ranks held, clamped to the aura's current maximum so a lowered setting takes effect at once. public static int RanksOf(Player player, string key) { if (player is null || !Mod.Settings.Enabled) return 0; if (!_cache.TryGetValue(player.Guid.Full, out var ranks) || !ranks.TryGetValue(key, out var held)) return 0; var aura = Aura.ByKey(key); return aura is null ? 0 : Math.Min(held, aura.MaxRanks); } /// Everything a character holds, unclamped, for the readouts. public static IReadOnlyDictionary AllRanksOf(Player player) => player is not null && _cache.TryGetValue(player.Guid.Full, out var ranks) ? ranks : new Dictionary(); private static void SetRanks(Player player, string key, int ranks) { var entry = _cache.GetOrAdd(player.Guid.Full, _ => new Dictionary(StringComparer.OrdinalIgnoreCase)); entry[key] = ranks; AuraDb.Save(player.Guid.Full, key, ranks); } /// /// Buy ranks with the account's banked Resonance. Priced one rank at a time up the /// line, paid in one guarded debit, then saved. Refuses with a reason rather than /// doing part of it. /// public static bool Buy(Player player, Aura aura, int count, out string message) { message = ""; if (!Mod.Settings.Enabled) { message = "Resonance auras are turned off on this server."; return false; } if (!AuraDb.Ready) { message = $"Aura storage is unavailable: {AuraDb.LastError}"; return false; } if (count < 1) { message = "Buy at least one rank."; return false; } if (aura.MaxRanks == 0) { message = $"{aura.Name} is not for sale on this server."; return false; } var held = RanksOf(player, aura.Key); if (held >= aura.MaxRanks) { message = $"{aura.Name} is already at its maximum of {aura.MaxRanks}."; return false; } if (held + count > aura.MaxRanks) { message = $"{aura.Name} goes to {aura.MaxRanks}; you have {held}, so at most {aura.MaxRanks - held} more."; return false; } if (player.Account is null) { message = "Auras are paid for in Resonance from your account bank, and this character has no account."; return false; } var cost = aura.PriceOfRanks(held, count); var accountId = player.Account.AccountId; var available = AuraDb.ResonanceBalance(accountId); if (cost > available) { message = $"{aura.Name} {(count == 1 ? $"rank {held + 1}" : $"ranks {held + 1}-{held + count}")} costs {cost:N0} Resonance; " + $"your account has {available:N0} banked. (Resonance earned in the last few seconds may not be banked yet.)"; return false; } if (!AuraDb.TryDebitResonance(accountId, cost, out var remaining)) { message = "That could not be paid for - your Resonance balance changed while the purchase was being made. Try again."; return false; } var now = held + count; SetRanks(player, aura.Key, now); AfterChange(player, aura.Key); Celebrate(player); message = $"{aura.Name} rises to rank {now} of {aura.MaxRanks}, for {cost:N0} Resonance ({remaining:N0} left). " + $"Each rank: {aura.PerRank}."; return true; } /// Admin: add (or, negative, take away) ranks without paying. Clamped to 0..max. public static string Grant(Player player, Aura aura, int delta) { if (!AuraDb.Ready) return $"Aura storage is unavailable: {AuraDb.LastError}"; var held = AllRanksOf(player).TryGetValue(aura.Key, out var h) ? h : 0; var now = Math.Clamp(held + delta, 0, Math.Max(aura.MaxRanks, held)); SetRanks(player, aura.Key, now); AfterChange(player, aura.Key); return $"{player.Name}: {aura.Name} {held} -> {now}."; } /// /// Some numbers the client holds a copy of. Carrying capacity is the one that /// shows: the client works out burden from its own copy of the augmentation /// count, so it is told the count with our ranks in through the same message /// ACE sends when a gem is used. The others are server-side only. /// private static void AfterChange(Player player, string key) { if (key == Aura.Carry) TellClientCarry(player); } /// The login description carried the stored count; correct it once the ranks are loaded. public static void TellClientOnLogin(Player player) { if (RanksOf(player, Aura.Carry) > 0) TellClientCarry(player); } private static void TellClientCarry(Player player) { try { if (player.Session is null) return; var shown = (player.GetProperty(PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0) + RanksOf(player, Aura.Carry); player.Session.Network.EnqueueSend( new GameMessagePrivateUpdatePropertyInt(player, PropertyInt.AugmentationIncreasedCarryingCapacity, shown)); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] client capacity update failed: {ex.Message}", ModManager.LogLevel.Warn); } } /// /// The retail "you used an augmentation gem" burst and chime, from an action on /// the player's landblock since Buy runs on the command thread. Nothing here can /// affect the purchase; it is paid for and saved by the time this runs. /// private static void Celebrate(Player player) { if (!Mod.Settings.Effects) return; try { var chain = new ActionChain(); chain.AddAction(player, () => { try { if (player?.Session is null || player.CurrentLandblock is null) return; player.ApplyVisualEffects(PlayScript.AugmentationUseOther); player.EnqueueBroadcast(new GameMessageSound(player.Guid, Sound.RaiseTrait, 1.0f)); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] aura effect failed: {ex.Message}", ModManager.LogLevel.Warn); } }); chain.EnqueueChain(); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] aura effect failed for {player.Name}: {ex.Message}", ModManager.LogLevel.Warn); } } }