osrs-codex/tools/validate.py
Claude 86c0c896d8 woodcutting-f2p: three explicit modes + corrected axe model (governor 10323 bounce-fix 3, owner decision 10288)
- fast-xp / afk-xp / banked-income modes with deterministic first-match
  ladders (no 'or'), level-1 fallbacks, pinned activity state
- axe model corrected: 41 Woodcutting to USE / 40 Attack to EQUIP;
  equip only when Attack permits, else carry the same axe — committed
  axe_examples incl. the 1-Attack/41-WC rune-axe carry case (owner)
- full per-mode lifecycle: prereqs, axe acquisition, initial inventory,
  travel, depletion/respawn, one chop attempt, inventory-full, complete
  dropping or banking route, breaks independent of incidental banking,
  recovery branches, completion
- per-mode worked examples: prep -> first full-inventory boundary ->
  first scheduled break -> resume (level-50 and level-70 pinned)
- realized rates: logs/hr = model chain (chop x capacity / cycle incl.
  walk/deposit or drop, x respawn duty x break duty); xp/gp derived
  and validator-enforced (axe_tiers / axe_examples / woodcutting_modes
  structured schema blocks + 16 committed selftest fixtures)
- wiki-verified axe table 2026-08-28; prices refreshed 2026-08-28
2026-08-28 04:21:31 -05:00

479 lines
25 KiB
Python

#!/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():
return sorted((ROOT / "codex").rglob("*.yaml"))
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())