# Aeshnidae.BuffBot

Everything the buff bots need that ACE will not do on its own, the Town Wardens' tiers, and
the Town Tinkers' skill.

Three NPCs use this mod today:

| wcid | | |
|---|---|---|
| 90300 | `aeshnidaebuffbot` | the hub bot - free, the same for everyone |
| 3000134 | `aelrynth-npc-townwarden` | the **Town Warden**, placed in twenty main towns (ledger `2026-10-05_001`) |
| 3000135 | `aelrynth-npc-towntinker` | the **Town Tinker**, beside each warden in the same twenty (ledger `2026-10-08_001`) |

The tinker is not a bot: he casts nothing and is not in `Bots`. He is in this mod because a
new mod needs a server restart and this one was already live - and because he and the warden
are the same idea (one weenie, twenty towns, bought per town) and share `Pyreals`,
`Wardens.TownName`, `Wardens.Announce`, `WardenDb.RadianceBalance` and `Settings.Towns`.

## What it does for every bot in `Bots`

**Armour buffs land.** ACE, as retail, lets an item spell cast at another creature
reach only *selectable* equipment - the shield - so a bot's Impenetrability and seven
Banes did nothing to armour. For the bots in `Bots`, the cast is redirected to every
enchantable piece of clothing and armour the player wears, plus the shield.

**The buff level follows the character level.** The bot's emotes name the top-level
spells; each is stepped down to the level the player is entitled to, by spell
category (the thing that makes Strength Other I and VII the same buff):

| character level | buff level |
|---|---|
| 1-10 | I |
| 11-15 | II |
| 16+ | III |

A spell with no version at the wanted level is cast as written.

One patch does both, a prefix on `WorldObject.TryCastSpell_WithRedirects` - the emote
path passes `itemCaster`, so the older hook one layer down never fired (2026-09-21).

## What it does for the Town Wardens only

One weenie stands in twenty towns, and what it will cast for you is what **that town** has
paid for. The tier is looked up by the landblock the **warden** is standing in, never the
player's, so a warden in Holtburg gives Holtburg's tier to a visitor from anywhere.

### The ladder (`Tiers` in Settings.json)

| tier | spell level | what lands |
|---|---|---|
| 0 | - | nothing; the warden says the town has not paid |
| 1-7 | = tier | the standard buffs |
| 8 | VIII | + the seven protections |
| 9 | VIII | + Impenetrability and the banes |
| 10 | VIII | + cantrips |

**The cast level is the LOWER of the character's level band and the town's tier.** A tier-7
town still gives a level-12 character level II buffs; a tier-2 town gives a level-275
character level II buffs.

### How a spell is sorted into a tier - never by spell id

`Tiers.GroupOf` reads the spell's own **category**, so adding or retiring a spell in the
warden's emote list needs no code change:

| group | rule | the warden's 68 |
|---|---|---|
| `cantrip` | the category's enum name ends in `CantripCategorySuffix` (`Rare`) | 0 |
| `impen` | ACE's own `Spell.IsImpenBaneType` | 8 |
| `protection` | a category whose name contains `Protection` and not `Ward` | 7 |
| `buff` | everything else | 53 |

The emote list carries no cantrip, because a cantrip is not one of the spells a buff bot's
emote can name. At tier 10 the mod adds them itself: for each spell it casts it also casts
the strongest spell in the category named `<that spell's category>Rare` - `StrengthRaising`
→ `StrengthRaisingRare` → *Prodigal Strength*. Eight families have no such category
(Dirty Fighting, Recklessness, Sneak Attack, Dual Wield, Shield and Summoning mastery,
Spirit Drinker, Hermetic Link) and simply get no cantrip. One Use is 68 passes through the
patch, so each cantrip family is cast once per `BurstSeconds`.

### What a tier costs

Both currencies, in full, before a tier turns over - a town cannot buy its way up on
pyreals alone. Geometric, like SkillMastery's bands: tier 1's price, times the growth
multiplier once per tier after it.

| tier | Radiance | pyreals |
|---|---|---|
| 1 | 200,000 | 100,000 |
| 5 | 3,200,000 | 1,600,000 |
| 10 | 102,400,000 | 51,200,000 |
| all ten | 204,600,000 | 102,300,000 |

Opening numbers (2026-10-05), to be tuned once a town has actually paid for one: all ten
tiers is about sixty of today's first mastery raises in Radiance and about ten allegiance-town
charters in pyreals, for a whole town's worth of players.

Contributions gather in the town's pot per currency and anyone may pay. When a contribution
finishes a tier, the tier goes up, the two prices come out of the pots, the contributor's
allegiance is recorded as the warden's keeper, and everyone standing in the town is told.

### Testing mode

`ForcedTier` is `-1` (off) by default. Set it to 0-10 and **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. Stored tiers and pots are untouched while it is on. **Staging runs it at 10.**

## Commands

```
/buffbot                        status: the bots, the wardens
buffbot-check <level> [wcid]    what every bot spell becomes for a character of that level (console)
/buffbot-reload                 re-read Settings.json

/warden                         this town's tier, what it gives, the pot, who maintains it
/warden pay radiance <n|all>    contribute from your bank
/warden pay pyreals <n|all>     contribute from your pack (change given back)
warden-status                   every town's tier, pot and keeper, and the price of every tier (console)
warden-set <landblock> <tier>   write a tier straight in, for testing (console)
warden-reload                   re-read the tiers from the shard (console)
```

## Settings

| key | default | |
|---|---|---|
| `Bots` | [90300, 3000134] | NPC weenies the redirect and the stepping apply to |
| `Wardens` | [3000134] | which of those are Town Wardens; the rest are unchanged by the tiers |
| `LevelBands` | 10→1, 15→2 | character level ceilings and the buff level under each |
| `SpellLevelAbove` | 3 | buff level past the last band; 0 = as the emote says |
| `Tiers` | 0-10, as above | what each tier allows: a spell level and which groups land |
| `CantripCategorySuffix` | `Rare` | how a cantrip family is found from a spell's category |
| `RadianceFirstTier` | 200,000 | what tier 1 costs in Radiance |
| `RadianceGrowth` | 2.0 | each tier's Radiance price as a multiple of the last |
| `PyrealsFirstTier` | 100,000 | what tier 1 costs in pyreals |
| `PyrealsGrowth` | 2.0 | each tier's pyreal price as a multiple of the last |
| `MaxTier` | 10 | the last tier that can be bought |
| `ForcedTier` | -1 | -1 off; 0-10 holds every town there and refuses all payment |
| `WardenRange` | 60 | how far `/warden` looks for a warden, in metres |
| `BurstSeconds` | 5 | one Use is 68 casts; anything said or added once per Use uses this window |
| `Towns` | the twenty | landblock (hex) → town name, for what `/warden` and the announcement say |

## Storage

`aeshnidae_town_warden`, on the shard beside the characters and the bank it draws on:

| column | |
|---|---|
| `landblock` | the landblock the warden stands in - the key |
| `tier` | what the town has bought, 0-10 |
| `radiance_paid` / `pyreals_paid` | the pot toward the **next** tier |
| `owner_allegiance_Id` / `owner_allegiance_name` | the allegiance that finished the last tier |
| `last_patron_name` | who paid last |
| `updated` | |

Radiance is debited straight out of `Aeshnidae.Bank`'s `aeshnidae_bank` row with the same
guarded `UPDATE` that `BankDb.TryAdjust` and SkillMastery's `MasteryDb` use - cross-mod types
are unsafe, the row is not. A contribution and the tier it may complete are one transaction
with the row held under `SELECT ... FOR UPDATE`, so two players paying the last of a tier at
the same instant cannot buy it twice or lose a payment. A non-positive amount never reaches
the debit. Pyreals come out of the pack on the world thread before the transaction opens and
are put straight back if the ledger refuses.

What this cannot see is Bank's in-memory buffer of earnings not yet flushed (ten seconds by
default), so a player who just killed something may read as a few thousand Radiance short
for a moment; the refusal says so.

## Two ladders, on purpose

The hub bot stops at level III however high your character is. A Town Warden has its own,
longer ladder, so a town's tier can be felt:

| character level | warden gives at most |
|---|---|
| 1-10 | I |
| 11-15 | II |
| 16-25 | III |
| 26-40 | IV |
| 41-60 | V |
| 61-90 | VI |
| 91-125 | VII |
| 126+ | VIII |

What lands is the lower of that 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. The hub
bot keeps its III cap because it is free - if it matched the wardens no town would ever
pay to raise one.

## The Town Tinker

A different thing from the warden, with the same economy under it. He gives no buff, takes
nothing by hand and has no flow of his own: **you tinker exactly as you always do**, salvage
onto the item, and while you are within `TinkerRange` of him and in his landblock, the skill
ACE's own formula uses is **the better of yours and his**.

- **Never worse.** When the player's own skill is higher the patch returns `true` and stock
  ACE runs untouched. The mod does not log, message or compute anything.
- **No trained requirement** when his skill is the one used. That is the single reason a copy
  of ACE's formula exists (below).
- **One number per town** for all four tinkering skills and for imbues.
- **Free to use** once the town has paid. Salvaging is deliberately untouched - the
  `AdvancementClass` test in `Player_Crafting.cs` that decides how much material a bag yields
  is a different path and the tinker does not reach it.
- **He says so.** When his skill is used the player gets one line naming both numbers, rate
  limited to `BurstSeconds` (one tink asks for the chance twice: dialog, then confirm).

### The ladder: +10 a step, no cap

`TinkerSkillPerStep` skill a purchase, priced geometrically from `TinkerRadianceFirstStep` /
`TinkerPyrealsFirstStep` by `Tinker*Growth`, both currencies owed in full. A lump sum that
covers several steps buys several, each priced on its own.

| step | skill | that step | running total |
|---|---|---|---|
| 1 | 10 | 50,000 R + 25,000 p | 50,000 R + 25,000 p |
| 10 | 100 | 118,000 R + 59,000 p | 798,000 R + 399,000 p |
| 40 | 400 | 2,100,000 R + 1,000,000 p | 22,259,000 R + 11,038,000 p |
| 100 | 1000 | 626,400,000 R + 313,200,000 p | 6,889,659,000 R + 3,444,738,000 p |

### Overflow: the lesson SkillMastery paid for

An unbounded compounding price is the exact shape of the 2026-09-22 bug in
`Aeshnidae.SkillMastery`: the price left the range of a `long`, the unchecked cast of the
`double` wrapped **negative**, the negative price passed every affordability check, and the
purchase **paid the buyer**. So, here:

- `Tinkers.Unaffordable` is `long.MaxValue / 4`, not `long.MaxValue`, so summing several of
  them still cannot wrap. `Tinkers.Price` saturates there on `NaN`, on infinity and on any
  value at or above it.
- Every caller refuses a saturated price **by name** rather than letting arithmetic decide:
  `Tinkers.Pay`, `TinkerDb.Contribute` (before the row is even read for update) and the
  multi-step loop all test `>= Unaffordable`.
- A non-positive amount never reaches the guarded `UPDATE` - it would *credit* the payer.
  Checked in `Tinkers.Pay` and again at the top of `TinkerDb.Contribute`.
- The pot additions saturate too, so a pot can never wrap to "nothing paid".
- `/tinker` prints *"more than any balance can hold"* instead of a number.

At 1.1 per step the Radiance price crosses the line at step 332 (skill 3,320) and the pyreal
price at step 339, so the practical effect is a hard wall at skill 3,310.

### Storage

`aeshnidae_town_tinker`, a separate table from the warden's rather than more columns on it:
the two are bought independently and in separate transactions, and a shared row would make
one wait on the other for nothing. Same schema shape, same `FOR UPDATE` discipline, same
pyreal handling and change.

| column | |
|---|---|
| `landblock` | the landblock the tinker stands in - the key |
| `skill` | what the town has bought; no cap |
| `radiance_paid` / `pyreals_paid` | the pot toward the **next** step |
| `owner_allegiance_Id` / `owner_allegiance_name` | the allegiance that finished the last step |
| `last_patron_name` | who paid last |
| `updated` | |

### The hook, and why there is a copy of ACE's formula

Both things the tinker changes - which skill number is used, and the trained gate - are
**inside the body** of `RecipeManager.GetTinkerChance`
(`Source/ACE.Server/Managers/RecipeManager.cs`, lines 200-246). A prefix cannot reach them; a
postfix is too late, because the gate has already returned `null` *and already messaged the
player*. The two ways to avoid a copy were both worse:

- **ReversePatch alone cannot do it** - it hands you the original body, gate and all. Bending
  it means a transpiler editing two sites in someone else's IL, and a transpiler that
  silently matches nothing when a line moves upstream is harder to notice than a copy with a
  hash on it.
- **Faking the skill object** (patching `CreatureSkill.Current` / `.AdvancementClass` for the
  duration of the call) would let stock run untouched, but those are two-line property
  getters on one of the hottest classes in the server, and this repo has been bitten twice by
  the JIT inlining a patched one-liner away (`Healing`'s `UpdateVitalDelta`, `CombatWindow`'s
  `CalculateDamage`). Not worth it for a crafting click.

So `Formula.Chance` is a faithful copy of those eight lines of arithmetic, with
`effectiveSkill` where the player's skill was and no gate. Nothing ACE already exposes is
copied: `GetMaterialMod`, `TinkeringDifficulty`, `SkillCheck.GetSkillChance`,
`Recipe.IsImbuing()` and `AugmentationBonusImbueChance` are **called**.

**ReversePatch is used as a floor, which is the part that matters.** Whenever the player is
trained, `Formula.Stock` - a Harmony reverse patch of the original method, so our own prefix
is bypassed - is asked for stock's own answer and the better of the two is used. That keeps
the foolproof salvages certain and keeps any upstream change the copy does not model from
costing a player anything. For an *untrained* player stock returns `null`, so the foolproof
list is read once by reflection instead (`tinker-status` says if that failed).

**The guard** is `Formula.OpcodeSha256`: the sha256 of the **opcode sequence** of the
`GetTinkerChance` the loaded `ACE.Server` actually contains - opcodes only, operands skipped,
because metadata tokens move on every rebuild while the opcode sequence does not move unless
the body really changes. It is compared with `TinkerFormulaOpcodeSha256`:

| setting | |
|---|---|
| `""` | not pinned: the hash is logged with a warning at load and the tinker works |
| matches | pinned and satisfied |
| differs | **error logged, the tinker lends nobody any skill**, every tink is stock ACE's |

`tinker-formula` (console) prints the current hash so it can be pinned. The source region's
own sha256 at the time of writing is recorded in `Formula.cs`'s comment, with the one-line
python to re-check it - the same pattern `ops/build-loot-ranges.py` uses.

### Naming one weenie in twenty towns

A weenie holds one name, so the per-town name is written onto each **instance**: a postfix on
`WorldObjectFactory.CreateNewWorldObjects` sets `Name` to "&lt;town&gt; Tinker" as each
landblock's statics are built. That runs once per landblock load, touches nothing but tinker
wcids, and cannot persist - landblock statics are rebuilt from the world database on every
load and never written back to the shard.

### Every gate on the tinkering path, read 2026-10-08

| | |
|---|---|
| `RecipeManager.UseObjectOnTarget` (~43) | busy / combat mode / same object / no recipe / `VerifyRequirements`, then `GetRecipeChance`. No skill of its own. |
| `RecipeManager.VerifyRequirements` (~769, ~825) | entirely data-driven from the recipe's own requirement rows; no tinkering recipe carries a skill requirement row. |
| `RecipeManager.GetRecipeChance` (~158) | its trained gate (line 185) is on the **non**-tinkering branch; a tinkering recipe returns at line 161 before reaching it. |
| **`RecipeManager.GetTinkerChance` (line 221)** | **the one real gate.** Lifted. |
| `RecipeManager.HandleRecipe` (~355) | re-verifies requirements, rolls, grants proficiency only when the recipe has a skill. No gate. |
| `Player_Crafting.cs` (line 315) | the only `AdvancementClass` test outside `RecipeManager` is in the **salvaging** path (how much material a bag yields). Not the tinkering path, deliberately left alone. |

### Tinker settings

| setting | default | |
|---|---|---|
| `Tinkers` | [3000135] | the tinker wcid(s) |
| `TinkerRange` | 60 | how far his skill reaches and how far `/tinker` looks, in metres |
| `TinkerSkillPerStep` | 10 | skill added per purchase; no cap |
| `TinkerRadianceFirstStep` | 50,000 | what the first +10 costs in Radiance |
| `TinkerRadianceGrowth` | 1.1 | each step's Radiance price as a multiple of the last |
| `TinkerPyrealsFirstStep` | 25,000 | what the first +10 costs in pyreals |
| `TinkerPyrealsGrowth` | 1.1 | each step's pyreal price as a multiple of the last |
| `ForcedTinkerSkill` | -1 | -1 off; 0+ gives every tinker that skill and refuses all payment |
| `TinkerFormulaOpcodeSha256` | `""` | the formula guard; see above |

### Commands

`/tinker`, `/tinker pay <radiance|pyreals> <amount|all>` (Player, in world);
`tinker-status`, `tinker-set <landblock hex> <skill>`, `tinker-reload`, `tinker-formula`
(Admin, console-safe).
