- 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
479 lines
25 KiB
Python
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())
|