diff --git a/codex/economy/low-alch-f2p.yaml b/codex/economy/low-alch-f2p.yaml index 74fc85e..ba33535 100644 --- a/codex/economy/low-alch-f2p.yaml +++ b/codex/economy/low-alch-f2p.yaml @@ -42,7 +42,12 @@ economy: net_per_cycle: -112 gp_per_hour_low: -250000 # negative: this burns GP gp_per_hour_high: -200000 - xp_per_hour: 55800 # 31 xp/cast (wiki) @ ~1800 casts/hr (3-tick, wiki) + xp_per_hour: 43400 # 31 xp/cast (wiki) @ 1,400 casts/hr pinned — one OSRS + # tick = 0.6s, 3-tick cast = 1.8s; mandatory post-action + # delay 1.6-3.4s (mid ~2.5s) -> 3,600/2.5 = ~1,440 casts/hr + # ceiling, pinned at 1,400 for bank-cycle overhead. The + # old 1,800 casts/hr (55,800 xp/hr) was not reachable + # under the entry's own pacing. supplies: - item: gold necklace qty_per_cycle: 1 @@ -51,7 +56,8 @@ economy: - item: nature rune qty_per_cycle: 1 source: ge - ge_limit: 12500 + ge_limit: 18000 # mapping 'limit' + Module:Exchange/Nature_rune agree; + # matches magic-alch-handoff (consistency check enforces) profit_drivers: - "NONE at current prices — XP is the product, not GP" - "Pick the candidate with the least-negative net each run (live rank)" @@ -122,7 +128,9 @@ actions: on: "itself" on_fail: retry anti_ban: - delay: [1600, 3400] # 3-tick cast = ~5.4s game; stay under it, humanised + delay: [1600, 3400] # 3-tick cast = 1.8s game (3 ticks x 0.6s); post-action + # delay mid ~2.5s -> ~1,400 casts/hr pinned; stay under + # it, humanised - id: a4 step: bank-loop diff --git a/codex/training/magic-alch-handoff.yaml b/codex/training/magic-alch-handoff.yaml index 090e71e..589271a 100644 --- a/codex/training/magic-alch-handoff.yaml +++ b/codex/training/magic-alch-handoff.yaml @@ -52,7 +52,14 @@ economy: item: nature rune item_id: 561 cost_per_cycle: 145 # one nature rune per cast (staff of fire), buy price 2026-08-27 - xp_per_hour: 117000 # HLA tier (65 xp/cast, ~1800 casts/hr at the 3-tick spell speed — derived; LA tier 55800, SII tier 95400) + xp_per_hour: 91000 # HLA tier: 65 xp/cast @ 1,400 casts/hr pinned — one + # tick = 0.6s, 3-tick cast = 1.8s; mandatory post-action + # delay 1.6-3.4s (mid ~2.5s) -> 3,600/2.5 = ~1,440 casts/hr + # ceiling, pinned at 1,400 for bank-cycle overhead. + # Tiers: LA 43,400 (31 x 1400) / SII 74,200 (53 x 1400) + # / HLA 91,000 (65 x 1400). Old 1,800 casts/hr figures + # (55,800 / 95,400 / 117,000) were not reachable under + # the entry's own pacing. supplies: - item: nature rune qty_per_cycle: 1 @@ -71,10 +78,12 @@ state_machine: goal: be at a bank with GE access, staff of fire equipped, nature runes in stock next: [cast, bank_supplies, complete, failed] cast: - goal: cast the current tier spell (LA below 43, SII 43-55 if chosen, HLA from 55) + goal: cast the active tier spell with the tier-correct target — LA < 43 on a + noted alch candidate; SII 43-55 on an unnoted ore whose live bar margin + is positive (never a noted stack); HLA >= 55 on a noted alch candidate next: [cast, bank_supplies, complete, failed] bank_supplies: - goal: deposit coins, restock nature runes (+ ores if running the SII window) + goal: deposit coins and smelted bars, restock nature runes (+ ores during the SII window) next: [cast, idle] complete: goal: magic level target reached @@ -85,10 +94,16 @@ state_machine: when: "nature rune count < buffer (or ore count < buffer in SII window)" to: bank_supplies - from: cast - when: "level 43 reached and the SII window is chosen (margins positive)" + when: "level < 43 and LA tier active (cast on the cheapest alch candidate)" to: cast - from: cast - when: "level 55 reached" + when: "level 43-55 and the SII window is chosen (some unnoted ore's bar margin is positive)" + to: cast + - from: cast + when: "level 43-55 and the SII window is skipped (no positive-margin ore this window) — stay on LA" + to: cast + - from: cast + when: "level >= 55 and HLA tier active (cast on the cheapest alch candidate)" to: cast - from: idle when: "magic level >= target" @@ -111,7 +126,7 @@ actions: verb: ge_buy target: grand-exchange params: - item: "nature rune (and ores during the SII window)" + item: "nature rune (and unnoted ores during the SII window — only recipes whose live bar margin is positive)" qty: "fill to buffer" max_price: "live low + 5%" on_fail: retry @@ -119,24 +134,38 @@ actions: delay: [1200, 3000] - id: a3 - step: cast-tier-spell + step: cast-alch verb: use_item_on - target: "tier supply in inventory (noted stack for LA/HLA, ore for SII)" + target: "noted alch candidate in inventory (LA and HLA tiers)" params: - spell: "Low Level Alchemy | Superheat Item | High Level Alchemy (per current tier)" - item: "cheapest live candidate in inventory" + spell: "Low Level Alchemy (level < 43) | High Level Alchemy (level >= 55)" + 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] # 3-tick cast = ~5.4s game; stay under it, humanised + delay: [1600, 3400] # 3-tick cast = 1.8s game (3 ticks x 0.6s); post-action + # delay mid ~2.5s -> ~1,400 casts/hr pinned; stay under + # it, humanised - id: a4 + step: cast-superheat + verb: use_item_on + target: "unnoted ore in inventory (SII tier only, 43-55)" + params: + spell: "Superheat Item" + item: "an unnoted ore whose live bar margin is positive (bar GE price covers ore + nature cost); never a noted stack, never a price-only pick" + on: "itself" + on_fail: skip # no positive-margin ore -> skip SII, stay on LA + anti_ban: + delay: [1600, 3400] + + - id: a5 step: bank-loop verb: bank_inventory target: bank params: - deposit: [all coins, all products] - withdraw: "nature runes up to buffer (+ ores during the SII window)" + deposit: [all coins, all products] # products incl. smelted bars during SII + withdraw: "nature runes up to buffer (+ unnoted ores during the SII window)" on_fail: alert run_control: diff --git a/tools/validate.py b/tools/validate.py index 7667c3d..267c1e1 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -11,11 +11,17 @@ Runs: => WARNING (entry should be re-verified, not dropped) - `best_practice` lines whose source starts with "researched" => WARNING (citation TODO — file a follow-up issue) + - Cross-entry supply-fact consistency: the same (supply item, + source) across entries must carry the same ge_limit. A diverging + limit (e.g. nature rune "12500" in one entry vs "18000" in + another) is a silent fact error, not a per-entry choice — GE + buy limits are item-global. 3. Global id uniqueness across the whole codex tree. Exit codes: 0 = clean (warnings ok), 1 = validation errors. -Usage: python3 tools/validate.py [--stale-days 30] +Usage: python3 tools/validate.py [--stale-days 30] [--selftest] + --selftest: run the supply-consistency regression fixtures only """ import datetime import json @@ -41,12 +47,77 @@ def load_entries(): return sorted(out) +def supply_limit_conflicts(rows): + """Cross-entry consistency for duplicate supply facts. + + rows: iterable of (rel, data). The same (item, source) appearing in + more than one entry must agree on ge_limit — GE buy limits are a + property of the item, so a divergence is a silent fact error. + Returns a list of error strings. + """ + facts = {} + for rel, data in rows: + eco = data.get("economy") or {} + for sup in eco.get("supplies", []): + lim = sup.get("ge_limit") + if lim is None: + continue + key = (sup.get("item"), sup.get("source")) + facts.setdefault(key, {}).setdefault(lim, set()).add(rel) + errors = [] + for (item, source), by_lim in sorted(facts.items()): + if len(by_lim) > 1: + detail = "; ".join( + f"limit {lim} in {', '.join(sorted(rs))}" + for lim, rs in sorted(by_lim.items())) + errors.append( + f"conflicting ge_limit for supply '{item}' (source {source}): {detail}") + return errors + + +def run_selftest(verbose=False): + """Regression fixtures for the supply-consistency gate; error strings.""" + fixtures = [ + # name, rows, expect_error + ("identical (item, source) with diverging ge_limit (18000 vs 12500)", + [("e1.yaml", {"economy": {"supplies": [ + {"item": "nature rune", "source": "ge", "ge_limit": 18000}]}}), + ("e2.yaml", {"economy": {"supplies": [ + {"item": "nature rune", "source": "ge", "ge_limit": 12500}]}})], True), + ("identical (item, source) with agreeing ge_limit (18000 both)", + [("e1.yaml", {"economy": {"supplies": [ + {"item": "nature rune", "source": "ge", "ge_limit": 18000}]}}), + ("e2.yaml", {"economy": {"supplies": [ + {"item": "nature rune", "source": "ge", "ge_limit": 18000}]}})], False), + ("no supplies / missing ge_limit are not gated", + [("e1.yaml", {"economy": {}}), + ("e2.yaml", {"economy": {"supplies": [ + {"item": "gold necklace", "source": "ge"}]}})], False), + ] + errors = [] + for name, rows, expect_error in fixtures: + got = bool(supply_limit_conflicts(rows)) + passed = got == expect_error + if verbose: + print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}") + if not passed: + errors.append( + f"selftest: '{name}' expected " + f"{'an error' if expect_error else 'no error'} but got " + f"{'an error' if got else 'no error'}") + return errors + + def main(): global STALE_DAYS if "--stale-days" in sys.argv: STALE_DAYS = int(sys.argv[sys.argv.index("--stale-days") + 1]) errors, warnings = [], [] + if "--selftest" in sys.argv: + errors += run_selftest(verbose=True) + print(f"RESULT: selftest, {len(errors)} errors, 0 warnings") + return 1 if errors else 0 entries = load_entries() if not entries: print("ERROR: no codex entries found under codex/") @@ -54,6 +125,7 @@ def main(): ids = {} today = datetime.date.today() + rows = [] for path in entries: rel = str(path.relative_to(ROOT)) @@ -62,6 +134,7 @@ def main(): except yaml.YAMLError as e: errors.append(f"{rel}: YAML parse error: {e}") continue + rows.append((rel, data)) # 1. schema v = jsonschema.Draft7Validator(SCHEMA) @@ -125,6 +198,9 @@ def main(): f"{rel}: best_practice '{line.get('item') or line.get('name')}' " f"carries a 'researched' source — citation TODO, file follow-up") + # 2b. cross-entry supply-fact consistency (GE buy limits are item-global) + errors += supply_limit_conflicts(rows) + # report for w in warnings: print(f"WARN {w}")