namespace Aeshnidae.BuffBot;
///
/// The buff bots that may bane armour and the Town Wardens' tier ladder, read from
/// Settings.json in the deployed mod folder (next to the dll, not in source). Written with
/// defaults on first run so it is easy to find, and openable in-game with
/// "/mod settings Aeshnidae.BuffBot".
///
/// THE NUMBERS THAT MATTER, all here at the top:
///
/// Bots every NPC whose Impen/Bane casts reach all of a player's armour
/// and whose spells are stepped down by character level
/// Wardens the subset of Bots that are Town Wardens: one weenie standing in
/// many towns, whose power is bought per town
/// LevelBands the character-level rule (unchanged since 2026-09-21)
/// Tiers what a town gets for each tier it has paid for
/// RadianceFirstTier / the cost of tier 1 and how much dearer each tier is than the last
/// PyrealsFirstTier (geometric, like SkillMastery's bands - the shape is in the
/// *Growth multiplier where you can read it, not in an exponent)
/// ForcedTier -1 off; >= 0 makes every town behave at that tier and nothing can
/// be paid. The testing switch.
///
/// Tinkers the Town Tinker: one weenie standing in the same towns, lending
/// the town's bought tinkering skill to anyone near him
/// TinkerSkillPerStep +10 skill a purchase, no cap
/// TinkerRadianceFirstStep / TinkerPyrealsFirstStep / Tinker*Growth
/// the endless geometric price of those steps, with the real curve
/// written out beside them
/// ForcedTinkerSkill -1 off; >= 0 gives every town's tinker that skill, nothing payable
///
public class Settings
{
public const string FileName = "Settings.json";
///
/// The buff bots: NPC weenie ids whose Impenetrability and Bane casts land on every
/// enchantable piece of clothing and armour the player wears, plus the shield.
/// ACE (as retail) lets an item spell cast at another creature reach only the
/// shield - "Bane Other" on someone else's armour was never a thing - so a bot
/// casting the seven Banes said "fails to affect you" seven times (Tom,
/// 2026-09-20). For these ids the cast is treated as the player's own.
///
/// 90300 is the hub bot; 3000134 is the Town Warden (ledger 2026-10-05_001).
///
public List Bots { get; set; } = new() { 90300, 3000134 };
///
/// Which of are Town Wardens - the bot whose buff power is bought
/// per town. A warden in a town that has paid nothing casts nothing; everything it
/// casts is held to its town's tier as well as to the player's level band. A bot that
/// is NOT listed here (the hub bot) behaves exactly as it did before the wardens
/// existed: the armour redirect and the level band, nothing else.
///
public List Wardens { get; set; } = new() { 3000134 };
///
/// What level of buff a player gets, by character level. The bot's emotes name the
/// top-level spells; a player at or under UpToLevel gets the same buff at
/// SpellLevel instead (2026-09-21: "level 1-10 - level 1 buffs, 11-15 - level 2,
/// level 3 from there on"). A new character should not walk out of the hub with
/// level-7 buffs and a level-15 dungeon that cannot touch them.
///
public List LevelBands { get; set; } = new()
{
new LevelBand { UpToLevel = 10, SpellLevel = 1 },
new LevelBand { UpToLevel = 15, SpellLevel = 2 },
};
/// The spell level for everyone above the last band. 0 = whatever the bot's emote says.
public uint SpellLevelAbove { get; set; } = 3;
///
/// The WARDEN ladder (2026-10-08). The hub bot's bands stop at level III on purpose -
/// it is free, it is where everyone starts, and if it handed out level VII nobody
/// would ever pay to raise a town. A warden is the thing a town buys, so it has its
/// own, longer ladder: a character is still capped by their level, but the cap keeps
/// climbing, which is what lets a tier 4 town be felt as level IV.
///
/// What lands is still the LOWER of this and the town's tier, so both have to be
/// earned - a level 80 in a tier 2 town gets level II, and a level 20 in a tier 10
/// town gets level III.
///
/// Empty falls back to and .
///
public List WardenLevelBands { get; set; } = new()
{
new LevelBand { UpToLevel = 10, SpellLevel = 1 },
new LevelBand { UpToLevel = 15, SpellLevel = 2 },
new LevelBand { UpToLevel = 25, SpellLevel = 3 },
new LevelBand { UpToLevel = 40, SpellLevel = 4 },
new LevelBand { UpToLevel = 60, SpellLevel = 5 },
new LevelBand { UpToLevel = 90, SpellLevel = 6 },
new LevelBand { UpToLevel = 125, SpellLevel = 7 },
};
/// The warden ladder's ceiling, for characters past the last band.
public uint WardenSpellLevelAbove { get; set; } = 8;
public class LevelBand
{
public int UpToLevel { get; set; }
public uint SpellLevel { get; set; }
}
// ------------------------------------------------------------------ the tier ladder
///
/// What a town's warden gives at each tier it has paid for. The warden's emotes always
/// name the top-level spell; this says which of them are allowed to land and at what
/// level, and the player's own level band is applied on top (the LOWER of the two
/// always wins - a tier-7 town still gives a level-12 character level II buffs).
///
/// The groups are not spell ids. Each of the warden's spells is classified from its
/// own CATEGORY (Tiers.GroupOf):
///
/// buff the 53 attribute / vital / skill / weapon buffs
/// protection the seven Protections (LifeMagic, category *Protection, not the Wards)
/// impen Impenetrability and the seven Banes (ACE's own Spell.IsImpenBaneType)
/// cantrip the Prodigal cantrips - category name ending in CantripCategorySuffix.
/// The warden's emotes carry none, so at tier 10 the cantrip of each
/// family it already casts is added by the mod: the strongest spell in
/// the category named "<that spell's category>Rare". A family with no
/// such category (Dirty Fighting, Recklessness, Sneak Attack, Dual Wield,
/// Shield, Summoning, Spirit Drinker, Hermetic Link) simply gets none.
///
/// Tier 0 is the default for a town that has paid nothing: the warden casts nothing and
/// says so.
///
public List Tiers { get; set; } = new()
{
new TierStep { Tier = 0, SpellLevel = 0, Groups = new() },
new TierStep { Tier = 1, SpellLevel = 1, Groups = new() { "buff" } },
new TierStep { Tier = 2, SpellLevel = 2, Groups = new() { "buff" } },
new TierStep { Tier = 3, SpellLevel = 3, Groups = new() { "buff" } },
new TierStep { Tier = 4, SpellLevel = 4, Groups = new() { "buff" } },
new TierStep { Tier = 5, SpellLevel = 5, Groups = new() { "buff" } },
new TierStep { Tier = 6, SpellLevel = 6, Groups = new() { "buff" } },
new TierStep { Tier = 7, SpellLevel = 7, Groups = new() { "buff" } },
new TierStep { Tier = 8, SpellLevel = 8, Groups = new() { "buff", "protection" } },
new TierStep { Tier = 9, SpellLevel = 8, Groups = new() { "buff", "protection", "impen" } },
new TierStep { Tier = 10, SpellLevel = 8, Groups = new() { "buff", "protection", "impen", "cantrip" } },
};
public class TierStep
{
public int Tier { get; set; }
/// The spell level the town allows. 0 = nothing lands at all.
public uint SpellLevel { get; set; }
/// buff / protection / impen / cantrip - see Tiers.GroupOf.
public List Groups { get; set; } = new();
}
///
/// How a cantrip family is found from a normal spell's category: the enum name of the
/// category with this on the end. "Rare" gives StrengthRaising -> StrengthRaisingRare
/// (Prodigal Strength), AcidProtection -> AcidProtectionRare (Prodigal Acid Protection),
/// ArmorValueRaising -> ArmorValueRaisingRare (Prodigal Impenetrability). A setting, not
/// a constant, so the rule is visible and changeable.
///
public string CantripCategorySuffix { get; set; } = "Rare";
// ------------------------------------------------------------------ what a tier costs
///
/// Tier 1 costs this much Radiance, and every tier after costs
/// times the one before. Both currencies are owed in full
/// before a tier turns over - a town cannot buy its way up on pyreals alone.
///
/// Opening numbers (2026-10-05), to be tuned once a town has actually paid for one:
/// tier 1 200,000 - tier 5 3,200,000 - tier 10 102,400,000; all ten 204,600,000,
/// about sixty of today's first mastery raises for a whole town.
///
public long RadianceFirstTier { get; set; } = 200_000;
/// Each tier's Radiance price as a multiple of the last. 2.0 - each tier is twice the last.
public double RadianceGrowth { get; set; } = 2.0;
///
/// Tier 1 costs this many pyreals, growing by per tier.
/// tier 1 100,000 - tier 5 1,600,000 - tier 10 51,200,000; all ten 102,300,000, about
/// ten allegiance-town charters.
///
public long PyrealsFirstTier { get; set; } = 100_000;
/// Each tier's pyreal price as a multiple of the last.
public double PyrealsGrowth { get; set; } = 2.0;
/// The last tier that can be bought. The ladder above stops at 10.
public int MaxTier { get; set; } = 10;
// ------------------------------------------------------------------ the Town Tinker
///
/// The Town Tinker (design settled 2026-10-08): the NPC weenie(s) that lend a town's
/// bought tinkering skill to anyone standing near them. 3000135 is the tinker (ledger
/// 2026-10-08_001).
///
/// There is NO new flow. You tinker exactly as you always did - salvage onto the item -
/// and while you are within of the town's tinker, the skill the
/// formula uses is the BETTER of yours and his. Never worse: if yours is higher, nothing
/// about the attempt changes.
///
/// One number per town, used for all four tinkering skills (Item, Weapon, Magic Item,
/// Armor) and for imbues, because a town buys a tinker, not a syllabus.
///
public List Tinkers { get; set; } = new() { 3000135 };
///
/// How far from a tinker his skill reaches, in metres, and how far /tinker will look for
/// him. 60: the length of a town square - the same semantics and the same default as
/// , and like it the tinker must be in the player's own
/// landblock as well as within range.
///
public double TinkerRange { get; set; } = 60.0;
///
/// Each purchase raises the town's tinker by this much skill. There is no cap - the
/// ladder is endless by design, so the prices must saturate instead (Tinkers.Price).
///
public int TinkerSkillPerStep { get; set; } = 10;
///
/// The first +10 costs this much Radiance, and every step after costs
/// times the one before. Both currencies are owed in
/// full before a step turns over, exactly as a warden tier is.
///
/// Opening numbers (2026-10-08), measured not guessed:
/// step 1 (skill 10) 50,000 R + 25,000 p - total 50,000 R
/// step 10 (skill 100) 118,000 R + 59,000 p - total 798,000 R
/// step 40 (skill 400) 2,100,000 R + 1,000,000 p - total 22,259,000 R
/// step 100 (skill 1000) 626,400,000 R + 313,200,000 p - total 6,889,659,000 R
/// Skill 400 is about a town's worth of mastery raises; skill 1000 is a server-wide
/// project. Past step 331 the Radiance price leaves the range of a long and the step is
/// reported as beyond any balance (see Tinkers.Unaffordable) - a hard wall at skill
/// ~3,310, which is an order of magnitude past anything a tinkering formula notices.
///
public long TinkerRadianceFirstStep { get; set; } = 50_000;
/// Each step's Radiance price as a multiple of the last. 1.1 - a tenth dearer each time.
public double TinkerRadianceGrowth { get; set; } = 1.1;
/// The first +10 costs this many pyreals, growing by .
public long TinkerPyrealsFirstStep { get; set; } = 25_000;
/// Each step's pyreal price as a multiple of the last.
public double TinkerPyrealsGrowth { get; set; } = 1.1;
///
/// -1 (off): every town's tinker has the skill it has been paid for.
///
/// 0 or more: EVERY town's tinker behaves at exactly that skill and nothing can be paid,
/// so the tinkers can be walked around and felt before any economy exists. Same switch,
/// same semantics as , and the stored skills and pots are
/// untouched while it is on.
///
public int ForcedTinkerSkill { get; set; } = -1;
///
/// The guard on our copy of ACE's tinkering formula: the sha256 of the opcode sequence of
/// RecipeManager.GetTinkerChance as the loaded ACE.Server actually contains it (opcodes
/// only, operands skipped, so a rebuild that merely shifts metadata tokens does not trip
/// it). See Formula.cs for why a copy exists at all.
///
/// "" not pinned: the hash is logged at load with a warning, and the tinker
/// works. Paste the logged value in here to pin it.
/// a hash pinned: if the loaded method does not match, an error is logged and the
/// tinker stops substituting skills - every tinker attempt is stock ACE's
/// again - rather than running a formula that may no longer be ACE's.
///
/// `tinker-formula` prints the current hash.
///
/// The default below was read off the running server on 2026-10-08 (live and staging run a
/// byte-identical ACE.Server.dll, md5 c711fe4ca6bff1ca33f238c9dda1cb7a, so one value suits
/// both). It is the OPCODE hash, so it survives a rebuild of ACE.Server from the same
/// source and only changes when the body of GetTinkerChance does.
///
public string TinkerFormulaOpcodeSha256 { get; set; } = "766c2154622c3291d34ba78f6b372872f158b0d4a861b0060fd6256b093d21c9";
// ------------------------------------------------------------------ testing
///
/// -1 (off): every town's tier is the one it has paid for.
///
/// 0 or more: EVERY town behaves at exactly that tier and nothing can be paid, so the
/// wardens can be walked around and felt before any economy exists. Staging runs this
/// at 10. The stored tiers and pots are untouched while it is on.
///
public int ForcedTier { get; set; } = -1;
///
/// How far from a warden /warden will find it, in metres. 60: the length of a town
/// square, so a player anywhere near the warden gets the town's answer.
///
public double WardenRange { get; set; } = 60.0;
///
/// A player is told "this town has not paid its warden" at most once in this many
/// seconds, and a cantrip is cast at most once per family in the same window - one Use
/// is 68 separate casts through the same patch, and without the window every one of
/// them would repeat the message.
///
public double BurstSeconds { get; set; } = 5.0;
///
/// The towns the wardens stand in: the landblock each one is placed in (ledger
/// 2026-10-05_001) and the town's name, for what /warden prints and what the
/// announcement says. A warden in a landblock that is not listed is simply "this town".
///
public List Towns { get; set; } = new()
{
new Town { Landblock = "A9B4", Name = "Holtburg" },
new Town { Landblock = "BB9F", Name = "Cragstone" },
new Town { Landblock = "C6A9", Name = "Arwic" },
new Town { Landblock = "CE94", Name = "Eastham" },
new Town { Landblock = "C080", Name = "Lytelthorpe" },
new Town { Landblock = "C98C", Name = "Rithwic" },
new Town { Landblock = "7D64", Name = "Yaraq" },
new Town { Landblock = "DA55", Name = "Shoushi" },
new Town { Landblock = "E63E", Name = "Nanto" },
new Town { Landblock = "977B", Name = "Samsur" },
new Town { Landblock = "8090", Name = "Zaikhal" },
new Town { Landblock = "8F58", Name = "Al-Arqas" },
new Town { Landblock = "9722", Name = "Qalaba'r" },
new Town { Landblock = "E64E", Name = "Hebian-to" },
new Town { Landblock = "CE41", Name = "Baishi" },
new Town { Landblock = "A0A4", Name = "Glenden Wood" },
new Town { Landblock = "DA75", Name = "Dryreach" },
new Town { Landblock = "64D5", Name = "Stonehold" },
new Town { Landblock = "E632", Name = "Mayoi" },
new Town { Landblock = "C95B", Name = "Sawato" },
};
public class Town
{
/// The landblock in hex, as the ledger writes it: "A9B4".
public string Landblock { get; set; } = "";
public string Name { get; set; } = "";
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
public static Settings Load(string modPath)
{
var path = Path.Combine(modPath, FileName);
try
{
if (File.Exists(path))
return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) ?? new Settings();
var defaults = new Settings();
defaults.Save(modPath);
ModManager.Log($"[{Mod.Name}] wrote default settings to {path}");
return defaults;
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not read {path}, using defaults: {ex.Message}", ModManager.LogLevel.Warn);
return new Settings();
}
}
public void Save(string modPath)
{
try
{
File.WriteAllText(Path.Combine(modPath, FileName), JsonSerializer.Serialize(this, JsonOptions));
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not save settings: {ex.Message}", ModManager.LogLevel.Error);
}
}
}