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 domain: economy
tags: [alching, magic-training, f2p, loss-training] tags: [alching, magic-training, f2p, loss-training]
status: verified status: verified
version: 1 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-27" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date updated: "2026-08-28" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date
summary: > summary: >
Magic training from level 21 by low-alching F2P items with the highest 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 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. 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: accounts:
membership: f2p membership: f2p
min_level: 21 # Magic 21 to cast Low Level Alchemy (wiki-verified) min_level: 21 # Magic 21 to cast Low Level Alchemy (wiki-verified)
requires_quest: [] requires_quest: []
requires_other: requires_other:
- "Staff of Fire equipped (removes 3 fire runes from the cost)" - "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" - "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: best_practice:
equipment: equipment:
- item: staff of fire - 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)" 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; Low Level Alchemy (Cost)" 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: techniques:
- name: cast on noted (stacked) items - 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)" source: "OSRS wiki — Low Level Alchemy (Usage: casting alchemy spells on noted items)"
- name: bank at a GE-adjacent bank - 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)" 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: economy:
# Verified 2026-08-27 against prices.runescape.wiki (live 'latest') # Verified 2026-08-27 against prices.runescape.wiki (live 'latest')
# + OSRS wiki Low_Level_Alchemy infobox. # + 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: Gold necklace # example anchor; rank by cheapest candidate live
item_id: 1654 item_id: 1654
cost_per_cycle: 292 # necklace buy 148 + nature rune 144 (staff of fire) 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) income_per_cycle: 180 # lowalch of gold necklace (40% of value 450)
net_per_cycle: -112 net_per_cycle: -112
gp_per_hour_low: -119000 # -112 net/cast x rate endpoints: max burn at 1.6s delay gp_per_hour_low: -110500 # realized (incl. restock + breaks), fastest cycle: 986.4 casts/hr x -112 = -110,477
# (3.6/3.4s = 1,058 casts/hr) ~ -118,500; mid 4.3s/cast gp_per_hour_high: -74000 # realized (incl. restock + breaks), slowest cycle: 660.6 casts/hr x -112 = -73,987
# (837 casts/hr) ~ -93,800 xp_per_hour: 24500 # realized midpoint: 791.3 casts/hr x 31 xp = 24,530 (pin 24,500)
gp_per_hour_high: -78000 # min burn at 3.4s delay (3.6/5.2s = 692 casts/hr) ~ -77,500 # ---- realized-rate derivation (pinned; owner guidance 2026-08-28) ----
xp_per_hour: 25953 # 31 xp/cast (wiki) @ ~837 casts/hr — additive wall time: # Cast: 1.8s (3 ticks x 0.6s) + anti_ban.delay [1.6, 3.4]s ADDITIVE after the
# 3-tick cast = 1.8s PLUS anti_ban.delay 1.6-3.4s which is # action (docs/action-grammar.md:135) = 3.4-5.2s, midpoint 4.3s.
# sleep AFTER the action (action-grammar.md) = 3.4-5.2s/cast, # BATCH model: carried_batch 1,000 candidate + 1,000 natures; restock_threshold
# mid 4.3s -> 3,600/4.3 = ~837 casts/hr (midpoint rate). # 100 -> 901-cast restock cadence (first restock after cast 901). Overhead per
# The old 1,800 casts/hr (55,800 xp/hr) ignored the additive # 901-cast batch: 90s restock trip (60-120s, GE-adjacent booth) + 135s breaks
# delay and was not reachable under the entry's own pacing. # (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: supplies:
- item: gold necklace - item: gold necklace
qty_per_cycle: 1 qty_per_cycle: 1
source: ge 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 - item: nature rune
qty_per_cycle: 1 qty_per_cycle: 1
source: ge source: ge
@ -63,24 +79,25 @@ economy:
profit_drivers: profit_drivers:
- "NONE at current prices — XP is the product, not GP" - "NONE at current prices — XP is the product, not GP"
- "Pick the candidate with the least-negative net each run (live rank)" - "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: state_machine:
initial: idle initial: idle
terminal: [complete, failed] terminal: [complete, failed]
states: states:
idle: idle:
goal: reach the bank + have supplies in inventory 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, bank_supplies] next: [casting, complete, failed]
casting: casting:
goal: cast Low Level Alchemy on the current candidate item 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, idle, complete, failed] next: [bank_supplies, complete, failed]
bank_supplies: bank_supplies:
goal: bank the produced coins, withdraw fresh candidate + runes goal: "deposit coins, withdraw candidate + natures back to carried_batch (1,000/1,000), resume casting"
next: [casting, idle] next: [casting, failed]
complete: 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: failed:
goal: hard failure — stop, log ERROR sentinel, alert goal: "hard failure — stop, log ERROR sentinel, alert"
transitions: transitions:
# Handoff (verified 2026-08-27 from the OSRS wiki): the LA page states # Handoff (verified 2026-08-27 from the OSRS wiki): the LA page states
# "Players with level 55 Magic are generally recommended to use High # "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, # ore-smelting margins justify it. Handoffs are soft efficiency choices,
# not hard caps (all spells stay castable); see # not hard caps (all spells stay castable); see
# codex/training/magic-alch-handoff.yaml. # codex/training/magic-alch-handoff.yaml.
- from: casting - from: idle
when: "candidate item count < buffer OR nature rune count < buffer" when: "staff equipped AND candidate == carried_batch AND nature == carried_batch (1,000 and 1,000)"
to: bank_supplies to: casting
- from: idle - from: idle
when: "magic level >= target (default 54, hand off to HLA at 55)" when: "magic level >= target (default 54, hand off to HLA at 55)"
to: complete 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: actions:
- id: a1 - id: a1
@ -102,7 +134,7 @@ actions:
verb: move_to verb: move_to
target: bank target: bank
params: 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] method: [teleport, walk]
on_fail: retry on_fail: retry
anti_ban: anti_ban:
@ -113,10 +145,10 @@ actions:
verb: ge_buy verb: ge_buy
target: grand-exchange target: grand-exchange
params: params:
item: "current candidate item" item: "current least-loss live candidate (default: gold necklace)"
qty: "fill to buffer" qty: "carried_batch = 1,000 (fits the 4h GE limit window of 18,000)"
max_price: "live low + 5%" 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: anti_ban:
delay: [1200, 3000] delay: [1200, 3000]
@ -128,11 +160,10 @@ actions:
spell: "Low Level Alchemy" spell: "Low Level Alchemy"
item: "cheapest live candidate in inventory" item: "cheapest live candidate in inventory"
on: "itself" 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 on_fail: retry
anti_ban: anti_ban:
delay: [1600, 3400] # sleep AFTER the action (action-grammar.md): 1.8s cast + 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)
# 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
- id: a4 - id: a4
step: bank-loop step: bank-loop
@ -140,32 +171,95 @@ actions:
target: bank target: bank
params: params:
deposit: [all coins, all products] 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 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: run_control:
iteration: "one cast" # schema.md: break.every counts iterations, so every 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 max_iterations: null
break: 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 for: [45, 120] # seconds of idle AFK
stop_conditions: stop_conditions:
- "magic level >= target" - "magic level >= target (default 54, hand off to HLA at 55)"
- "gp under floor" - "gp under floor (working capital below one carried_batch = 292,000 gp)"
- "3 consecutive failed casts (client issue — do not loop)" - "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: risk:
ban_level: low ban_level: low
notes: notes:
- "High cast cadence is the main detection vector; keep anti_ban delays." - "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)."
- "GE buy bursts: pace offers, respect the 4h limit window." - "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: sources:
- "OSRS wiki — Low Level Alchemy (level 21, 31 xp/cast, 3-tick, F2P; infobox 2026-08-27)" - "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 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)" - "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: related:
- magic-alch-handoff - magic-alch-handoff

View File

@ -3,8 +3,8 @@ title: Magic training via alchemy (LA → SII → HLA handoff)
domain: training domain: training
tags: [magic-training, alching, f2p, xp-training] tags: [magic-training, alching, f2p, xp-training]
status: verified status: verified
version: 1 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-27" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date updated: "2026-08-28" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date
summary: > summary: >
The standard F2P alchemy training path: Low Level Alchemy from level 21 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 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 recommended end-state). All three spells are F2P and stay castable after
their unlock levels, so the handoffs are efficiency recommendations, not 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: accounts:
membership: f2p # LA, SII and HLA are all members=No spells membership: f2p # LA, SII and HLA are all members=No spells
min_level: 21 # Magic 21 — Low Level Alchemy unlock min_level: 21 # Magic 21 — Low Level Alchemy unlock
requires_quest: [] requires_quest: []
requires_other: requires_other:
- "Staff of Fire equipped (drops LA/SII/HLA rune cost to 1 nature rune per cast)" - "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)"
- "A bank with GE access within reach (runes + alch items come from the GE)" - "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: best_practice:
equipment: equipment:
- item: staff of fire - item: staff of fire
why: "unlimited fire runes — the rune cost of every alchemy spell drops to a single nature rune per cast" 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; Staff of fire)" 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: techniques:
- name: hand off LA -> HLA at level 55 - 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" 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 - 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" 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')" 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 - name: select the highest UNLOCKED SII recipe, never an unmet gate
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" 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)" 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 - 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" 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)" source: "OSRS wiki — High Level Alchemy (GE buying for break-even/profit; noted-item casting)"
@ -56,38 +64,61 @@ economy:
item: nature rune item: nature rune
item_id: 561 item_id: 561
cost_per_cycle: 145 # one nature rune per cast (staff of fire), buy price 2026-08-27 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 xp_per_hour: 51400 # HLA tier, REALIZED (breaks + banking included), pinned below
# time: 3-tick cast = 1.8s PLUS anti_ban.delay 1.6-3.4s # ---- realized-rate derivation (owner guidance 2026-08-28: rates must
# after the action (action-grammar.md) = 3.4-5.2s/cast, # include the mandatory breaks and banking, not just the cast loop) ----
# mid 4.3s -> 3,600/4.3 = ~837 casts/hr. Tiers pinned at # Cast: 1.8s (3 ticks x 0.6s) + anti_ban.delay [1.6, 3.4]s ADDITIVE after
# the midpoint rate: LA 25,953 (31 x 837) # the action (docs/action-grammar.md:135) = 3.4-5.2s, midpoint 4.3s.
# / SII 44,372 (53 x 837) / HLA 54,419 (65 x 837). # ALCH tiers (LA/HLA): 901-cast restock batch (carried_batch 1,000 -
# Old 1,400-1,800 casts/hr figures (43,400 / 74,200 / # restock_threshold 100) + 225s overhead per batch (90s restock trip at a
# 91,000) ignored the additive delay — unreachable. # 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: supplies:
- item: nature rune - item: nature rune
qty_per_cycle: 1 qty_per_cycle: 1
source: ge source: ge
ge_limit: 18000 # mapping + Module:Exchange agree ge_limit: 18000 # mapping + Module:Exchange agree
profit_drivers: 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)" - "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 # Concrete F2P bar recipes for Superheat Item. SII requires the bar's
# Smithing level and its exact materials, so every selectable bar is modelled # Smithing level and its exact materials, so every selectable bar is modelled
# with ALL inputs and quantities (id 453 coal appears in steel/mithril/ # with ALL inputs and quantities (id 453 coal appears in steel/mithril/
# adamantite/runite). Levels, xp and item ids from the OSRS wiki (Smithing # adamantite/runite). Levels, xp and item ids from the OSRS wiki (Smithing
# smelting table; Superheat Item grants the bar's Smithing XP per cast). # 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 + # 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 # one nature rune 145); the client selects via selection.mode.
# smithing_level <= current Smithing level and withdraws its exact inputs.
recipes: recipes:
bronze-bar: bronze-bar:
bar: Bronze bar bar: Bronze bar
bar_item_id: 2349 bar_item_id: 2349
smithing_level: 1 smithing_level: 1
smithing_xp: 6.2 smithing_xp: 6.2
target_input: Copper ore
inputs: inputs:
- item: Copper ore - item: Copper ore
item_id: 436 item_id: 436
@ -102,6 +133,7 @@ recipes:
bar_item_id: 2351 bar_item_id: 2351
smithing_level: 15 smithing_level: 15
smithing_xp: 12.5 smithing_xp: 12.5
target_input: Iron ore
inputs: inputs:
- item: Iron ore - item: Iron ore
item_id: 440 item_id: 440
@ -112,6 +144,7 @@ recipes:
bar_item_id: 2355 bar_item_id: 2355
smithing_level: 20 smithing_level: 20
smithing_xp: 13.7 smithing_xp: 13.7
target_input: Silver ore
inputs: inputs:
- item: Silver ore - item: Silver ore
item_id: 442 item_id: 442
@ -122,6 +155,7 @@ recipes:
bar_item_id: 2353 bar_item_id: 2353
smithing_level: 30 smithing_level: 30
smithing_xp: 17.5 smithing_xp: 17.5
target_input: Iron ore
inputs: inputs:
- item: Iron ore - item: Iron ore
item_id: 440 item_id: 440
@ -136,6 +170,7 @@ recipes:
bar_item_id: 2357 bar_item_id: 2357
smithing_level: 40 smithing_level: 40
smithing_xp: 22.5 smithing_xp: 22.5
target_input: Gold ore
inputs: inputs:
- item: Gold ore - item: Gold ore
item_id: 444 item_id: 444
@ -146,6 +181,7 @@ recipes:
bar_item_id: 2359 bar_item_id: 2359
smithing_level: 50 smithing_level: 50
smithing_xp: 30 smithing_xp: 30
target_input: Mithril ore
inputs: inputs:
- item: Mithril ore - item: Mithril ore
item_id: 447 item_id: 447
@ -160,6 +196,7 @@ recipes:
bar_item_id: 2361 bar_item_id: 2361
smithing_level: 70 smithing_level: 70
smithing_xp: 37.5 smithing_xp: 37.5
target_input: Adamantite ore
inputs: inputs:
- item: Adamantite ore - item: Adamantite ore
item_id: 449 item_id: 449
@ -174,6 +211,7 @@ recipes:
bar_item_id: 2363 bar_item_id: 2363
smithing_level: 85 smithing_level: 85
smithing_xp: 50 smithing_xp: 50
target_input: Runite ore
inputs: inputs:
- item: Runite ore - item: Runite ore
item_id: 451 item_id: 451
@ -189,40 +227,55 @@ state_machine:
terminal: [complete, failed] terminal: [complete, failed]
states: states:
idle: idle:
goal: be at a bank with GE access, staff of fire equipped, nature runes in stock 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, bank_supplies, complete, failed] next: [cast, complete, failed]
cast: cast:
goal: cast the active tier spell with the tier-correct target — LA < 43 on a 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"
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
next: [cast, bank_supplies, complete, failed] next: [cast, bank_supplies, complete, failed]
bank_supplies: bank_supplies:
goal: deposit coins and smelted bars, restock nature runes (+ the exact recipe inputs during the SII window) 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, idle] next: [cast, failed]
complete: complete:
goal: magic level target reached goal: "magic level target reached (default 55 -> HLA is the end-state)"
failed: failed:
goal: hard failure — stop, log ERROR sentinel, alert goal: "hard failure — stop, log ERROR sentinel, alert"
transitions: 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 - 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 to: bank_supplies
- from: cast - from: cast
when: "level < 43 and LA tier active (cast on the cheapest alch candidate)" when: "level < 43 and LA tier active (cast on the cheapest alch candidate)"
to: cast to: cast
- from: 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 to: cast
- from: 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 to: cast
- from: cast - from: cast
when: "level >= 55 and HLA tier active (cast on the cheapest alch candidate)" when: "level >= 55 and HLA tier active (cast on the cheapest alch candidate)"
to: cast to: cast
- from: idle - from: cast
when: "magic level >= target" when: "magic level >= target (55 -> HLA end-state)"
to: complete 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: actions:
- id: a1 - id: a1
@ -230,25 +283,48 @@ actions:
verb: move_to verb: move_to
target: bank target: bank
params: 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] method: [teleport, walk]
on_fail: retry on_fail: retry
anti_ban: anti_ban:
delay: [400, 1600] delay: [400, 1600]
- id: a2 - id: a2
step: buy-supplies step: buy-staff
verb: ge_buy verb: ge_buy
target: grand-exchange target: grand-exchange
params: 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" item: "staff of fire"
qty: "fill to buffer" qty: 1
max_price: "live low + 5%" max_price: "live low + 25% (wiki value 1,500; hard cap 5,000)"
on_fail: retry on_fail: retry # retry once; on a second failure use the fire-rune fallback (see a3 note / recovery block)
anti_ban: anti_ban:
delay: [1200, 3000] delay: [1200, 3000]
- id: a3 - 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 step: cast-alch
verb: use_item_on verb: use_item_on
target: "noted alch candidate in inventory (LA and HLA tiers)" target: "noted alch candidate in inventory (LA and HLA tiers)"
@ -258,64 +334,124 @@ actions:
on: "itself" on: "itself"
on_fail: retry on_fail: retry
anti_ban: anti_ban:
delay: [1600, 3400] # sleep AFTER the action (action-grammar.md): 1.8s cast + 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)
# 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
- id: a4 - id: a6
step: cast-superheat step: cast-superheat
verb: use_item_on 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: params:
spell: "Superheat Item" spell: "Superheat Item"
recipe: "steel-bar" # concrete recipes-table entry — every input and quantity, 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"
# smithing gate, bar output (steel-bar = 1 iron ore + target_input: "the selected recipe's declared target_input field (primary ore; coal can never be the spell target)"
# 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"
on: "itself" 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: anti_ban:
delay: [1600, 3400] # post-action sleep (additive): 1.8s cast + delay (mid 2.5s) 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)
# = 4.3s/cast mid -> ~837 casts/hr, same pacing as a3
- id: a5 - id: a7
step: bank-loop step: bank-loop
verb: bank_inventory verb: bank_inventory
target: bank target: bank
params: params:
deposit: [all coins, all products] # products incl. smelted bars during SII 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 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: run_control:
iteration: "one cast" # schema.md: break.every counts iterations, so every 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 max_iterations: null
break: 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 for: [45, 120] # seconds of idle AFK
stop_conditions: stop_conditions:
- "magic level >= target" - "magic level >= target (default 55 -> HLA is the end-state)"
- "gp under floor" - "gp under floor (below one full carried_batch of natures + tier supplies)"
- "3 consecutive failed casts (client issue — do not loop)" - "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: risk:
ban_level: low ban_level: low
notes: 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)." - "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: 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." - "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: 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 — 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 — 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)" - "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/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)" - "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)"
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 (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: related:
- low-alch-f2p - 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": { "recipes": {
"type": "object", "type": "object",
"minProperties": 1, "minProperties": 1,
"propertyNames": { "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, "propertyNames": { "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
"additionalProperties": { "additionalProperties": {
"type": "object", "type": "object",
"required": ["bar", "smithing_level", "inputs"], "required": ["bar", "smithing_level", "target_input", "inputs"],
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"bar": { "type": "string", "minLength": 2 }, "bar": { "type": "string", "minLength": 2 },
"bar_item_id": { "type": "integer", "minimum": 1 }, "bar_item_id": { "type": "integer", "minimum": 1 },
"smithing_level": { "type": "integer", "minimum": 1, "maximum": 126 }, "smithing_level": { "type": "integer", "minimum": 1, "maximum": 126 },
"smithing_xp": { "type": "number", "exclusiveMinimum": 0 }, "smithing_xp": { "type": "number", "exclusiveMinimum": 0 },
"target_input": { "type": "string", "minLength": 2 },
"inputs": { "inputs": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,

View File

@ -17,15 +17,25 @@ Runs:
another) is a silent fact error, not a per-entry choice — GE another) is a silent fact error, not a per-entry choice — GE
buy limits are item-global. buy limits are item-global.
- SII translatability: an action that casts Superheat Item must - SII translatability: an action that casts Superheat Item must
name a concrete recipe id declared in the entry's `recipes:` declare a `selection:` block (mode highest-unlocked, eligible rule,
table (all inputs + quantities, smithing gate, bar output). The fallback) and the state machine must cover both the SII window and
client translates a bar from its exact recipe, not from a vague the stay-on-LA fallback. Every declared recipe must pin a
"positive margin ore" rule. `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. 3. Global id uniqueness across the whole codex tree.
Malformed/scalar YAML documents (an empty doc, a bare string, a list) Malformed/scalar YAML documents (an empty doc, a bare string, a list)
are rejected with a normal validator ERROR — they never reach the 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. Exit codes: 0 = clean (warnings ok), 1 = validation errors.
@ -88,8 +98,12 @@ def supply_limit_conflicts(rows):
for rel, data in rows: for rel, data in rows:
if not isinstance(data, dict): # never crash on malformed rows if not isinstance(data, dict): # never crash on malformed rows
continue 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", []): for sup in eco.get("supplies", []):
if not isinstance(sup, dict):
continue
lim = sup.get("ge_limit") lim = sup.get("ge_limit")
if lim is None: if lim is None:
continue continue
@ -129,28 +143,152 @@ def recipe_margin(recipe, bar_price, input_prices, nature_price):
return bar_price - (cost + nature_price) return bar_price - (cost + nature_price)
def recipe_errors(rel, data): # --- realized-cadence constants (pinned model; owner guidance 2026-08-28) ---
"""Entry-level SII translatability checks. Returns error strings.""" # 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 = [] errors = []
recipes = data.get("recipes") recipes = data.get("recipes")
if recipes is not None and not isinstance(recipes, dict): if recipes is not None and not isinstance(recipes, dict):
return [ return [
f"{rel}: 'recipes' must be a mapping of recipe id -> recipe definition"] f"{rel}: 'recipes' must be a mapping of recipe id -> recipe definition"]
for action in data.get("actions", []): if isinstance(recipes, dict):
params = action.get("params") or {} seen_levels = {}
spell = str(params.get("spell", "")) for rid, recipe in recipes.items():
if "superheat" not in spell.lower(): if not isinstance(recipe, dict):
continue continue # schema reports the shape
rid = params.get("recipe") inputs = recipe.get("inputs") or []
if not isinstance(rid, str) or not rid: 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( errors.append(
f"{rel}: action {action.get('id')} casts Superheat Item but has no " f"{rel}: a Superheat Item action requires a 'selection' block "
f"params.recipe naming a concrete recipes: entry (the client cannot " f"(mode, eligible_rule, fallback)")
f"translate an ore choice into a bar without the full recipe)") else:
elif recipes is None or rid not in recipes: 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( errors.append(
f"{rel}: action {action.get('id')} references recipe '{rid}' which " f"{rel}: no transition covers the SII window (43-55) — the "
f"is not declared in recipes:") 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 return errors
@ -188,6 +326,8 @@ def run_selftest(verbose=False):
# --- malformed-document regression: no traceback, normal errors --- # --- malformed-document regression: no traceback, normal errors ---
check("supply_limit_conflicts handles (bad.yaml, None) rows without crashing", check("supply_limit_conflicts handles (bad.yaml, None) rows without crashing",
supply_limit_conflicts([("bad.yaml", None)]) == []) 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]): for val in (None, "just a scalar", [1, 2, 3]):
msg = document_error("bad.yaml", val) msg = document_error("bad.yaml", val)
check( check(
@ -195,9 +335,12 @@ def run_selftest(verbose=False):
isinstance(msg, str) and "not a YAML mapping" in msg) isinstance(msg, str) and "not a YAML mapping" in msg)
check("document_error accepts mapping documents", check("document_error accepts mapping documents",
document_error("ok.yaml", {"id": "x"}) is None) 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) --- # --- deterministic multi-input recipe fixture: steel-bar (1 iron + 2 coal) ---
steel = {"bar": "Steel bar", "bar_item_id": 2353, "smithing_level": 30, steel = {"bar": "Steel bar", "bar_item_id": 2353, "smithing_level": 30,
"target_input": "Iron ore",
"inputs": [ "inputs": [
{"item": "Iron ore", "item_id": 440, "qty": 1, "source": "ge"}, {"item": "Iron ore", "item_id": 440, "qty": 1, "source": "ge"},
{"item": "Coal", "item_id": 453, "qty": 2, "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}, input_prices={"Iron ore": 100, "Coal": 150},
nature_price=145) < 0) nature_price=145) < 0)
# --- SII translatability fixtures --- # --- recipe-model fixtures: target_input, unique gates, selection block ---
entry_ok = {"actions": [ f2p_bars = {
{"id": "a4", "step": "cast-superheat", "verb": "use_item_on", "bronze-bar": {"bar": "Bronze bar", "bar_item_id": 2349, "smithing_level": 1,
"params": {"spell": "Superheat Item", "recipe": "steel-bar"}, "smithing_xp": 6.2, "target_input": "Copper ore",
"on_fail": "skip"}], "inputs": [{"item": "Copper ore", "item_id": 436, "qty": 1, "source": "ge"},
"recipes": {"steel-bar": steel}} {"item": "Tin ore", "item_id": 438, "qty": 1, "source": "ge"}]},
entry_undeclared = {"actions": [ "iron-bar": {"bar": "Iron bar", "bar_item_id": 2351, "smithing_level": 15,
{"id": "a4", "step": "cast-superheat", "verb": "use_item_on", "smithing_xp": 12.5, "target_input": "Iron ore",
"params": {"spell": "Superheat Item", "recipe": "steel-bar"}, "inputs": [{"item": "Iron ore", "item_id": 440, "qty": 1, "source": "ge"}]},
"on_fail": "skip"}]} "silver-bar": {"bar": "Silver bar", "bar_item_id": 2355, "smithing_level": 20,
entry_unpinned = {"actions": [ "smithing_xp": 13.7, "target_input": "Silver ore",
{"id": "a4", "step": "cast-superheat", "verb": "use_item_on", "inputs": [{"item": "Silver ore", "item_id": 442, "qty": 1, "source": "ge"}]},
"params": {"spell": "Superheat Item"}, "on_fail": "skip"}], "steel-bar": steel,
"recipes": {"steel-bar": steel}} "runite-bar": {"bar": "Runite bar", "bar_item_id": 2363, "smithing_level": 85,
check("Superheat action naming a declared recipes: id passes", "smithing_xp": 50, "target_input": "Runite ore",
recipe_errors("ok.yaml", entry_ok) == []) "inputs": [{"item": "Runite ore", "item_id": 451, "qty": 1, "source": "ge"},
undeclared = recipe_errors("bad.yaml", entry_undeclared) {"item": "Coal", "item_id": 453, "qty": 8, "source": "ge"}]},
check("Superheat action naming an undeclared recipe id errors", }
any("not declared in recipes:" in e for e in undeclared)) # Smithing-level eligibility: highest unlocked, never an unmet gate
unpinned = recipe_errors("bad.yaml", entry_unpinned) check("smithing 1 -> bronze-bar (highest unlocked)",
check("Superheat action without params.recipe errors", highest_unlocked_recipe(f2p_bars, 1) == "bronze-bar")
any("no params.recipe" in e for e in unpinned)) 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 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(): def main():
global STALE_DAYS global STALE_DAYS
if "--stale-days" in sys.argv: if "--stale-days" in sys.argv:
@ -269,9 +518,12 @@ def main():
if shape_err: if shape_err:
errors.append(shape_err) errors.append(shape_err)
continue 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) v = jsonschema.Draft7Validator(SCHEMA)
schema_errors = sorted(v.iter_errors(data), key=lambda e: list(e.path)) schema_errors = sorted(v.iter_errors(data), key=lambda e: list(e.path))
for err in schema_errors: for err in schema_errors:
@ -281,6 +533,8 @@ def main():
# cross-checks on a schema-invalid doc are noise # cross-checks on a schema-invalid doc are noise
continue continue
rows.append((rel, data))
eid = data.get("id", rel) eid = data.get("id", rel)
if eid in ids: if eid in ids:
errors.append(f"{rel}: duplicate entry id '{eid}' (also in {ids[eid]})") 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)): if len(a_steps) != len(set(a_steps)):
errors.append(f"{rel}: duplicate action step names") errors.append(f"{rel}: duplicate action step names")
# SII translatability (concrete recipes: entry per Superheat action) # SII recipe-model + selection gates + realized-rate consistency
errors += recipe_errors(rel, data) errors += recipe_model_errors(rel, data)
errors += rate_errors(rel, data)
# 3. staleness / research TODOs # 3. staleness / research TODOs
if data.get("status") == "verified": if data.get("status") == "verified":