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