codex: dynamic SII recipe selection + realized rates + validator crash fix (bounce fix attempt 3, owner guidance)

Owner decision (2026-08-28): Superheat Item uses a dynamic recipe table
governed by Smithing level — highest UNLOCKED recipe selected, primary ore
(target_input, never coal) is the spell target, fallback to the next lower
unlocked recipe, else skip SII and stay on LA.

- magic-alch-handoff: per-recipe target_input; selection block
  (mode highest-unlocked / eligible_rule / fallback); full-lifecycle
  actions (buy-staff, equip-staff w/ fire-rune failure path, buy-supplies,
  cast-alch, cast-superheat, bank-loop, take-break); deterministic restock
  (LA/HLA carried_batch 1,000 + threshold 100 -> 901-cast batches; SII
  24-cast bank cycles, bars >= 24); breaks [400,700] COMPLETED casts,
  independent of banking; worked example pinned (prep -> first restock @24
  -> break @550 -> resume -> restock @552); stop conditions + recovery block
- rates REALIZED incl. mandatory breaks + banking: LA 24,500 / SII 27,500 /
  HLA 51,400 xp/hr (formulas pinned; loop-only 837/hr and 305/hr regimes
  superseded); low-alch-f2p also switched (xp 24,500, gp -110,500..-74,000)
- validate.py: rows now collected ONLY after schema validation (schema-
  invalid MAPPINGS incl. 'economy: not-a-mapping' produce normal ERRORs,
  never AttributeError tracebacks — end-to-end subprocess regression added);
  new gates: recipe_model_errors (target_input in declared inputs, coal
  never targetable, unique smithing gates, selection block + SII/fallback
  transitions) and rate_errors (xp_per_hour must match the realized cadence,
  tolerance 2%); highest_unlocked_recipe helper + 6 eligibility fixtures
- entry.schema.json: optional selection block + per-recipe target_input
- Evidence: validate 2 entries 0 errors 0 warnings; --selftest 31 PASS;
  governor's exact direct repro supply_limit_conflicts([('bad.yaml',
  {'economy':'not-a-mapping'})]) -> [] no crash; synthetic 3-way merge of
  main 79cab78 (incl. issue-6 catalog) + head: clean, gate green; git
  diff --check clean
This commit is contained in:
Claude 2026-08-28 03:11:07 -05:00
parent 75a9965c9f
commit 34f36d2275
4 changed files with 658 additions and 161 deletions

View File

@ -3,58 +3,74 @@ title: Low-Level Alchemy (F2P)
domain: economy
tags: [alching, magic-training, f2p, loss-training]
status: verified
version: 1
updated: "2026-08-27" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date
version: 2 # 2: bounce-fix 3 2026-08-28 (owner guidance) — realized rates incl. mandatory breaks + banking, break.every in CAST counts, numeric carried_batch/restock_threshold, pinned worked example
updated: "2026-08-28" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date
summary: >
Magic training from level 21 by low-alching F2P items with the highest
lowalch-value among affordable, high-limit items. At current prices it is
a LOSS activity (money in exchange for Magic XP); run it only for XP.
Model pin (owner guidance 2026-08-28): one iteration = one cast; breaks
every 400-700 COMPLETED CASTS (cast-count based, independent of bank
visits); restock when candidate or nature drops below the numeric
restock_threshold (100) against a carried_batch of 1,000/1,000 — worked
example pinned below; all rates are REALIZED (delays + breaks + banking).
accounts:
membership: f2p
min_level: 21 # Magic 21 to cast Low Level Alchemy (wiki-verified)
requires_quest: []
requires_other:
- "Staff of Fire equipped (removes 3 fire runes from the cost)"
- "A bank with GE access within reach"
- "Staff of Fire equipped (removes 3 fire runes from the cost; NO level requirement documented on the OSRS wiki — verified 2026-08-28, see sources)"
- "A bank with GE access within reach (alch spot = the bank booth; restock trip 60-120s)"
- "Working capital >= 292,000 gp for one carried_batch (1,000 x 148 gp gold necklaces + 1,000 x 144 gp natures, live 2026-08-27)"
best_practice:
equipment:
- item: staff of fire
why: "unlimited fire runes — LA rune cost drops from 3 fire + 1 nature to 1 nature per cast (~15gp/cycle saved)"
source: "OSRS wiki — Staff of Fire; Low Level Alchemy (Cost)"
why: "unlimited fire runes — LA rune cost drops from 3 fire + 1 nature to 1 nature per cast (~15gp/cycle saved); equip requirement: none documented on the OSRS wiki (verified 2026-08-28)"
source: "OSRS wiki — Staff of fire (id 1387: no requirements row in the infobox, none in the prose — contrast Battlestaff 'requires 30 Attack and 30 Magic'); Low Level Alchemy (Cost)"
techniques:
- name: cast on noted (stacked) items
why: "one inventory slot, minimal mouse travel between spell icon and item"
why: "one inventory slot, minimal mouse travel between spell icon and item; means an inventory does NOT define a batch size — batch sizes are the explicit numeric carried_batch below"
source: "OSRS wiki — Low Level Alchemy (Usage: casting alchemy spells on noted items)"
- name: bank at a GE-adjacent bank
why: "supply restock without a second travel leg — runes and alch items both come from the GE"
why: "supply restock without a second travel leg — runes and alch items both come from the GE; restock trip 60-120s"
source: "OSRS wiki — Money making guide/High alching profitable free-to-play items (bulk alching via noted items; West Varrock Bank is the common F2P alch/trade hub); OSRS wiki — High Level Alchemy (buying GE items below alch value for break-even/profit; noted-item casting saves bank withdrawals)"
economy:
# Verified 2026-08-27 against prices.runescape.wiki (live 'latest')
# + OSRS wiki Low_Level_Alchemy infobox.
# Bounce-fix 3 (2026-08-28, owner guidance): all rate fields are REALIZED
# figures — mandatory breaks + banking included, not just the cast loop.
item: Gold necklace # example anchor; rank by cheapest candidate live
item_id: 1654
cost_per_cycle: 292 # necklace buy 148 + nature rune 144 (staff of fire)
income_per_cycle: 180 # lowalch of gold necklace (40% of value 450)
net_per_cycle: -112
gp_per_hour_low: -119000 # -112 net/cast x rate endpoints: max burn at 1.6s delay
# (3.6/3.4s = 1,058 casts/hr) ~ -118,500; mid 4.3s/cast
# (837 casts/hr) ~ -93,800
gp_per_hour_high: -78000 # min burn at 3.4s delay (3.6/5.2s = 692 casts/hr) ~ -77,500
xp_per_hour: 25953 # 31 xp/cast (wiki) @ ~837 casts/hr — additive wall time:
# 3-tick cast = 1.8s PLUS anti_ban.delay 1.6-3.4s which is
# sleep AFTER the action (action-grammar.md) = 3.4-5.2s/cast,
# mid 4.3s -> 3,600/4.3 = ~837 casts/hr (midpoint rate).
# The old 1,800 casts/hr (55,800 xp/hr) ignored the additive
# delay and was not reachable under the entry's own pacing.
gp_per_hour_low: -110500 # realized (incl. restock + breaks), fastest cycle: 986.4 casts/hr x -112 = -110,477
gp_per_hour_high: -74000 # realized (incl. restock + breaks), slowest cycle: 660.6 casts/hr x -112 = -73,987
xp_per_hour: 24500 # realized midpoint: 791.3 casts/hr x 31 xp = 24,530 (pin 24,500)
# ---- realized-rate derivation (pinned; owner guidance 2026-08-28) ----
# Cast: 1.8s (3 ticks x 0.6s) + anti_ban.delay [1.6, 3.4]s ADDITIVE after the
# action (docs/action-grammar.md:135) = 3.4-5.2s, midpoint 4.3s.
# BATCH model: carried_batch 1,000 candidate + 1,000 natures; restock_threshold
# 100 -> 901-cast restock cadence (first restock after cast 901). Overhead per
# 901-cast batch: 90s restock trip (60-120s, GE-adjacent booth) + 135s breaks
# (every [400, 700] casts, mid 550, for [45, 120]s -> 901/550 x 82.5s) = 225s.
# REALIZED casts/hr = 901 / ((901 x cycle_s + 225) / 3600):
# fast 3.4s -> 986.4; midpoint 4.3s -> 791.3; slow 5.2s -> 660.6.
# xp_per_hour = 791.3 x 31 = 24,530 (pin 24,500).
# gp_per_hour = casts/hr x -112: fast -110,477 (low), slow -73,987 (high),
# midpoint -88,626.
# Superseded figures (fail the realized gate on purpose): loop-only 837
# casts/hr (25,953 xp/hr) ignored breaks + banking; the 8-14-cast break
# regime (~305 casts/hr) and the 1,800-cast regime ignored the additive delay.
supplies:
- item: gold necklace
qty_per_cycle: 1
source: ge
ge_limit: 18000 # mapping 'limit', wiki-verified
ge_limit: 18000 # mapping 'limit', wiki-verified; one carried_batch of 1,000 fits the 4h window
- item: nature rune
qty_per_cycle: 1
source: ge
@ -63,24 +79,25 @@ economy:
profit_drivers:
- "NONE at current prices — XP is the product, not GP"
- "Pick the candidate with the least-negative net each run (live rank)"
- "Batch-scale economics (worst case 1 nature/cast): carried_batch 1,000/1,000, restock every 901 casts — one batch = 901 x -112 = -100,912 gp, +27,931 xp"
state_machine:
initial: idle
terminal: [complete, failed]
states:
idle:
goal: reach the bank + have supplies in inventory
next: [casting, bank_supplies]
goal: "reach the bank, acquire + equip the staff of fire, GE-buy candidate + natures, build the starting inventory to carried_batch (1,000 candidate + 1,000 nature)"
next: [casting, complete, failed]
casting:
goal: cast Low Level Alchemy on the current candidate item
next: [bank_supplies, idle, complete, failed]
goal: "cast Low Level Alchemy on the current candidate item — each cast consumes 1 candidate + 1 nature and pays 180 gp (lowalch), +31 xp, net -112 gp; take a break when the completed-cast counter hits run_control.break.every ([400, 700] casts)"
next: [bank_supplies, complete, failed]
bank_supplies:
goal: bank the produced coins, withdraw fresh candidate + runes
next: [casting, idle]
goal: "deposit coins, withdraw candidate + natures back to carried_batch (1,000/1,000), resume casting"
next: [casting, failed]
complete:
goal: reached the run's XP/level target
goal: "reached the run's XP/level target (default 54, hand off to HLA at 55)"
failed:
goal: hard failure — stop, log ERROR sentinel, alert
goal: "hard failure — stop, log ERROR sentinel, alert"
transitions:
# Handoff (verified 2026-08-27 from the OSRS wiki): the LA page states
# "Players with level 55 Magic are generally recommended to use High
@ -89,12 +106,27 @@ state_machine:
# ore-smelting margins justify it. Handoffs are soft efficiency choices,
# not hard caps (all spells stay castable); see
# codex/training/magic-alch-handoff.yaml.
- from: casting
when: "candidate item count < buffer OR nature rune count < buffer"
to: bank_supplies
- from: idle
when: "staff equipped AND candidate == carried_batch AND nature == carried_batch (1,000 and 1,000)"
to: casting
- from: idle
when: "magic level >= target (default 54, hand off to HLA at 55)"
to: complete
- from: idle
when: "3 consecutive purchase/equip failures with no fallback available (recovery block)"
to: failed
- from: casting
when: "candidate count < restock_threshold (100) OR nature rune count < restock_threshold (100) — deterministic restock trigger, pinned numbers"
to: bank_supplies
- from: casting
when: "magic level >= target (default 54, hand off to HLA at 55)"
to: complete
- from: casting
when: "3 consecutive failed casts (client issue — do not loop)"
to: failed
- from: bank_supplies
when: "restocked to carried_batch (1,000 candidate + 1,000 nature)"
to: casting
actions:
- id: a1
@ -102,7 +134,7 @@ actions:
verb: move_to
target: bank
params:
location: "nearest bank with GE"
location: "GE-adjacent bank (alch spot = the bank booth, so restock is a single short leg)"
method: [teleport, walk]
on_fail: retry
anti_ban:
@ -113,10 +145,10 @@ actions:
verb: ge_buy
target: grand-exchange
params:
item: "current candidate item"
qty: "fill to buffer"
item: "current least-loss live candidate (default: gold necklace)"
qty: "carried_batch = 1,000 (fits the 4h GE limit window of 18,000)"
max_price: "live low + 5%"
on_fail: retry
on_fail: retry # re-place at live low +5% with paced offers; 3 consecutive failures -> failed (recovery block)
anti_ban:
delay: [1200, 3000]
@ -128,11 +160,10 @@ actions:
spell: "Low Level Alchemy"
item: "cheapest live candidate in inventory"
on: "itself"
consumption: "1 candidate + 1 nature per cast (staff of fire default); pays 180 gp (lowalch) + 31 xp, net -112 gp"
on_fail: retry
anti_ban:
delay: [1600, 3400] # sleep AFTER the action (action-grammar.md): 1.8s cast +
# 1.6-3.4s delay = 3.4-5.2s/cast, mid 4.3s -> ~837 casts/hr
# mid; xp_per_hour pinned at that additive rate, humanised
delay: [1600, 3400] # MANDATORY post-action sleep, ADDITIVE to the 1.8s cast (3 ticks x 0.6s; action-grammar.md:135): cycle = 3.4-5.2s, mid 4.3s -> realized ~791 casts/hr incl. restock + breaks (pinned in economy)
- id: a4
step: bank-loop
@ -140,32 +171,95 @@ actions:
target: bank
params:
deposit: [all coins, all products]
withdraw: "candidate item + nature runes up to buffer"
withdraw: "candidate + nature runes up to carried_batch (1,000/1,000)"
on_fail: alert
- id: a5
step: take-break
verb: take_break
target: "current location"
params:
duration: "[45, 120] seconds of idle AFK per break (matches run_control.break.for)"
note: "executed by the translator when the completed-cast counter reaches run_control.break.every — this IS run_control.break's executor, fired on CAST COUNTS, not on bank visits; the cast counter is not reset by restocking"
on_fail: skip
run_control:
iteration: "one cast" # schema.md: break.every counts iterations, so every
# value below is in casts (one iteration = one cast)
# value below is in completed casts, NOT bank trips
# (owner decision 2026-08-28)
max_iterations: null
break:
every: [8, 14] # casts (one iteration = one cast); no bank-visit alignment assumed
every: [400, 700] # COMPLETED CASTS between AFK breaks (midpoint 550 = ~39
# min at the 4.3s midpoint cycle). Cast-count-based and
# independent of bank visits; a restock trip NEVER counts
# as a break and does not reset this counter
for: [45, 120] # seconds of idle AFK
stop_conditions:
- "magic level >= target"
- "gp under floor"
- "magic level >= target (default 54, hand off to HLA at 55)"
- "gp under floor (working capital below one carried_batch = 292,000 gp)"
- "3 consecutive failed casts (client issue — do not loop)"
- "3 consecutive failed GE purchases with no fallback candidate available"
# ================= WORKED EXAMPLE (pinned, deterministic — owner guidance 2026-08-28) =================
# Constants: carried_batch = 1,000 candidate + 1,000 nature; restock_threshold = 100;
# break.every = [400, 700] casts (midpoint 550); break.for = [45, 120] s;
# default staff-of-fire path consumes 1 candidate + 1 nature per cast (worst case 1:1).
#
# Step 0 — before the first inventory: account holds >= 292,000 gp working capital
# (1,000 x 148 + 1,000 x 144). At the GE bank: GE-buy staff of fire, Wield it
# (no documented requirement), GE-buy 1,000 gold necklaces (noted, stacked) +
# 1,000 nature runes, withdraw both -> loadout = carried_batch 1,000/1,000.
# Cast counter := 0.
# Step 1 — casts #1..#550: each cast consumes 1 candidate + 1 nature (-292 gp) and
# pays 180 gp (lowalch) + 31 xp -> net -112 gp. Cast counter 0 -> 550 (~39 min at
# midpoint cadence).
# Step 2 — FIRST SCHEDULED BREAK at cast 550 (midpoint of break.every [400, 700]):
# take-break for 45-120 s. Resume casting at cast 551. The break fired on the CAST
# COUNT, not on any bank visit; the restock counter is untouched.
# Step 3 — casts #551..#901: candidate 1000-901 = 99 < 100 (restock_threshold) and
# nature 1000-901 = 99 < 100 simultaneously (worst case 1:1) -> the transition
# fires at the end of cast #901: FIRST RESTOCK/BANK EVENT at cast 901.
# Coins deposited at this restock: 901 x 180 = 162,180 gp; batch net -100,912 gp;
# batch xp 901 x 31 = 27,931.
# Step 4 — restock: deposit coins + products, withdraw 901 candidate + 901 nature ->
# carried_batch restored to 1,000/1,000. Resume casting at cast 902 (the cast
# counter continues; the break counter also continues — restocking never resets either).
# Step 5 — SECOND BREAK when the cast-count interval since break #1 completes
# ([400, 700], midpoint: cast 902 + 550 = 1,452): take-break, resume at 1,453.
# Step 6 — restock #2 fires at cast 1,802 (901-cast batch cadence), and so on, until a
# stop_condition fires (Magic 54+ -> hand off to HLA at 55; gp under floor; 3 failed casts).
# Bank visits and breaks are INDEPENDENT by construction: restock cadence = 901 casts;
# break cadence = 400-700 casts, and the two counters do not interact.
# ============================================================================================
risk:
ban_level: low
notes:
- "High cast cadence is the main detection vector; keep anti_ban delays."
- "GE buy bursts: pace offers, respect the 4h limit window."
- "High cast cadence is the main detection vector; keep the mandatory anti_ban delays (cadence pin: additive post-cast sleep [1.6, 3.4]s; realized ~791 casts/hr at the 4.3s midpoint cycle incl. restock + breaks; loop-only 837)."
- "Breaks are CAST-COUNT-based (every [400, 700] completed casts, 45-120s AFK) and independent of bank visits — never treat a restock trip as a break."
- "GE buy bursts: pace offers, respect the 4h limit window (18,000 necklaces / 18,000 natures)."
# --- recovery block (owner guidance 2026-08-28: every activity must cover its complete runnable lifecycle) ---
# - Missing materials / depleted supplies: the restock_threshold transition (casting ->
# bank_supplies at candidate OR nature < 100) catches both mid-run depletions; restock
# action a4 rebuilds to carried_batch and the loop resumes (worked example steps 3-4).
# - Failed GE purchases (offer unfilled): re-place at live low + 5% with paced offers (a2);
# after 3 consecutive failures, alert + stop (failed) unless a fallback candidate is
# switched in — never sit in an unfillable offer loop.
# - Staff of fire cannot be equipped: fall back to fire runes (3 fire + 1 nature = +15 gp
# per cast rune cost), log WARN, continue; never loop on equip attempts.
# - Invalid loadout (wrong item in hand / spell unavailable): re-run build-loadout (a2/a4);
# 3 consecutive failed casts -> failed (stop, log ERROR sentinel, alert).
# - Explicit completion/stop: stop_conditions above (Magic 54+ HLA handoff, gp floor,
# repeated failures).
sources:
- "OSRS wiki — Low Level Alchemy (level 21, 31 xp/cast, 3-tick, F2P; infobox 2026-08-27)"
- "prices.runescape.wiki mapping (id 1654 gold necklace: members=false, limit 18000, lowalch 180, highalch 270, value 450; id 561 nature rune; id 554 fire rune)"
- "prices.runescape.wiki latest 2026-08-27 (necklace buy 148, nature buy 144, fire buy 5)"
verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing)
- "OSRS wiki — Staff of fire (page verified 2026-08-28 for the equip requirement: id 1387, F2P item, tradeable, options Wield — NO requirements row in the Infobox Item and NO requirement statement in the prose; contrast Battlestaff, whose prose states 'requires 30 Attack and 30 Magic to wield')"
- "docs/schema.md run_control (iteration unit; break.every counts iterations) + docs/action-grammar.md:135 (anti_ban.delay = additive post-action sleep) + verb take_break — the cadence + break + lifecycle model; owner guidance 2026-08-28 (realized rates incl. breaks + banking, worked example)"
verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27
related:
- magic-alch-handoff

View File

@ -3,8 +3,8 @@ title: Magic training via alchemy (LA → SII → HLA handoff)
domain: training
tags: [magic-training, alching, f2p, xp-training]
status: verified
version: 1
updated: "2026-08-27" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date
version: 2 # 2: bounce-fix 3 2026-08-28 (owner guidance) — dynamic recipe selection (highest-unlocked, target_input, fallback), realized rates incl. mandatory breaks + banking, full-lifecycle actions, worked example pinned
updated: "2026-08-28" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date
summary: >
The standard F2P alchemy training path: Low Level Alchemy from level 21
@ -12,21 +12,29 @@ summary: >
ore into bars), then High Level Alchemy from 55 (65 xp/cast, the wiki's
recommended end-state). All three spells are F2P and stay castable after
their unlock levels, so the handoffs are efficiency recommendations, not
hard caps.
hard caps. SII model (owner guidance 2026-08-28): the recipe table is
dynamic and governed by the account's Smithing level — the highest UNLOCKED
recipe with a positive complete-cost margin is selected, its primary ore
(target_input, never coal) is the spell target, and fallback is to the next
lower unlocked recipe, else skip SII and stay on LA. Rates are REALIZED:
mandatory additive delays PLUS breaks (every 400-700 completed casts) PLUS
banking (901-cast alch batches; 24-cast SII bank cycles) — pinned below.
accounts:
membership: f2p # LA, SII and HLA are all members=No spells
min_level: 21 # Magic 21 — Low Level Alchemy unlock
requires_quest: []
requires_other:
- "Staff of Fire equipped (drops LA/SII/HLA rune cost to 1 nature rune per cast)"
- "A bank with GE access within reach (runes + alch items come from the GE)"
- "Staff of Fire equipped (drops LA/SII/HLA rune cost to 1 nature rune per cast; NO level requirement documented on the OSRS wiki — verified 2026-08-28, see sources)"
- "SII tier requires the selected recipe's Smithing level (recipes table: bronze 1 / iron 15 / silver 20 / steel 30 / gold 40 / mithril 50 / adamantite 70 / runite 85) — the highest UNLOCKED recipe is selected and an unmet gate is NEVER selected (selection.mode)"
- "Working capital >= 150,000 gp for 1,000 nature runes plus the selected recipe's exact inputs (live GE buy; steel example: 1 iron ore 100 + 2 coal 150 each per cast at the 2026-08-27 snapshot)"
- "A bank with GE access within reach (alch spot = the bank booth; restock trips 60-120s)"
best_practice:
equipment:
- item: staff of fire
why: "unlimited fire runes — the rune cost of every alchemy spell drops to a single nature rune per cast"
source: "OSRS wiki — High Level Alchemy (rune cost table; Staff of fire)"
why: "unlimited fire runes — the rune cost of every alchemy spell drops to a single nature rune per cast; equip requirement: none documented on the OSRS wiki (verified 2026-08-28)"
source: "OSRS wiki — High Level Alchemy (rune cost table); OSRS wiki — Staff of fire (id 1387: no requirements row in the infobox, none in the prose — contrast Battlestaff 'requires 30 Attack and 30 Magic')"
techniques:
- name: hand off LA -> HLA at level 55
why: "LA page: 'Players with level 55 Magic are generally recommended to use High Level Alchemy instead, as it provides more coins and experience per cast' — 65 vs 31 xp/cast, 60% vs 40% of item value"
@ -34,9 +42,9 @@ best_practice:
- name: optional Superheat Item window 43-55
why: "SII unlocks at 43 (53 xp/cast) and smelts ore into bars without a furnace — a training window that can profit when ore/bar margins are positive; it cannot be cast on noted ore, so it needs the exact recipe inputs in inventory rather than a note stack"
source: "OSRS wiki — Superheat Item (43 Magic, 53 xp, F2P; 'smelt ore into bars without a furnace'; 'cannot be cast on noted ore')"
- name: select SII bars by complete recipe margin
why: "SII smelts a concrete bar from its exact inputs (see the recipes table below) — the bar's Smithing gate must be met and the complete input cost (all ores + coal + one nature rune, live GE buy) must stay under the bar's GE price; withdraw those exact inputs and bank the produced bar"
source: "OSRS wiki — Superheat Item (requires the Smithing level and materials of the bar); Smithing (bar smelting table); prices.runescape.wiki (live GE prices)"
- name: select the highest UNLOCKED SII recipe, never an unmet gate
why: "the recipe table is dynamic: at each cast the active recipe = the recipes-table entry with the highest smithing_level <= current Smithing whose complete input cost (ALL inputs at live GE buy + one nature rune) stays under the bar's GE price; the spell targets that recipe's declared target_input (the primary ore — never coal); when the selected recipe's materials cannot be obtained, fall back to the next lower unlocked recipe; when none has a positive margin, skip SII and stay on LA"
source: "OSRS wiki — Superheat Item (requires the Smithing level and materials of the bar); Smithing (bar smelting table); prices.runescape.wiki (live GE prices); owner guidance 2026-08-28 (selection.mode)"
- name: buy GE items below alch value, cast on noted items at a bank
why: "HLA page: 'Players who train this way can break even on the cost of runes, or even turn a profit, by buying items on the Grand Exchange for a lower cost than the item's High Alchemy value'; all alchemy spells work on noted items, saving bank withdrawals per cast"
source: "OSRS wiki — High Level Alchemy (GE buying for break-even/profit; noted-item casting)"
@ -56,38 +64,61 @@ economy:
item: nature rune
item_id: 561
cost_per_cycle: 145 # one nature rune per cast (staff of fire), buy price 2026-08-27
xp_per_hour: 54419 # HLA tier: 65 xp/cast @ ~837 casts/hr — additive wall
# time: 3-tick cast = 1.8s PLUS anti_ban.delay 1.6-3.4s
# after the action (action-grammar.md) = 3.4-5.2s/cast,
# mid 4.3s -> 3,600/4.3 = ~837 casts/hr. Tiers pinned at
# the midpoint rate: LA 25,953 (31 x 837)
# / SII 44,372 (53 x 837) / HLA 54,419 (65 x 837).
# Old 1,400-1,800 casts/hr figures (43,400 / 74,200 /
# 91,000) ignored the additive delay — unreachable.
xp_per_hour: 51400 # HLA tier, REALIZED (breaks + banking included), pinned below
# ---- realized-rate derivation (owner guidance 2026-08-28: rates must
# include the mandatory breaks and banking, not just the cast loop) ----
# Cast: 1.8s (3 ticks x 0.6s) + anti_ban.delay [1.6, 3.4]s ADDITIVE after
# the action (docs/action-grammar.md:135) = 3.4-5.2s, midpoint 4.3s.
# ALCH tiers (LA/HLA): 901-cast restock batch (carried_batch 1,000 -
# restock_threshold 100) + 225s overhead per batch (90s restock trip at a
# GE-adjacent booth + 135s breaks every [400, 700] casts, mid 550, for
# [45, 120]s): realized casts/hr = 901 / ((901 x 4.3 + 225) / 3600) = 791.3.
# LA 24,500 xp/hr = 31 x 791.3 (pin 24,530 -> 24,500)
# HLA 51,400 xp/hr = 65 x 791.3 (pin 51,435 -> 51,400)
# SII tier: bank cycle is INVENTORY-bound (bars are non-stackable): restock
# when bars_in_inventory >= 24, i.e. 24-cast cycles with a ~60s trip. Per
# 550-cast break window: 550 x 4.3 = 2,365s casting + 22.9 trips x 60s +
# 82.5s break = 3,822.5s -> realized casts/hr = 550 / (3,822.5/3600) = 518.0.
# SII 27,500 xp/hr = 53 x 518.0 (pin 27,454 -> 27,500; range 24,300 by
# slow cycle 458.6/hr .. 31,500 by fast cycle 595.0/hr)
# Superseded figures (fail the realized gate on purpose): loop-only 837
# casts/hr (54,419 HLA) ignored breaks+banking; the 8-14-cast break regime
# (~305 casts/hr, 19,830 HLA) and the 1,400/1,800-cast regimes ignored the
# additive delay.
supplies:
- item: nature rune
qty_per_cycle: 1
source: ge
ge_limit: 18000 # mapping + Module:Exchange agree
profit_drivers:
- "XP per cast by tier (wiki infoboxes): LA 31 (level 21) / SII 53 (level 43) / HLA 65 (level 55)"
- "XP per cast by tier (wiki infoboxes): LA 31 (level 21) / SII 53 (level 43) / HLA 65 (level 55); SII also grants the bar's Smithing XP (recipes table)"
- "HLA can break even or profit on GE-bought items priced below their High Alchemy value (HLA page); LA is a loss at current prices (see low-alch-f2p)"
- "SII turns a profit only when the complete recipe cost (all inputs + nature rune) stays under the resulting bar's GE price — re-check margins per window (SII page; the recipes table)"
- "SII turns a profit only when the complete recipe cost (ALL inputs + one nature rune) stays under the resulting bar's GE price — re-check margins per window and select the highest UNLOCKED recipe with a positive margin (selection.mode); never a steel-only default"
# Selection model (owner guidance 2026-08-28): the Superheat recipe is chosen
# at runtime from the recipes table — never hardcoded to one bar, never above
# the account's Smithing level, with a deterministic fallback chain.
selection:
mode: highest-unlocked
eligible_rule: "at each cast, the active recipe = the recipes-table entry with the highest smithing_level <= current Smithing level whose complete input cost (ALL inputs at live GE buy) + one nature rune 145 < the bar's live GE price"
fallback: "when the selected recipe's materials cannot be obtained (3 consecutive failed GE purchases / unfillable offers), drop to the next lower unlocked recipe and re-check its margin; when no unlocked recipe has a positive margin, skip SII for this window and stay on LA — never select a recipe whose Smithing requirement is unmet, never cast on coal"
# Concrete F2P bar recipes for Superheat Item. SII requires the bar's
# Smithing level and its exact materials, so every selectable bar is modelled
# with ALL inputs and quantities (id 453 coal appears in steel/mithril/
# adamantite/runite). Levels, xp and item ids from the OSRS wiki (Smithing
# smelting table; Superheat Item grants the bar's Smithing XP per cast).
# target_input = the PRIMARY ORE the spell is cast on (a declared input,
# never coal — the validator enforces both).
# econ 2026-08-27: bar margin = bar GE price - (all inputs at live GE buy +
# one nature rune 145); the client selects the highest-margin recipe whose
# smithing_level <= current Smithing level and withdraws its exact inputs.
# one nature rune 145); the client selects via selection.mode.
recipes:
bronze-bar:
bar: Bronze bar
bar_item_id: 2349
smithing_level: 1
smithing_xp: 6.2
target_input: Copper ore
inputs:
- item: Copper ore
item_id: 436
@ -102,6 +133,7 @@ recipes:
bar_item_id: 2351
smithing_level: 15
smithing_xp: 12.5
target_input: Iron ore
inputs:
- item: Iron ore
item_id: 440
@ -112,6 +144,7 @@ recipes:
bar_item_id: 2355
smithing_level: 20
smithing_xp: 13.7
target_input: Silver ore
inputs:
- item: Silver ore
item_id: 442
@ -122,6 +155,7 @@ recipes:
bar_item_id: 2353
smithing_level: 30
smithing_xp: 17.5
target_input: Iron ore
inputs:
- item: Iron ore
item_id: 440
@ -136,6 +170,7 @@ recipes:
bar_item_id: 2357
smithing_level: 40
smithing_xp: 22.5
target_input: Gold ore
inputs:
- item: Gold ore
item_id: 444
@ -146,6 +181,7 @@ recipes:
bar_item_id: 2359
smithing_level: 50
smithing_xp: 30
target_input: Mithril ore
inputs:
- item: Mithril ore
item_id: 447
@ -160,6 +196,7 @@ recipes:
bar_item_id: 2361
smithing_level: 70
smithing_xp: 37.5
target_input: Adamantite ore
inputs:
- item: Adamantite ore
item_id: 449
@ -174,6 +211,7 @@ recipes:
bar_item_id: 2363
smithing_level: 85
smithing_xp: 50
target_input: Runite ore
inputs:
- item: Runite ore
item_id: 451
@ -189,40 +227,55 @@ state_machine:
terminal: [complete, failed]
states:
idle:
goal: be at a bank with GE access, staff of fire equipped, nature runes in stock
next: [cast, bank_supplies, complete, failed]
goal: "acquire + equip the staff of fire, GE-buy nature runes and the tier supplies (alch candidates, or the selected recipe's exact inputs), build the starting inventory to its carried_batch"
next: [cast, complete, failed]
cast:
goal: cast the active tier spell with the tier-correct target — LA < 43 on a
noted alch candidate; SII 43-55 on an unnoted input of the selected
recipe (all exact inputs withdrawn; see the recipes table); HLA >= 55
on a noted alch candidate
goal: "cast the active tier spell with the tier-correct target — LA (< 43) or HLA (>= 55) on a noted alch candidate; SII (43-55) on the selected recipe's declared target_input (primary ore, never coal; exact inputs withdrawn, margin positive); a break fires on the completed-cast counter, NOT on bank visits"
next: [cast, bank_supplies, complete, failed]
bank_supplies:
goal: deposit coins and smelted bars, restock nature runes (+ the exact recipe inputs during the SII window)
next: [cast, idle]
goal: "deposit coins and smelted bars; restock to the tier's carried_batch (LA/HLA: candidate + natures to 1,000/1,000; SII: one 24-cast input pack of the selected recipe) and resume casting"
next: [cast, failed]
complete:
goal: magic level target reached
goal: "magic level target reached (default 55 -> HLA is the end-state)"
failed:
goal: hard failure — stop, log ERROR sentinel, alert
goal: "hard failure — stop, log ERROR sentinel, alert"
transitions:
- from: idle
when: "staff equipped AND tier supplies at carried_batch (LA/HLA: 1,000 candidate + 1,000 natures; SII: 24 iron-or-primary + 48 coal + 24 natures for the selected recipe)"
to: cast
- from: idle
when: "magic level >= target (55 -> HLA end-state)"
to: complete
- from: idle
when: "3 consecutive purchase/equip failures with no fallback available (recovery block)"
to: failed
- from: cast
when: "nature rune count < buffer (or a selected recipe's inputs < buffer in SII window)"
when: "LA/HLA tier: nature count < restock_threshold (100) OR candidate count < restock_threshold (100); SII tier: bars_in_inventory >= 24 (non-stackable bars fill the inventory) OR any selected-recipe input count < 1 — deterministic restock triggers, pinned numbers"
to: bank_supplies
- from: cast
when: "level < 43 and LA tier active (cast on the cheapest alch candidate)"
to: cast
- from: cast
when: "level 43-55 and the SII window is chosen (a recipes-table entry has smithing_level <= current Smithing and a positive complete-cost margin)"
when: "level 43-55 and the SII window is chosen — selection.mode picks the recipes-table entry with the highest smithing_level <= current Smithing and a positive complete-cost margin; cast on its target_input"
to: cast
- from: cast
when: "level 43-55 and the SII window is skipped (no recipes-table entry has a positive margin this window) — stay on LA"
when: "level 43-55 — SII fallback: the selected recipe's materials cannot be obtained, so drop to the next lower unlocked recipe (never an unmet Smithing gate) and continue SII if its margin is positive"
to: cast
- from: cast
when: "level 43-55 and no unlocked recipe has a positive margin this window — skip SII and stay on LA"
to: cast
- from: cast
when: "level >= 55 and HLA tier active (cast on the cheapest alch candidate)"
to: cast
- from: idle
when: "magic level >= target"
- from: cast
when: "magic level >= target (55 -> HLA end-state)"
to: complete
- from: cast
when: "3 consecutive failed casts (client issue — do not loop)"
to: failed
- from: bank_supplies
when: "restocked to the tier's carried_batch (see bank_supplies goal)"
to: cast
actions:
- id: a1
@ -230,25 +283,48 @@ actions:
verb: move_to
target: bank
params:
location: "nearest bank with GE"
location: "GE-adjacent bank (alch spot = the bank booth, so restock is a single short leg, 60-120s)"
method: [teleport, walk]
on_fail: retry
anti_ban:
delay: [400, 1600]
- id: a2
step: buy-supplies
step: buy-staff
verb: ge_buy
target: grand-exchange
params:
item: "nature rune + the exact SII recipe inputs (unnoted) of the selected recipe — every input and quantity from the recipes table (e.g. steel-bar = 1 iron ore + 2 coal), only while its complete-cost margin is positive"
qty: "fill to buffer"
max_price: "live low + 5%"
on_fail: retry
item: "staff of fire"
qty: 1
max_price: "live low + 25% (wiki value 1,500; hard cap 5,000)"
on_fail: retry # retry once; on a second failure use the fire-rune fallback (see a3 note / recovery block)
anti_ban:
delay: [1200, 3000]
- id: a3
step: equip-staff
verb: menu_option
target: "staff of fire in inventory"
params:
option: "Wield"
note: "Equip requirement is NONE documented on the OSRS wiki Staff of fire page (no requirements row in the infobox, none in the prose; compare Battlestaff 'requires 30 Attack and 30 Magic'; verified 2026-08-28). FAILURE PATH — if Wield is unavailable or the staff cannot be equipped, fall back to buying fire runes per cast (LA/HLA: 3 fire + 1 nature = 155 gp vs 140; SII: 4 fire + 1 nature = 160 gp vs 140 — the recipe margin rule compares against the FULL rune cost), log WARN and continue; never loop on equip attempts."
on_fail: retry
anti_ban:
delay: [600, 1800]
- id: a4
step: buy-supplies
verb: ge_buy
target: grand-exchange
params:
item: "nature rune + the tier's supplies — LA/HLA: the current least-loss alch candidate; SII: the exact unnoted inputs of the SELECTED recipe (every input and quantity from the recipes table, e.g. steel-bar = 1 iron ore + 2 coal), only while its complete-cost margin is positive"
qty: "LA/HLA: carried_batch 1,000 (4h GE limits: natures 18,000, candidates per mapping); SII: one 24-cast input pack (24 x each input quantity + 24 natures) per bank cycle"
max_price: "live low + 5%"
on_fail: retry # re-place at live low +5% with paced offers; 3 consecutive failures -> fallback to next unlocked recipe (SII) or -> failed (recovery block)
anti_ban:
delay: [1200, 3000]
- id: a5
step: cast-alch
verb: use_item_on
target: "noted alch candidate in inventory (LA and HLA tiers)"
@ -258,64 +334,124 @@ actions:
on: "itself"
on_fail: retry
anti_ban:
delay: [1600, 3400] # sleep AFTER the action (action-grammar.md): 1.8s cast +
# 1.6-3.4s delay = 3.4-5.2s/cast, mid 4.3s -> ~837 casts/hr
# mid; xp_per_hour pinned at that additive rate, humanised
delay: [1600, 3400] # MANDATORY post-action sleep, ADDITIVE to the 1.8s cast (3 ticks x 0.6s; action-grammar.md:135): cycle = 3.4-5.2s, mid 4.3s -> realized 791.3 casts/hr incl. breaks + banking (pinned in economy)
- id: a4
- id: a6
step: cast-superheat
verb: use_item_on
target: "an unnoted input of the selected recipe in inventory (SII tier only, 43-55)"
target: "the selected recipe's declared target_input in inventory (the primary ore — never coal; SII tier only, 43-55)"
params:
spell: "Superheat Item"
recipe: "steel-bar" # concrete recipes-table entry — every input and quantity,
# smithing gate, bar output (steel-bar = 1 iron ore +
# 2 coal, Smithing 30). Any recipes-table bar may be
# selected live; steel-bar is the fixture anchor.
recipe_rule: "select a recipe from the recipes table whose smithing_level <= current Smithing level and whose complete input cost (ALL inputs, live GE buy) + one nature rune < the bar's live GE price; withdraw those exact inputs (unnoted) and cast on one of them; never a noted stack, never a price-only pick"
selected_recipe: "runtime selection per selection.mode: the recipes-table entry with the highest smithing_level <= current Smithing whose complete input cost (ALL inputs, live GE buy) + one nature rune < the bar's live GE price; fall back to the next lower unlocked recipe when its materials cannot be obtained"
target_input: "the selected recipe's declared target_input field (primary ore; coal can never be the spell target)"
on: "itself"
on_fail: skip # no positive-margin recipe -> skip SII, stay on LA
on_fail: skip # no unlocked positive-margin recipe -> skip SII, stay on LA (selection.fallback)
anti_ban:
delay: [1600, 3400] # post-action sleep (additive): 1.8s cast + delay (mid 2.5s)
# = 4.3s/cast mid -> ~837 casts/hr, same pacing as a3
delay: [1600, 3400] # post-action sleep (additive): same 3.4-5.2s/cast as a5; SII realized 518.0 casts/hr incl. 24-cast bank cycles + breaks (pinned in economy)
- id: a5
- id: a7
step: bank-loop
verb: bank_inventory
target: bank
params:
deposit: [all coins, all products] # products incl. smelted bars during SII
withdraw: "nature runes up to buffer + the exact unnoted inputs of the selected SII recipe (per the recipes table)"
withdraw: "LA/HLA: candidate + nature runes up to carried_batch (1,000/1,000); SII: one 24-cast input pack of the selected recipe (24 x each input quantity + 24 natures)"
on_fail: alert
- id: a8
step: take-break
verb: take_break
target: "current location"
params:
duration: "[45, 120] seconds of idle AFK per break (matches run_control.break.for)"
note: "executed by the translator when the completed-cast counter reaches run_control.break.every [400, 700] casts — this IS run_control.break's executor, fired on CAST COUNTS, not on bank visits; the cast counter is not reset by restocking"
on_fail: skip
run_control:
iteration: "one cast" # schema.md: break.every counts iterations, so every
# value below is in casts (one iteration = one cast)
# value below is in completed casts, NOT bank trips
max_iterations: null
break:
every: [8, 14] # casts (one iteration = one cast); no bank-visit alignment assumed
every: [400, 700] # COMPLETED CASTS between AFK breaks (midpoint 550 = ~39 min
# at the 4.3s midpoint cycle). Cast-count-based, independent of
# banking; a restock trip NEVER counts as a break and does not
# reset this counter (owner decision 2026-08-28)
for: [45, 120] # seconds of idle AFK
stop_conditions:
- "magic level >= target"
- "gp under floor"
- "magic level >= target (default 55 -> HLA is the end-state)"
- "gp under floor (below one full carried_batch of natures + tier supplies)"
- "3 consecutive failed casts (client issue — do not loop)"
- "3 consecutive failed GE purchases with no fallback recipe/candidate available"
# ================= WORKED EXAMPLE (pinned, deterministic — owner guidance 2026-08-28) =================
# SII tier, steel-bar path (Magic 43-54, Smithing 30). Constants: break.every
# [400, 700] casts (midpoint 550), break.for [45, 120] s; SII bank cycle = 24
# casts (bars_in_inventory >= 24 or any input < 1 -> bank_supplies); LA/HLA use
# the 901-cast batch model pinned in low-alch-f2p (carried_batch 1,000/1,000,
# restock_threshold 100).
#
# Step 0 — prep, before the first inventory: account has 30 Smithing + 43-54
# Magic; GE-buy staff of fire and Wield it (no documented requirement); buy
# 1,000 nature runes and steel inputs (1 iron ore + 2 coal per cast, live GE).
# Build the starting inventory: 24 iron ore + 48 coal + 24 natures (one
# 24-cast pack). Cast counter := 0. Steel is the highest UNLOCKED recipe
# (Smithing 30); bronze (1), iron (15) and silver (20) are unlocked too but
# LOWER, so steel is selected. Smithing 20 accounts select silver-bar and
# steel stays locked (never an unmet gate).
# Step 1 — casts #1..#24: each cast consumes 1 iron ore + 2 coal + 1 nature
# and produces 1 steel bar (+53 Magic xp, +17.5 Smithing xp). After cast #24
# the inventory holds 24 bars -> FIRST RESTOCK/BANK EVENT at cast 24: deposit
# the 24 bars, withdraw the next 24 iron + 48 coal + 24 natures.
# Step 2 — resume at cast #25; the 24-cast cycle repeats: restocks at casts
# 48, 72, 96, ... (every 24 casts, deterministic).
# Step 3 — FIRST SCHEDULED BREAK at cast 550 (midpoint of break.every
# [400, 700]): the 23rd cycle (casts 529-552) is mid-flight — take-break for
# 45-120 s, resume at cast #551, finish the cycle (restock at 552).
# The break fired on the CAST COUNTER while a bank cycle was still running:
# breaks and banking are independent by construction.
# Step 4 — break #2 at cast 1,100 (550 after #1); restocks continue every 24
# casts; the SII window ends at Magic 55 -> HLA tier (901-cast batch model,
# 65 xp/cast). Stop at the target level, gp floor, or 3 failed casts.
# Fallback chain pinned: steel inputs unfillable at the GE (3 failed offers)
# -> drop to silver-bar (20, next lower unlocked) -> if silver is also
# unfillable or margin-negative -> skip SII this window and stay on LA.
# =====================================================================================================
risk:
ban_level: low
notes:
- "3-tick cast cadence is the main detection vector; keep anti_ban delays."
- "3-tick cast cadence is the main detection vector; keep anti_ban delays (cadence pin: additive post-cast sleep [1.6, 3.4]s; realized ~791 casts/hr alch tiers and ~518 casts/hr SII incl. breaks + banking)."
- "Breaks are CAST-COUNT-based (every [400, 700] completed casts, 45-120s AFK) and independent of bank visits — never treat a restock trip as a break."
- "The valuable-item warning fires on item GE value (not alch value) — the script must handle the warning widget when alching high-value items (HLA page)."
- "SII: withdraw the exact recipe inputs (unnoted, per the recipes table) before casting — every cast consumes one of each; bank the produced bar after each cast; never cast on noted ores."
- "SII ore-buy bursts are pace-sensitive like any GE buy; respect the 4h limit window."
- "SII: withdraw the exact recipe inputs (unnoted, per the recipes table) and cast on the declared target_input (primary ore) — never on coal, never on a noted stack; bank the produced bars when bars_in_inventory >= 24."
- "SII ore-buy bursts are pace-sensitive like any GE buy; respect the 4h limit window; re-check the complete-cost margin per window because ore/bar prices move independently."
# --- recovery block (owner guidance 2026-08-28: every activity must cover its complete runnable lifecycle) ---
# - Missing materials / depleted supplies: LA/HLA restock at candidate OR nature
# < 100 (carried_batch 1,000/1,000); SII restocks at bars >= 24 or any selected-
# recipe input < 1 — both are deterministic threshold transitions to bank_supplies.
# - Failed GE purchases (offer unfilled): re-place at live low + 5% with paced
# offers; after 3 consecutive failures in the SII window, fall back to the next
# lower unlocked recipe (selection.fallback); with no fallback recipe left,
# skip SII and stay on LA — never sit in an unfillable offer loop.
# - Staff of fire cannot be equipped (a3): fall back to fire runes (LA/HLA: +15
# gp/cast; SII: +20 gp/cast rune cost), log WARN, continue; never loop on equip
# attempts.
# - Invalid loadout (wrong item in hand / spell unavailable): re-run build-loadout
# (a4/a7); 3 consecutive failed casts -> failed (stop, log ERROR sentinel, alert).
# - Explicit completion/stop: stop_conditions above (Magic 55 HLA end-state, gp
# floor, repeated failures).
sources:
- "OSRS wiki — Low Level Alchemy (21 Magic, 31 xp/cast, members=No, 3-tick, 40% of item value; 'recommended to use High Level Alchemy instead' at 55 Magic)"
- "OSRS wiki — Superheat Item (43 Magic, 53 xp/cast, members=No; smelts ore into bars without a furnace; cannot be cast on noted ore; grants the bar's Smithing XP)"
- "OSRS wiki — Superheat Item (43 Magic, 53 xp/cast, members=No; smelts ore into bars without a furnace; cannot be cast on noted ore; grants the bar's Smithing XP; requires the bar's Smithing level and materials)"
- "OSRS wiki — High Level Alchemy (55 Magic, 65 xp/cast, members=No, 60% of item value; GE buying for break-even/profit; noted-item casting; valuable-item warning)"
- "OSRS wiki — Smithing (bar smelting table: smithing levels, per-bar XP and ore/coal quantities for bronze/iron/silver/steel/gold/mithril/adamantite/runite)"
- "prices.runescape.wiki/api/v1/osrs/mapping (id 561 nature rune: members=false, limit 18000, value 180, lowalch 72; limit cross-checked against Module:Exchange/Nature_rune — agree)"
- "prices.runescape.wiki/api/v1/osrs/latest (nature rune buy 145, snapshot 2026-08-27)"
verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing)
- "prices.runescape.wiki/api/v1/osrs/latest (nature rune buy 145, snapshot 2026-08-27; steel-bar fixture prices: iron ore 100, coal 150, steel bar 600)"
- "OSRS wiki — Staff of fire (page verified 2026-08-28 for the equip requirement: id 1387, F2P item, tradeable, options Wield — NO requirements row in the Infobox Item and NO requirement statement in the prose; contrast Battlestaff, whose prose states 'requires 30 Attack and 30 Magic to wield')"
- "docs/schema.md run_control (iteration unit; break.every counts iterations) + docs/action-grammar.md:135 (anti_ban.delay = additive post-action sleep) + verb take_break — the cadence + break + lifecycle model; owner guidance 2026-08-28 (dynamic recipe selection, realized rates, worked example)"
verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27
related:
- low-alch-f2p

View File

@ -62,19 +62,31 @@
}
},
"selection": {
"type": "object",
"required": ["mode", "eligible_rule", "fallback"],
"additionalProperties": false,
"properties": {
"mode": { "enum": ["highest-unlocked"] },
"eligible_rule": { "type": "string", "minLength": 10 },
"fallback": { "type": "string", "minLength": 10 }
}
},
"recipes": {
"type": "object",
"minProperties": 1,
"propertyNames": { "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
"additionalProperties": {
"type": "object",
"required": ["bar", "smithing_level", "inputs"],
"required": ["bar", "smithing_level", "target_input", "inputs"],
"additionalProperties": false,
"properties": {
"bar": { "type": "string", "minLength": 2 },
"bar_item_id": { "type": "integer", "minimum": 1 },
"smithing_level": { "type": "integer", "minimum": 1, "maximum": 126 },
"smithing_xp": { "type": "number", "exclusiveMinimum": 0 },
"target_input": { "type": "string", "minLength": 2 },
"inputs": {
"type": "array",
"minItems": 1,

View File

@ -17,15 +17,25 @@ Runs:
another) is a silent fact error, not a per-entry choice — GE
buy limits are item-global.
- SII translatability: an action that casts Superheat Item must
name a concrete recipe id declared in the entry's `recipes:`
table (all inputs + quantities, smithing gate, bar output). The
client translates a bar from its exact recipe, not from a vague
"positive margin ore" rule.
declare a `selection:` block (mode highest-unlocked, eligible rule,
fallback) and the state machine must cover both the SII window and
the stay-on-LA fallback. Every declared recipe must pin a
`target_input` that is one of its declared inputs and never coal
(the spell targets the primary ore), and smithing_level gates must
be unique so "highest unlocked" selection is deterministic.
- Realized-rate consistency: xp_per_hour must derive from the pinned
realized cadence (mandatory additive delays PLUS breaks PLUS
banking), not from the loop-only 837 casts/hr or the superseded
8-14-cast-break regime (~305 casts/hr).
3. Global id uniqueness across the whole codex tree.
Malformed/scalar YAML documents (an empty doc, a bare string, a list)
are rejected with a normal validator ERROR — they never reach the
cross-entry gates and never crash the tool with a traceback.
cross-entry gates and never crash the tool with a traceback. The same
holds for schema-INVALID MAPPINGS (e.g. `economy: not-a-mapping`):
shared-gate rows are only collected after schema validation succeeds
(regression: a malformed nested field used to crash
`supply_limit_conflicts` with an AttributeError).
Exit codes: 0 = clean (warnings ok), 1 = validation errors.
@ -88,8 +98,12 @@ def supply_limit_conflicts(rows):
for rel, data in rows:
if not isinstance(data, dict): # never crash on malformed rows
continue
eco = data.get("economy") or {}
eco = data.get("economy")
if not isinstance(eco, dict): # schema-invalid nested mapping (e.g. economy: "x")
continue
for sup in eco.get("supplies", []):
if not isinstance(sup, dict):
continue
lim = sup.get("ge_limit")
if lim is None:
continue
@ -129,28 +143,152 @@ def recipe_margin(recipe, bar_price, input_prices, nature_price):
return bar_price - (cost + nature_price)
def recipe_errors(rel, data):
"""Entry-level SII translatability checks. Returns error strings."""
# --- realized-cadence constants (pinned model; owner guidance 2026-08-28) ---
# docs/action-grammar.md:135: anti_ban.delay is an ADDITIVE post-action sleep,
# so a cast = 1.8s (3 ticks x 0.6s) + [1.6, 3.4]s = 3.4-5.2s (mid 4.3s).
# LA/HLA realized casts/hr: 901-cast restock batch (carried_batch 1,000 -
# restock_threshold 100) with 225s overhead per batch (90s restock trip +
# 135s breaks at every [400,700] casts, mid 550) -> 791.3 casts/hr midpoint.
# SII realized casts/hr: 24-cast bank cycle (non-stackable bars fill the
# inventory) -> 518.0 casts/hr midpoint (550-cast break window: 2,365s
# casting + 22.9 trips x 60s + 82.5s break).
REALIZED_CastsPerHr_ALCH = 791.3
REALIZED_CastsPerHr_SII = 518.0
XP_LA, XP_SII, XP_HLA = 31, 53, 65
RATE_TOLERANCE = 0.02 # pinned values may round to 3 significant figures
def highest_unlocked_recipe(recipes, smithing_level):
"""Deterministic Superheat selection: the declared recipe with the
highest smithing_level <= the account's Smithing level, else None.
Uniqueness of the gates is enforced by recipe_model_errors, so this
helper's ordering cannot be ambiguous. An account can NEVER be
offered a recipe above its Smithing level.
"""
best, best_lvl = None, -1
for rid, recipe in (recipes or {}).items():
lvl = recipe.get("smithing_level") if isinstance(recipe, dict) else None
if isinstance(lvl, int) and lvl <= smithing_level and lvl > best_lvl:
best, best_lvl = rid, lvl
return best
def recipe_model_errors(rel, data):
"""SII recipe-model + selection gates (owner guidance 2026-08-28).
- every declared recipe pins a `target_input` that is one of its
declared inputs and is never coal (the spell targets the primary
ore — Superheat cannot be cast on coal);
- smithing_level gates are unique, so "highest unlocked" selection
is deterministic;
- an entry casting Superheat Item must declare a `selection:` block
(mode highest-unlocked + eligible_rule + fallback) and its state
machine must cover both the SII window (43-55) and the fallback
(skip SII / stay on LA / next unlocked recipe).
"""
errors = []
recipes = data.get("recipes")
if recipes is not None and not isinstance(recipes, dict):
return [
f"{rel}: 'recipes' must be a mapping of recipe id -> recipe definition"]
for action in data.get("actions", []):
params = action.get("params") or {}
spell = str(params.get("spell", ""))
if "superheat" not in spell.lower():
continue
rid = params.get("recipe")
if not isinstance(rid, str) or not rid:
if isinstance(recipes, dict):
seen_levels = {}
for rid, recipe in recipes.items():
if not isinstance(recipe, dict):
continue # schema reports the shape
inputs = recipe.get("inputs") or []
in_names = [str(i.get("item", "")).lower()
for i in inputs if isinstance(i, dict)]
ti = recipe.get("target_input")
if not isinstance(ti, str) or not ti:
errors.append(
f"{rel}: recipe '{rid}' has no target_input — the spell "
f"targets the primary ore and it must be pinned per recipe")
elif ti.lower() not in in_names:
errors.append(
f"{rel}: recipe '{rid}' target_input '{ti}' is not one of "
f"its declared inputs")
if ti and (ti.lower() == "coal" or ti.strip().isdigit()
and int(ti) in {453}):
errors.append(
f"{rel}: recipe '{rid}' target_input '{ti}' is coal — coal "
f"can never be the Superheat spell target")
lvl = recipe.get("smithing_level")
if isinstance(lvl, int):
if lvl in seen_levels:
errors.append(
f"{rel}: recipes '{seen_levels[lvl]}' and '{rid}' share "
f"smithing_level {lvl} — highest-unlocked selection "
f"would be ambiguous")
seen_levels[lvl] = rid
has_sii = any(
"superheat" in (str(a.get("params") or {}).lower()
+ str(a.get("target") or "").lower()
+ str(a.get("step") or "").lower())
for a in data.get("actions", []))
if has_sii:
sel = data.get("selection")
if not isinstance(sel, dict):
errors.append(
f"{rel}: action {action.get('id')} casts Superheat Item but has no "
f"params.recipe naming a concrete recipes: entry (the client cannot "
f"translate an ore choice into a bar without the full recipe)")
elif recipes is None or rid not in recipes:
f"{rel}: a Superheat Item action requires a 'selection' block "
f"(mode, eligible_rule, fallback)")
else:
if sel.get("mode") != "highest-unlocked":
errors.append(
f"{rel}: selection.mode must be 'highest-unlocked'")
if not (isinstance(sel.get("eligible_rule"), str)
and len(sel["eligible_rule"]) >= 10):
errors.append(
f"{rel}: selection.eligible_rule must state the eligibility "
f"rule (smithing gate + complete-recipe margin)")
if not (isinstance(sel.get("fallback"), str)
and len(sel["fallback"]) >= 10):
errors.append(
f"{rel}: selection.fallback must state the fallback path "
f"(next unlocked recipe; else skip SII / stay on LA)")
whens = [str(t.get("when", ""))
for t in data.get("state_machine", {}).get("transitions", [])]
if not any("43-55" in w for w in whens):
errors.append(
f"{rel}: action {action.get('id')} references recipe '{rid}' which "
f"is not declared in recipes:")
f"{rel}: no transition covers the SII window (43-55) — the "
f"selected recipe tier is unreachable")
if not any(("stay on la" in w or "skip" in w or "fallback" in w)
for w in whens):
errors.append(
f"{rel}: no transition covers the SII fallback (skip SII / "
f"stay on LA) — selection is not deterministic")
return errors
def rate_errors(rel, data):
"""xp_per_hour must derive from the REALIZED cadence: mandatory
additive delays PLUS the pinned mandatory breaks PLUS banking.
Regression guard: the loop-only figure (837 casts/hr) and the
superseded 8-14-cast-break regime (~305 casts/hr) both sit outside
the tolerance band around the realized rate.
"""
errors = []
eco = data.get("economy")
if not isinstance(eco, dict):
return errors
xph = eco.get("xp_per_hour")
if not isinstance(xph, (int, float)):
return errors
has_sii = any(
"superheat" in (str(a.get("params") or {}).lower()
+ str(a.get("target") or "").lower()
+ str(a.get("step") or "").lower())
for a in data.get("actions", []))
per_cast = XP_HLA if has_sii else XP_LA
expected = per_cast * REALIZED_CastsPerHr_ALCH
if abs(xph - expected) / expected > RATE_TOLERANCE:
errors.append(
f"{rel}: xp_per_hour {xph} is not the realized cadence rate "
f"(expected ~{expected:.0f} = {per_cast} xp/cast x "
f"{REALIZED_CastsPerHr_ALCH} casts/hr including mandatory breaks "
f"and banking; tolerance {RATE_TOLERANCE:.0%})")
return errors
@ -188,6 +326,8 @@ def run_selftest(verbose=False):
# --- malformed-document regression: no traceback, normal errors ---
check("supply_limit_conflicts handles (bad.yaml, None) rows without crashing",
supply_limit_conflicts([("bad.yaml", None)]) == [])
check("supply_limit_conflicts ignores schema-invalid nested mappings (economy: not-a-mapping) without raising",
supply_limit_conflicts([("bad.yaml", {"economy": "not-a-mapping"})]) == [])
for val in (None, "just a scalar", [1, 2, 3]):
msg = document_error("bad.yaml", val)
check(
@ -195,9 +335,12 @@ def run_selftest(verbose=False):
isinstance(msg, str) and "not a YAML mapping" in msg)
check("document_error accepts mapping documents",
document_error("ok.yaml", {"id": "x"}) is None)
check("end-to-end: schema-invalid MAPPING -> exit 1, clean ERROR, no Traceback",
_end_to_end_bad_mapping())
# --- deterministic multi-input recipe fixture: steel-bar (1 iron + 2 coal) ---
steel = {"bar": "Steel bar", "bar_item_id": 2353, "smithing_level": 30,
"target_input": "Iron ore",
"inputs": [
{"item": "Iron ore", "item_id": 440, "qty": 1, "source": "ge"},
{"item": "Coal", "item_id": 453, "qty": 2, "source": "ge"}]}
@ -213,32 +356,138 @@ def run_selftest(verbose=False):
input_prices={"Iron ore": 100, "Coal": 150},
nature_price=145) < 0)
# --- SII translatability fixtures ---
entry_ok = {"actions": [
{"id": "a4", "step": "cast-superheat", "verb": "use_item_on",
"params": {"spell": "Superheat Item", "recipe": "steel-bar"},
"on_fail": "skip"}],
"recipes": {"steel-bar": steel}}
entry_undeclared = {"actions": [
{"id": "a4", "step": "cast-superheat", "verb": "use_item_on",
"params": {"spell": "Superheat Item", "recipe": "steel-bar"},
"on_fail": "skip"}]}
entry_unpinned = {"actions": [
{"id": "a4", "step": "cast-superheat", "verb": "use_item_on",
"params": {"spell": "Superheat Item"}, "on_fail": "skip"}],
"recipes": {"steel-bar": steel}}
check("Superheat action naming a declared recipes: id passes",
recipe_errors("ok.yaml", entry_ok) == [])
undeclared = recipe_errors("bad.yaml", entry_undeclared)
check("Superheat action naming an undeclared recipe id errors",
any("not declared in recipes:" in e for e in undeclared))
unpinned = recipe_errors("bad.yaml", entry_unpinned)
check("Superheat action without params.recipe errors",
any("no params.recipe" in e for e in unpinned))
# --- recipe-model fixtures: target_input, unique gates, selection block ---
f2p_bars = {
"bronze-bar": {"bar": "Bronze bar", "bar_item_id": 2349, "smithing_level": 1,
"smithing_xp": 6.2, "target_input": "Copper ore",
"inputs": [{"item": "Copper ore", "item_id": 436, "qty": 1, "source": "ge"},
{"item": "Tin ore", "item_id": 438, "qty": 1, "source": "ge"}]},
"iron-bar": {"bar": "Iron bar", "bar_item_id": 2351, "smithing_level": 15,
"smithing_xp": 12.5, "target_input": "Iron ore",
"inputs": [{"item": "Iron ore", "item_id": 440, "qty": 1, "source": "ge"}]},
"silver-bar": {"bar": "Silver bar", "bar_item_id": 2355, "smithing_level": 20,
"smithing_xp": 13.7, "target_input": "Silver ore",
"inputs": [{"item": "Silver ore", "item_id": 442, "qty": 1, "source": "ge"}]},
"steel-bar": steel,
"runite-bar": {"bar": "Runite bar", "bar_item_id": 2363, "smithing_level": 85,
"smithing_xp": 50, "target_input": "Runite ore",
"inputs": [{"item": "Runite ore", "item_id": 451, "qty": 1, "source": "ge"},
{"item": "Coal", "item_id": 453, "qty": 8, "source": "ge"}]},
}
# Smithing-level eligibility: highest unlocked, never an unmet gate
check("smithing 1 -> bronze-bar (highest unlocked)",
highest_unlocked_recipe(f2p_bars, 1) == "bronze-bar")
check("smithing 15 -> iron-bar",
highest_unlocked_recipe(f2p_bars, 15) == "iron-bar")
check("smithing 20 -> silver-bar (steel at 30 stays locked)",
highest_unlocked_recipe(f2p_bars, 20) == "silver-bar")
check("smithing 30 -> steel-bar",
highest_unlocked_recipe(f2p_bars, 30) == "steel-bar")
check("smithing 85 -> runite-bar",
highest_unlocked_recipe(f2p_bars, 85) == "runite-bar")
check("smithing 0 -> None (no recipe selected below the lowest gate)",
highest_unlocked_recipe(f2p_bars, 0) is None)
superheat_action = {
"id": "a6", "step": "cast-superheat", "verb": "use_item_on",
"target": "the selected recipe's declared target_input in inventory (primary ore — never coal)",
"params": {"spell": "Superheat Item",
"selected_recipe": "runtime selection per selection.mode: highest-unlocked recipe with a positive complete-cost margin",
"target_input": "the selected recipe's declared target_input field"},
"on_fail": "skip"}
selection_ok = {"mode": "highest-unlocked",
"eligible_rule": "recipes entries with smithing_level <= current Smithing and a positive complete-cost margin",
"fallback": "next lower unlocked recipe when materials cannot be obtained; else skip SII and stay on LA"}
transitions_ok = [
{"from": "cast", "when": "level 43-55 and the SII window is chosen (selection.mode picks the highest unlocked recipe with positive margin)", "to": "cast"},
{"from": "cast", "when": "no unlocked recipe has a positive margin this window — skip SII and stay on LA", "to": "cast"}]
entry_model_ok = {"actions": [superheat_action], "recipes": f2p_bars,
"selection": selection_ok,
"state_machine": {"transitions": transitions_ok}}
check("recipe model with declared target_inputs + selection block passes",
recipe_model_errors("ok.yaml", entry_model_ok) == [])
# coal can never be the spell target
entry_coal = {"actions": [superheat_action],
"recipes": {"steel-bar": dict(steel, target_input="Coal")},
"selection": selection_ok,
"state_machine": {"transitions": transitions_ok}}
coal_errs = recipe_model_errors("bad.yaml", entry_coal)
check("steel-bar target_input 'Coal' is rejected (coal is never the spell target)",
any("is coal" in e for e in coal_errs))
# target_input must be a declared input
entry_bad_ti = {"actions": [superheat_action],
"recipes": {"steel-bar": dict(steel, target_input="Runite ore")},
"selection": selection_ok,
"state_machine": {"transitions": transitions_ok}}
bad_ti = recipe_model_errors("bad.yaml", entry_bad_ti)
check("target_input not among the recipe's declared inputs is rejected",
any("not one of" in e for e in bad_ti))
# duplicate smithing gates would make highest-unlocked ambiguous
entry_dup = {"actions": [superheat_action],
"recipes": {"steel-bar": steel,
"steel-bar-2": dict(steel, bar="Steel bar 2", bar_item_id=2353)},
"selection": selection_ok,
"state_machine": {"transitions": transitions_ok}}
dup_errs = recipe_model_errors("bad.yaml", entry_dup)
check("duplicate smithing_level across recipes is rejected (ambiguous highest-unlocked)",
any("share smithing_level" in e for e in dup_errs))
# Superheat action requires the selection block + fallback transitions
entry_no_sel = {"actions": [superheat_action], "recipes": f2p_bars,
"state_machine": {"transitions": transitions_ok}}
no_sel = recipe_model_errors("bad.yaml", entry_no_sel)
check("Superheat action without a selection block is rejected",
any("'selection' block" in e for e in no_sel))
entry_no_fb = {"actions": [superheat_action], "recipes": f2p_bars,
"selection": selection_ok,
"state_machine": {"transitions": [
{"from": "cast", "when": "level 43-55 and the SII window is chosen", "to": "cast"}]}}
no_fb = recipe_model_errors("bad.yaml", entry_no_fb)
check("Superheat entry without an SII fallback transition is rejected",
any("SII fallback" in e for e in no_fb))
# --- realized-rate fixtures: breaks + banking included ---
check("LA-only xp_per_hour 24,500 matches the realized cadence (31 x 791.3)",
rate_errors("ok.yaml", {"economy": {"xp_per_hour": 24500}}) == [])
check("loop-only LA xp_per_hour 25,953 fails the realized-cadence gate",
any("realized cadence" in e
for e in rate_errors("bad.yaml", {"economy": {"xp_per_hour": 25953}})))
check("8-14-cast-break regime LA rate (31 x ~305 = 9,455) fails the realized-cadence gate",
any("realized cadence" in e
for e in rate_errors("bad.yaml", {"economy": {"xp_per_hour": 9455}})))
check("SII entry xp_per_hour 51,400 matches the HLA-tier realized cadence (65 x 791.3)",
rate_errors("ok2.yaml", {"actions": [superheat_action],
"economy": {"xp_per_hour": 51400}}) == [])
check("SII entry with the old loop-only HLA rate 54,419 fails the realized-cadence gate",
any("realized cadence" in e
for e in rate_errors("bad2.yaml", {"actions": [superheat_action],
"economy": {"xp_per_hour": 54419}})))
return errors
def _end_to_end_bad_mapping():
"""Regression (governor bounce 3, finding 1): a schema-invalid MAPPING
with a malformed nested field — `economy: not-a-mapping` — must yield
exit 1 with a normal ERROR line and NO traceback.
Writes the fixture into the real codex tree and shells out to the real
validator so the full main() path is exercised, then removes the file.
Returns True when the gate behaves.
"""
import subprocess
bad = ROOT / "codex" / "selftest-bad-mapping.yaml"
try:
bad.write_text("id: selftest-bad-mapping\neconomy: not-a-mapping\n")
proc = subprocess.run(
[sys.executable, str(Path(__file__).resolve()), "--stale-days", "9999"],
capture_output=True, text=True, cwd=str(ROOT), timeout=120)
out = (proc.stdout or "") + (proc.stderr or "")
return (proc.returncode == 1 and "selftest-bad-mapping" in out
and "ERROR" in out and "Traceback" not in out)
finally:
bad.unlink(missing_ok=True)
def main():
global STALE_DAYS
if "--stale-days" in sys.argv:
@ -269,9 +518,12 @@ def main():
if shape_err:
errors.append(shape_err)
continue
rows.append((rel, data))
# 1. schema
# 1. schema — BEFORE the shared row set. Schema-invalid documents
# (including mappings with malformed nested fields like
# `economy: not-a-mapping`) must never reach the cross-entry gates:
# regression — rows.append() used to run first, and
# supply_limit_conflicts() then crashed with an AttributeError.
v = jsonschema.Draft7Validator(SCHEMA)
schema_errors = sorted(v.iter_errors(data), key=lambda e: list(e.path))
for err in schema_errors:
@ -281,6 +533,8 @@ def main():
# cross-checks on a schema-invalid doc are noise
continue
rows.append((rel, data))
eid = data.get("id", rel)
if eid in ids:
errors.append(f"{rel}: duplicate entry id '{eid}' (also in {ids[eid]})")
@ -316,8 +570,9 @@ def main():
if len(a_steps) != len(set(a_steps)):
errors.append(f"{rel}: duplicate action step names")
# SII translatability (concrete recipes: entry per Superheat action)
errors += recipe_errors(rel, data)
# SII recipe-model + selection gates + realized-rate consistency
errors += recipe_model_errors(rel, data)
errors += rate_errors(rel, data)
# 3. staleness / research TODOs
if data.get("status") == "verified":