namespace Aeshnidae.BuffBot; /// /// Which level of a buff a bot casts on a given player: the bot's emotes name the /// top-level spells, and the player's character level says how far down to step. /// /// The step is by spell CATEGORY, the thing that makes "Strength Other I" and /// "Strength Other VII" the same buff at different strengths (same category spells do /// not stack). Within a category the self and other versions share the category, so /// the self-targeted flag is matched too, and the level is ACE's own power-based /// Spell.Level. A spell with no version at the wanted level - a cantrip, a one-off - /// is cast as written. /// public static class Bands { /// (category, self-targeted) -> every spell id in it with its level. private static Dictionary<(SpellCategory Category, bool Self), List<(uint Id, uint Level)>>? _index; private static readonly Dictionary<(uint Source, uint Level), uint> _cache = new(); /// category -> the strongest spell in it, for the cantrip lookup. private static readonly Dictionary _strongest = new(); public static void Forget() { _index = null; _cache.Clear(); _strongest.Clear(); } /// /// The strongest spell in a category, or null when the client has none. Used for the /// cantrips a tier-10 warden adds: Tiers.CantripFor names the category, this finds the /// spell. The other-targeted version is preferred - a bot casts at someone else. /// public static Spell? Strongest(SpellCategory category) { if (category == 0) return null; lock (_cache) { if (_strongest.TryGetValue(category, out var known)) return known == 0 ? null : new Spell(known); _index ??= Build(); uint found = 0; foreach (var self in new[] { false, true }) { if (!_index.TryGetValue((category, self), out var family)) continue; var top = family.OrderByDescending(f => f.Level).ThenBy(f => f.Id).FirstOrDefault(); if (top.Id != 0) { found = top.Id; break; } } _strongest[category] = found; return found == 0 ? null : new Spell(found); } } /// The spell level a player of this character level receives. public static uint LevelFor(Player player) { var level = player.Level ?? 1; foreach (var band in Mod.Settings.LevelBands.OrderBy(b => b.UpToLevel)) if (level <= band.UpToLevel) return band.SpellLevel; return Mod.Settings.SpellLevelAbove; } /// /// The spell level a player of this character level receives FROM A WARDEN. A longer /// ladder than the hub bot's, so that a town's tier can be felt above level III - /// see Settings.WardenLevelBands for why the two differ. /// public static uint LevelForWarden(Player player) { var bands = Mod.Settings.WardenLevelBands; if (bands is null || bands.Count == 0) return LevelFor(player); var level = player.Level ?? 1; foreach (var band in bands.OrderBy(b => b.UpToLevel)) if (level <= band.UpToLevel) return band.SpellLevel; return Mod.Settings.WardenSpellLevelAbove; } /// The same buff at the wanted level, or the spell itself when there is none - or when it is already at or below it. public static Spell At(Spell spell, uint wanted) { if (spell.NotFound || wanted == 0 || spell.Level <= wanted) return spell; lock (_cache) { if (_cache.TryGetValue((spell.Id, wanted), out var known)) return known == spell.Id ? spell : new Spell(known); var found = spell.Id; // ACE's own family table first: 739 explicit progressions, eight entries // each, level 1..8 in order. It is the only authoritative answer to "the // same spell, weaker". // // Category is NOT a fine enough key and was the bug (2026-10-08): spells // that do the same job share a category so they cannot stack, so Heartseeker // and Soulhunter sit together, and picking the lowest id in the category // handed out Aura of Soulhunter where Heartseeker was asked for. var levels = ACE.Server.Factories.Tables.SpellLevelProgression.GetSpellLevels((ACE.Entity.Enum.SpellId)spell.Id); if (levels is not null && wanted >= 1 && wanted <= (uint)levels.Count) { found = (uint)levels[(int)wanted - 1]; } else { // Not in the table (a one-off, or a custom spell): fall back to the // category index, which is right whenever a category holds one family. _index ??= Build(); if (_index.TryGetValue((spell.Category, spell.IsSelfTargeted), out var family)) { var match = family.Where(f => f.Level == wanted).OrderBy(f => f.Id).FirstOrDefault(); if (match.Id != 0) found = match.Id; } } _cache[(spell.Id, wanted)] = found; return found == spell.Id ? spell : new Spell(found); } } private static Dictionary<(SpellCategory, bool), List<(uint, uint)>> Build() { var index = new Dictionary<(SpellCategory, bool), List<(uint, uint)>>(); foreach (var id in ACE.DatLoader.DatManager.PortalDat.SpellTable.Spells.Keys) { Spell s; try { s = new Spell(id); } catch { continue; } if (s.NotFound || s.Category == 0) continue; var key = (s.Category, s.IsSelfTargeted); if (!index.TryGetValue(key, out var list)) index[key] = list = new List<(uint, uint)>(); list.Add(((uint)id, s.Level)); } ModManager.Log($"[{Mod.Name}] spell families indexed: {index.Count} (category, self/other) groups"); return index; } }