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