diff --git a/codex/economy/low-alch-f2p.yaml b/codex/economy/low-alch-f2p.yaml index 7fa3a64..02238be 100644 --- a/codex/economy/low-alch-f2p.yaml +++ b/codex/economy/low-alch-f2p.yaml @@ -3,32 +3,39 @@ title: Low-Level Alchemy (F2P) domain: economy tags: [alching, magic-training, f2p, loss-training] status: verified -version: 2 # 2: bounce-fix 3 2026-08-28 (owner guidance) — realized rates incl. mandatory breaks + banking, break.every in CAST counts, numeric carried_batch/restock_threshold, pinned worked example +version: 3 # 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 pin (owner guidance 2026-08-28): one iteration = one cast; breaks + Model pins (owner guidance 2026-08-28): one iteration = one cast; breaks every 400-700 COMPLETED CASTS (cast-count based, independent of bank - visits); restock when candidate or nature drops below the numeric - restock_threshold (100) against a carried_batch of 1,000/1,000 — worked - example pinned below; all rates are REALIZED (delays + breaks + banking). + 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; NO level requirement documented on the OSRS wiki — verified 2026-08-28, see sources)" - - "A bank with GE access within reach (alch spot = the bank booth; restock trip 60-120s)" - - "Working capital >= 292,000 gp for one carried_batch (1,000 x 148 gp gold necklaces + 1,000 x 144 gp natures, live 2026-08-27)" + - "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 -> 54) + 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); equip requirement: none documented on the OSRS wiki (verified 2026-08-28)" + 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 @@ -41,31 +48,40 @@ best_practice: economy: # Verified 2026-08-27 against prices.runescape.wiki (live 'latest') # + OSRS wiki Low_Level_Alchemy infobox. - # Bounce-fix 3 (2026-08-28, owner guidance): all rate fields are REALIZED - # figures — mandatory breaks + banking included, not just the cast loop. + # Bounce-fix 4 (2026-08-28, owner guidance 10331): all rate fields are + # SESSION-REALIZED — startup + restock + mandatory breaks included over + # the declared 2 h session horizon (rate_model). 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) income_per_cycle: 180 # lowalch of gold necklace (40% of value 450) net_per_cycle: -112 - gp_per_hour_low: -110500 # realized (incl. restock + breaks), fastest cycle: 986.4 casts/hr x -112 = -110,477 - gp_per_hour_high: -74000 # realized (incl. restock + breaks), slowest cycle: 660.6 casts/hr x -112 = -73,987 - xp_per_hour: 24500 # realized midpoint: 791.3 casts/hr x 31 xp = 24,530 (pin 24,500) - # ---- realized-rate derivation (pinned; owner guidance 2026-08-28) ---- - # Cast: 1.8s (3 ticks x 0.6s) + anti_ban.delay [1.6, 3.4]s ADDITIVE after the - # action (docs/action-grammar.md:135) = 3.4-5.2s, midpoint 4.3s. + gp_per_hour_low: -109500 # session-realized fast bound: 977.1 casts/hr x -112 = -109,435 (3.4 s cycle, 300 s startup, 2 h horizon) + gp_per_hour_high: -65600 # session-realized slow bound: 585.9 casts/hr x -112 = -65,621 (5.2 s cycle, 600 s startup, 2 h 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 (first restock after cast 901). Overhead per - # 901-cast batch: 90s restock trip (60-120s, GE-adjacent booth) + 135s breaks - # (every [400, 700] casts, mid 550, for [45, 120]s -> 901/550 x 82.5s) = 225s. - # REALIZED casts/hr = 901 / ((901 x cycle_s + 225) / 3600): - # fast 3.4s -> 986.4; midpoint 4.3s -> 791.3; slow 5.2s -> 660.6. - # xp_per_hour = 791.3 x 31 = 24,530 (pin 24,500). - # gp_per_hour = casts/hr x -112: fast -110,477 (low), slow -73,987 (high), - # midpoint -88,626. - # Superseded figures (fail the realized gate on purpose): loop-only 837 - # casts/hr (25,953 xp/hr) ignored breaks + banking; the 8-14-cast break - # regime (~305 casts/hr) and the 1,800-cast regime ignored the additive delay. + # 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 -112: low -109,435, high -65,621 (mid -83,082). + # 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 -> 54) = 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 @@ -79,54 +95,86 @@ economy: profit_drivers: - "NONE at current prices — XP is the product, not GP" - "Pick the candidate with the least-negative net each run (live rank)" - - "Batch-scale economics (worst case 1 nature/cast): carried_batch 1,000/1,000, restock every 901 casts — one batch = 901 x -112 = -100,912 gp, +27,931 xp" + - "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, acquire + equip the staff of fire, GE-buy candidate + natures, build the starting inventory to carried_batch (1,000 candidate + 1,000 nature)" - next: [casting, complete, failed] + 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 loadout to carried_batch 1,000 candidate + 1,000 nature (a8; purchases from a6 are already in the inventory — deposit overflow, withdraw only the shortfall)" + next: [casting, failed] casting: - goal: "cast Low Level Alchemy on the current candidate item — each cast consumes 1 candidate + 1 nature and pays 180 gp (lowalch), +31 xp, net -112 gp; take a break when the completed-cast counter hits run_control.break.every ([400, 700] casts)" + goal: "cast Low Level Alchemy on the current candidate item (a8) — each cast consumes 1 candidate + 1 nature and pays 180 gp (lowalch), +31 xp, net -112 gp; take a break when the completed-cast counter hits run_control.break.every ([400, 700] casts, via a12); the counter is NEVER reset by restocking" next: [bank_supplies, complete, failed] bank_supplies: - goal: "deposit coins, withdraw candidate + natures back to carried_batch (1,000/1,000), resume casting" - next: [casting, failed] + goal: "restock to carried_batch: deposit coins + products and withdraw ALL candidate/natures the bank holds (a9 — bank-stock fast path when the bank already holds enough); for the remaining deficit, open GE (a2), buy the deficit (a10, live low + 5%), collect it (a11) — 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 (default 54, hand off to HLA at 55)" + goal: "reached the run's XP/level target (default 54, hand off to HLA at 55) or the 2 h session horizon" failed: goal: "hard failure — stop, log ERROR sentinel, alert" transitions: - # Handoff (verified 2026-08-27 from the OSRS wiki): the LA page states - # "Players with level 55 Magic are generally recommended to use High - # Level Alchemy instead" (65 vs 31 xp/cast, 60% vs 40% of item value). - # Superheat Item unlocks at 43 (53 xp/cast) — an optional window if - # ore-smelting margins justify it. Handoffs are soft efficiency choices, - # not hard caps (all spells stay castable); see - # codex/training/magic-alch-handoff.yaml. - from: idle - when: "staff equipped AND candidate == carried_batch AND nature == carried_batch (1,000 and 1,000)" - to: casting + when: "staff + candidate + nature offers all placed (a3/a4/a5)" + to: wait_offers - from: idle - when: "magic level >= target (default 54, hand off to HLA at 55)" + when: "magic level >= target (54, hand off to HLA at 55) at session start" to: complete - from: idle - when: "3 consecutive purchase/equip failures with no fallback available (recovery block)" + 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 loadout at carried_batch 1,000/1,000 (a8)" + to: casting + - from: ready + when: "staff equip failed after bounded retries — FAIL CLOSED" to: failed - from: casting - when: "candidate count < restock_threshold (100) OR nature rune count < restock_threshold (100) — deterministic restock trigger, pinned numbers" + 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 (default 54, hand off to HLA at 55)" + when: "magic level >= target (54, hand off to HLA at 55)" to: complete - from: casting when: "3 consecutive failed casts (client issue — do not loop)" to: failed - from: bank_supplies - when: "restocked to carried_batch (1,000 candidate + 1,000 nature)" + 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 @@ -141,18 +189,76 @@ actions: 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/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: "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: 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%" - on_fail: retry # re-place at live low +5% with paced offers; 3 consecutive failures -> failed (recovery block) + 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: a3 + - 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: cast-alch verb: use_item_on target: "candidate item in inventory" @@ -163,102 +269,144 @@ actions: consumption: "1 candidate + 1 nature per cast (staff of fire default); pays 180 gp (lowalch) + 31 xp, net -112 gp" on_fail: retry anti_ban: - delay: [1600, 3400] # MANDATORY post-action sleep, ADDITIVE to the 1.8s cast (3 ticks x 0.6s; action-grammar.md:135): cycle = 3.4-5.2s, mid 4.3s -> realized ~791 casts/hr incl. restock + breaks (pinned in economy) + 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: a9 + step: restock-deposit verb: bank_inventory target: bank params: deposit: [all coins, all products] - withdraw: "candidate + nature runes up to carried_batch (1,000/1,000)" + 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 (a10) and collected (a11)" on_fail: alert + anti_ban: + delay: [500, 1100] - - id: a5 + - id: a10 + 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 a9 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: a11 + 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: a12 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" + 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" # schema.md: break.every counts iterations, so every - # value below is in completed casts, NOT bank trips - # (owner decision 2026-08-28) - 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: [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 + 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 (default 54, hand off to HLA at 55)" - - "gp under floor (working capital below one carried_batch = 292,000 gp)" + - "magic level >= target (54 -> hand off to HLA at 55)" + - "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 (total assets - initial capital) > 684,400 — the 6-batch worst-case loss reserve is exhausted" - "3 consecutive failed casts (client issue — do not loop)" - - "3 consecutive failed GE purchases with no fallback candidate available" + - "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 (midpoint 550); break.for = [45, 120] s; - # default staff-of-fire path consumes 1 candidate + 1 nature per cast (worst case 1:1). + # 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 — before the first inventory: account holds >= 292,000 gp working capital - # (1,000 x 148 + 1,000 x 144). At the GE bank: GE-buy staff of fire, Wield it - # (no documented requirement), GE-buy 1,000 gold necklaces (noted, stacked) + - # 1,000 nature runes, withdraw both -> loadout = carried_batch 1,000/1,000. - # Cast counter := 0. - # Step 1 — casts #1..#550: each cast consumes 1 candidate + 1 nature (-292 gp) and - # pays 180 gp (lowalch) + 31 xp -> net -112 gp. Cast counter 0 -> 550 (~39 min at - # midpoint cadence). - # Step 2 — FIRST SCHEDULED BREAK at cast 550 (midpoint of break.every [400, 700]): - # take-break for 45-120 s. Resume casting at cast 551. The break fired on the CAST - # COUNT, not on any bank visit; the restock counter is untouched. - # Step 3 — casts #551..#901: candidate 1000-901 = 99 < 100 (restock_threshold) and - # nature 1000-901 = 99 < 100 simultaneously (worst case 1:1) -> the transition - # fires at the end of cast #901: FIRST RESTOCK/BANK EVENT at cast 901. - # Coins deposited at this restock: 901 x 180 = 162,180 gp; batch net -100,912 gp; - # batch xp 901 x 31 = 27,931. - # Step 4 — restock: deposit coins + products, withdraw 901 candidate + 901 nature -> - # carried_batch restored to 1,000/1,000. Resume casting at cast 902 (the cast - # counter continues; the break counter also continues — restocking never resets either). - # Step 5 — SECOND BREAK when the cast-count interval since break #1 completes - # ([400, 700], midpoint: cast 902 + 550 = 1,452): take-break, resume at 1,453. - # Step 6 — restock #2 fires at cast 1,802 (901-cast batch cadence), and so on, until a - # stop_condition fires (Magic 54+ -> hand off to HLA at 55; gp under floor; 3 failed casts). - # Bank visits and breaks are INDEPENDENT by construction: restock cadence = 901 casts; - # break cadence = 400-700 casts, and the two counters do not interact. + # 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 (a12). 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 (a9); 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 (a10, live low + 5 %), collect (a11) -> + # 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 54+ (HLA handoff at + # 55), the 2 h session horizon, the capital stops (assets floor 311,600 or + # net loss > 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 the mandatory anti_ban delays (cadence pin: additive post-cast sleep [1.6, 3.4]s; realized ~791 casts/hr at the 4.3s midpoint cycle incl. restock + breaks; loop-only 837)." - - "Breaks are CAST-COUNT-based (every [400, 700] completed casts, 45-120s AFK) and independent of bank visits — never treat a restock trip as a break." - - "GE buy bursts: pace offers, respect the 4h limit window (18,000 necklaces / 18,000 natures)." + - "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; restock -# action a4 rebuilds to carried_batch and the loop resumes (worked example steps 3-4). -# - Failed GE purchases (offer unfilled): re-place at live low + 5% with paced offers (a2); -# after 3 consecutive failures, alert + stop (failed) unless a fallback candidate is -# switched in — never sit in an unfillable offer loop. -# - Staff of fire cannot be equipped: fall back to fire runes (3 fire + 1 nature = +15 gp -# per cast rune cost), log WARN, continue; never loop on equip attempts. -# - Invalid loadout (wrong item in hand / spell unavailable): re-run build-loadout (a2/a4); -# 3 consecutive failed casts -> failed (stop, log ERROR sentinel, alert). -# - Explicit completion/stop: stop_conditions above (Magic 54+ HLA handoff, gp floor, -# repeated failures). +# 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): re-run build-loadout +# (a8); 3 consecutive failed casts -> failed (stop, log ERROR sentinel, alert). +# - 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 54+ HLA handoff at 55, +# 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)" + - "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) + verb take_break — the cadence + break + lifecycle model; owner guidance 2026-08-28 (realized rates incl. breaks + banking, worked example)" + - "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)" verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27 related: diff --git a/codex/training/magic-alch-handoff.yaml b/codex/training/magic-alch-handoff.yaml index 49a6632..a6f90ae 100644 --- a/codex/training/magic-alch-handoff.yaml +++ b/codex/training/magic-alch-handoff.yaml @@ -3,7 +3,7 @@ title: Magic training via alchemy (LA → SII → HLA handoff) domain: training tags: [magic-training, alching, f2p, xp-training] status: verified -version: 2 # 2: bounce-fix 3 2026-08-28 (owner guidance) — dynamic recipe selection (highest-unlocked, target_input, fallback), realized rates incl. mandatory breaks + banking, full-lifecycle actions, worked example pinned +version: 3 # 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: > @@ -12,44 +12,59 @@ summary: > ore into bars), then High Level Alchemy from 55 (65 xp/cast, the wiki's recommended end-state). All three spells are F2P and stay castable after their unlock levels, so the handoffs are efficiency recommendations, not - hard caps. SII model (owner guidance 2026-08-28): the recipe table is - dynamic and governed by the account's Smithing level — the highest UNLOCKED - recipe with a positive complete-cost margin is selected, its primary ore - (target_input, never coal) is the spell target, and fallback is to the next - lower unlocked recipe, else skip SII and stay on LA. Rates are REALIZED: - mandatory additive delays PLUS breaks (every 400-700 completed casts) PLUS - banking (901-cast alch batches; 24-cast SII bank cycles) — pinned below. + 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)" + - "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)" - - "Working capital >= 150,000 gp for 1,000 nature runes plus the selected recipe's exact inputs (live GE buy; steel example: 1 iron ore 100 + 2 coal 150 each per cast at the 2026-08-27 snapshot)" - - "A bank with GE access within reach (alch spot = the bank booth; restock trips 60-120s)" + - "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)" + 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 - 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" + - 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-55 - why: "SII unlocks at 43 (53 xp/cast) and smelts ore into bars without a furnace — a training window that can profit when ore/bar margins are positive; it cannot be cast on noted ore, so it needs the exact recipe inputs in inventory rather than a note stack" + why: "SII unlocks at 43 (53 xp/cast) and smelts ore into bars without a furnace — a training window that can profit when ore/bar margins are positive; it cannot be cast on noted ore, so it needs the exact recipe inputs in inventory rather than a note stack. 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: "the recipe table is dynamic: at each cast the active recipe = the recipes-table entry with the highest smithing_level <= current Smithing whose complete input cost (ALL inputs at live GE buy + one nature rune) stays under the bar's GE price; the spell targets that recipe's declared target_input (the primary ore — never coal); when the selected recipe's materials cannot be obtained, fall back to the next lower unlocked recipe; when none has a positive margin, skip SII and stay on LA" - source: "OSRS wiki — Superheat Item (requires the Smithing level and materials of the bar); Smithing (bar smelting table); prices.runescape.wiki (live GE prices); owner guidance 2026-08-28 (selection.mode)" + 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" + 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: @@ -57,34 +72,37 @@ economy: # 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, so they live in the - # per-activity entries (low-alch-f2p, f2p-alch-catalog): this entry only - # prices the shared consumable. SII recipe economics live in the recipes - # table below. + # 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: 51400 # HLA tier, REALIZED (breaks + banking included), pinned below - # ---- realized-rate derivation (owner guidance 2026-08-28: rates must - # include the mandatory breaks and banking, not just the cast loop) ---- - # Cast: 1.8s (3 ticks x 0.6s) + anti_ban.delay [1.6, 3.4]s ADDITIVE after - # the action (docs/action-grammar.md:135) = 3.4-5.2s, midpoint 4.3s. + 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) + 225s overhead per batch (90s restock trip at a - # GE-adjacent booth + 135s breaks every [400, 700] casts, mid 550, for - # [45, 120]s): realized casts/hr = 901 / ((901 x 4.3 + 225) / 3600) = 791.3. - # LA 24,500 xp/hr = 31 x 791.3 (pin 24,530 -> 24,500) - # HLA 51,400 xp/hr = 65 x 791.3 (pin 51,435 -> 51,400) - # SII tier: bank cycle is INVENTORY-bound (bars are non-stackable): restock - # when bars_in_inventory >= 24, i.e. 24-cast cycles with a ~60s trip. Per - # 550-cast break window: 550 x 4.3 = 2,365s casting + 22.9 trips x 60s + - # 82.5s break = 3,822.5s -> realized casts/hr = 550 / (3,822.5/3600) = 518.0. - # SII 27,500 xp/hr = 53 x 518.0 (pin 27,454 -> 27,500; range 24,300 by - # slow cycle 458.6/hr .. 31,500 by fast cycle 595.0/hr) - # Superseded figures (fail the realized gate on purpose): loop-only 837 - # casts/hr (54,419 HLA) ignored breaks+banking; the 8-14-cast break regime - # (~305 casts/hr, 19,830 HLA) and the 1,400/1,800-cast regimes ignored the - # additive delay. + # 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 @@ -93,15 +111,18 @@ economy: 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 + one nature rune) stays under the resulting bar's GE price — re-check margins per window and select the highest UNLOCKED recipe with a positive margin (selection.mode); never a steel-only default" + - "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 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 (ALL inputs at live GE buy) + one nature rune 145 < the bar's live GE price" - fallback: "when the selected recipe's materials cannot be obtained (3 consecutive failed GE purchases / unfillable offers), drop to the next lower unlocked recipe and re-check its margin; when no unlocked recipe has a positive margin, skip SII for this window and stay on LA — never select a recipe whose Smithing requirement is unmet, never cast on coal" + 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 @@ -110,8 +131,14 @@ selection: # smelting table; Superheat Item grants the bar's Smithing XP per cast). # target_input = the PRIMARY ORE the spell is cast on (a declared input, # never coal — the validator enforces both). -# econ 2026-08-27: bar margin = bar GE price - (all inputs at live GE buy + -# one nature rune 145); the client selects via selection.mode. +# 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 @@ -128,6 +155,10 @@ recipes: 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 @@ -139,6 +170,10 @@ recipes: 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 @@ -150,6 +185,10 @@ recipes: 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 @@ -165,6 +204,10 @@ recipes: 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 @@ -176,6 +219,10 @@ recipes: 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 @@ -191,6 +238,10 @@ recipes: 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 @@ -206,6 +257,10 @@ recipes: 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 @@ -221,42 +276,78 @@ recipes: 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: "acquire + equip the staff of fire, GE-buy nature runes and the tier supplies (alch candidates, or the selected recipe's exact inputs), build the starting inventory to its carried_batch" - next: [cast, complete, failed] + 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 loadout to the tier's carried amounts (a7; purchases from a5 are already in the inventory — deposit overflow, withdraw only the shortfall)" + next: [cast, failed] cast: - goal: "cast the active tier spell with the tier-correct target — LA (< 43) or HLA (>= 55) on a noted alch candidate; SII (43-55) on the selected recipe's declared target_input (primary ore, never coal; exact inputs withdrawn, margin positive); a break fires on the completed-cast counter, NOT on bank visits" + 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-55, selection.mode) on the selected recipe's declared target_input (primary ore, never coal) with its exact allowed-fill inputs withdrawn (a9). 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: "deposit coins and smelted bars; restock to the tier's carried_batch (LA/HLA: candidate + natures to 1,000/1,000; SII: one 24-cast input pack of the selected recipe) and resume casting" - next: [cast, failed] + 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 target reached (default 55 -> HLA is the end-state)" + 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 equipped AND tier supplies at carried_batch (LA/HLA: 1,000 candidate + 1,000 natures; SII: 24 iron-or-primary + 48 coal + 24 natures for the selected recipe)" - to: cast + when: "staff + tier supply offers all placed (a3/a4)" + to: wait_offers - from: idle - when: "magic level >= target (55 -> HLA end-state)" + when: "magic level >= session target at session start" to: complete - from: idle - when: "3 consecutive purchase/equip failures with no fallback available (recovery block)" + 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 loadout at the tier's carried amounts (a7)" + to: cast + - from: ready + when: "staff equip failed after bounded retries — FAIL CLOSED" to: failed - from: cast - when: "LA/HLA tier: nature count < restock_threshold (100) OR candidate count < restock_threshold (100); SII tier: bars_in_inventory >= 24 (non-stackable bars fill the inventory) OR any selected-recipe input count < 1 — deterministic restock triggers, pinned numbers" - to: bank_supplies - - from: cast - when: "level < 43 and LA tier active (cast on the cheapest alch candidate)" + when: "level < 43, or level 43-55 with SII skipped — LA tier: cast on the cheapest alch candidate (LA is executable throughout the 43-54 window when no recipe has a positive margin)" to: cast - from: cast - when: "level 43-55 and the SII window is chosen — selection.mode picks the recipes-table entry with the highest smithing_level <= current Smithing and a positive complete-cost margin; cast on its target_input" + when: "level 43-55 and the SII window is chosen — selection.mode picks the recipes-table entry with the highest smithing_level <= current Smithing and a positive allowed-fill complete-cost margin; cast on its target_input" to: cast - from: cast when: "level 43-55 — SII fallback: the selected recipe's materials cannot be obtained, so drop to the next lower unlocked recipe (never an unmet Smithing gate) and continue SII if its margin is positive" @@ -265,17 +356,26 @@ state_machine: when: "level 43-55 and no unlocked recipe has a positive margin this window — skip SII and stay on LA" to: cast - from: cast - when: "level >= 55 and HLA tier active (cast on the cheapest alch candidate)" + when: "level >= 55 and session target > 55 — the HLA tier executes (a8) until magic reaches the target" to: cast - from: cast - when: "magic level >= target (55 -> HLA end-state)" + 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)" to: complete - from: cast when: "3 consecutive failed casts (client issue — do not loop)" to: failed - from: bank_supplies - when: "restocked to the tier's carried_batch (see bank_supplies goal)" + 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 @@ -290,81 +390,140 @@ actions: 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)" - on_fail: retry # retry once; on a second failure use the fire-rune fallback (see a3 note / recovery block) - anti_ban: - delay: [1200, 3000] - - - id: a3 - step: equip-staff - verb: menu_option - target: "staff of fire in inventory" - params: - option: "Wield" - note: "Equip requirement is NONE documented on the OSRS wiki Staff of fire page (no requirements row in the infobox, none in the prose; compare Battlestaff 'requires 30 Attack and 30 Magic'; verified 2026-08-28). FAILURE PATH — if Wield is unavailable or the staff cannot be equipped, fall back to buying fire runes per cast (LA/HLA: 3 fire + 1 nature = 155 gp vs 140; SII: 4 fire + 1 nature = 160 gp vs 140 — the recipe margin rule compares against the FULL rune cost), log WARN and continue; never loop on equip attempts." + 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: [600, 1800] + delay: [1200, 3000] - id: a4 step: buy-supplies verb: ge_buy target: grand-exchange params: - item: "nature rune + the tier's supplies — LA/HLA: the current least-loss alch candidate; SII: the exact unnoted inputs of the SELECTED recipe (every input and quantity from the recipes table, e.g. steel-bar = 1 iron ore + 2 coal), only while its complete-cost margin is positive" - qty: "LA/HLA: carried_batch 1,000 (4h GE limits: natures 18,000, candidates per mapping); SII: one 24-cast input pack (24 x each input quantity + 24 natures) per bank cycle" - max_price: "live low + 5%" - on_fail: retry # re-place at live low +5% with paced offers; 3 consecutive failures -> fallback to next unlocked recipe (SII) or -> failed (recovery block) + 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 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)" + 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) | High Level Alchemy (level >= 55)" + 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)" 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 -> realized 791.3 casts/hr incl. breaks + banking (pinned in economy) + 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: a6 + - 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-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 (ALL inputs, live GE buy) + one nature rune < the bar's live GE price; fall back to the next lower unlocked recipe when its materials cannot be obtained" + 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 a5; SII realized 518.0 casts/hr incl. 24-cast bank cycles + breaks (pinned in economy) + 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: a7 - step: bank-loop + - id: a10 + step: restock-deposit verb: bank_inventory target: bank params: deposit: [all coins, all products] # products incl. smelted bars during SII - withdraw: "LA/HLA: candidate + nature runes up to carried_batch (1,000/1,000); SII: one 24-cast input pack of the selected recipe (24 x each input quantity + 24 natures)" + 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: a8 + - 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" + 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: @@ -378,79 +537,105 @@ run_control: # reset this counter (owner decision 2026-08-28) for: [45, 120] # seconds of idle AFK stop_conditions: - - "magic level >= target (default 55 -> HLA is the end-state)" - - "gp under floor (below one full carried_batch of natures + tier supplies)" + - "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 (total assets - initial capital) > 690,075 — the 6-batch worst-case LA reserve (Magic 21 -> 55 at max fills) is exhausted" - "3 consecutive failed casts (client issue — do not loop)" - - "3 consecutive failed GE purchases with no fallback recipe/candidate available" + - "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). Constants: break.every - # [400, 700] casts (midpoint 550), break.for [45, 120] s; SII bank cycle = 24 - # casts (bars_in_inventory >= 24 or any input < 1 -> bank_supplies); LA/HLA use - # the 901-cast batch model pinned in low-alch-f2p (carried_batch 1,000/1,000, - # restock_threshold 100). + # 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, before the first inventory: account has 30 Smithing + 43-54 - # Magic; GE-buy staff of fire and Wield it (no documented requirement); buy - # 1,000 nature runes and steel inputs (1 iron ore + 2 coal per cast, live GE). - # Build the starting inventory: 24 iron ore + 48 coal + 24 natures (one - # 24-cast pack). Cast counter := 0. Steel is the highest UNLOCKED recipe - # (Smithing 30); bronze (1), iron (15) and silver (20) are unlocked too but - # LOWER, so steel is selected. Smithing 20 accounts select silver-bar and - # steel stays locked (never an unmet gate). - # Step 1 — casts #1..#24: each cast consumes 1 iron ore + 2 coal + 1 nature - # and produces 1 steel bar (+53 Magic xp, +17.5 Smithing xp). After cast #24 - # the inventory holds 24 bars -> FIRST RESTOCK/BANK EVENT at cast 24: deposit - # the 24 bars, withdraw the next 24 iron + 48 coal + 24 natures. - # Step 2 — resume at cast #25; the 24-cast cycle repeats: restocks at casts - # 48, 72, 96, ... (every 24 casts, deterministic). - # Step 3 — FIRST SCHEDULED BREAK at cast 550 (midpoint of break.every - # [400, 700]): the 23rd cycle (casts 529-552) is mid-flight — take-break for - # 45-120 s, resume at cast #551, finish the cycle (restock at 552). - # The break fired on the CAST COUNTER while a bank cycle was still running: - # breaks and banking are independent by construction. - # Step 4 — break #2 at cast 1,100 (550 after #1); restocks continue every 24 - # casts; the SII window ends at Magic 55 -> HLA tier (901-cast batch model, - # 65 xp/cast). Stop at the target level, gp floor, or 3 failed casts. + # 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). 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 -> skip SII this window and stay on LA. + # 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; realized ~791 casts/hr alch tiers and ~518 casts/hr SII incl. breaks + banking)." - - "Breaks are CAST-COUNT-based (every [400, 700] completed casts, 45-120s AFK) and independent of bank visits — never treat a restock trip as a break." + - "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) and cast on the declared target_input (primary ore) — never on coal, never on a noted stack; bank the produced bars when bars_in_inventory >= 24." - - "SII ore-buy bursts are pace-sensitive like any GE buy; respect the 4h limit window; re-check the complete-cost margin per window because ore/bar prices move independently." + - "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 >= 24 or any selected- -# recipe input < 1 — both are deterministic threshold transitions to bank_supplies. -# - Failed GE purchases (offer unfilled): re-place at live low + 5% with paced -# offers; after 3 consecutive failures in the SII window, fall back to the next -# lower unlocked recipe (selection.fallback); with no fallback recipe left, -# skip SII and stay on LA — never sit in an unfillable offer loop. -# - Staff of fire cannot be equipped (a3): fall back to fire runes (LA/HLA: +15 -# gp/cast; SII: +20 gp/cast rune cost), log WARN, continue; never loop on equip -# attempts. -# - Invalid loadout (wrong item in hand / spell unavailable): re-run build-loadout -# (a4/a7); 3 consecutive failed casts -> failed (stop, log ERROR sentinel, alert). -# - Explicit completion/stop: stop_conditions above (Magic 55 HLA end-state, gp -# floor, repeated failures). +# < 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) + verb take_break — the cadence + break + lifecycle model; owner guidance 2026-08-28 (dynamic recipe selection, realized rates, worked example)" + - "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)" verified: "2026-08-27" # keep quoted (YAML 1.1 date parsing); prices last re-fetched live 2026-08-27 related: diff --git a/schemas/entry.schema.json b/schemas/entry.schema.json index a3f2ea8..22ed647 100644 --- a/schemas/entry.schema.json +++ b/schemas/entry.schema.json @@ -101,11 +101,66 @@ "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 } + } + }, + "best_practice": { "type": "object", "required": ["equipment"], diff --git a/tools/validate.py b/tools/validate.py index 57cf683..eb99614 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -23,10 +23,17 @@ Runs: `target_input` that is one of its declared inputs and never coal (the spell targets the primary ore), and smithing_level gates must be unique so "highest unlocked" selection is deterministic. - - Realized-rate consistency: xp_per_hour must derive from the pinned - realized cadence (mandatory additive delays PLUS breaks PLUS - banking), not from the loop-only 837 casts/hr or the superseded - 8-14-cast-break regime (~305 casts/hr). + - 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). 3. Global id uniqueness across the whole codex tree. Malformed/scalar YAML documents (an empty doc, a bare string, a list) @@ -42,6 +49,7 @@ Exit codes: 0 = clean (warnings ok), 1 = validation errors. Usage: python3 tools/validate.py [--stale-days 30] [--selftest] --selftest: run the regression fixtures only """ +import copy import datetime import json import re @@ -143,21 +151,240 @@ def recipe_margin(recipe, bar_price, input_prices, nature_price): return bar_price - (cost + nature_price) -# --- realized-cadence constants (pinned model; owner guidance 2026-08-28) --- -# docs/action-grammar.md:135: anti_ban.delay is an ADDITIVE post-action sleep, -# so a cast = 1.8s (3 ticks x 0.6s) + [1.6, 3.4]s = 3.4-5.2s (mid 4.3s). -# LA/HLA realized casts/hr: 901-cast restock batch (carried_batch 1,000 - -# restock_threshold 100) with 225s overhead per batch (90s restock trip + -# 135s breaks at every [400,700] casts, mid 550) -> 791.3 casts/hr midpoint. -# SII realized casts/hr: 24-cast bank cycle (non-stackable bars fill the -# inventory) -> 518.0 casts/hr midpoint (550-cast break window: 2,365s -# casting + 22.9 trips x 60s + 82.5s break). -REALIZED_CastsPerHr_ALCH = 791.3 -REALIZED_CastsPerHr_SII = 518.0 +# --- 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 _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. @@ -174,6 +401,37 @@ def highest_unlocked_recipe(recipes, smithing_level): 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). @@ -261,37 +519,6 @@ def recipe_model_errors(rel, data): return errors -def rate_errors(rel, data): - """xp_per_hour must derive from the REALIZED cadence: mandatory - additive delays PLUS the pinned mandatory breaks PLUS banking. - - Regression guard: the loop-only figure (837 casts/hr) and the - superseded 8-14-cast-break regime (~305 casts/hr) both sit outside - the tolerance band around the realized rate. - """ - errors = [] - eco = data.get("economy") - if not isinstance(eco, dict): - return errors - xph = eco.get("xp_per_hour") - if not isinstance(xph, (int, float)): - return errors - has_sii = any( - "superheat" in (str(a.get("params") or {}).lower() - + str(a.get("target") or "").lower() - + str(a.get("step") or "").lower()) - for a in data.get("actions", [])) - per_cast = XP_HLA if has_sii else XP_LA - expected = per_cast * REALIZED_CastsPerHr_ALCH - if abs(xph - expected) / expected > RATE_TOLERANCE: - errors.append( - f"{rel}: xp_per_hour {xph} is not the realized cadence rate " - f"(expected ~{expected:.0f} = {per_cast} xp/cast x " - f"{REALIZED_CastsPerHr_ALCH} casts/hr including mandatory breaks " - f"and banking; tolerance {RATE_TOLERANCE:.0%})") - return errors - - def run_selftest(verbose=False): """Regression fixtures for the cross-field gates; error strings.""" errors = [] @@ -387,6 +614,27 @@ def run_selftest(verbose=False): 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", @@ -445,22 +693,87 @@ def run_selftest(verbose=False): check("Superheat entry without an SII fallback transition is rejected", any("SII fallback" in e for e in no_fb)) - # --- realized-rate fixtures: breaks + banking included --- - check("LA-only xp_per_hour 24,500 matches the realized cadence (31 x 791.3)", - rate_errors("ok.yaml", {"economy": {"xp_per_hour": 24500}}) == []) - check("loop-only LA xp_per_hour 25,953 fails the realized-cadence gate", - any("realized cadence" in e - for e in rate_errors("bad.yaml", {"economy": {"xp_per_hour": 25953}}))) - check("8-14-cast-break regime LA rate (31 x ~305 = 9,455) fails the realized-cadence gate", - any("realized cadence" in e - for e in rate_errors("bad.yaml", {"economy": {"xp_per_hour": 9455}}))) - check("SII entry xp_per_hour 51,400 matches the HLA-tier realized cadence (65 x 791.3)", - rate_errors("ok2.yaml", {"actions": [superheat_action], - "economy": {"xp_per_hour": 51400}}) == []) - check("SII entry with the old loop-only HLA rate 54,419 fails the realized-cadence gate", - any("realized cadence" in e - for e in rate_errors("bad2.yaml", {"actions": [superheat_action], - "economy": {"xp_per_hour": 54419}}))) + # --- 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))) return errors @@ -572,7 +885,7 @@ def main(): # SII recipe-model + selection gates + realized-rate consistency errors += recipe_model_errors(rel, data) - errors += rate_errors(rel, data) + errors += rate_model_errors(rel, data) # 3. staleness / research TODOs if data.get("status") == "verified":