diff --git a/codex/economy/low-alch-f2p.yaml b/codex/economy/low-alch-f2p.yaml index 44cbb9a..1e2069f 100644 --- a/codex/economy/low-alch-f2p.yaml +++ b/codex/economy/low-alch-f2p.yaml @@ -3,83 +3,189 @@ 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: 4 # 4: bounce-fix 6 2026-08-28 (governor comment 10394, PR #10) — real build-loadout action a8 (verified collected staff/candidate/natures, overflow deposit, bank-stock-shortfall-only withdrawal, carried counts PROVEN before the first cast a9; action ids renumbered a9-a13), capital-loss stop sign corrected (initial capital - total assets > 684,400), Magic target pinned at 55 (the LA -> HLA handoff; 54 is a continue), GP bounds tied to the two declared fill scenarios (max-fill net -126.6 -> low -123,700; live-low net -112 -> high -65,600). 3: bounce-fix 4 2026-08-28 (owner guidance 10331, PR #10) — explicit rate_model (session-realized rates incl. startup over a 2h horizon: xp 23,000, gp -109,500..-65,600), complete GE acquisition path (open -> fill -> collect) for the initial buy AND every restock (bank-stock fast path), fail-closed staff equip, monotonic break example (550/1,100/1,650 independent of 901-cast restocks), assets-based capital stops +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 pins (owner guidance 2026-08-28): one iteration = one cast; breaks + every 400-700 COMPLETED CASTS (cast-count based, independent of bank + visits — midpoint example 550, 1,100, 1,650, ...); restock when candidate + or nature drops below the numeric restock_threshold (100) against a + carried_batch of 1,000/1,000 (restock cadence 901 casts, monotonic and + independent of breaks). EVERY purchase follows the complete GE path open + -> place -> fill/wait -> collect, for the initial acquisition and for + every restock (a bank-stock fast path applies only when the bank actually + holds enough stock). Staff-of-fire equip failure is FAIL-CLOSED (bounded + retries, then log ERROR + alert + stop — no fire-rune fallback is + modeled). Session horizon 2 h; all rates are SESSION-REALIZED under the + declared rate_model (startup + restock + breaks included; see economy). 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). Equip failure is FAIL-CLOSED: bounded retries (3), then log ERROR + alert + stop — there is no fire-rune fallback in this model" + - "A bank with GE access within reach (alch spot = the bank booth; restock trip [60, 120] s)" + - "Starting capital >= ~1.30M gp CASH (staff hard cap 5,000 + first batch 1,000/1,000 at MAX allowed fill 306,600 + session loss reserve 684,400 = 6 worst-case 901-cast batches at live-low+5% fills (covers Magic 21 -> 55) + resting-offer float 306,600). The capital stop checks TOTAL ASSETS (cash + supplies at current buy cost), never raw cash — cash legitimately converts into supplies mid-run" 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); if it cannot be equipped after bounded retries the run FAILS CLOSED (no fallback modeled)" + 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" - source: "researched 2026-08-27 (standard operator practice; wiki LA Cost section corroborates rune economy)" + 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 6 (2026-08-28, governor comment 10394): the GP bounds are tied + # to the DECLARED actual-fill scenarios with the SAME costs as the capital + # model — live-low fills (net -112/cast: 148 + 144) and MAX allowed fills + # (live low + 5 %: 155.4 + 151.2 = 306.6 -> net -126.6/cast), because the + # executable purchase accepts live low + 5 % (a4/a10 max_price). 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) + cost_per_cycle: 292 # necklace buy 148 + nature rune 144 (staff of fire, live low) + cost_per_cycle_max_fill: 306.6 # live low + 5 %: 155.4 + 151.2 (same costs as the capital model) income_per_cycle: 180 # lowalch of gold necklace (40% of value 450) - net_per_cycle: -112 - gp_per_hour_low: -250000 # negative: this burns GP - gp_per_hour_high: -200000 - xp_per_hour: 55800 # 31 xp/cast (wiki) @ ~1800 casts/hr (3-tick, wiki) + net_per_cycle: -112 # at live-low fills + net_per_cycle_max_fill: -126.6 # at MAX allowed fills (worst declared scenario) + gp_per_hour_low: -123700 # session-realized WORST declared scenario: 977.1 casts/hr x -126.6 (max-fill) = -123,700 (fast 3.4s cycle, 300s startup, 2h horizon) + gp_per_hour_high: -65600 # session-realized BEST declared scenario: 585.9 casts/hr x -112 (live-low) = -65,621 (slow 5.2s cycle, 600s startup, 2h horizon) + xp_per_hour: 23000 # session-realized midpoint: 741.8 casts/hr x 31 = 22,995 (pin 23,000) + # ---- rate model (derived; enforced by tools/validate.py rate_model_errors) ---- + # Cast: 1.8s (3 ticks x 0.6s, rate_model.cast_ticks) + anti_ban.delay + # [1.6, 3.4]s ADDITIVE (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. Overhead per 901-cast batch: restock trip + # [60, 120]s (mid 90) + breaks (every [400, 700] casts, mid 550, for + # [45, 120]s, mid 82.5s -> 82.5 x 901/550 = 135.2s). + # STEADY-STATE casts/hr = 901 x 3600 / (901 x cycle + trip + break_share x 901): + # fast 3.4s -> 1,019.7; midpoint 4.3s -> 791.3; slow 5.2s -> 639.2. + # SESSION-REALIZED (2 h horizon = 7,200 s; startup 300-600 s, mid 450 s -> + # 450/7200 = 6.25 % amortised): 977.1 / 741.8 / 585.9 casts/hr. + # xp_per_hour = 741.8 x 31 = 22,995 (pin 23,000); + # gp_per_hour = casts/hr x net-per-cast over the TWO declared fill + # scenarios (same costs as the capital model): + # worst = fast casts/hr x max-fill net: 977.1 x -126.6 = -123,700 (low) + # best = slow casts/hr x live-low net: 585.9 x -112 = -65,621 (high) + # (mid x mid-fill -119.3 = -88,497 is inside the band) + # BATCH ECONOMICS (901 casts; deposits 901 x 180 = 162,180 gp per batch): + # input cost at live low: 901 x 292 = 263,092 -> net -100,912/batch. + # input cost at MAX fill (live low + 5 %: 1,000 x 155.4 + 1,000 x 151.2 = + # 306,600 per batch): 901 x 306.6 = 276,247 -> net -114,067/batch (worst + # case). Reserve = 6 worst-case batches (Magic 21 -> 55) = 684,400 gp; + # the capital stops check TOTAL ASSETS, not raw cash (accounts + stop_conditions). + # Superseded figures (all fail the derived gate on purpose): loop-only 837 + # casts/hr (25,953 xp/hr); the 8-14-cast-break regime (~305 casts/hr); the + # 1,800-cast regime; the pre-startup realized 24,500 (791.3 x 31) — startup + # amortisation is part of the model. 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 - ge_limit: 12500 + ge_limit: 18000 # mapping 'limit' + Module:Exchange/Nature_rune agree; + # matches magic-alch-handoff (consistency check enforces) 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 at live-low fills (-114,067 at MAX allowed fills), +27,931 xp, deposits 162,180 gp" + +# Explicit activity/rate discriminator (owner guidance 2026-08-28, bounce 4): +# this entry OPTS IN to the derived-rate gate; every rate derives from the +# entry's own declared lifecycle and the validator re-derives it. +rate_model: + anchor_tier: la # xp_per_hour pins the LA tier's realized rate + cast_ticks: 3 # LA is a 3-tick cast -> 1.8 s (0.6 s/tick) + restock_trip_s: [60, 120] # GE-adjacent booth trip per restock + session_horizon_s: 7200 # 2 h session; startup is amortised over it + startup_s: [300, 600] # one-time GE acquisition + loadout + alch_carry_batch: 1000 # stacked candidates + natures + alch_restock_threshold: 100 # -> 901-cast LA restock cadence state_machine: initial: idle terminal: [complete, failed] states: idle: - goal: reach the bank + have supplies in inventory - next: [casting, bank_supplies] + goal: "session start at the GE bank booth. Open GE (a2), then place the initial offers: staff of fire (a3), candidate 1,000 (a4), nature runes 1,000 (a5) at live low + 5% (staff: +25% up to the 5,000 cap). Record initial capital in the cash ledger (total assets = cash + supplies at current buy cost). Nothing is bought or collected without an open GE interface" + next: [wait_offers, complete, failed] + wait_offers: + goal: "wait for the pending offers (ge_offers_done); when filled, collect every purchase + refund from the collection box (a6 — never leave purchases in the box). PARTIAL FILLS: collect the filled portion, re-place the remainder at the current edge (a4/a5); 3 consecutive no-fill rounds -> failed. An offer still unfilled at session end is left resting by design — its coins are tied up (float reserve) and return when it fills or the operator aborts it (the single operator-touch point)" + next: [ready, wait_offers, failed] + ready: + goal: "equip the staff of fire (a7 — bounded retries, then FAIL CLOSED) and build the VERIFIED loadout (a8 build-loadout): verify the collected staff + candidate + natures (from a6), deposit overflow, withdraw only the real bank-stock shortfall, and PROVE the carried counts equal carried_batch 1,000/1,000 BEFORE the first cast (a9)" + next: [casting, bank_supplies, 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 (a9) — each cast consumes 1 candidate + 1 nature and pays 180 gp (lowalch), +31 xp, net -112 gp at live-low fills; take a break when the completed-cast counter hits run_control.break.every ([400, 700] casts, via a13); the counter is NEVER reset by restocking" + next: [bank_supplies, complete, failed] bank_supplies: - goal: bank the produced coins, withdraw fresh candidate + runes - next: [casting, idle] + goal: "restock to carried_batch: deposit coins + products and withdraw ALL candidate/natures the bank holds (a10 — bank-stock fast path when the bank already holds enough); for the remaining deficit, open GE (a2), buy the deficit (a11, live low + 5%), collect it (a12) — never withdraw a batch that was never bought. Restocking never resets the cast counter or the break counter; the break cadence (550/1,100/1,650, ...) is independent of the restock cadence (901/1,802, ...)" + next: [casting, bank_supplies, failed] complete: - goal: reached the run's XP/level target + goal: "reached the run's XP/level target (default 55 — the LA -> HLA handoff) or the 2 h session horizon" failed: - goal: hard failure — stop, log ERROR sentinel, alert + goal: "hard failure — stop, log ERROR sentinel, alert" transitions: - - from: casting - when: "candidate item count < buffer OR nature rune count < buffer" - to: bank_supplies - from: idle - when: "magic level >= target (default 54, hand off to HLA at 55)" + when: "staff + candidate + nature offers all placed (a3/a4/a5)" + to: wait_offers + - from: idle + when: "magic level >= target (55 — the LA -> HLA handoff) at session start" to: complete + - from: idle + when: "3 consecutive offer-place failures at session start (no fallback candidate)" + to: failed + - from: wait_offers + when: "ge_offers_done AND all purchases collected (a6)" + to: ready + - from: wait_offers + when: "partial fill: filled portion collected (a6), remainder re-placed at the current edge (a4/a5) — still waiting" + to: wait_offers + - from: wait_offers + when: "3 consecutive no-fill rounds (offer cannot fill at the accepted edge)" + to: failed + - from: ready + when: "staff equipped (a7) AND build-loadout verified the carried counts at carried_batch 1,000/1,000 (a8 — collected staff/candidate/natures present, overflow deposited, only a real bank-stock shortfall withdrawn)" + to: casting + - from: ready + when: "staff equip failed after bounded retries — FAIL CLOSED" + to: failed + - from: ready + when: "build-loadout verification shows a carried shortfall (candidate or nature < carried_batch) that the bank does not hold — restock first" + to: bank_supplies + - from: casting + when: "candidate count < restock_threshold (100) OR nature count < restock_threshold (100) — deterministic restock trigger, pinned numbers" + to: bank_supplies + - from: casting + when: "magic level >= target (55 — the LA -> HLA handoff; level 54 is a CONTINUE: LA keeps casting until the handoff boundary)" + 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) — bank fast path or re-bought deficit collected" + to: casting + - from: bank_supplies + when: "restock partial: deficit re-bought but offers still filling (ge_offers_done false) — loop the restock phase" + to: bank_supplies + - from: bank_supplies + when: "3 consecutive no-fill restock rounds OR a capital stop fires (assets / loss-reserve checks in stop_conditions)" + to: failed actions: - id: a1 @@ -87,25 +193,95 @@ 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: delay: [400, 1600] - id: a2 - step: buy-supplies + step: open-ge + verb: ge_open + target: grand-exchange + params: + note: "open the GE interface at the booth. The entry NEVER buys (a3/a4/a5/a10) or collects (a6/a11) without an open GE interface — the action grammar defines ge_open, ge_buy, ge_collect as separate verbs, so the open step is explicit" + on_fail: retry + anti_ban: + delay: [600, 1400] + + - id: a3 + step: buy-staff verb: ge_buy target: grand-exchange params: - item: "current candidate item" - qty: "fill to buffer" - max_price: "live low + 5%" + item: "staff of fire" + qty: 1 + max_price: "live low + 25 % (wiki value 1,500; hard cap 5,000)" + note: "offer rests until filled or the run ends; on a partial/no fill, collect what filled (a6) and re-place the remainder at the current edge — bounded retries, then the run fails closed (no fire-rune fallback exists)" on_fail: retry anti_ban: delay: [1200, 3000] - - id: a3 + - id: a4 + step: buy-supplies + verb: ge_buy + target: grand-exchange + params: + 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 %" + note: "offer rests until filled or the run ends; on a partial fill, collect the filled portion (a6) and re-place the remainder at the current edge" + on_fail: retry + anti_ban: + delay: [1200, 3000] + + - id: a5 + step: buy-natures + verb: ge_buy + target: grand-exchange + params: + item: "nature rune" + qty: "carried_batch = 1,000 (4h GE limit 18,000)" + max_price: "live low + 5 %" + note: "offer rests until filled or the run ends; partial fills handled like a4" + on_fail: retry + anti_ban: + delay: [1200, 3000] + + - id: a6 + step: collect-purchases + verb: ge_collect + target: grand-exchange + params: + note: "collect every filled purchase and refund from the GE collection box into the inventory — staff, candidate, natures (and the equivalent on every restock). Partial offers: the collected portion is added to the ledger; nothing is left in the box at session start" + on_fail: retry + anti_ban: + delay: [600, 1400] + + - id: a7 + 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). FAIL-CLOSED path: bounded retries (3), then log ERROR + alert + stop — no fire-rune fallback is modeled (never loop on equip attempts)" + on_fail: retry + anti_ban: + delay: [600, 1800] + + - id: a8 + step: build-loadout + verb: bank_inventory + target: bank + params: + deposit: [overflow products] + withdraw: "only the real bank-stock shortfall up to carried_batch (1,000 candidate + 1,000 nature) — the purchases collected by a6 are already in the inventory; a deficit the bank does not actually hold is NEVER withdrawn (it is bought at the GE via the bank_supplies phase, a11)" + verify: "staff of fire collected and present (a6), candidate count == carried_batch 1,000, nature count == carried_batch 1,000 — the carried counts are PROVEN before the first cast (a9); a verified shortfall sends the slot to bank_supplies instead of casting" + on_fail: retry + anti_ban: + delay: [500, 1100] + + - id: a9 step: cast-alch verb: use_item_on target: "candidate item in inventory" @@ -113,43 +289,153 @@ 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 at live-low fills" on_fail: retry anti_ban: - delay: [1600, 3400] # 3-tick cast = ~5.4s game; stay under it, humanised + delay: [1600, 3400] # MANDATORY post-action sleep, ADDITIVE to the 1.8s cast (3 ticks x 0.6s; action-grammar.md:135): cycle = 1.8s + 1.6-3.4s = 3.4-5.2s, midpoint 4.3s -> session-realized ~742 casts/hr incl. startup + restock + breaks (rate_model) - - id: a4 - step: bank-loop + - id: a10 + step: restock-deposit verb: bank_inventory target: bank params: deposit: [all coins, all products] - withdraw: "candidate item + nature runes up to buffer" + withdraw: "ALL candidate + nature runes currently in the bank, up to carried_batch (1,000/1,000) — the bank-stock FAST PATH applies only when the bank actually holds enough stock; the remaining deficit is bought at the GE (a11) and collected (a12)" on_fail: alert + anti_ban: + delay: [500, 1100] + + - id: a11 + step: restock-buy + verb: ge_buy + target: grand-exchange + params: + item: "current least-loss live candidate + nature runes" + qty: "deficit to carried_batch = carried_batch - inventory count after the a10 fast path (e.g. 901 candidate + 901 nature after a 901-cast batch with an empty bank)" + max_price: "live low + 5 %" + note: "the deficit is RE-BOUGHT at the GE on every restock — the entry never withdraws a batch that was never purchased; partial/failed fills use the same collect + re-place recovery as the initial acquisition" + on_fail: retry + anti_ban: + delay: [1200, 3000] + + - id: a12 + step: restock-collect + verb: ge_collect + target: grand-exchange + params: + note: "collect the re-bought deficit into the inventory — inventory returns to carried_batch 1,000/1,000 before casting resumes" + on_fail: retry + anti_ban: + delay: [600, 1400] + + - id: a13 + 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 (breaks land at 550/1,100/1,650 in the midpoint example, independent of the 901-cast restock cadence)" + on_fail: skip run_control: - iteration: "one cast" - max_iterations: null + iteration: "one cast" # = one Low Level Alchemy cast (+31 xp, -112 gp at the anchor). Per docs/schema.md run_control, break.every counts ITERATIONS — so it counts CASTS here, NOT bank trips (owner decision 2026-08-28) + max_iterations: null # null = until complete condition break: - every: [8, 14] # bank cycles (breaks land on bank visits) - for: [45, 120] # seconds of idle AFK + 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 per break stop_conditions: - - "magic level >= target" - - "gp under floor" + - "magic level >= target (55 — the LA -> HLA handoff)" + - "session elapsed >= 2 h (session horizon — startup is amortised over this window in the rate model)" + - "total assets (cash + supplies at current buy cost) < 311,600 — cannot fund the next batch at max fill (306,600) plus staff replacement (5,000); assets, NOT raw cash: cash that became supplies is not a loss" + - "session net loss = initial capital - total assets > 684,400 — the 6-batch worst-case loss reserve is exhausted (a loss is WHEN ASSETS FALL; total assets - initial capital goes NEGATIVE on losses and can never exceed the reserve — the reversed sign never fires)" - "3 consecutive failed casts (client issue — do not loop)" + - "3 consecutive no-fill GE rounds (initial acquisition or restock) with no fallback candidate available" + - "staff of fire equip failed after bounded retries — FAIL CLOSED (log ERROR, alert, stop)" + + # ================= 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 — the example pins the MIDPOINT 550; + # break.for = [45, 120] s; restock cadence = 901 casts (1,000 - 99); the + # staff-of-fire path consumes 1 candidate + 1 nature per cast (worst case 1:1). + # The two counters are MONOTONIC and INDEPENDENT: breaks at 550, 1,100, 1,650, + # 2,200... (multiples of 550) and restocks at 901, 1,802, 2,703... (multiples + # of 901) — gcd(550, 901) = 1, so they interleave forever and restocking NEVER + # resets or shifts the break counter. + # + # Step 0 — preparation (one-time startup, ~300-600 s): account holds >= ~1.30M + # gp cash (staff cap 5,000 + first batch max 306,600 + reserve 684,400 + + # float 306,600). At the GE bank: open GE (a2); place offers — staff (a3), + # 1,000 gold necklaces (a4), 1,000 nature runes (a5); wait for fills + # (ge_offers_done) and collect everything (a6); equip the staff (a7); loadout + # = carried_batch 1,000/1,000 (a8). Cast counter := 0; cash ledger opened. + # 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). + # Step 2 — FIRST SCHEDULED BREAK at cast 550 (the interval since cast 0): + # take-break 45-120 s (a13). Resume casting at cast 551. The break fired on + # the CAST COUNT, not on any bank visit. + # Step 3 — casts #551..#901: countdown candidate 1,000-901 = 99 < 100 + # (restock_threshold) and nature 99 < 100 simultaneously (worst case 1:1) -> + # the transition fires at the end of cast #901: FIRST RESTOCK at cast 901. + # Deposit 901 x 180 = 162,180 gp (a10); the bank holds 0 supplies (all sales + # were GE buys), so the fast path yields nothing -> open GE (a2), RE-BUY the + # deficit 901 candidate + 901 nature (a11, live low + 5 %), collect (a12) -> + # inventory back to 1,000/1,000. Batch net: -100,912 gp (live-low fills) or + # -114,067 gp (max fills). The cast counter (901) and break counter continue. + # Step 4 — SECOND BREAK at cast 1,100: the interval since break #1 is exactly + # 550 casts (1,100 - 550); the cast-901 restock did NOT reset or shift it. + # Step 5 — THIRD BREAK at cast 1,650 (1,100 + 550); SECOND RESTOCK at cast + # 1,802 (901-cadence, 1,802 = 901 x 2). The sequences 550/1,100/1,650/2,200 + # and 901/1,802/2,703 interleave independently. + # Step 6 — continue until a stop_condition fires: Magic 55 (the LA -> HLA + # handoff — level 54 CONTINUES casting), the 2 h session horizon, the + # capital stops (assets floor 311,600 or initial capital - total assets > + # 684,400), 3 failed casts, or 3 consecutive no-fill GE rounds. + # ============================================================================================ 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; session-realized ~742 casts/hr midpoint incl. startup + restock + breaks over a 2 h session; steady-state ~791; loop-only 837 is a ceiling reference, never the output)." + - "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, and never reset the break counter on restock (gcd(550, 901) = 1)." + - "GE buy bursts: pace offers, respect the 4h limit window (18,000 necklaces / 18,000 natures); offers persist until filled or aborted and a 4h limit reset is NOT an offer expiry." + +# --- 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; the +# restock phase (a9 -> a2 -> a10 -> a11) rebuilds to carried_batch and the loop +# resumes (worked example steps 3-4). The entry NEVER withdraws a batch that was +# never purchased — every restock re-buys the deficit at the GE. +# - GE offers: partial fills are collected (a6/a11) and the remainder is re-placed at +# the current edge (a3/a4/a5/a10); after 3 consecutive no-fill rounds (initial or +# restock) the run stops (failed) — never sit in an unfillable offer loop. Unfilled +# offers at session end are left resting by design (float reserve; coins return when +# they fill or the operator aborts them — the single operator-touch point). +# - Staff of fire cannot be equipped (a7): bounded retries (3), then FAIL CLOSED — +# log ERROR + alert + stop. No fire-rune fallback is modeled (it would need its own +# purchase/supply/rate path; the owner decision rejects a prose-only fallback). +# - Invalid loadout (wrong item in hand / spell unavailable / carried counts below +# carried_batch): re-run build-loadout (a8); a verified shortfall the bank does +# not hold goes to bank_supplies; 3 consecutive failed casts -> failed. +# - Capital: the stop checks TOTAL ASSETS (cash + supplies at current buy cost), and +# both capital stops are assets-based (see stop_conditions) — cash converted into +# supplies is not a loss and never fires a stop by itself. +# - Explicit completion/stop: stop_conditions above (Magic 55 LA -> HLA handoff, +# 2 h session horizon, assets floor 311,600, loss reserve 684,400, repeated +# failures, fail-closed staff equip). 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 — Grand Exchange (offers persist until filled or aborted; filled items and coin refunds are collected from the booth/collection box; partial fills are separately collectable) — verified 2026-08-28" + - "OSRS wiki — Grand Exchange/Buying limits (4-hour window; the limit resets 4 hours after the FIRST unit is bought; outstanding offers remain active and a limit reset is NOT an offer expiry) — verified 2026-08-28" + - "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) + verbs ge_open/ge_buy/ge_collect/ge_offers_done (separate GE primitives) — the cadence + break + GE + lifecycle model; owner guidance 2026-08-28 (rate_model, fail-closed staff, monotonic counters); governor comment 10394 (build-loadout verified action, sign-corrected loss stop, 55 handoff boundary, fill-scenario GP bounds)" +verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27 related: + - magic-alch-handoff - high-alch-p2p - f2p-alch-catalog - mule-topup diff --git a/codex/training/magic-alch-handoff.yaml b/codex/training/magic-alch-handoff.yaml new file mode 100644 index 0000000..c3da286 --- /dev/null +++ b/codex/training/magic-alch-handoff.yaml @@ -0,0 +1,652 @@ +id: magic-alch-handoff +title: Magic training via alchemy (LA → SII → HLA handoff) +domain: training +tags: [magic-training, alching, f2p, xp-training] +status: verified +version: 5 # 5: bounce-fix 7 2026-08-29 (governor comment 10477, PR #10) — EXECUTABLE tier boundary: every tier predicate is mutually exclusive — SII only while 43 <= Magic < 55, HLA only when Magic >= 55 AND the session target is above 55, LA below 43 or the explicit SII-skip path in 43-54, completion FIRST when the configured target is reached (no Magic level can match two tiers; the old "43-55" SII window overlapped HLA at exactly 55). 4: bounce-fix 6 2026-08-28 (governor comment 10394) — build-loadout a7 VERIFIES collected staff/supplies and PROVES carried counts before the first cast (ready/ready->cast strengthened), capital-loss stop sign corrected (initial capital - total assets > 690,075). 3: bounce-fix 4 2026-08-28 (owner guidance 10331) — explicit rate_model discriminator (derived per-entry lifecycle rates), per-recipe inventory capacity floor(27/qty) with derived min/mid/max rates pinned in recipes.rates, complete GE acquisition path (open -> fill -> collect) for initial buy AND every restock (bank-stock fast path), fail-closed staff equip (bounded retries, no fire-rune fallback), configurable session target (55 = LA/SII->HLA handoff completion; >55 = executable HLA tier to the target; LA stays the 43-54 fallback), capital model (cash vs supplies, max-fill costs, reserve, assets-based stops) +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 + (31 xp/cast), an optional Superheat Item window from 43 (53 xp/cast, smelts + 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. The SESSION TARGET is configurable: the default 55 completes at + the LA/SII -> HLA handoff; a target above 55 transitions into the + executable HLA tier and continues until the target — HLA is never + declared-but-unreachable, and Low Alchemy remains the executable fallback + throughout Magic 43-54 whenever SII is skipped. SII model (owner guidance + 2026-08-28): a dynamic recipe table governed by the account's Smithing + level — the highest UNLOCKED recipe whose complete recipe cost (ALL inputs + at ALLOWED fill prices, live low + 5%) plus one nature rune stays under the + bar's live GE price is selected; its primary ore (target_input, never coal) + is the spell target; materials unobtainable -> next lower unlocked recipe, + else skip SII and stay on LA. SII inventory cycles are CAPACITY-DERIVED: + floor(27 / total input qty) casts per bank cycle (bronze 13, iron/silver/ + gold 27, steel 9, mithril 5, adamantite 3, runite 3) — never a fixed pack. + Rates are SESSION-REALIZED under the declared rate_model: cast cycle + + mandatory additive delays + breaks + banking + startup over a 2 h horizon + (anchors: LA 23,000 / SII steel 12,400 / HLA 48,200 Magic XP/hr; per-recipe + min/mid/max rates pinned in the recipes table and enforced by the + validator). Every purchase follows the complete GE path open -> place -> + fill/wait -> collect, for the initial acquisition and every restock (bank + fast path only when the bank holds stock). Staff-of-fire equip failure is + FAIL-CLOSED (bounded retries, then log ERROR + alert + stop). + +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; NO level requirement documented on the OSRS wiki — verified 2026-08-28, see sources). Equip failure is FAIL-CLOSED: bounded retries (3), then log ERROR + alert + stop — there is no fire-rune fallback in this model" + - "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)" + - "Starting capital >= ~1.31M gp CASH for the LA/HLA tier path (staff hard cap 5,000 + first batch 1,000/1,000 at MAX allowed fill 307,650 + session loss reserve 690,075 = 6 worst-case 901-cast batches at live-low+5% fills (covers Magic 21 -> 55) + resting-offer float 307,650). The SII window uses capacity-sized packs (steel example: 9 x (1 iron 105 + 2 coal 157.5 + 1 nature 152.25) = 5,150 gp per pack at max fill). The capital stop checks TOTAL ASSETS (cash + supplies at current buy cost), never raw cash — cash legitimately converts into supplies" + - "Session target Magic level configured at session start: default 55 (completes at the LA/SII -> HLA handoff); a target above 55 runs the HLA tier until the target; LA remains the executable fallback throughout 43-54 when SII is skipped" + - "A bank with GE access within reach (alch spot = the bank booth; restock trips [60, 120] s)" + +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; equip requirement: none documented on the OSRS wiki (verified 2026-08-28); if it cannot be equipped after bounded retries the run FAILS CLOSED (no fallback modeled)" + 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 (configurable target) + 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. The default session target is 55; a target above 55 keeps casting HLA (65 xp/cast) until the configured level — the HLA tier is never declared but unreachable" + source: "OSRS wiki — Low Level Alchemy (Comparison with High Level Alchemy); High Level Alchemy (infobox: level 55, 65 xp)" + - name: optional Superheat Item window 43-54 (43 <= Magic < 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. The inventory holds floor(27 / total input qty) casts per bank cycle (steel = 9) — never a fixed 24-cast pack" + 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 the highest UNLOCKED SII recipe, never an unmet gate + why: "at each cast the active recipe = the recipes-table entry with the highest smithing_level <= current Smithing whose complete input cost (ALL inputs at ALLOWED fill prices = live low + 5%) + one nature rune at allowed fill stays under the bar's live 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 (LA is executable throughout 43-54)" + 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, allowed-fill eligibility)" + - 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)" + - name: treat the handoffs as soft efficiency choices + why: "all three spells remain castable past 21/43/55 and stay on the standard F2P spellbook, so the tier switches optimise xp/cast and cost rather than gate the path; LA is the executable fallback whenever the SII window is skipped" + source: "OSRS wiki — Low Level Alchemy; Superheat Item; High Level Alchemy (level + experience infoboxes, members=No)" + +economy: + # Per-cast economics at 2026-08-27 snapshot (prices.runescape.wiki latest): + # staff of fire -> 1 nature rune per cast. Nature rune id 561 buy 145 + # (mapping limit 18000, cross-checked against Module:Exchange/Nature_rune, + # members=false, lowalch 72 = 40% of value 180 — consistency assertion). + # Income/net per cast depend on the alched item or the recipe bar, so they + # live in the per-activity entries and in the recipes table (this entry + # prices the shared consumable + the rate model). + # xp_per_hour pins the rate_model ANCHOR tier (sii, steel-bar): the entry's + # own declared lifecycle — no hard-coded per-activity constants. + 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: 12400 # SII anchor, SESSION-REALIZED: steel mid 249.1 casts/hr x 53 x startup factor 0.9375 = 12,379 (pin 12,400) + # ---- rate model (derived from rate_model + actions + run_control + recipes; + # enforced by the validator's rate_model_errors gate) ---- + # Cast: 1.8s (3 ticks x 0.6s, rate_model.cast_ticks) + anti_ban.delay + # [1.6, 3.4]s ADDITIVE (action-grammar.md:135) = 3.4-5.2s, midpoint 4.3s. + # Restock trip [60, 120]s (rate_model.restock_trip_s); breaks every + # [400, 700] casts for [45, 120]s (run_control.break) -> amortised break + # share 45/700 fast, 82.5/550 mid, 120/400 slow. + # ALCH tiers (LA/HLA): 901-cast restock batch (carried_batch 1,000 - + # restock_threshold 100), steady-state 1,019.7 / 791.3 / 639.2 casts/hr; + # SESSION-REALIZED over the 2 h horizon with startup 300-600s (factor + # 0.9583/0.9375/0.9167): 977.1 / 741.8 / 585.9 casts/hr -> + # LA 23,000 xp/hr = 31 x 741.8 (22,995) + # HLA 48,200 xp/hr = 65 x 741.8 (48,217) + # SII tiers: per-recipe capacity = floor(27 / total input qty) (steel 9, + # bronze 13, iron/silver/gold 27, mithril 5, adamantite/runite 3); full + # derived min/mid/max rates live in each recipe's rates block (validator- + # checked): steel steady mid 249.1 casts/hr = 13,204 Magic XP/hr; the + # xp_per_hour anchor adds the startup factor (0.9375 mid) -> 12,400. + # Superseded figures (all fail the derived gate on purpose): loop-only + # 837 casts/hr; the 8-14-cast-break regime (~305 casts/hr); the 1,400/ + # 1,800-cast regimes; the impossible 24-cast SII pack (518.0 casts/hr) — + # capacity is floor(27/qty), never a fixed pack. + 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); 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 at ALLOWED fill prices + one nature rune at allowed fill) stays under the resulting bar's GE price — re-check margins per window and select the highest UNLOCKED recipe with a positive margin; never a steel-only default, never an unmet Smithing gate" + +# 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. The +# validator proves eligibility (smithing gate + complete-recipe margin), +# target_input (declared input, never coal), unique gates, and the fallback +# path via its fixtures. +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 at ALLOWED fill prices (every input at live GE low + 5%, the a4 max_price cap) + one nature rune at allowed fill < the bar's live GE price — the full actual/allowed recipe cost, not a headline ore 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 allowed-fill margin; when no unlocked recipe has a positive margin, skip SII for this window and stay on LA throughout 43-54 — LA remains executable (a8 handles level < 43 AND the 43-54 SII-skip window); 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). +# rates (validator-checked, derived from the entry's rate_model): capacity = +# floor(27 / total input qty) casts per bank cycle (28-slot inventory minus +# the nature stack); casts_per_hour and magic_xp_per_hour are the STEADY- +# STATE min/mid/max (cycle endpoints 3.4/4.3/5.2s, trip 60/90/120s, +# break share 45/700, 82.5/550, 120/400). The economy.xp_per_hour anchor +# adds the startup factor (see rate_model). +# econ 2026-08-27: bar margin = bar GE price - (all inputs at allowed fill + +# one nature rune at allowed fill); 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 + qty: 1 + source: ge + - item: Tin ore + item_id: 438 + qty: 1 + source: ge + rates: + capacity: 13 + casts_per_hour: {min: 445.6, mid: 316.5, max: 244.4} + magic_xp_per_hour: {min: 23617, mid: 16776, max: 12952} + 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 + rates: + capacity: 27 + casts_per_hour: {min: 633.1, mid: 462.5, max: 362.0} + magic_xp_per_hour: {min: 33554, mid: 24514, max: 19187} + 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 + rates: + capacity: 27 + casts_per_hour: {min: 633.1, mid: 462.5, max: 362.0} + magic_xp_per_hour: {min: 33554, mid: 24514, max: 19187} + steel-bar: + bar: Steel bar + bar_item_id: 2353 + smithing_level: 30 + smithing_xp: 17.5 + target_input: Iron ore + inputs: + - item: Iron ore + item_id: 440 + qty: 1 + source: ge + - item: Coal + item_id: 453 + qty: 2 + source: ge + rates: + capacity: 9 + casts_per_hour: {min: 355.3, mid: 249.1, max: 191.2} + magic_xp_per_hour: {min: 18833, mid: 13204, max: 10131} + gold-bar: + bar: Gold bar + bar_item_id: 2357 + smithing_level: 40 + smithing_xp: 22.5 + target_input: Gold ore + inputs: + - item: Gold ore + item_id: 444 + qty: 1 + source: ge + rates: + capacity: 27 + casts_per_hour: {min: 633.1, mid: 462.5, max: 362.0} + magic_xp_per_hour: {min: 33554, mid: 24514, max: 19187} + mithril-bar: + bar: Mithril bar + bar_item_id: 2359 + smithing_level: 50 + smithing_xp: 30 + target_input: Mithril ore + inputs: + - item: Mithril ore + item_id: 447 + qty: 1 + source: ge + - item: Coal + item_id: 453 + qty: 4 + source: ge + rates: + capacity: 5 + casts_per_hour: {min: 232.8, mid: 160.4, max: 122.0} + magic_xp_per_hour: {min: 12338, mid: 8499, max: 6468} + adamantite-bar: + bar: Adamantite bar + bar_item_id: 2361 + smithing_level: 70 + smithing_xp: 37.5 + target_input: Adamantite ore + inputs: + - item: Adamantite ore + item_id: 449 + qty: 1 + source: ge + - item: Coal + item_id: 453 + qty: 6 + source: ge + rates: + capacity: 3 + casts_per_hour: {min: 153.4, mid: 104.5, max: 79.1} + magic_xp_per_hour: {min: 8131, mid: 5538, max: 4194} + 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 + rates: + capacity: 3 + casts_per_hour: {min: 153.4, mid: 104.5, max: 79.1} + magic_xp_per_hour: {min: 8131, mid: 5538, max: 4194} + +# Explicit activity/rate discriminator (owner guidance 2026-08-28, bounce 4): +# this entry OPTS IN to the derived-rate gate; every rate below derives from +# the entry's own declared lifecycle, and the validator re-derives it. +rate_model: + anchor_tier: sii # xp_per_hour pins the SII tier's realized rate + anchor_recipe: steel-bar # the anchor recipe (must be a declared recipe) + cast_ticks: 3 # LA/SII/HLA are 3-tick casts -> 1.8 s (0.6 s/tick) + restock_trip_s: [60, 120] # GE-adjacent booth trip per restock + session_horizon_s: 7200 # 2 h session; startup is amortised over it + startup_s: [300, 600] # one-time GE acquisition + loadout + alch_carry_batch: 1000 # LA/HLA tier: stacked candidates + natures + alch_restock_threshold: 100 # -> 901-cast LA/HLA restock cadence + sii_capacity_slots: 27 # 28-slot inventory minus the nature stack + +state_machine: + initial: idle + terminal: [complete, failed] + states: + idle: + goal: "session start at the GE bank booth; choose the session Magic target (default 55 — completes at the LA/SII -> HLA handoff; above 55 the HLA tier runs to the target). Open GE (a2), place the initial offers (staff a3, tier supplies a4), record initial capital in the cash ledger (total assets = cash + supplies at current buy cost). Nothing is bought or collected without an open GE interface" + next: [wait_offers, complete, failed] + wait_offers: + goal: "wait for the pending offers (ge_offers_done); when filled, collect every purchase + refund from the collection box (a5 — never leave purchases in the box). PARTIAL FILLS: collect the filled portion, re-place the remainder at the current edge (a3/a4); 3 consecutive no-fill rounds -> failed. An offer still unfilled at session end is left resting by design — its coins are tied up (float reserve) and return when it fills or the operator aborts it (the single operator-touch point)" + next: [ready, wait_offers, failed] + ready: + goal: "equip the staff of fire (a6 — bounded retries, then FAIL CLOSED) and build the VERIFIED loadout (a7 build-loadout): verify the collected staff + tier supplies (from a5), deposit overflow, withdraw only the real bank-stock shortfall, and PROVE the carried counts equal the tier's carried amounts BEFORE the first cast (a8/a9)" + next: [cast, bank_supplies, failed] + cast: + goal: "cast the active tier spell with the tier-correct target — LA (< 43, or 43-54 when SII is skipped) or HLA (>= 55, only when the session target is above 55) on a noted alch candidate (a8); SII (43 <= Magic < 55, selection.mode) on the selected recipe's declared target_input (primary ore, never coal) with its exact allowed-fill inputs withdrawn (a9). The tier predicates are MUTUALLY EXCLUSIVE: at any Magic level exactly one tier is selectable — SII only while 43 <= Magic < 55, HLA only when Magic >= 55 and the target is higher, completion FIRST when the target is reached (no level can match two tiers; the old '43-55' SII window overlapped HLA at exactly 55). A break fires on the completed-cast counter (a13), NEVER on bank visits, and restocking never resets it" + next: [cast, bank_supplies, complete, failed] + bank_supplies: + goal: "restock to the tier's carried amounts: deposit coins + smelted bars and withdraw ALL tier supplies the bank holds (a10 — bank-stock fast path only when the bank actually holds enough); for the remaining deficit, open GE (a2), buy the deficit (a11, live low + 5%), collect it (a12) — never withdraw a batch that was never purchased. Restocking never resets the cast or break counters" + next: [cast, bank_supplies, failed] + complete: + goal: "magic level >= the configured session target (default 55 — completes at the LA/SII -> HLA handoff; a target above 55 finishes only after the executable HLA tier reaches it)" + failed: + goal: "hard failure — stop, log ERROR sentinel, alert" + transitions: + - from: idle + when: "staff + tier supply offers all placed (a3/a4)" + to: wait_offers + - from: idle + when: "magic level >= session target at session start" + to: complete + - from: idle + when: "3 consecutive offer-place failures at session start (no fallback candidate/recipe)" + to: failed + - from: wait_offers + when: "ge_offers_done AND all purchases collected (a5)" + to: ready + - from: wait_offers + when: "partial fill: filled portion collected (a5), remainder re-placed at the current edge (a3/a4) — still waiting" + to: wait_offers + - from: wait_offers + when: "3 consecutive no-fill rounds (offer cannot fill at the accepted edge)" + to: failed + - from: ready + when: "staff equipped (a6) AND build-loadout verified the carried counts at the tier's carried amounts (a7 — collected staff/supplies present, overflow deposited, only a real bank-stock shortfall withdrawn; carried counts PROVEN)" + to: cast + - from: ready + when: "staff equip failed after bounded retries — FAIL CLOSED" + to: failed + - from: ready + when: "build-loadout verification shows a carried shortfall the bank does not hold — restock first" + to: bank_supplies + - from: cast + when: "level < 43, or (43 <= level < 55 with SII skipped) — LA tier: cast on the cheapest alch candidate (LA is executable below 43 and throughout the 43-54 window only when a recipe-margin issue explicitly skips SII; at Magic 55+ the LA predicate is never true — no level matches two tiers)" + to: cast + - from: cast + when: "43 <= level < 55 and the SII window is chosen (MUTUALLY EXCLUSIVE with HLA — SII is selectable ONLY below 55, never at 55+) — selection.mode picks the recipes-table entry with the highest smithing_level <= current Smithing and a positive allowed-fill complete-cost margin; cast on its target_input" + to: cast + - from: cast + when: "43 <= level < 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: "43 <= level < 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 session target > 55 — the HLA tier executes (a8) until magic reaches the target (MUTUALLY EXCLUSIVE: HLA is selectable only when the target is ABOVE 55, so an account at 55 with target 55 completes first rather than matching SII/HLA)" + to: cast + - from: cast + when: "LA/HLA tier: candidate count < restock_threshold (100) OR nature count < restock_threshold (100); SII tier: bars_in_inventory >= the selected recipe's capacity (floor(27 / total input qty): steel 9, bronze 13, mithril 5, ...) OR any selected-recipe input < 1 — deterministic restock triggers, pinned numbers" + to: bank_supplies + - from: cast + when: "magic level >= session target (default 55 — completes at the LA/SII -> HLA handoff; targets above 55 require the executable HLA tier FIRST — completion is evaluated before tier selection, so the configured target always wins over any remaining tier predicate)" + 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 amounts (bank fast path or re-bought deficit collected)" + to: cast + - from: bank_supplies + when: "restock partial: deficit re-bought but offers still filling (ge_offers_done false) — loop the restock phase" + to: bank_supplies + - from: bank_supplies + when: "3 consecutive no-fill restock rounds OR a capital stop fires (assets / loss-reserve checks in stop_conditions)" + to: failed + +actions: + - id: a1 + step: reach-bank + verb: move_to + target: bank + params: + 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: open-ge + verb: ge_open + target: grand-exchange + params: + note: "open the GE interface at the booth. The entry NEVER buys (a3/a4/a11) or collects (a5/a12) without an open GE interface — the action grammar defines ge_open, ge_buy, ge_collect as separate verbs, so the open step is explicit" + on_fail: retry + anti_ban: + delay: [600, 1400] + + - id: a3 + step: buy-staff + verb: ge_buy + target: grand-exchange + params: + item: "staff of fire" + qty: 1 + max_price: "live low + 25 % (wiki value 1,500; hard cap 5,000)" + note: "offer rests until filled or the run ends; on a partial/no fill, collect what filled (a5) and re-place the remainder at the current edge — bounded retries, then the run fails closed (no fire-rune fallback exists)" + on_fail: retry + anti_ban: + delay: [1200, 3000] + + - id: a4 + step: buy-supplies + verb: ge_buy + target: grand-exchange + params: + item: "nature runes + 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 the allowed-fill complete-cost margin is positive" + qty: "LA/HLA: carried_batch 1,000 (4h GE limits: natures 18,000, candidates per mapping); SII: one capacity-sized pack of the selected recipe — capacity = floor(27 / total input qty) casts (steel = 9, so 9 x each input quantity + 9 natures per pack)" + max_price: "live low + 5 %" + note: "offer rests until filled or the run ends; partial fills are collected (a5) and the remainder re-placed at the current edge; 3 consecutive no-fills -> SII recipe fallback, else failed" + on_fail: retry + anti_ban: + delay: [1200, 3000] + + - id: a5 + step: collect-purchases + verb: ge_collect + target: grand-exchange + params: + note: "collect every filled purchase and refund from the GE collection box into the inventory — staff, candidates/natures, or the recipe's exact inputs (and the equivalent on every restock). Partial offers: the collected portion is added to the ledger; nothing is left in the box at session start" + on_fail: retry + anti_ban: + delay: [600, 1400] + + - id: a6 + 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). FAIL-CLOSED path: bounded retries (3), then log ERROR + alert + stop — no fire-rune fallback is modeled (never loop on equip attempts)" + on_fail: retry + anti_ban: + delay: [600, 1800] + + - id: a7 + step: build-loadout + verb: bank_inventory + target: bank + params: + deposit: [overflow products] + withdraw: "to the tier's carried amounts (only the real bank-stock shortfall — collected purchases from a5 are already in the inventory): LA/HLA 1,000 candidate + 1,000 nature; SII one capacity-sized pack of the selected recipe (steel: 9 iron + 18 coal + 9 natures)" + verify: "the collected staff of fire + tier supplies (a5) are present, and the carried counts EQUAL the tier's carried amounts — the loadout is PROVEN before the first cast (a8/a9); a verified shortfall the bank does not hold sends the slot to bank_supplies instead" + on_fail: retry + anti_ban: + delay: [500, 1100] + + - id: a8 + step: cast-alch + verb: use_item_on + target: "noted alch candidate in inventory (LA and HLA tiers)" + params: + spell: "Low Level Alchemy (level < 43, or 43-54 when SII is skipped) | High Level Alchemy (level >= 55 — only when the session target is above 55; HLA is never declared-but-unreachable; tier predicates are MUTUALLY EXCLUSIVE — SII only 43 <= Magic < 55, HLA only >= 55 with the target higher, completion first at the target)" + item: "cheapest live alch candidate in inventory — LA/HLA are alchemy tiers, so the selection rule is the alch-candidate rule" + on: "itself" + on_fail: retry + anti_ban: + 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 -> LA/HLA session-realized ~742 casts/hr incl. startup + restock + breaks (rate_model) + + - id: a9 + step: cast-superheat + verb: use_item_on + target: "the selected recipe's declared target_input in inventory (the primary ore — never coal; SII tier only, 43 <= Magic < 55)" + params: + spell: "Superheat Item" + selected_recipe: "runtime selection per selection.mode: the recipes-table entry with the highest smithing_level <= current Smithing whose complete input cost at ALLOWED fill prices + one nature rune at allowed fill < 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" + consumption: "1 x each recipe input (e.g. steel = 1 iron ore + 2 coal) + 1 nature per cast -> 1 bar, +53 Magic xp + the bar's Smithing xp; the pack holds capacity = floor(27 / total input qty) casts (steel = 9)" + on_fail: skip # no unlocked positive-margin recipe -> skip SII, stay on LA (selection.fallback) + anti_ban: + delay: [1600, 3400] # post-action sleep (additive): same 3.4-5.2s/cast as a8; per-recipe realized rates in recipes.rates (steel mid 249.1 casts/hr steady, session anchor 12,400 xp/hr) + + - id: a10 + step: restock-deposit + verb: bank_inventory + target: bank + params: + deposit: [all coins, all products] # products incl. smelted bars during SII + withdraw: "ALL tier supplies currently in the bank, up to the carried amounts (LA/HLA 1,000/1,000; SII one capacity-sized pack of the selected recipe) — the bank-stock FAST PATH applies only when the bank actually holds enough; the remaining deficit is bought at the GE (a11) and collected (a12)" + on_fail: alert + anti_ban: + delay: [500, 1100] + + - id: a11 + step: restock-buy + verb: ge_buy + target: grand-exchange + params: + item: "the tier's supplies to top up to the carried amounts" + qty: "deficit = carried amount - inventory count after the a10 fast path (LA/HLA: 901 candidate + 901 natures after a 901-cast batch with an empty bank; SII: the selected recipe's capacity-sized pack minus whatever the bank held)" + max_price: "live low + 5 %" + note: "the deficit is RE-BOUGHT at the GE on every restock — the entry never withdraws a batch that was never purchased; partial/failed fills use the same collect + re-place recovery as the initial acquisition" + on_fail: retry + anti_ban: + delay: [1200, 3000] + + - id: a12 + step: restock-collect + verb: ge_collect + target: grand-exchange + params: + note: "collect the re-bought deficit into the inventory — carried amounts are restored before casting resumes" + on_fail: retry + anti_ban: + delay: [600, 1400] + + - id: a13 + 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 (breaks land at 550/1,100/1,650 in the midpoint example, independent of the 901-cast alch / capacity-sized SII restock cadences)" + on_fail: skip + +run_control: + iteration: "one cast" # schema.md: break.every counts iterations, so every + # value below is in completed casts, NOT bank trips + max_iterations: null + break: + 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 >= session target (default 55: completes at the LA/SII -> HLA handoff; when the configured target is above 55, the HLA tier executes from 55 until the target — HLA is never declared-but-unreachable)" + - "session elapsed >= 2 h (session horizon — the rate model amortises startup over it)" + - "total assets (cash + supplies at current buy cost) < the next tier batch at max fill plus staff replacement (LA/HLA: 312,650 = 307,650 + 5,000; SII steel: 10,150 = 5,150 + 5,000) — assets, NOT raw cash: cash that became supplies is not a loss" + - "session net loss = initial capital - total assets > 690,075 — the 6-batch worst-case LA reserve (Magic 21 -> 55 at max fills) is exhausted (a loss is WHEN ASSETS FALL; total assets - initial capital goes NEGATIVE on losses and can never exceed the reserve — the reversed sign never fires)" + - "3 consecutive failed casts (client issue — do not loop)" + - "3 consecutive no-fill GE rounds (initial acquisition or restock) with no fallback recipe/candidate available" + - "staff of fire equip failed after bounded retries — FAIL CLOSED (log ERROR, alert, stop)" + + # ================= WORKED EXAMPLE (pinned, deterministic — owner guidance 2026-08-28) ================= + # SII tier, steel-bar path (Magic 43-54, Smithing 30, session target 55). + # Constants: break.every [400, 700] casts (the example pins the midpoint + # 550), break.for [45, 120] s; steel capacity = floor(27 / (1 iron + 2 coal)) + # = 9 casts per bank cycle — restocks every 9 casts; the counter sequences + # are MONOTONIC and INDEPENDENT (restocking never resets the break counter). + # + # Step 0 — prep (one-time startup ~300-600 s): account has 30 Smithing + + # 43-54 Magic and the LA/HLA-tier capital (>= ~1.31M gp). At the GE bank: + # choose the session target (55); open GE (a2); place offers — staff (a3), + # 1,000 nature runes + LA candidate OR - for the SII window - the selected + # recipe's first pack (steel: 9 iron ore + 18 coal + 9 natures, a4); wait + # for fills (ge_offers_done) and collect everything (a5); equip the staff + # (a6); loadout = 9 iron + 18 coal + 9 natures (a7). Cast counter := 0. + # Steel is the highest UNLOCKED recipe (Smithing 30); bronze (1), iron (15) + # and silver (20) are unlocked but LOWER, so steel is selected. Smithing 20 + # accounts select silver-bar (capacity 27) and steel stays locked (never an + # unmet gate). + # Step 1 — casts #1..#9: each cast consumes 1 iron ore + 2 coal + 1 nature + # and produces 1 steel bar (+53 Magic xp, +17.5 Smithing xp). After cast #9 + # the inventory holds 9 bars (capacity reached) -> FIRST RESTOCK at cast 9: + # deposit the 9 bars (a10); the bank holds 0 steel inputs (all sales were + # GE buys), so the fast path yields nothing -> open GE (a2), RE-BUY the + # pack deficit 9 iron + 18 coal + 9 natures (a11, live low + 5 %), collect + # (a12) -> inventory rebuilt. Restocks repeat every 9 casts (9, 18, 27, ..., + # and at 558 after the first break below) — capacity-derived, deterministic. + # Step 2 — FIRST SCHEDULED BREAK at cast 550 (midpoint of break.every + # [400, 700]): the 61st cycle is mid-flight — take-break 45-120 s (a13), + # resume at cast 551, finish the cycle (restock at 558). The break fired on + # the CAST COUNTER while a bank cycle was still running: breaks and banking + # are independent by construction, and restocking did NOT shift the break. + # Step 3 — break #2 at cast 1,100 (exactly 550 after #1 — monotonic, + # unaffected by the 9-cast restock cadence); break #3 at 1,650; restocks + # continue at 1,107, 1,116, ... (every 9). On the alch tiers (LA and the + # post-55 HLA tier) the restock cadence is the 901-cast batch: restock #1 + # at cast 901, restock #2 at cast 1,802 — monotonic, independent pins just + # like the breaks (restocking never resets or shifts the break counter). + # The SII window ends at Magic 55: + # with the default target 55 the session completes at the LA/SII -> HLA + # handoff; with a target above 55 the HLA tier (a8, 65 xp/cast, 901-cast + # batch model) executes until the target. + # 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 at allowed fills -> skip SII this window and + # stay on LA throughout 43-54 (a8 handles the SII-skip window explicitly). + # ===================================================================================================== + +risk: + ban_level: low + notes: + - "3-tick cast cadence is the main detection vector; keep anti_ban delays (cadence pin: additive post-cast sleep [1.6, 3.4]s; session-realized ~742 casts/hr on the alch tiers and ~234 casts/hr on the steel SII anchor, incl. startup + restock + breaks over a 2 h session — loop-only 837 is a ceiling reference, never the output)." + - "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, and never reset the break counter on restock (break interval 550 and restock cadences 901/9 are coprime by construction)." + - "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) sized to the derived capacity (floor(27 / input qty)) 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 >= capacity." + - "SII ore-buy bursts are pace-sensitive like any GE buy; respect the 4h limit window; re-check the allowed-fill 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 >= the selected +# recipe's capacity (floor(27 / input qty)) or any selected-recipe input < 1 — +# deterministic threshold transitions to bank_supplies; the restock phase +# (a10 -> a2 -> a11 -> a12) RE-BUYS the deficit at the GE — never withdraw a +# batch that was never purchased. +# - GE offers: partial fills are collected (a5/a12) and the remainder re-placed +# at the current edge (a3/a4/a11); after 3 consecutive no-fill rounds (initial +# or restock) the SII recipe falls back to the next lower unlocked recipe; +# with no fallback recipe left, SII is skipped and LA takes over; with no +# fallback candidate, the run stops (failed). Unfilled offers at session end +# are left resting by design (float reserve; coins return when they fill or +# the operator aborts them — the single operator-touch point). +# - Staff of fire cannot be equipped (a6): bounded retries (3), then FAIL +# CLOSED — log ERROR + alert + stop. No fire-rune fallback is modeled (it +# would need its own purchase/supply/rate path; the owner decision rejects a +# prose-only fallback). +# - Invalid loadout (wrong item in hand / spell unavailable): re-run +# build-loadout (a7); 3 consecutive failed casts -> failed (stop, log ERROR +# sentinel, alert). +# - Capital: both capital stops are assets-based (total assets = cash + +# supplies at current buy cost) — cash converted into supplies is not a loss +# and never fires a stop by itself (see stop_conditions). +# - Explicit completion/stop: stop_conditions above (session target 55 or the +# >55 HLA tier, 2 h session horizon, assets floor 312,650 / 10,150, loss +# reserve 690,075, repeated failures, fail-closed staff equip). + +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; 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)" + - "OSRS wiki — Grand Exchange (offers persist until filled or aborted; filled items and coin refunds are collected from the booth/collection box; partial fills are separately collectable) — verified 2026-08-28" + - "OSRS wiki — Grand Exchange/Buying limits (4-hour window; the limit resets 4 hours after the FIRST unit is bought; outstanding offers remain active and a limit reset is NOT an offer expiry) — verified 2026-08-28" + - "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; 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) + verbs ge_open/ge_buy/ge_collect/ge_offers_done (separate GE primitives) — the cadence + break + GE + lifecycle model; owner guidance 2026-08-28 (capacity-derived SII, rate_model, configurable target, fail-closed staff); governor comment 10394 (verified build-loadout a7, sign-corrected loss stop)" +verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27 + +related: + - low-alch-f2p + - f2p-alch-catalog + - high-alch-p2p diff --git a/schemas/entry.schema.json b/schemas/entry.schema.json index 9620548..532bffb 100644 --- a/schemas/entry.schema.json +++ b/schemas/entry.schema.json @@ -39,8 +39,10 @@ "item": { "type": "string" }, "item_id": { "type": "integer" }, "cost_per_cycle": { "type": "number" }, + "cost_per_cycle_max_fill": { "type": "number" }, "income_per_cycle": { "type": "number" }, "net_per_cycle": { "type": "number" }, + "net_per_cycle_max_fill": { "type": "number" }, "gp_per_hour_low": { "type": "number" }, "gp_per_hour_high": { "type": "number" }, "ceiling_gp_per_hour": { "type": "number", "exclusiveMinimum": 0 }, @@ -63,6 +65,105 @@ } }, + "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", "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, + "items": { + "type": "object", + "required": ["item", "qty", "source"], + "additionalProperties": false, + "properties": { + "item": { "type": "string", "minLength": 2 }, + "item_id": { "type": "integer", "minimum": 1 }, + "qty": { "type": "integer", "minimum": 1 }, + "source": { "enum": ["ge", "p2p", "drops", "craft", "bank", "npc-shop"] } + } + } + }, + "rates": { + "type": "object", + "required": ["capacity", "casts_per_hour", "magic_xp_per_hour"], + "additionalProperties": false, + "properties": { + "capacity": { "type": "integer", "minimum": 1 }, + "casts_per_hour": { + "type": "object", + "required": ["min", "mid", "max"], + "additionalProperties": false, + "properties": { + "min": { "type": "number", "minimum": 0 }, + "mid": { "type": "number", "minimum": 0 }, + "max": { "type": "number", "minimum": 0 } + } + }, + "magic_xp_per_hour": { + "type": "object", + "required": ["min", "mid", "max"], + "additionalProperties": false, + "properties": { + "min": { "type": "number", "minimum": 0 }, + "mid": { "type": "number", "minimum": 0 }, + "max": { "type": "number", "minimum": 0 } + } + } + } + } + } + } + }, + + "rate_model": { + "type": "object", + "required": ["anchor_tier", "cast_ticks", "restock_trip_s", "session_horizon_s"], + "additionalProperties": false, + "properties": { + "anchor_tier": { "enum": ["la", "sii", "hla"] }, + "anchor_recipe": { "type": "string" }, + "cast_ticks": { "type": "integer", "minimum": 1 }, + "restock_trip_s": { + "type": "array", + "items": { "type": "number", "minimum": 0 }, + "minItems": 2, + "maxItems": 2 + }, + "session_horizon_s": { "type": "integer", "minimum": 1 }, + "startup_s": { + "type": "array", + "items": { "type": "number", "minimum": 0 }, + "minItems": 2, + "maxItems": 2 + }, + "alch_carry_batch": { "type": "integer", "minimum": 1 }, + "alch_restock_threshold": { "type": "integer", "minimum": 0 }, + "sii_capacity_slots": { "type": "integer", "minimum": 1 } + } + }, + "pickaxe_tiers": { "type": "object", "minProperties": 1, diff --git a/tools/validate.py b/tools/validate.py index cecacc0..6bfeb68 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -11,7 +11,31 @@ Runs: => WARNING (entry should be re-verified, not dropped) - `best_practice` lines whose source starts with "researched" => WARNING (citation TODO — file a follow-up issue) - - Mining model (pickaxe gates + run-mode contract) — STRUCTURED + - Cross-entry supply-fact consistency: the same (supply item, + source) across entries must carry the same ge_limit. A diverging + limit (e.g. nature rune "12500" in one entry vs "18000" in + 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 + 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 (owner guidance 2026-08-28, bounce 4): + entries that DECLARE a `rate_model` must have xp_per_hour derived + from their OWN declared lifecycle — cast ticks + the casting + action's mandatory anti_ban.delay (additive), run_control breaks, + per-recipe inventory capacity (floor(slots / total input qty)), + restock trips, and startup amortised over the session horizon. + The gate is behind an explicit discriminator: entries without + `rate_model` (e.g. mining) are never treated as alchemy. Every + declared SII recipe must pin `rates` (capacity + min/mid/max + casts/hr + Magic xp/hr) matching the derived model, and capacity + must fit a 28-slot inventory (1 nature stack -> 27 input slots). + + - Mining model (pickaxe gates + run-mode contract) — STRUCTURED checks on the declared `pickaxe_tiers` / `pickaxe_examples` / `mining_modes` / `mine_sites` blocks (owner guidance 2026-08-28): * pickaxe tier values must match the canonical F2P table exactly @@ -82,23 +106,37 @@ Runs: validator), drop-mode realized GP zero, and state-machine wiring (executable prepare -> walk_to_trees, no axe -> failed, drop transition gated to the afk-xp drop mode). - - Regression fixtures (--selftest): a fractional-tax flipping - entry must FAIL; an above-cap high-value flip with the - correctly capped-tax net must PASS; a non-margin entry carrying - only `ge` plus a non-flip tag must PASS; plus per-slot scheduler - dispatch, conservation, buy-limit and nesting fixtures, and the - woodcutting axe/mode/fixture/wiring fixtures. - 3. Global id uniqueness across the whole codex tree. + - Regression fixtures (--selftest): a fractional-tax flipping + entry must FAIL; an above-cap high-value flip with the + correctly capped-tax net must PASS; a non-margin entry carrying + only `ge` plus a non-flip tag must PASS; plus per-slot scheduler + dispatch, conservation, buy-limit and nesting fixtures, the + Mining pickaxe suite, the Woodcutting axe/mode/fixture/wiring + suite, and the alchemy rate-model/tier/loadout suite — each + suite runs exactly once per invocation (no aliases or + duplicated executions). + + 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. 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. Usage: python3 tools/validate.py [--stale-days 30] [--selftest] - --selftest: run the committed regression fixtures only (no tree scan) + --selftest: run the full committed regression fixture suites only (no tree scan) """ +import copy import datetime import json import math +import os import re import sys from pathlib import Path @@ -676,6 +714,540 @@ def run_mining_selftest(verbose=False): return errors MARGIN_TAGS = {"flipping", "merchanting"} +def document_error(rel, data): + """Shape check: an entry must parse to a YAML mapping. + + Returns an error string, or None for a well-formed mapping. The + caller keeps malformed/scalar documents out of the shared row set + so downstream gates never see None/scalars (regression: a scalar + document used to crash `supply_limit_conflicts` with an + AttributeError instead of reporting a validation error). + """ + if isinstance(data, dict): + return None + if data is None: + got = "an empty document" + elif isinstance(data, (list, tuple)): + got = f"a {type(data).__name__} document" + else: + got = f"{type(data).__name__} ({data!r})" + return f"{rel}: document is not a YAML mapping (parsed as {got})" + + +def supply_limit_conflicts(rows): + """Cross-entry consistency for duplicate supply facts. + + rows: iterable of (rel, data). The same (item, source) appearing in + more than one entry must agree on ge_limit — GE buy limits are a + property of the item, so a divergence is a silent fact error. + Returns a list of error strings. + """ + facts = {} + for rel, data in rows: + if not isinstance(data, dict): # never crash on malformed rows + continue + 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 + key = (sup.get("item"), sup.get("source")) + facts.setdefault(key, {}).setdefault(lim, set()).add(rel) + errors = [] + for (item, source), by_lim in sorted(facts.items()): + if len(by_lim) > 1: + detail = "; ".join( + f"limit {lim} in {', '.join(sorted(rs))}" + for lim, rs in sorted(by_lim.items())) + errors.append( + f"conflicting ge_limit for supply '{item}' (source {source}): {detail}") + return errors + + +def recipe_total_input_qty(recipe): + """Total input count of a recipe — every input and quantity counts. + + A multi-input recipe (e.g. steel-bar = 1 iron ore + 2 coal) must + account for all inputs, not just the headline ore. + """ + return sum(int(inp.get("qty", 0)) for inp in recipe.get("inputs", [])) + + +def recipe_margin(recipe, bar_price, input_prices, nature_price): + """Complete-recipe margin for Superheat Item. + + bar GE price minus (ALL inputs at live GE buy prices + one nature + rune). Negative/zero => the bar is not worth producing with SII. + This is the exact formula the entries pin; tests use it with fixed + prices so the fixture is deterministic. + """ + cost = sum( + int(inp.get("qty", 0)) * int(input_prices.get(inp.get("item"), 0)) + for inp in recipe.get("inputs", [])) + return bar_price - (cost + nature_price) + + +# --- rate-model constants (owner guidance 2026-08-28, bounce 4) --- +# The cadence is DERIVED from each entry's declared rate_model + actions + +# run_control + recipes — nothing is hard-coded per activity class. These +# are the only universal facts: +# * one game tick = 0.6 s (docs/action-grammar.md); +# * anti_ban.delay is an ADDITIVE post-action sleep +# (docs/action-grammar.md:135), so cycle = cast_ticks*0.6 + delay; +# * LA/SII/HLA Magic XP per cast (wiki infoboxes): 31 / 53 / 65. +XP_LA, XP_SII, XP_HLA = 31, 53, 65 +RATE_TOLERANCE = 0.02 # pinned values may round to 3 significant figures + + +def _mean(pair): + return (pair[0] + pair[1]) / 2.0 if pair else 0.0 + + +def target_reached(level, target): + """Magic-level stop boundary (governor 10394 finding 3): the run stops + when level >= target. Default sessions target 55 (the LA/SII -> HLA + handoff); a session at level 54 with target 55 CONTINUES (False), and + level 55 hands off (True).""" + return level >= target + + +def selectable_tier(level, target, sii_skipped=False): + """Executable tier selection (governor 10477): exactly ONE tier + predicate is true at any (level, target) — the predicates are mutually + exclusive, so no Magic level can match two tiers (the old '43-55' SII + window overlapped HLA at exactly 55 for targets above 55). + + Order of evaluation (completion always wins first): + - 'complete' when level >= target (the configured target always + beats any remaining tier predicate); + - 'hla' when Magic >= 55 AND the target is higher + (level >= 55 but < target implies target > 55); + - 'sii' only while 43 <= Magic < 55 (never at 55+); + - 'la' level < 43, OR the explicit SII-skip path while + 43 <= Magic < 55. + """ + if level >= target: + return "complete" + if level >= 55: + return "hla" # target > 55 implied by level < target + if 43 <= level < 55 and not sii_skipped: + return "sii" + return "la" # level < 43, or 43-54 with SII skipped + + +def tier_boundary_errors(rel, data): + """Executable tier mutual-exclusivity (governor 10477): no Magic level + may match TWO tier predicates at once. The old inclusive '43-55' SII + window overlapped HLA (>= 55) at exactly Magic 55 for targets above + 55, so this gate: + - rejects an SII window spelled as the inclusive '43-55'; + - requires SII to be declared strictly below 55 + ('43 <= Magic < 55' / '43-54'); + - requires an HLA transition that names Magic >= 55 AND a session + target above 55 (HLA is executable, never declared-but-unreachable); + - requires a completion transition at the configured target. + Only entries that cast Superheat Item are scrutinised here (the + LA-only entry has no tier overlap and is untouched).""" + errors = [] + transitions = data.get("state_machine", {}).get("transitions", []) + whens = [str(t.get("when", "")) for t in transitions] + has_sii = "superheat" in (str(data.get("actions", []))).lower() + if not has_sii: + return [] + # Reject the inclusive '43-55' spelling (the overlap defect). + if any("43-55" in w for w in whens): + errors.append( + f"{rel}: SII window spelled as the inclusive '43-55' overlaps " + f"HLA at exactly Magic 55 — declare SII strictly below 55 " + f"(43 <= Magic < 55), HLA at >= 55 requiring the target above 55") + # The SII window must exist in strictly-below-55 form. + if not any(("43 <= " in w and "< 55" in w) or "43-54" in w + for w in whens): + errors.append( + f"{rel}: no transition declares the SII window strictly below " + f"55 (43 <= Magic < 55) — SII must never be selectable at 55+") + # HLA requires both >= 55 and a target above 55 in the same clause. + if not any((">= 55" in w and "> 55" in w) for w in whens): + errors.append( + f"{rel}: no mutually-exclusive HLA transition (Magic >= 55 AND " + f"session target > 55) — HLA would overlap SII or be unreachable") + return errors + + +def stop_loss_exceeded(initial_capital, total_assets, reserve): + """Sign-corrected capital-loss stop (governor 10394 finding 2): + exceeded when initial capital - total assets > reserve. total_assets - + initial capital goes NEGATIVE on losses and can never exceed the + reserve — the reversed sign would never fire.""" + return (initial_capital - total_assets) > reserve + + +def loadout_verified(carried, required): + """Build-loadout proof (governor 10394 finding 1): the carried counts + must EQUAL the required carried amounts before the first cast. A + shortfall is not verified (False) — it routes to bank_supplies.""" + return all(carried.get(k, 0) >= qty for k, qty in required.items()) + + +def _casting_delay(data): + """The mandatory post-action delay of the entry's casting action(s), + converted from ms (anti_ban.delay units) to seconds. + + Derived from the entry's OWN actions: the first action whose step + starts with 'cast' and carries an anti_ban.delay pair. Returns + (delay_min_s, delay_max_s) or None. + """ + for a in data.get("actions", []): + if not isinstance(a, dict): + continue + if not str(a.get("step", "")).startswith("cast"): + continue + delay = (a.get("anti_ban") or {}).get("delay") + if isinstance(delay, list) and len(delay) == 2: + return float(delay[0]) / 1000.0, float(delay[1]) / 1000.0 + return None + + +def _break_amort(data): + """Break time per cast from the entry's OWN run_control.break. + + break.every counts completed casts (one-cast iteration model), so the + amortised break share = mean(break.for) / mean(break.every). Fast/slow + ends use for_min/every_max and for_max/every_min respectively. + """ + brk = (data.get("run_control") or {}).get("break") or {} + every, for_ = brk.get("every"), brk.get("for") + if not (isinstance(every, list) and len(every) == 2 + and isinstance(for_, list) and len(for_) == 2): + return None + e_min, e_max = float(every[0]), float(every[1]) + f_min, f_max = float(for_[0]), float(for_[1]) + return { + "fast": f_min / e_max, "mid": _mean(for_) / _mean(every), + "slow": f_max / e_min} + + +def _session_factor(rm, pace): + """Startup amortisation factor = 1 - startup_s / session_horizon_s. + + pace selects the matching end of the startup range: fast cast pace + pairs with the fast (short) startup, slow with the slow one, mid with + the midpoint. Returns None when the horizon is missing or startup + exceeds the horizon (impossible session).""" + startup = rm.get("startup_s") or [300, 600] + horizon = rm.get("session_horizon_s") + if not isinstance(horizon, (int, float)) or horizon <= 0: + return None + if not (isinstance(startup, list) and len(startup) == 2): + return None + s_min, s_max = float(startup[0]), float(startup[1]) + pick = {"fast": s_min, "mid": _mean(startup), "slow": s_max}[pace] + if pick >= horizon: + return None + return 1.0 - pick / horizon + + +def recipe_capacity(recipe, slots): + """Inventory capacity of a recipe: floor(slots / total input qty). + + SII bars are non-stackable and one slot holds the nature rune stack, + so a 28-slot inventory leaves `slots` (=27) for recipe inputs. A + recipe whose inputs exceed the slots cannot exist (capacity 0).""" + qty = recipe_total_input_qty(recipe) + if qty <= 0: + return 0 + return int(slots // qty) + + +def _alch_pace_casts_hr(cycle, trip, break_share, batch_casts): + """Steady-state realized casts/hr for an alch tier (LA/HLA), derived + from the batch model (carried_batch - restock_threshold casts per + restock trip) plus breaks.""" + batch_s = batch_casts * cycle + trip + break_share * batch_casts + if batch_s <= 0: + return 0.0 + return batch_casts * 3600.0 / batch_s + + +def rate_model_errors(rel, data): + """xp_per_hour and per-recipe rates must derive from the entry's OWN + declared lifecycle (owner guidance 2026-08-28, bounce 4). + + The gate is OPT-IN behind an explicit `rate_model` discriminator: + entries without one (e.g. a mining entry) are never treated as + alchemy. For entries that declare it, the model is derived from the + entry's own fields — cast_ticks + the casting action's mandatory + anti_ban.delay, run_control.break, restock trips, the recipes table + (capacity = floor(slots / total input qty)), and startup amortised + over session_horizon_s. Regression coverage (--selftest): + non-alchemy pass, loop-only/old-regime failures, cadence-mutation + failure (one-hour breaks), per-recipe capacity and rate pins.""" + + def err(msg): + return f"{rel}: {msg}" + + rm = data.get("rate_model") + if not isinstance(rm, dict): + return [] # explicit discriminator — unrelated entries pass + errors = [] + tick_s = 0.6 + cast_ticks = rm.get("cast_ticks") + if not isinstance(cast_ticks, (int, float)) or cast_ticks < 1: + errors.append(err("rate_model.cast_ticks must be >= 1")) + return errors + cast_s = cast_ticks * tick_s + delay = _casting_delay(data) + if delay is None: + errors.append(err("rate_model requires a casting action (step 'cast-*') " + "with anti_ban.delay [min, max] — the rate derives from " + "the entry's own mandatory pacing")) + return errors + cycle = {"fast": cast_s + delay[0], "mid": cast_s + _mean(delay), + "slow": cast_s + delay[1]} + break_amort = _break_amort(data) + if break_amort is None: + errors.append(err("rate_model requires run_control.break with every + for " + "ranges (cast-count iteration model)")) + return errors + trip = rm.get("restock_trip_s") + if not (isinstance(trip, list) and len(trip) == 2): + errors.append(err("rate_model.restock_trip_s must be [min, max] seconds")) + return errors + trip = {"fast": float(trip[0]), "mid": _mean(trip), "slow": float(trip[1])} + slots = rm.get("sii_capacity_slots", 27) + + # --- per-recipe SII rates (capacity + min/mid/max), fully derived --- + recipes = data.get("recipes") + if isinstance(recipes, dict): + for rid, recipe in recipes.items(): + if not isinstance(recipe, dict): + continue + cap = recipe_capacity(recipe, slots) + rates = recipe.get("rates") + if not isinstance(rates, dict): + errors.append(err(f"recipe '{rid}' must pin a rates block " + f"(capacity, casts_per_hour, magic_xp_per_hour)")) + continue + if cap < 1: + errors.append(err(f"recipe '{rid}' cannot fit the inventory: " + f"total input qty {recipe_total_input_qty(recipe)} " + f"> {slots} input slots (capacity 0)")) + continue + pinned_cap = rates.get("capacity") + if pinned_cap != cap: + errors.append(err(f"recipe '{rid}' rates.capacity {pinned_cap} != " + f"derived floor({slots} / {recipe_total_input_qty(recipe)}) = {cap}")) + casts = {} + for pace in ("min", "mid", "max"): + p = {"min": "fast", "mid": "mid", "max": "slow"}[pace] + per_cast = cycle[p] + trip[p] / cap + break_amort[p] + casts[pace] = 3600.0 / per_cast if per_cast > 0 else 0.0 + pin_cph = (rates.get("casts_per_hour") or {}) + pin_xp = (rates.get("magic_xp_per_hour") or {}) + for pace, computed in casts.items(): + expect_xp = computed * XP_SII + pc, px = pin_cph.get(pace), pin_xp.get(pace) + for label, pinned, expected in (("casts_per_hour", pc, computed), + ("magic_xp_per_hour", px, expect_xp)): + if not isinstance(pinned, (int, float)): + errors.append(err(f"recipe '{rid}' rates.{label}.{pace} missing")) + elif abs(pinned - expected) / expected > RATE_TOLERANCE: + errors.append(err( + f"recipe '{rid}' rates.{label}.{pace} {pinned} is not the " + f"derived rate ({expected:.1f} from cycle {cycle[p]:.2f}s + " + f"trip {trip[p]:.1f}s/{cap} capacity + break {break_amort[p]:.3f}s; " + f"tolerance {RATE_TOLERANCE:.0%})")) + + # --- xp_per_hour anchor: the declared anchor tier, same lifecycle --- + eco = data.get("economy") + xph = eco.get("xp_per_hour") if isinstance(eco, dict) else None + if not isinstance(xph, (int, float)): + errors.append(err("rate_model requires economy.xp_per_hour to pin the " + "anchor tier's realized rate")) + return errors + anchor = rm.get("anchor_tier") + per_cast = {"la": XP_LA, "sii": XP_SII, "hla": XP_HLA}.get(anchor) + if per_cast is None: + errors.append(err("rate_model.anchor_tier must be la, sii or hla")) + return errors + if anchor == "sii": + rid = rm.get("anchor_recipe") + recipe = (recipes or {}).get(rid) if isinstance(recipes, dict) else None + if not isinstance(recipe, dict): + errors.append(err(f"rate_model.anchor_recipe '{rid}' is not a declared " + f"recipe — the anchor must be one of the recipes")) + return errors + cap = recipe_capacity(recipe, slots) + if cap < 1: + return errors # already reported as capacity 0 + casts_mid = 3600.0 / (cycle["mid"] + trip["mid"] / cap + break_amort["mid"]) + else: + batch = rm.get("alch_carry_batch") + threshold = rm.get("alch_restock_threshold") + if not (isinstance(batch, (int, float)) and batch > 1 + and isinstance(threshold, (int, float)) and 0 <= threshold < batch): + errors.append(err("rate_model.alch_carry_batch and alch_restock_threshold " + "are required for la/hla anchors (threshold < batch)")) + return errors + batch_casts = batch - threshold + casts_mid = _alch_pace_casts_hr(cycle["mid"], trip["mid"], + break_amort["mid"], batch_casts) + factor = _session_factor(rm, "mid") + if factor is None: + errors.append(err("rate_model startup_s must be < session_horizon_s " + "(startup cannot exceed the session)")) + return errors + expected = per_cast * casts_mid * factor + if abs(xph - expected) / expected > RATE_TOLERANCE: + errors.append(err( + f"xp_per_hour {xph} is not the anchor tier's derived realized rate " + f"(expected ~{expected:.0f} = {per_cast} xp/cast x {casts_mid:.1f} " + f"steady-state casts/hr x startup factor {factor:.3f} over " + f"{rm.get('session_horizon_s')}s; tolerance {RATE_TOLERANCE:.0%})")) + return errors + + +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 select_recipe(recipes, smithing_level, bar_prices, input_prices, + nature_price, fill_factor=1.0): + """Deterministic full selection (owner guidance 2026-08-28, bounce 4): + among the recipes whose Smithing gate is unlocked, pick the HIGHEST-gate + one whose complete-recipe margin is positive at the given prices. + + fill_factor scales every input and the nature rune price to the ALLOWED + fill cost (live low + 5% -> 1.05), so eligibility uses the FULL + actual/allowed recipe cost. Returns None when no unlocked recipe has a + positive margin — the caller then skips SII and stays on LA (the + fallback chain: this helper already drops to the next lower unlocked + recipe whenever the highest one is margin-negative).""" + unlocked = [] + for rid, recipe in (recipes or {}).items(): + if not isinstance(recipe, dict): + continue + lvl = recipe.get("smithing_level") + if isinstance(lvl, int) and lvl <= smithing_level: + cost = sum( + int(inp.get("qty", 0)) * int(input_prices.get(inp.get("item"), 0)) + for inp in recipe.get("inputs", [])) + margin = (bar_prices.get(recipe.get("bar"), 0) + - (cost * fill_factor + nature_price * fill_factor)) + unlocked.append((lvl, rid, margin)) + unlocked.sort(key=lambda t: t[0], reverse=True) + for _lvl, rid, margin in unlocked: + if margin > 0: + return rid + return None + + +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"] + 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}: 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", [])] + # SII window must be declared as 43 <= Magic < 55 (also accept the + # historical "43-55" spelling so legacy fixtures stay readable). + if not any(("43 <= " in w and "< 55" in w) or "43-55" in w + for w in whens): + errors.append( + f"{rel}: no transition covers the SII window (43 <= Magic " + f"< 55) — the 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 ge_floor_tax_error(rel, data): """GE floor-tax gate for a single entry. @@ -1243,6 +1815,7 @@ def ge_lifecycle_errors(rel, data): return errors + # --- Woodcutting structured gates (owner 10288 + governor 10398 shape) --- # Canonical F2P axe table (OSRS wiki, verified 2026-08-28): the Woodcutting # level is the TOOL-USE requirement (chopping), the Attack level is the @@ -1589,9 +2162,12 @@ def woodcutting_errors(rel, data): return errors -def run_selftest(verbose=False): - """Regression fixtures for the GE floor-tax + lifecycle gates and the - woodcutting structured gates; returns error strings.""" +def run_woodcutting_selftest(verbose=False): + """Regression fixtures for the woodcutting structured gates + (canonical axe table, deterministic per-mode ladders, + per-selected-row 27/28 dynamic rates, drop-mode realized GP zero, + prepare/failed wiring, drop-transition gating); returns error + strings.""" errors = [] def check(name, passed): @@ -1600,7 +2176,537 @@ def run_selftest(verbose=False): if not passed: errors.append(f"selftest: {name}") - # (body below carries flip + lifecycle fixtures, then woodcutting gates) + # --- woodcutting structured gates (owner 10288 / governor 10398) --- + check("axe: 41-Woodcutting/1-Attack account carries and USES a rune axe (does not pick a lower axe)", + best_axe(41, 1) == ("rune", False)) + check("axe: 41-Woodcutting/40-Attack account equips the rune axe", + best_axe(41, 40) == ("rune", True)) + check("axe: 30-Woodcutting account is below adamant (31) and uses mithril (equippable at 20 Attack)", + best_axe(30, 40) == ("mithril", True)) + check("axe: 31-Woodcutting/5-Attack account uses adamant but cannot equip it (carries)", + best_axe(31, 5) == ("adamant", False)) + check("axe: level-1 account uses an iron axe equipped from the start", + best_axe(1, 1) == ("iron", True)) + check("axe: dragon (61/60, members) is never selected for an F2P woodcutting entry; maxed account still picks the top F2P axe (rune)", + best_axe(99, 99) == ("rune", True)) + canon = {(row[0], row[1], row[2]) for row in AXE_TIERS} - {("dragon", 61, 60)} + want = {("bronze", 1, 1), ("iron", 1, 1), ("steel", 6, 5), ("black", 11, 10), + ("mithril", 21, 20), ("adamant", 31, 30), ("rune", 41, 40), ("gilded", 41, 40)} + check("axe: canonical F2P axe table matches the wiki (WC to use / Attack to equip)", + canon == want) + + def wc_fake_entry(**over): + base = {"tags": ["woodcutting"], + "axe_tiers": [{"axe": a, "woodcutting_level": w, "attack_level": k, "members": m} + for a, w, k, m in AXE_TIERS], + "axe_examples": [{"account": "41WC/1Atk", "woodcutting_level": 41, "attack_level": 1, + "expected_axe": "rune", "expected_equipped": False}], + "state_machine": {"states": {"idle": {"goal": "start", "next": ["arm_run"]}, + "arm_run": {"goal": "pick", "next": ["prepare"]}, + "prepare": {"goal": "acquire axe", "next": ["walk_to_trees", "failed"]}, + "chopping": {"goal": "chop", "next": ["drop_logs", "walk_to_bank"]}, + "drop_logs": {"goal": "drop", "next": ["chopping"]}, + "walk_to_bank": {"goal": "bank", "next": ["chopping"]}, + "failed": {"goal": "fail"}}, + "initial": "idle", "terminal": ["failed"], + "transitions": [ + {"from": "idle", "when": "session target reached", "to": "complete"}, + {"from": "arm_run", "when": "row pinned", "to": "prepare"}, + {"from": "prepare", "when": "axe acquired", "to": "walk_to_trees"}, + {"from": "prepare", "when": "axe unobtainable", "to": "failed"}, + {"from": "chopping", "when": "mode is afk-xp (inventory_policy drop) and inventory full", "to": "drop_logs"}, + {"from": "chopping", "when": "mode is fast-xp or banked-income and inventory full", "to": "walk_to_bank"}]}, + "woodcutting_fixtures": [ + {"account": "f1", "mode": "fast-xp", "woodcutting_level": 50, "attack_level": 40, + "quest_gate": False, "combat_level": 50, "expected_tree": "willow", + "expected_axe": "rune", "expected_equipped": True, + "expected_logs_per_hour": 622, "expected_xp_per_hour": 41985, + "expected_gp_per_hour": 13062}], + "woodcutting_modes": [ + {"mode": "fast-xp", "intent": "max xp per hour on the F2P ladder", + "inventory_policy": "bank", + "ladder": [ + {"tree": "willow", "min_level": 30, "gate": "combat-15-plus", + "spot": "draynor willows", "bank": "draynor", + "chop_seconds_per_log": 4.0, "walk_seconds_each_way": 12, + "respawn_duty": 0.92, "log_price": 21}, + {"tree": "willow", "min_level": 30, "gate": "combat-under-15", + "spot": "edgeville willow", "bank": "edgeville", + "chop_seconds_per_log": 4.0, "walk_seconds_each_way": 6, + "respawn_duty": 0.90, "log_price": 21}, + {"tree": "oak", "min_level": 15, "gate": "none", + "spot": "draynor oaks", "bank": "draynor", + "chop_seconds_per_log": 3.5, "walk_seconds_each_way": 8, + "respawn_duty": 0.93, "log_price": 40}, + {"tree": "tree", "min_level": 1, "gate": "none", + "spot": "lumbridge west", "bank": "lumbridge", + "chop_seconds_per_log": 3.0, "walk_seconds_each_way": 10, + "respawn_duty": 0.95, "log_price": 23}], + "model": {"drop_seconds_per_log": 0, "deposit_seconds": 4, + "break_duty": 0.94}, + "rates": {"logs_per_hour": 622, "xp_per_hour": 41985, + "gp_per_hour": 13062, "log_price": 21, + "price_verified": "2026-08-28"}}, + {"mode": "afk-xp", "intent": "low-interaction quest-gated progress", + "inventory_policy": "drop", + "ladder": [ + {"tree": "maple", "min_level": 45, "gate": "dragon-slayer-i", + "spot": "corsair cove resource area", "bank": "none-drop", + "chop_seconds_per_log": 6.0, "walk_seconds_each_way": 0, + "respawn_duty": 0.80, "log_price": 12}, + {"tree": "willow", "min_level": 30, "gate": "combat-15-plus", + "spot": "draynor willows", "bank": "none-drop", + "chop_seconds_per_log": 6.0, "walk_seconds_each_way": 0, + "respawn_duty": 0.92, "log_price": 21}, + {"tree": "oak", "min_level": 15, "gate": "none", + "spot": "draynor oaks", "bank": "none-drop", + "chop_seconds_per_log": 5.5, "walk_seconds_each_way": 0, + "respawn_duty": 0.93, "log_price": 40}, + {"tree": "tree", "min_level": 1, "gate": "none", + "spot": "lumbridge west", "bank": "none-drop", + "chop_seconds_per_log": 5.0, "walk_seconds_each_way": 0, + "respawn_duty": 0.95, "log_price": 23}], + "model": {"drop_seconds_per_log": 1.0, "deposit_seconds": 0, + "break_duty": 0.94}, + "rates": {"logs_per_hour": 386, "xp_per_hour": 38600, + "gp_per_hour": 0, "log_price": 12, + "price_verified": "2026-08-28"}}, + {"mode": "banked-income", "intent": "banked logs sold at the GE", + "inventory_policy": "bank", + "ladder": [ + {"tree": "yew", "min_level": 60, "gate": "none", + "spot": "edgeville south yews", "bank": "edgeville", + "chop_seconds_per_log": 7.5, "walk_seconds_each_way": 8, + "respawn_duty": 0.52, "log_price": 111}, + {"tree": "willow", "min_level": 30, "gate": "combat-15-plus", + "spot": "draynor willows", "bank": "draynor", + "chop_seconds_per_log": 4.0, "walk_seconds_each_way": 12, + "respawn_duty": 0.92, "log_price": 21}, + {"tree": "oak", "min_level": 15, "gate": "none", + "spot": "draynor oaks", "bank": "draynor", + "chop_seconds_per_log": 3.5, "walk_seconds_each_way": 8, + "respawn_duty": 0.93, "log_price": 40}, + {"tree": "tree", "min_level": 1, "gate": "none", + "spot": "lumbridge west", "bank": "lumbridge", + "chop_seconds_per_log": 3.0, "walk_seconds_each_way": 10, + "respawn_duty": 0.95, "log_price": 23}], + "model": {"drop_seconds_per_log": 0, "deposit_seconds": 4, + "break_duty": 0.94}, + "rates": {"logs_per_hour": 214, "xp_per_hour": 37450, + "gp_per_hour": 23754, "log_price": 111, + "price_verified": "2026-08-28"}}]} + base.update(over) + return base + + check("modes: a compliant 3-mode entry (per-row rates, drop-GP-zero, prepare wiring) passes every structured gate", + woodcutting_errors("selftest/wc-good", wc_fake_entry()) == []) + bad = wc_fake_entry(woodcutting_modes=wc_fake_entry()["woodcutting_modes"][1:]) + check("modes: missing a mode (only 2 declared) is rejected", + any("exactly" in e for e in woodcutting_errors("selftest/wc-missing", bad))) + bad = wc_fake_entry(axe_examples=[{"account": "41WC/1Atk", "woodcutting_level": 41, "attack_level": 1, + "expected_axe": "adamant", "expected_equipped": True}]) + check("modes: a 41-WC/1-Attack example declaring a lower/equipped axe is rejected", + any("recomputed rune" in e for e in woodcutting_errors("selftest/wc-badex", bad))) + bad = wc_fake_entry(woodcutting_modes=[dict(m, ladder=[dict(r, min_level=30 if r["tree"] == "maple" else r["min_level"]) for r in m["ladder"]]) + for m in wc_fake_entry()["woodcutting_modes"]]) + check("modes: maple below level 45 is rejected", + any("maple must have min_level 45" in e for e in woodcutting_errors("selftest/wc-maple45", bad))) + bad = wc_fake_entry(woodcutting_modes=[dict(m, ladder=[dict(r, gate="none") for r in m["ladder"]]) + for m in wc_fake_entry()["woodcutting_modes"]]) + check("modes: maple without the dragon-slayer-i gate is rejected", + any("gate dragon-slayer-i" in e for e in woodcutting_errors("selftest/wc-maplegate", bad))) + bad = wc_fake_entry(woodcutting_modes=[dict(m, ladder=[dict(r) for r in m["ladder"][::-1]]) + for m in wc_fake_entry()["woodcutting_modes"]]) + check("modes: a ladder not ordered by min_level DESC is rejected (nondeterministic)", + any("ordered by min_level DESC" in e for e in woodcutting_errors("selftest/wc-order", bad))) + badm = [dict(m, rates=dict(m["rates"], logs_per_hour=9999)) + for m in wc_fake_entry()["woodcutting_modes"]] + check("modes: a rate far above the model chain (9999/hr) is rejected", + any("SELECTED-row" in e for e in woodcutting_errors("selftest/wc-rate", wc_fake_entry(woodcutting_modes=badm)))) + bad = wc_fake_entry(axe_tiers=[{"axe": a, "woodcutting_level": w, "attack_level": k, "members": m} + for a, w, k, m in AXE_TIERS if a != "rune"]) + check("modes: a non-canonical axe table (rune missing) is rejected", + any("canonical F2P table" in e for e in woodcutting_errors("selftest/wc-axes", bad))) + badm = wc_fake_entry()["woodcutting_modes"] + badm[0] = dict(badm[0], rates=dict(badm[0]["rates"], xp_per_hour=40000)) + check("modes: xp_per_hour not equal to logs x per-log xp is rejected", + any("xp_per_hour" in e for e in woodcutting_errors("selftest/wc-xp", wc_fake_entry(woodcutting_modes=badm)))) + # governor 10398 finding 4: a fixture whose expected logs/XP do not match + # the selected-row chain with the 27/28 capacity is rejected. + bad = wc_fake_entry(woodcutting_fixtures=[ + {"account": "carried-rune-27", "mode": "afk-xp", "woodcutting_level": 50, "attack_level": 1, + "quest_gate": True, "combat_level": 50, "expected_tree": "maple", + "expected_axe": "rune", "expected_equipped": False, + "expected_logs_per_hour": 9999, "expected_xp_per_hour": 999999, + "expected_gp_per_hour": 0}]) + check("modes: a fixture whose declared rates do not match the selected-row 27-slot chain is rejected (governor 10398 finding 4)", + any("selected-row chain" in e for e in woodcutting_errors("selftest/wc-fxrate", bad))) + # governor 10398 finding 5: a drop mode declaring non-zero realized GP. + badm = wc_fake_entry()["woodcutting_modes"] + badm[1] = dict(badm[1], rates=dict(badm[1]["rates"], gp_per_hour=4632)) + check("modes: drop mode with non-zero realized gp_per_hour is rejected (dropped logs realize 0 gp — governor 10398 finding 5)", + any("realize gp_per_hour 0" in e for e in woodcutting_errors("selftest/wc-dropgp", wc_fake_entry(woodcutting_modes=badm)))) + # governor 10398 finding 2: idle -> complete must not be the no-axe hatch. + bad = wc_fake_entry(state_machine=dict( + wc_fake_entry()["state_machine"], + transitions=[{"from": "idle", "when": "no axe obtainable", "to": "complete"}])) + check("modes: idle -> complete on a missing axe is rejected (unobtainable axe must go to failed — governor 10398 finding 2)", + any("never on a missing axe" in e for e in woodcutting_errors("selftest/wc-idlecomplete", bad))) + # governor 10398 finding 3: the drop transition must be gated to afk-xp. + bad = wc_fake_entry(state_machine=dict( + wc_fake_entry()["state_machine"], + transitions=[{"from": "chopping", "when": "inventory full", "to": "drop_logs"}])) + check("modes: an ungated chopping -> drop_logs transition is rejected (drop route exclusive to the afk-xp drop mode — governor 10398 finding 3)", + any("gated explicitly" in e for e in woodcutting_errors("selftest/wc-dropgate", bad))) + + return errors + + +def run_selftest(verbose=False): + """Regression fixtures for the cross-field gates; error strings.""" + errors = [] + + def check(name, passed): + if verbose: + print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}") + if not passed: + errors.append(f"selftest: {name}") + + # --- supply-consistency fixtures --- + fixtures = [ + ("identical (item, source) with diverging ge_limit (18000 vs 12500)", + [("e1.yaml", {"economy": {"supplies": [ + {"item": "nature rune", "source": "ge", "ge_limit": 18000}]}}), + ("e2.yaml", {"economy": {"supplies": [ + {"item": "nature rune", "source": "ge", "ge_limit": 12500}]}})], True), + ("identical (item, source) with agreeing ge_limit (18000 both)", + [("e1.yaml", {"economy": {"supplies": [ + {"item": "nature rune", "source": "ge", "ge_limit": 18000}]}}), + ("e2.yaml", {"economy": {"supplies": [ + {"item": "nature rune", "source": "ge", "ge_limit": 18000}]}})], False), + ("no supplies / missing ge_limit are not gated", + [("e1.yaml", {"economy": {}}), + ("e2.yaml", {"economy": {"supplies": [ + {"item": "gold necklace", "source": "ge"}]}})], False), + ] + for name, rows, expect_error in fixtures: + got = bool(supply_limit_conflicts(rows)) + check(name, got == expect_error) + + # --- 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( + f"document_error rejects {type(val).__name__} docs with an ERROR, no raise", + 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"}]} + check("steel-bar recipe totals 3 inputs (1 iron ore + 2 coal)", + recipe_total_input_qty(steel) == 3) + # cost = 100*1 + 150*2 = 400; bar 600 - (400 + nature 145) = +55 + check("steel-bar complete-recipe margin = 55 (bar 600 - (iron 100 + 2*coal 150 + nature 145))", + recipe_margin(steel, bar_price=600, + input_prices={"Iron ore": 100, "Coal": 150}, + nature_price=145) == 55) + check("steel-bar margin goes negative when the bar price no longer covers the full recipe", + recipe_margin(steel, bar_price=500, + input_prices={"Iron ore": 100, "Coal": 150}, + nature_price=145) < 0) + + # --- 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) + # Full selection: Smithing gate + complete-recipe margin at actual and + # ALLOWED fill prices, falling back to the next lower unlocked recipe + bar_prices = {"Bronze bar": 200, "Iron bar": 380, "Silver bar": 450, + "Steel bar": 600, "Runite bar": 12500} + input_prices = {"Copper ore": 50, "Tin ore": 30, "Iron ore": 100, + "Silver ore": 150, "Coal": 150, "Runite ore": 11000} + nature = 145 + check("full selection at live prices: smithing 30 + steel margin +55 -> steel-bar", + select_recipe(f2p_bars, 30, bar_prices, input_prices, nature) == "steel-bar") + check("allowed-fill eligibility (x1.05): steel margin +27.75 -> steel-bar still selected", + select_recipe(f2p_bars, 30, bar_prices, input_prices, nature, + fill_factor=1.05) == "steel-bar") + check("material availability: steel margin-negative at allowed fills -> next lower unlocked (silver-bar)", + select_recipe(f2p_bars, 30, {**bar_prices, "Steel bar": 570}, input_prices, + nature, fill_factor=1.05) == "silver-bar") + check("no unlocked recipe with a positive margin -> None (skip SII, stay on LA)", + select_recipe(f2p_bars, 30, {"Bronze bar": 10, "Iron bar": 10, + "Silver bar": 10, "Steel bar": 10}, + input_prices, nature) is None) + check("an unmet Smithing gate is never selected (smithing 20 -> silver-bar, steel stays locked)", + select_recipe(f2p_bars, 20, bar_prices, input_prices, nature) == "silver-bar") + + 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": "43 <= level < 55 and the SII window is chosen (selection.mode picks the highest unlocked recipe with positive margin; MUTUALLY EXCLUSIVE — never at 55+)", "to": "cast"}, + {"from": "cast", "when": "no unlocked recipe has a positive margin this window — skip SII and stay on LA", "to": "cast"}, + {"from": "cast", "when": "level >= 55 and session target > 55 — the HLA tier executes until the target", "to": "cast"}, + {"from": "cast", "when": "magic level >= session target — completion at the configured target", "to": "complete"}] + 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) == []) + # --- governor comment 10477 regression: mutually exclusive tier boundaries --- + # The old SII window '43-55' overlapped HLA at exactly Magic 55 for + # targets above 55. selectable_tier() must prove exactly ONE tier at + # every (level, target) — completion first, HLA only >= 55 with a + # higher target, SII only 43 <= Magic < 55. + check("tier boundary 10477: Magic 55 with target 56 selects ONLY HLA (not SII/LA/complete)", + selectable_tier(55, 56) == "hla") + check("tier boundary 10477: Magic 55 with target 56 is never SII (the old overlap)", + selectable_tier(55, 56) != "sii") + check("tier boundary 10477: Magic 54 with target 56 still selects SII (below 55)", + selectable_tier(54, 56) == "sii") + check("tier boundary 10477: Magic 54 with SII skipped selects LA (SII-skip path)", + selectable_tier(54, 55, sii_skipped=True) == "la") + check("tier boundary 10477: Magic 55 with target 55 COMPLETES first (target wins)", + selectable_tier(55, 55) == "complete") + check("tier boundary 10477: Magic 56 with target 55 completes too", + selectable_tier(56, 55) == "complete") + check("tier boundary 10477: Magic 56 with target 60 selects HLA (above 55, target higher)", + selectable_tier(56, 60) == "hla") + check("tier boundary 10477: Magic 42 with target 55 selects LA (below 43)", + selectable_tier(42, 55) == "la") + check("tier boundary 10477: Magic 43 with target 55 selects SII (window lower edge)", + selectable_tier(43, 55) == "sii") + check("tier boundary 10477: exactly one tier for every level 40..60 at target 55", + all(len({selectable_tier(l, 55)}) == 1 + for l in range(40, 61))) + check("tier boundary 10477: SII is NEVER selectable at Magic 55+ (the old overlap)", + all(selectable_tier(l, 56) != "sii" for l in range(55, 61))) + check("tier boundary 10477: HLA is NEVER selected below Magic 55", + all(selectable_tier(l, 60) != "hla" for l in range(40, 55))) + check("tier boundary 10477: at 55 the mutual-exclusion table is exact", + [selectable_tier(l, 55) for l in (42, 43, 54, 55, 56)] == + ["la", "sii", "sii", "complete", "complete"]) + # tier_boundary_errors() gate: the old inclusive '43-55' spelling is rejected + entry_overlap = {"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"}, + {"from": "cast", "when": "level >= 55 and session target > 55 — HLA", "to": "cast"}]}} + overlap_errs = tier_boundary_errors("bad.yaml", entry_overlap) + check("tier boundary 10477: SII window '43-55' overlapping HLA is REJECTED", + any("overlaps" in e or "43-55" in e for e in overlap_errs)) + check("tier boundary 10477: mutually-exclusive spelling passes the boundary gate", + tier_boundary_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)) + + # --- rate_model fixtures (owner guidance 2026-08-28, bounce 4) --- + # The gate is OPT-IN: entries without rate_model are never treated as + # alchemy, and the rate is DERIVED from the entry's own declared + # lifecycle (cast action delay, run_control.break, recipes, startup). + cast_action = {"id": "a3", "step": "cast-alch", "verb": "use_item_on", + "params": {"spell": "Low Level Alchemy"}, + "on_fail": "retry", + "anti_ban": {"delay": [1600, 3400]}} + base = { + "rate_model": {"anchor_tier": "la", "cast_ticks": 3, + "restock_trip_s": [60, 120], "session_horizon_s": 7200, + "startup_s": [300, 600], + "alch_carry_batch": 1000, "alch_restock_threshold": 100}, + "actions": [cast_action], + "run_control": {"break": {"every": [400, 700], "for": [45, 120]}}, + "economy": {"xp_per_hour": 23000}} + check("non-alchemy entry without rate_model passes (mining xp 52,500 untouched)", + rate_model_errors("mining.yaml", {"economy": {"xp_per_hour": 52500}}) == []) + check("LA entry declaring rate_model with the derived 23,000 xp/hr passes", + rate_model_errors("ok.yaml", dict(base)) == []) + check("loop-only LA 25,953 fails the derived realized-rate gate", + any("derived realized rate" in e + for e in rate_model_errors("bad.yaml", dict(base, **{"economy": {"xp_per_hour": 25953}})))) + check("pre-startup realized 24,500 fails too (startup amortisation is part of the model)", + any("derived realized rate" in e + for e in rate_model_errors("bad.yaml", dict(base, **{"economy": {"xp_per_hour": 24500}})))) + check("8-14-cast-break regime 9,455 fails the derived rate gate", + any("derived realized rate" in e + for e in rate_model_errors("bad.yaml", dict(base, **{"economy": {"xp_per_hour": 9455}})))) + mutated = copy.deepcopy(base) + mutated["run_control"]["break"]["for"] = [3600, 3600] # one-hour break per cast + check("cadence mutation caught: one-hour break per cast fails the derived rate gate", + any("derived realized rate" in e + for e in rate_model_errors("bad.yaml", mutated))) + # SII: per-recipe capacity (floor(27 / total input qty)) + derived rates + def recipe(rid, bar, bar_id, lvl, ti, inputs, cap, cph, xp): + return {"bar": bar, "bar_item_id": bar_id, "smithing_level": lvl, + "target_input": ti, + "inputs": [{"item": it, "item_id": iid, "qty": q, "source": "ge"} + for it, iid, q in inputs], + "rates": {"capacity": cap, + "casts_per_hour": {"min": cph[0], "mid": cph[1], "max": cph[2]}, + "magic_xp_per_hour": {"min": xp[0], "mid": xp[1], "max": xp[2]}}} + steel_recipe = recipe("steel-bar", "Steel bar", 2353, 30, "Iron ore", + [("Iron ore", 440, 1), ("Coal", 453, 2)], + 9, (355.3, 249.1, 191.2), (18833, 13204, 10131)) + bronze_recipe = recipe("bronze-bar", "Bronze bar", 2349, 1, "Copper ore", + [("Copper ore", 436, 1), ("Tin ore", 438, 1)], + 13, (445.6, 316.5, 244.4), (23617, 16776, 12952)) + runite_recipe = recipe("runite-bar", "Runite bar", 2363, 85, "Runite ore", + [("Runite ore", 451, 1), ("Coal", 453, 8)], + 3, (153.4, 104.5, 79.1), (8131, 5538, 4194)) + sii_base = dict(base) + sii_base["rate_model"] = {"anchor_tier": "sii", "anchor_recipe": "steel-bar", + "cast_ticks": 3, "restock_trip_s": [60, 120], + "session_horizon_s": 7200, "startup_s": [300, 600]} + sii_base["economy"] = {"xp_per_hour": 12400} + sii_base["recipes"] = {"steel-bar": steel_recipe, "bronze-bar": bronze_recipe, + "runite-bar": runite_recipe} + check("SII entry passes: capacities floor(27/qty) = steel 9, bronze 13, runite 3, all rate pins derived", + rate_model_errors("ok.yaml", dict(sii_base)) == []) + check("steel anchor xp 12,400 (249.1 x 53 x startup 0.9375) passes the anchor gate", + rate_model_errors("ok2.yaml", dict(sii_base)) == []) + check("old 24-pack SII rate 27,500 fails the derived per-recipe rate", + any("derived realized rate" in e + for e in rate_model_errors("bad.yaml", dict(sii_base, **{"economy": {"xp_per_hour": 27500}})))) + bad_cap = copy.deepcopy(sii_base) + bad_cap["recipes"]["steel-bar"]["rates"]["capacity"] = 8 + check("capacity pin mismatch (8 vs floor(27/3)=9) is rejected", + any("rates.capacity 8" in e for e in rate_model_errors("bad.yaml", bad_cap))) + bad_xp = copy.deepcopy(sii_base) + bad_xp["recipes"]["steel-bar"]["rates"]["magic_xp_per_hour"]["mid"] = 20000 + check("recipe magic_xp pin far from the derived 13,204 is rejected", + any("magic_xp_per_hour.mid" in e for e in rate_model_errors("bad.yaml", bad_xp))) + oversized = copy.deepcopy(sii_base) + oversized["recipes"]["oversized-bar"] = recipe("oversized-bar", "Oversized bar", 9999, 90, + "Iron ore", [("Iron ore", 440, 28)], + 1, (1, 1, 1), (53, 53, 53)) + check("recipe needing more input slots than the inventory (qty 28 > 27) is rejected", + any("cannot fit the inventory" in e + for e in rate_model_errors("bad.yaml", oversized))) + + # --- governor comment 10394 regression fixtures (bounce-fix 6) --- + # Finding 3: Magic target boundary — the entry may stop at the declared + # target (55 = the LA/SII -> HLA handoff) but must CONTINUE below it. + check("target boundary: level 54 with target 55 is a CONTINUE (not complete)", + target_reached(54, 55) is False) + check("target boundary: level 55 with target 55 HANDS OFF (complete)", + target_reached(55, 55) is True) + check("target boundary: level 56 with target 55 completes too", + target_reached(56, 55) is True) + # Finding 2: sign-corrected capital-loss stop — a loss is when assets + # FALL; the old reversed sign (total_assets - initial_capital) can never + # exceed the reserve on losses. + check("loss stop: assets 800,000 below initial capital (800,000 > 684,400 reserve) FIRES", + stop_loss_exceeded(1_300_000, 500_000, 684_400) is True) + check("loss stop: assets 604,400 below initial capital (604,400 < reserve) does NOT fire", + stop_loss_exceeded(1_300_000, 695_600, 684_400) is False) + check("loss stop reversed-sign trap: a PROFIT of 100,000 must NOT fire the loss stop", + stop_loss_exceeded(1_200_000, 1_300_000, 684_400) is False) + check("loss stop: crossing the reserve boundary exactly (loss == reserve) does NOT fire (strict >)", + stop_loss_exceeded(1_300_000, 615_600, 684_400) is False) + # Finding 1: build-loadout proof — carried counts must equal the carried + # amounts before the first cast; a shortfall is not verified. + required = {"Gold necklace": 1000, "Nature rune": 1000} + check("loadout verified at exactly 1,000/1,000 (proven before the first cast)", + loadout_verified({"Gold necklace": 1000, "Nature rune": 1000}, required) is True) + check("loadout NOT verified at 999/1,000 (shortfall routes to bank_supplies)", + loadout_verified({"Gold necklace": 999, "Nature rune": 1000}, required) is False) + check("loadout NOT verified when natures are missing entirely", + loadout_verified({"Gold necklace": 1000}, required) is False) + + return errors + + +def run_flip_selftest(verbose=False): + """Regression fixtures for the GE floor-tax + GE-flip lifecycle gates + (merged from PR #7 / main reconciliation); returns error strings.""" + errors = [] + + def check(name, passed): + if verbose: + print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}") + if not passed: + errors.append(f"selftest: {name}") fixtures = [ # name, tags, economy, expect_error @@ -1832,196 +2938,33 @@ def run_selftest(verbose=False): any("closed -> free" in v for v in slot_reuse_violations(closed_no_free))) - # --- woodcutting structured gates (owner 10288 / governor 10398) --- - check("axe: 41-Woodcutting/1-Attack account carries and USES a rune axe (does not pick a lower axe)", - best_axe(41, 1) == ("rune", False)) - check("axe: 41-Woodcutting/40-Attack account equips the rune axe", - best_axe(41, 40) == ("rune", True)) - check("axe: 30-Woodcutting account is below adamant (31) and uses mithril (equippable at 20 Attack)", - best_axe(30, 40) == ("mithril", True)) - check("axe: 31-Woodcutting/5-Attack account uses adamant but cannot equip it (carries)", - best_axe(31, 5) == ("adamant", False)) - check("axe: level-1 account uses an iron axe equipped from the start", - best_axe(1, 1) == ("iron", True)) - check("axe: dragon (61/60, members) is never selected for an F2P woodcutting entry; maxed account still picks the top F2P axe (rune)", - best_axe(99, 99) == ("rune", True)) - canon = {(row[0], row[1], row[2]) for row in AXE_TIERS} - {("dragon", 61, 60)} - want = {("bronze", 1, 1), ("iron", 1, 1), ("steel", 6, 5), ("black", 11, 10), - ("mithril", 21, 20), ("adamant", 31, 30), ("rune", 41, 40), ("gilded", 41, 40)} - check("axe: canonical F2P axe table matches the wiki (WC to use / Attack to equip)", - canon == want) - - def wc_fake_entry(**over): - base = {"tags": ["woodcutting"], - "axe_tiers": [{"axe": a, "woodcutting_level": w, "attack_level": k, "members": m} - for a, w, k, m in AXE_TIERS], - "axe_examples": [{"account": "41WC/1Atk", "woodcutting_level": 41, "attack_level": 1, - "expected_axe": "rune", "expected_equipped": False}], - "state_machine": {"states": {"idle": {"goal": "start", "next": ["arm_run"]}, - "arm_run": {"goal": "pick", "next": ["prepare"]}, - "prepare": {"goal": "acquire axe", "next": ["walk_to_trees", "failed"]}, - "chopping": {"goal": "chop", "next": ["drop_logs", "walk_to_bank"]}, - "drop_logs": {"goal": "drop", "next": ["chopping"]}, - "walk_to_bank": {"goal": "bank", "next": ["chopping"]}, - "failed": {"goal": "fail"}}, - "initial": "idle", "terminal": ["failed"], - "transitions": [ - {"from": "idle", "when": "session target reached", "to": "complete"}, - {"from": "arm_run", "when": "row pinned", "to": "prepare"}, - {"from": "prepare", "when": "axe acquired", "to": "walk_to_trees"}, - {"from": "prepare", "when": "axe unobtainable", "to": "failed"}, - {"from": "chopping", "when": "mode is afk-xp (inventory_policy drop) and inventory full", "to": "drop_logs"}, - {"from": "chopping", "when": "mode is fast-xp or banked-income and inventory full", "to": "walk_to_bank"}]}, - "woodcutting_fixtures": [ - {"account": "f1", "mode": "fast-xp", "woodcutting_level": 50, "attack_level": 40, - "quest_gate": False, "combat_level": 50, "expected_tree": "willow", - "expected_axe": "rune", "expected_equipped": True, - "expected_logs_per_hour": 622, "expected_xp_per_hour": 41985, - "expected_gp_per_hour": 13062}], - "woodcutting_modes": [ - {"mode": "fast-xp", "intent": "max xp per hour on the F2P ladder", - "inventory_policy": "bank", - "ladder": [ - {"tree": "willow", "min_level": 30, "gate": "combat-15-plus", - "spot": "draynor willows", "bank": "draynor", - "chop_seconds_per_log": 4.0, "walk_seconds_each_way": 12, - "respawn_duty": 0.92, "log_price": 21}, - {"tree": "willow", "min_level": 30, "gate": "combat-under-15", - "spot": "edgeville willow", "bank": "edgeville", - "chop_seconds_per_log": 4.0, "walk_seconds_each_way": 6, - "respawn_duty": 0.90, "log_price": 21}, - {"tree": "oak", "min_level": 15, "gate": "none", - "spot": "draynor oaks", "bank": "draynor", - "chop_seconds_per_log": 3.5, "walk_seconds_each_way": 8, - "respawn_duty": 0.93, "log_price": 40}, - {"tree": "tree", "min_level": 1, "gate": "none", - "spot": "lumbridge west", "bank": "lumbridge", - "chop_seconds_per_log": 3.0, "walk_seconds_each_way": 10, - "respawn_duty": 0.95, "log_price": 23}], - "model": {"drop_seconds_per_log": 0, "deposit_seconds": 4, - "break_duty": 0.94}, - "rates": {"logs_per_hour": 622, "xp_per_hour": 41985, - "gp_per_hour": 13062, "log_price": 21, - "price_verified": "2026-08-28"}}, - {"mode": "afk-xp", "intent": "low-interaction quest-gated progress", - "inventory_policy": "drop", - "ladder": [ - {"tree": "maple", "min_level": 45, "gate": "dragon-slayer-i", - "spot": "corsair cove resource area", "bank": "none-drop", - "chop_seconds_per_log": 6.0, "walk_seconds_each_way": 0, - "respawn_duty": 0.80, "log_price": 12}, - {"tree": "willow", "min_level": 30, "gate": "combat-15-plus", - "spot": "draynor willows", "bank": "none-drop", - "chop_seconds_per_log": 6.0, "walk_seconds_each_way": 0, - "respawn_duty": 0.92, "log_price": 21}, - {"tree": "oak", "min_level": 15, "gate": "none", - "spot": "draynor oaks", "bank": "none-drop", - "chop_seconds_per_log": 5.5, "walk_seconds_each_way": 0, - "respawn_duty": 0.93, "log_price": 40}, - {"tree": "tree", "min_level": 1, "gate": "none", - "spot": "lumbridge west", "bank": "none-drop", - "chop_seconds_per_log": 5.0, "walk_seconds_each_way": 0, - "respawn_duty": 0.95, "log_price": 23}], - "model": {"drop_seconds_per_log": 1.0, "deposit_seconds": 0, - "break_duty": 0.94}, - "rates": {"logs_per_hour": 386, "xp_per_hour": 38600, - "gp_per_hour": 0, "log_price": 12, - "price_verified": "2026-08-28"}}, - {"mode": "banked-income", "intent": "banked logs sold at the GE", - "inventory_policy": "bank", - "ladder": [ - {"tree": "yew", "min_level": 60, "gate": "none", - "spot": "edgeville south yews", "bank": "edgeville", - "chop_seconds_per_log": 7.5, "walk_seconds_each_way": 8, - "respawn_duty": 0.52, "log_price": 111}, - {"tree": "willow", "min_level": 30, "gate": "combat-15-plus", - "spot": "draynor willows", "bank": "draynor", - "chop_seconds_per_log": 4.0, "walk_seconds_each_way": 12, - "respawn_duty": 0.92, "log_price": 21}, - {"tree": "oak", "min_level": 15, "gate": "none", - "spot": "draynor oaks", "bank": "draynor", - "chop_seconds_per_log": 3.5, "walk_seconds_each_way": 8, - "respawn_duty": 0.93, "log_price": 40}, - {"tree": "tree", "min_level": 1, "gate": "none", - "spot": "lumbridge west", "bank": "lumbridge", - "chop_seconds_per_log": 3.0, "walk_seconds_each_way": 10, - "respawn_duty": 0.95, "log_price": 23}], - "model": {"drop_seconds_per_log": 0, "deposit_seconds": 4, - "break_duty": 0.94}, - "rates": {"logs_per_hour": 214, "xp_per_hour": 37450, - "gp_per_hour": 23754, "log_price": 111, - "price_verified": "2026-08-28"}}]} - base.update(over) - return base - - check("modes: a compliant 3-mode entry (per-row rates, drop-GP-zero, prepare wiring) passes every structured gate", - woodcutting_errors("selftest/wc-good", wc_fake_entry()) == []) - bad = wc_fake_entry(woodcutting_modes=wc_fake_entry()["woodcutting_modes"][1:]) - check("modes: missing a mode (only 2 declared) is rejected", - any("exactly" in e for e in woodcutting_errors("selftest/wc-missing", bad))) - bad = wc_fake_entry(axe_examples=[{"account": "41WC/1Atk", "woodcutting_level": 41, "attack_level": 1, - "expected_axe": "adamant", "expected_equipped": True}]) - check("modes: a 41-WC/1-Attack example declaring a lower/equipped axe is rejected", - any("recomputed rune" in e for e in woodcutting_errors("selftest/wc-badex", bad))) - bad = wc_fake_entry(woodcutting_modes=[dict(m, ladder=[dict(r, min_level=30 if r["tree"] == "maple" else r["min_level"]) for r in m["ladder"]]) - for m in wc_fake_entry()["woodcutting_modes"]]) - check("modes: maple below level 45 is rejected", - any("maple must have min_level 45" in e for e in woodcutting_errors("selftest/wc-maple45", bad))) - bad = wc_fake_entry(woodcutting_modes=[dict(m, ladder=[dict(r, gate="none") for r in m["ladder"]]) - for m in wc_fake_entry()["woodcutting_modes"]]) - check("modes: maple without the dragon-slayer-i gate is rejected", - any("gate dragon-slayer-i" in e for e in woodcutting_errors("selftest/wc-maplegate", bad))) - bad = wc_fake_entry(woodcutting_modes=[dict(m, ladder=[dict(r) for r in m["ladder"][::-1]]) - for m in wc_fake_entry()["woodcutting_modes"]]) - check("modes: a ladder not ordered by min_level DESC is rejected (nondeterministic)", - any("ordered by min_level DESC" in e for e in woodcutting_errors("selftest/wc-order", bad))) - badm = [dict(m, rates=dict(m["rates"], logs_per_hour=9999)) - for m in wc_fake_entry()["woodcutting_modes"]] - check("modes: a rate far above the model chain (9999/hr) is rejected", - any("SELECTED-row" in e for e in woodcutting_errors("selftest/wc-rate", wc_fake_entry(woodcutting_modes=badm)))) - bad = wc_fake_entry(axe_tiers=[{"axe": a, "woodcutting_level": w, "attack_level": k, "members": m} - for a, w, k, m in AXE_TIERS if a != "rune"]) - check("modes: a non-canonical axe table (rune missing) is rejected", - any("canonical F2P table" in e for e in woodcutting_errors("selftest/wc-axes", bad))) - badm = wc_fake_entry()["woodcutting_modes"] - badm[0] = dict(badm[0], rates=dict(badm[0]["rates"], xp_per_hour=40000)) - check("modes: xp_per_hour not equal to logs x per-log xp is rejected", - any("xp_per_hour" in e for e in woodcutting_errors("selftest/wc-xp", wc_fake_entry(woodcutting_modes=badm)))) - # governor 10398 finding 4: a fixture whose expected logs/XP do not match - # the selected-row chain with the 27/28 capacity is rejected. - bad = wc_fake_entry(woodcutting_fixtures=[ - {"account": "carried-rune-27", "mode": "afk-xp", "woodcutting_level": 50, "attack_level": 1, - "quest_gate": True, "combat_level": 50, "expected_tree": "maple", - "expected_axe": "rune", "expected_equipped": False, - "expected_logs_per_hour": 9999, "expected_xp_per_hour": 999999, - "expected_gp_per_hour": 0}]) - check("modes: a fixture whose declared rates do not match the selected-row 27-slot chain is rejected (governor 10398 finding 4)", - any("selected-row chain" in e for e in woodcutting_errors("selftest/wc-fxrate", bad))) - # governor 10398 finding 5: a drop mode declaring non-zero realized GP. - badm = wc_fake_entry()["woodcutting_modes"] - badm[1] = dict(badm[1], rates=dict(badm[1]["rates"], gp_per_hour=4632)) - check("modes: drop mode with non-zero realized gp_per_hour is rejected (dropped logs realize 0 gp — governor 10398 finding 5)", - any("realize gp_per_hour 0" in e for e in woodcutting_errors("selftest/wc-dropgp", wc_fake_entry(woodcutting_modes=badm)))) - # governor 10398 finding 2: idle -> complete must not be the no-axe hatch. - bad = wc_fake_entry(state_machine=dict( - wc_fake_entry()["state_machine"], - transitions=[{"from": "idle", "when": "no axe obtainable", "to": "complete"}])) - check("modes: idle -> complete on a missing axe is rejected (unobtainable axe must go to failed — governor 10398 finding 2)", - any("never on a missing axe" in e for e in woodcutting_errors("selftest/wc-idlecomplete", bad))) - # governor 10398 finding 3: the drop transition must be gated to afk-xp. - bad = wc_fake_entry(state_machine=dict( - wc_fake_entry()["state_machine"], - transitions=[{"from": "chopping", "when": "inventory full", "to": "drop_logs"}])) - check("modes: an ungated chopping -> drop_logs transition is rejected (drop route exclusive to the afk-xp drop mode — governor 10398 finding 3)", - any("gated explicitly" in e for e in woodcutting_errors("selftest/wc-dropgate", bad))) - return errors -def run_flip_selftest(verbose=False): - """Alias: the combined selftest runs the flip fixtures; kept so the - flip-specific call sites remain intact (they defer to run_selftest).""" - return run_selftest(verbose) + +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") + env = dict(os.environ, CODEX_SKIP_REGRESSION_SUITES="1") + proc = subprocess.run( + [sys.executable, str(Path(__file__).resolve()), "--stale-days", "9999"], + capture_output=True, text=True, cwd=str(ROOT), timeout=120, env=env) + 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(): @@ -2031,17 +2974,24 @@ def main(): errors, warnings = [], [] if "--selftest" in sys.argv: - errors += run_mining_selftest(verbose=True) errors += run_selftest(verbose=True) errors += run_flip_selftest(verbose=True) + errors += run_mining_selftest(verbose=True) + errors += run_woodcutting_selftest(verbose=True) print(f"RESULT: selftest, {len(errors)} errors, 0 warnings") return 1 if errors else 0 - # The fixes themselves are gated: both regression suites run on every - # invocation (mining model + GE flip lifecycle), not just --selftest. - errors += run_mining_selftest(verbose=False) - errors += run_selftest(verbose=False) - errors += run_flip_selftest(verbose=False) + # The fixes themselves are gated: all regression suites run on every + # invocation (alchemy/citation, mining model, GE flip lifecycle, + # woodcutting), + # not just --selftest. A nested validator spawned by the + # _end_to_end_bad_mapping fixture must not re-run the suites (that + # would recurse); the fixture's child scans the tree only. + if not os.environ.get("CODEX_SKIP_REGRESSION_SUITES"): + errors += run_selftest(verbose=False) + errors += run_flip_selftest(verbose=False) + errors += run_mining_selftest(verbose=False) + errors += run_woodcutting_selftest(verbose=False) entries = load_entries() if not entries: print("ERROR: no codex entries found under codex/") @@ -2049,6 +2999,7 @@ def main(): ids = {} today = datetime.date.today() + rows = [] for path in entries: rel = str(path.relative_to(ROOT)) @@ -2057,16 +3008,27 @@ def main(): except yaml.YAMLError as e: errors.append(f"{rel}: YAML parse error: {e}") continue + shape_err = document_error(rel, data) + if shape_err: + errors.append(shape_err) + continue - # 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) - for err in 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: loc = "/".join(str(p) for p in err.path) or "(root)" errors.append(f"{rel} [{loc}]: {err.message}") - if errors and errors[-1].startswith(rel): - # schema already broke; cross-checks on a broken doc are noise + if schema_errors: + # 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]})") @@ -2094,15 +3056,6 @@ def main(): if term not in declared: errors.append(f"{rel}: terminal '{term}' not declared") - # one-cast worked-example regression (owner guidance 2026-08-28, - # PR #8): monotonic break/restock counters, scoped to the one-cast model - errors += alch_worked_example_regression(rel, data, path.read_text()) - - # loss-reserve direction + fill-price GP bound regression (owner - # guidance 2026-08-29 for governor 10361, PR #8), scoped to - # low-alch-members - errors += alch_accounting_regression(rel, data, path.read_text()) - # action id/step uniqueness a_ids = [a["id"] for a in data["actions"]] a_steps = [a["step"] for a in data["actions"]] @@ -2114,6 +3067,11 @@ def main(): # 2b. structured mining-model gates (pickaxe gates + run-mode contract) errors += mining_model_errors(rel, data) + # SII recipe-model + selection gates + realized-rate consistency + errors += recipe_model_errors(rel, data) + errors += tier_boundary_errors(rel, data) + errors += rate_model_errors(rel, data) + # GE-margin floor-tax arithmetic (see ge_floor_tax_error): scoped to # the unambiguous margin tags flipping/merchanting. The generic `ge` # tag alone must not gate this rule — alchemy/processing/procurement @@ -2124,9 +3082,14 @@ def main(): # GE-flip lifecycle gates (per-slot async model, 4h limit ledger vs # offer expiry, typed ceiling vs realized scenario bounds). errors += ge_lifecycle_errors(rel, data) - # Woodcutting structured gates (owner 10288 / governor 10398 shape: - # canonical axe table, deterministic per-mode ladders, per-selected- - # row dynamic rates with 27/28 capacity, drop-mode realized GP zero). + # one-cast worked-example + loss-reserve/fill-price regressions + # (merged from low-alch-members PR #8) + errors += alch_worked_example_regression(rel, data, path.read_text()) + errors += alch_accounting_regression(rel, data, path.read_text()) + # Woodcutting structured gates (owner 10288 / governor 10398 + # shape: canonical axe table, deterministic per-mode ladders, + # per-selected-row dynamic rates with 27/28 capacity, drop-mode + # realized GP zero, prepare/failed wiring). errors += woodcutting_errors(rel, data) # 3. staleness / research TODOs @@ -2147,6 +3110,9 @@ def main(): f"{rel}: best_practice '{line.get('item') or line.get('name')}' " f"carries a 'researched' source — citation TODO, file follow-up") + # 2b. cross-entry supply-fact consistency (GE buy limits are item-global) + errors += supply_limit_conflicts(rows) + # report for w in warnings: print(f"WARN {w}") @@ -2157,4 +3123,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + sys.exit(main()) \ No newline at end of file