From 34f36d2275c57be6d8f07f4e5047a7b0bbc89369 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 03:11:07 -0500 Subject: [PATCH] codex: dynamic SII recipe selection + realized rates + validator crash fix (bounce fix attempt 3, owner guidance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- codex/economy/low-alch-f2p.yaml | 184 +++++++++---- codex/training/magic-alch-handoff.yaml | 272 ++++++++++++++----- schemas/entry.schema.json | 14 +- tools/validate.py | 349 +++++++++++++++++++++---- 4 files changed, 658 insertions(+), 161 deletions(-) diff --git a/codex/economy/low-alch-f2p.yaml b/codex/economy/low-alch-f2p.yaml index 6dc8501..7fa3a64 100644 --- a/codex/economy/low-alch-f2p.yaml +++ b/codex/economy/low-alch-f2p.yaml @@ -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 diff --git a/codex/training/magic-alch-handoff.yaml b/codex/training/magic-alch-handoff.yaml index a6d7b34..49a6632 100644 --- a/codex/training/magic-alch-handoff.yaml +++ b/codex/training/magic-alch-handoff.yaml @@ -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 diff --git a/schemas/entry.schema.json b/schemas/entry.schema.json index a73c4e2..a3f2ea8 100644 --- a/schemas/entry.schema.json +++ b/schemas/entry.schema.json @@ -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, diff --git a/tools/validate.py b/tools/validate.py index 7c34cc7..57cf683 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -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":