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