#!/usr/bin/env python3 """Validate OSRS Codex skill/economy entries. Checks per entry: 1. JSON-Schema validation (schemas/entry.schema.json). 2. State-machine consistency: initial/terminal declared, every non-terminal state has a `next`, every transition from/to is declared. 3. Action id/step uniqueness. 4. Staleness / research TODOs: status:verified entries with `verified:` older than --stale-days => WARNING; best_practice lines whose source starts with "researched" => WARNING (citation TODO). 5. GE-margin entries (flipping/merchanting tags): floored+capped 2% tax arithmetic (see ge_floor_tax_error). 6. Woodcutting entries (woodcutting tag): structured axe + mode gates (canonical axe tier table, deterministic per-mode ladders, modeled realized rates) — see woodcutting_errors. Regression fixtures (--selftest) commit the canonical table, the equip-vs-carry rule (a 1-Attack/41-Woodcutting account carries and uses a rune axe), the per-mode constraints, and the rate chain. Exit codes: 0 = clean (warnings ok), 1 = validation errors. Usage: python3 tools/validate.py [--stale-days 30] [--selftest] --selftest: run the committed regression fixtures only (no tree scan) """ import datetime import json import re import sys from pathlib import Path import jsonschema import yaml ROOT = Path(__file__).resolve().parent.parent SCHEMA = json.loads((ROOT / "schemas" / "entry.schema.json").read_text()) STALE_DAYS = 30 XP_PER_LOG = {"tree": 25, "oak": 37.5, "willow": 67.5, "maple": 100, "yew": 175} TREE_MIN_LEVEL = {"tree": 1, "oak": 15, "willow": 30, "maple": 45, "yew": 60} # Canonical F2P axe table (OSRS wiki, verified 2026-08-28): the Woodcutting # level is the TOOL-USE requirement (chopping), the Attack level is the # EQUIP requirement. An account uses the highest axe whose Woodcutting level # it meets, and equips it only when its Attack level is also met — otherwise # it CARRIES that same axe (costing one inventory slot). Order is the tier # tiebreak (iron beats bronze at equal requirements; gilded ~ rune). AXE_TIERS = [ # (axe, woodcutting_level_to_use, attack_level_to_equip, members_only) ("bronze", 1, 1, False), ("iron", 1, 1, False), ("steel", 6, 5, False), ("black", 11, 10, False), ("mithril", 21, 20, False), ("adamant", 31, 30, False), ("rune", 41, 40, False), ("gilded", 41, 40, False), ("dragon", 61, 60, True), ] AXE_NAMES = {row[0] for row in AXE_TIERS} # Preference rank: at equal Woodcutting requirements the highest-rank axe wins # (iron beats bronze; rune and gilded are stat-identical, rune wins the tie). TIER_RANK = {"bronze": 0, "iron": 1, "steel": 2, "black": 3, "mithril": 4, "adamant": 5, "rune": 6, "gilded": 6, "dragon": 7} def best_axe(wc_level, attack_level): """Deterministic axe selection (owner guidance 2026-08-28 / governor 10323): pick the highest tier usable at wc_level (tool-use requirement); equip it only when attack_level meets that tier's equip requirement, else carry it. Returns (axe, equipped_bool). Ties at the same Woodcutting requirement are broken by TIER_RANK (iron beats bronze; rune beats the cosmetic gilded).""" usable = [row for row in AXE_TIERS if row[3] is False and wc_level >= row[1]] if not usable: return None, False best = max(usable, key=lambda row: (row[1], TIER_RANK[row[0]])) return best[0], attack_level >= best[2] def ge_floor_tax_error(rel, data): """GE flip-tax gate (2% convenience fee, floored per item, capped 5M): net == income - min(floor(0.02*income), 5_000_000) - cost.""" tags = {str(t).lower() for t in data.get("tags", [])} if not tags & {"flipping", "merchanting"}: return None eco = data.get("economy") or {} income = eco.get("income_per_cycle") cost = eco.get("cost_per_cycle") net = eco.get("net_per_cycle") if not (isinstance(income, (int, float)) and isinstance(cost, (int, float)) and isinstance(net, (int, float))): return f"{rel}: flipping entry needs numeric income/cost/net_per_cycle" tax = min(int(income * 0.02), 5_000_000) if net != income - tax - cost: return (f"{rel}: GE flip net mismatch: net {net} != income {income}" f" - min(floor(0.02*income), 5,000,000) = {tax} - cost {cost}" f" (= {income - tax - cost})") return None def woodcutting_errors(rel, data): """Structured gates for woodcutting entries (owner guidance 10288, governor 10323 pattern: no phrase-search, exact computed checks). - axe_tiers must match the canonical table exactly; - axe_examples are recomputed and must match (incl. the 1-Attack / 41-Woodcutting rune-axe carry case); - woodcutting_modes must be exactly [fast-xp, afk-xp, banked-income], each ladder deterministic (rows ordered by min_level desc, first match), with canonical per-tree level/gate constraints, and each rate must equal the documented model chain (chop + walking/banking or dropping + respawn duty + break duty) within tolerance, with xp/gp derived from the canonical per-log XP and the declared price. """ errors = [] tags = {str(t).lower() for t in data.get("tags", [])} if "woodcutting" not in tags: return errors # --- canonical axe table --- tiers = data.get("axe_tiers") or [] canon = {(row[0], row[1], row[2], bool(row[3])) for row in AXE_TIERS} declared = {(str(t.get("axe")), t.get("woodcutting_level"), t.get("attack_level"), bool(t.get("members"))) for t in tiers} if declared != canon: errors.append( f"{rel}: axe_tiers must match the canonical F2P table exactly " f"(bronze 1/1, iron 1/1, steel 6/5, black 11/10, mithril 21/20, " f"adamant 31/30, rune 41/40, gilded 41/40, dragon 61/60 members); " f"got {sorted(declared)}") return errors # --- recompute the committed examples --- for ex in data.get("axe_examples") or []: name = ex.get("account", "?") wc, atk = ex.get("woodcutting_level"), ex.get("attack_level") axe, eq = best_axe(wc, atk) if axe != ex.get("expected_axe") or eq != ex.get("expected_equipped"): errors.append( f"{rel}: axe_examples '{name}' (WC {wc}, Attack {atk}) — " f"recomputed {axe}/equipped={eq}, entry declares " f"{ex.get('expected_axe')}/equipped={ex.get('expected_equipped')}") # --- modes --- modes = data.get("woodcutting_modes") or [] if sorted(str(m.get("mode")) for m in modes) != ["afk-xp", "banked-income", "fast-xp"]: errors.append(f"{rel}: woodcutting_modes must declare exactly " f"fast-xp, afk-xp and banked-income") return errors for m in modes: mode = m["mode"] ladder = m.get("ladder") or [] if not ladder: errors.append(f"{rel}: {mode} ladder is empty") continue levels = [int(r["min_level"]) for r in ladder] if levels != sorted(levels, reverse=True): errors.append(f"{rel}: {mode} ladder must be ordered by " f"min_level DESC (deterministic first-match); got {levels}") if any(re.search(r"\bor\b", str(r.get("spot", "")).lower()) or re.search(r"\bor\b", str(r.get("bank", "")).lower()) for r in ladder): errors.append(f"{rel}: {mode} ladder rows must be unambiguous " f"(no 'or' inside spot/bank fields)") for r in ladder: tree, min_level, gate = r["tree"], int(r["min_level"]), r.get("gate") if min_level != TREE_MIN_LEVEL.get(tree): errors.append(f"{rel}: {mode} {tree} must have min_level " f"{TREE_MIN_LEVEL.get(tree)} (got {min_level})") if tree == "maple" and gate != "dragon-slayer-i": errors.append(f"{rel}: {mode} maple requires gate " f"dragon-slayer-i (Corsair Cove Resource Area)") if tree != "maple" and gate == "dragon-slayer-i": errors.append(f"{rel}: {mode} gate dragon-slayer-i may only " f"apply to the maple row (got {tree})") if mode == "fast-xp" and not any(r["tree"] == "willow" and int(r["min_level"]) == 30 for r in ladder): errors.append(f"{rel}: fast-xp must include a willow row at " f"min_level 30 (willow is the intended fast-XP target)") if mode == "banked-income" and not any(r["tree"] == "yew" for r in ladder): errors.append(f"{rel}: banked-income must include a yew row " f"(yew from 60 when eligible)") if mode == "afk-xp" and not any(r["tree"] == "maple" for r in ladder): errors.append(f"{rel}: afk-xp must include a maple row (quest-gated " f"AFK target); include quest-gated maple only when " f"access is satisfied — with lower fallbacks below") if not any(int(r["min_level"]) == 1 for r in ladder): errors.append(f"{rel}: {mode} ladder must bottom out at a level-1 " f"fallback (tree) — deterministic lower-level fallback") # --- rate chain: logs/hr = capacity / cycle_seconds * 3600 * duties --- cap = 28 # equipped axe; carried-axe accounts model 27-log loads (entry text) model = m.get("model") or {} try: chop = float(model["chop_seconds_per_log"]) drop = float(model.get("drop_seconds_per_log") or 0) walk = float(model.get("walk_seconds_each_way") or 0) deposit = float(model.get("deposit_seconds") or 0) respawn = float(model["respawn_duty"]) brk = float(model["break_duty"]) except (KeyError, TypeError, ValueError): errors.append(f"{rel}: {mode} model fields must be numeric " f"(chop/drop/walk/deposit seconds, respawn/break duty)") continue if m.get("inventory_policy") == "drop": cycle = cap * (chop + drop) + 0 * walk + deposit else: cycle = cap * chop + 2 * walk + deposit if cycle <= 0: errors.append(f"{rel}: {mode} cycle time must be positive") continue computed = (cap / cycle) * 3600.0 * respawn * brk rates = m.get("rates") or {} logs = rates.get("logs_per_hour") if not isinstance(logs, (int, float)) or abs(logs - computed) / computed > 0.01: errors.append( f"{rel}: {mode} logs_per_hour {logs} must equal the model chain " f"(capacity {cap} / {cycle:.0f}s cycle x 3600 x respawn " f"{respawn} x break {brk} = {computed:.1f}) within 1%") tree0 = ladder[0]["tree"] xp_expect = logs * XP_PER_LOG[tree0] if isinstance(logs, (int, float)) else None if xp_expect is not None and abs(rates.get("xp_per_hour", -1) - xp_expect) / xp_expect > 0.02: errors.append(f"{rel}: {mode} xp_per_hour {rates.get('xp_per_hour')} " f"must equal logs_per_hour x {XP_PER_LOG[tree0]} " f"({xp_expect:.0f}) within 2%") price = rates.get("log_price") if isinstance(logs, (int, float)) and isinstance(price, (int, float)): gp_expect = logs * price if abs(rates.get("gp_per_hour", -1) - gp_expect) / gp_expect > 0.03: errors.append(f"{rel}: {mode} gp_per_hour {rates.get('gp_per_hour')} " f"must equal logs_per_hour x declared log_price " f"({gp_expect:.0f}) within 3%") return errors def run_selftest(verbose=False): """Committed regression fixtures. Returns error strings.""" errors = [] def check(name, passed): if verbose: print(f"SELFTEST {'PASS' if passed else 'FAIL'} {name}") if not passed: errors.append(f"selftest: {name}") # --- owner-mandated axe coverage (10288): equip vs carry --- check("axe: 41-Woodcutting/1-Attack account carries and USES a rune axe (does not pick a lower axe)", best_axe(41, 1) == ("rune", False)) check("axe: 41-Woodcutting/40-Attack account equips the rune axe", best_axe(41, 40) == ("rune", True)) check("axe: 30-Woodcutting account is below adamant (31) and uses mithril (equippable at 20 Attack)", best_axe(30, 40) == ("mithril", True)) check("axe: 31-Woodcutting/5-Attack account uses adamant but cannot equip it (carries)", best_axe(31, 5) == ("adamant", False)) check("axe: level-1 account uses an iron axe equipped from the start", best_axe(1, 1) == ("iron", True)) check("axe: dragon (61/60, members) is never selected for an F2P woodcutting entry; maxed account still picks the top F2P axe (rune)", best_axe(99, 99) == ("rune", True)) # --- canonical table integrity --- canon = {(row[0], row[1], row[2]) for row in AXE_TIERS} - {("dragon", 61, 60)} want = {("bronze", 1, 1), ("iron", 1, 1), ("steel", 6, 5), ("black", 11, 10), ("mithril", 21, 20), ("adamant", 31, 30), ("rune", 41, 40), ("gilded", 41, 40)} check("axe: canonical F2P axe table matches the wiki (WC to use / Attack to equip)", canon == want) def fake_entry(**over): base = {"tags": ["woodcutting"], "axe_tiers": [{"axe": a, "woodcutting_level": w, "attack_level": k, "members": m} for a, w, k, m in AXE_TIERS], "axe_examples": [{"account": "41WC/1Atk", "woodcutting_level": 41, "attack_level": 1, "expected_axe": "rune", "expected_equipped": False}], "woodcutting_modes": [ {"mode": "fast-xp", "intent": "max xp per hour on the F2P ladder", "inventory_policy": "bank", "ladder": [ {"tree": "willow", "min_level": 30, "gate": "combat-15-plus", "spot": "draynor willows", "bank": "draynor"}, {"tree": "willow", "min_level": 30, "gate": "combat-under-15", "spot": "edgeville willow", "bank": "edgeville"}, {"tree": "oak", "min_level": 15, "gate": "none", "spot": "draynor oaks", "bank": "draynor"}, {"tree": "tree", "min_level": 1, "gate": "none", "spot": "lumbridge west", "bank": "lumbridge"}], "model": {"chop_seconds_per_log": 4.0, "drop_seconds_per_log": 0, "walk_seconds_each_way": 12, "deposit_seconds": 4, "respawn_duty": 0.92, "break_duty": 0.94}, "rates": {"logs_per_hour": 622, "xp_per_hour": 41985, "gp_per_hour": 13062, "log_price": 21, "price_verified": "2026-08-28"}}, {"mode": "afk-xp", "intent": "low-interaction quest-gated progress", "inventory_policy": "drop", "ladder": [ {"tree": "maple", "min_level": 45, "gate": "dragon-slayer-i", "spot": "corsair cove resource area", "bank": "none (drop)"}, {"tree": "willow", "min_level": 30, "gate": "combat-15-plus", "spot": "draynor willows", "bank": "draynor"}, {"tree": "oak", "min_level": 15, "gate": "none", "spot": "draynor oaks", "bank": "draynor"}, {"tree": "tree", "min_level": 1, "gate": "none", "spot": "lumbridge west", "bank": "lumbridge"}], "model": {"chop_seconds_per_log": 6.0, "drop_seconds_per_log": 1.0, "walk_seconds_each_way": 0, "deposit_seconds": 0, "respawn_duty": 0.80, "break_duty": 0.94}, "rates": {"logs_per_hour": 386, "xp_per_hour": 38600, "gp_per_hour": 4632, "log_price": 12, "price_verified": "2026-08-28"}}, {"mode": "banked-income", "intent": "banked logs sold at the GE", "inventory_policy": "bank", "ladder": [ {"tree": "yew", "min_level": 60, "gate": "none", "spot": "edgeville south yews", "bank": "edgeville"}, {"tree": "willow", "min_level": 30, "gate": "combat-15-plus", "spot": "draynor willows", "bank": "draynor"}, {"tree": "oak", "min_level": 15, "gate": "none", "spot": "draynor oaks", "bank": "draynor"}, {"tree": "tree", "min_level": 1, "gate": "none", "spot": "lumbridge west", "bank": "lumbridge"}], "model": {"chop_seconds_per_log": 7.5, "drop_seconds_per_log": 0, "walk_seconds_each_way": 8, "deposit_seconds": 4, "respawn_duty": 0.52, "break_duty": 0.94}, "rates": {"logs_per_hour": 214, "xp_per_hour": 37450, "gp_per_hour": 23754, "log_price": 111, "price_verified": "2026-08-28"}}]} base.update(over) return base check("modes: a compliant 3-mode entry passes every structured gate", woodcutting_errors("selftest/good", fake_entry()) == []) bad = fake_entry(woodcutting_modes=fake_entry()["woodcutting_modes"][1:]) check("modes: missing a mode (only 2 declared) is rejected", any("exactly" in e for e in woodcutting_errors("selftest/missing", bad))) bad = fake_entry(axe_examples=[{"account": "41WC/1Atk", "woodcutting_level": 41, "attack_level": 1, "expected_axe": "adamant", "expected_equipped": True}]) check("modes: a 41-WC/1-Attack example declaring a lower/equipped axe is rejected", any("recomputed rune" in e for e in woodcutting_errors("selftest/badex", bad))) bad = fake_entry(woodcutting_modes=[dict(m, ladder=[dict(r, min_level=30 if r["tree"] == "maple" else r["min_level"]) for r in m["ladder"]]) for m in fake_entry()["woodcutting_modes"]]) check("modes: maple below level 45 is rejected", any("maple must have min_level 45" in e for e in woodcutting_errors("selftest/maple45", bad))) bad = fake_entry(woodcutting_modes=[dict(m, ladder=[dict(r, gate="none") for r in m["ladder"]]) for m in fake_entry()["woodcutting_modes"]]) check("modes: maple without the dragon-slayer-i gate is rejected", any("gate dragon-slayer-i" in e for e in woodcutting_errors("selftest/maplegate", bad))) bad = fake_entry(woodcutting_modes=[dict(m, ladder=[dict(r) for r in m["ladder"][::-1]]) for m in fake_entry()["woodcutting_modes"]]) check("modes: a ladder not ordered by min_level DESC is rejected (nondeterministic)", any("ordered by min_level DESC" in e for e in woodcutting_errors("selftest/order", bad))) # rates: 9999 logs/hr cannot match the model chain (min cycle ~140s -> <=720/hr) badm = [dict(m, rates=dict(m["rates"], logs_per_hour=9999)) for m in fake_entry()["woodcutting_modes"]] bad = fake_entry(woodcutting_modes=badm) check("modes: a rate far above the model chain (9999/hr) is rejected", any("model chain" in e for e in woodcutting_errors("selftest/rate", bad))) bad = fake_entry(axe_tiers=[{"axe": a, "woodcutting_level": w, "attack_level": k, "members": m} for a, w, k, m in AXE_TIERS if a != "rune"]) check("modes: a non-canonical axe table (rune missing) is rejected", any("canonical F2P table" in e for e in woodcutting_errors("selftest/axes", bad))) # xp mismatch: 622 logs of willow = 41,985; declaring 40,000 must fail badm = fake_entry()["woodcutting_modes"] badm[0] = dict(badm[0], rates=dict(badm[0]["rates"], xp_per_hour=40000)) bad = fake_entry(woodcutting_modes=badm) check("modes: xp_per_hour not equal to logs x per-log xp is rejected", any("xp_per_hour" in e for e in woodcutting_errors("selftest/xp", bad))) return errors def load_entries(): out = [] for p in sorted((ROOT / "codex").rglob("*.yaml")): if p.name.startswith("_"): continue # dataset files (e.g. _f2p-alch-catalog.yaml) are not entries out.append(p) return out def main(): global STALE_DAYS if "--stale-days" in sys.argv: STALE_DAYS = int(sys.argv[sys.argv.index("--stale-days") + 1]) if "--selftest" in sys.argv: errs = run_selftest(verbose=True) for e in errs: print(f"SELFTEST FAIL {e}") print(f"RESULT: selftest, {len(errs)} errors, 0 warnings") return 1 if errs else 0 errors, warnings = [], [] entries = load_entries() if not entries: print("ERROR: no codex entries found under codex/") return 1 ids = {} today = datetime.date.today() for path in entries: rel = str(path.relative_to(ROOT)) try: data = yaml.safe_load(path.read_text()) except yaml.YAMLError as e: errors.append(f"{rel}: YAML parse error: {e}") continue # 1. schema v = jsonschema.Draft7Validator(SCHEMA) for err in sorted(v.iter_errors(data), key=lambda e: list(e.path)): loc = "/".join(str(p) for p in err.path) or "(root)" errors.append(f"{rel} [{loc}]: {err.message}") if errors and errors[-1].startswith(rel): # schema already broke; cross-checks on a broken doc are noise continue eid = data.get("id", rel) if eid in ids: errors.append(f"{rel}: duplicate entry id '{eid}' (also in {ids[eid]})") ids[eid] = rel # 2. state machine consistency sm = data.get("state_machine", {}) states = sm.get("states", {}) declared = set(states) terminals = set(sm.get("terminal", [])) for sname, sdef in states.items(): if sname not in terminals and not sdef.get("next"): errors.append(f"{rel}: non-terminal state '{sname}' has no 'next'") for nxt in sdef.get("next", []): if nxt not in declared: errors.append(f"{rel}: state '{sname}' next->'{nxt}' not declared") if sm.get("initial") not in declared: errors.append(f"{rel}: initial state '{sm.get('initial')}' not declared") for t in sm.get("transitions", []): if t["from"] not in declared: errors.append(f"{rel}: transition from '{t['from']}' not declared") if t["to"] not in declared: errors.append(f"{rel}: transition to '{t['to']}' not declared") for term in sm.get("terminal", []): if term not in declared: errors.append(f"{rel}: terminal '{term}' not declared") # action id/step uniqueness a_ids = [a["id"] for a in data["actions"]] a_steps = [a["step"] for a in data["actions"]] if len(a_ids) != len(set(a_ids)): errors.append(f"{rel}: duplicate action ids") if len(a_steps) != len(set(a_steps)): errors.append(f"{rel}: duplicate action step names") # 2b. GE flip-tax and woodcutting structured gates err = ge_floor_tax_error(rel, data) if err: errors.append(err) errors += woodcutting_errors(rel, data) # 3. staleness / research TODOs if data.get("status") == "verified": try: vdate = datetime.date.fromisoformat(data["verified"]) age = (today - vdate).days if age > STALE_DAYS: warnings.append( f"{rel}: status=verified but facts are {age}d old " f"(> {STALE_DAYS}d) — re-verify or drop to status:stale") except ValueError: pass for line in data.get("best_practice", {}).get("equipment", []) + \ data.get("best_practice", {}).get("techniques", []): if str(line.get("source", "")).startswith("researched"): warnings.append( f"{rel}: best_practice '{line.get('item') or line.get('name')}' " f"carries a 'researched' source — citation TODO, file follow-up") # report for w in warnings: print(f"WARN {w}") for e in errors: print(f"ERROR {e}") print(f"RESULT: {len(entries)} entries, {len(errors)} errors, {len(warnings)} warnings") return 1 if errors else 0 if __name__ == "__main__": sys.exit(main())