diff --git a/codex/training/woodcutting-f2p.yaml b/codex/training/woodcutting-f2p.yaml new file mode 100644 index 0000000..8526468 --- /dev/null +++ b/codex/training/woodcutting-f2p.yaml @@ -0,0 +1,692 @@ +id: woodcutting-f2p +title: Woodcutting (F2P) +domain: training +tags: [woodcutting, gathering, f2p, skill-training] +status: verified +version: 5 # 5: bounce-fix 4 2026-08-29 — governor 10398 + owner decision 10499: executable axe preparation (a2-a9 check/withdraw/GE-buy/wait/collect/equip-or-carry/loadout), unobtainable-axe -> failed (never complete), drop transition gated to afk-xp, per-SELECTED-row dynamic rates with 27/28 capacity from axe_equipped (woodcutting_fixtures recomputed by the validator), drop-mode realized GP zero +updated: "2026-08-29" # ISO dates must stay quoted — YAML 1.1 parses bare dates to datetime.date + +summary: >- + F2P woodcutting on the verified F2P tree ladder — Tree (1/25xp), Oak + (15/37.5xp), Willow (30/67.5xp), Maple (45/100xp, optional), Yew + (60/175xp) — with THREE explicit, mutually exclusive run modes (owner + decision 10288, 2026-08-28): `fast-xp` (XP-oriented progression, willow + as the intended fast-XP target), `afk-xp` (quest/location-gated + low-interaction progression, maple only when Dragon Slayer I access is + satisfied), and `banked-income` (banked logs sold at the GE, yew from 60 + when eligible). Each mode has a deterministic first-match ladder (no + parenthesized "or"), a level-1 fallback, a pinned activity state + (mode + tree + spot), a complete start-to-finish lifecycle, and a worked + example covering preparation, the first full-inventory boundary, the + first scheduled break and the resume. Axe model (corrected per governor + 10323 / owner 10288): the account uses the best F2P axe whose + WOODCUTTING level it meets (rune/gilded = 41); it equips it only when + its separate ATTACK level is met (rune/gilded = 40), otherwise it + carries that same axe (costing one inventory slot — 27 log slots vs 28 + when equipped). RATES are realized, not idealized: every mode's + logs/hour equals its documented model chain (chop cadence x capacity / + cycle incl. walking/depositing or dropping, x respawn/switch duty x + break duty), and xp/hour and gp/hour derive from it — committed as + structured fixtures in tools/validate.py. Verified 2026-08-28 (wiki + axe/tree pages + prices.runescape.wiki latest). + +accounts: + membership: f2p + min_level: 1 # Woodcutting 1 — normal trees from the start + requires_quest: [] # EMPTY for the base entry — Tree/Oak/Willow/Yew need no quest. Dragon Slayer I is NOT an entry-level prerequisite: it gates ONLY the afk-xp maple row (Corsair Cove Resource Area), expressed in woodcutting_modes and the a1 ladder — never in accounts.requires_quest. + requires_other: + - "An axe. The account USES the best F2P axe whose Woodcutting level it meets (bronze 1 / iron 1 / steel 6 / black 11 / mithril 21 / adamant 31 / rune 41 / gilded 41 — verified wiki table 2026-08-28); it EQUIPS it only when its separate Attack level is met (steel 5 / black 10 / mithril 20 / adamant 30 / rune 40 / gilded 40), otherwise it CARRIES that same axe and chops from the inventory (legal at every level). A carried axe costs ONE log slot (27 log slots), an equipped axe costs none (28 log slots); inventory-full logic derives the count dynamically, never hardcoded." + - "Combat 15+ to use the Draynor Village willow spot (two aggressive level-7 Dark wizards attack players under combat 15); accounts below that use the Edgeville willow instead (fast-xp and banked-income ladders both carry the combat split as two deterministic rows)" + - "For the afk-xp maple row only: Dragon Slayer I completed (Corsair Cove Resource Area entrance gate); without it the mode's ladder falls back to willow" + - "Bank within a short walk of the tier spot (Draynor, Edgeville, Lumbridge, or Corsair Cove bank); rune axe one-time cost ~7.1-7.3k gp (GE live 2026-08-28) if not already owned" + +axe_tiers: + # Canonical table (OSRS wiki axe pages, raw wikitext verified 2026-08-28): + # woodcutting_level = tool-USE requirement; attack_level = EQUIP requirement. + # tools/validate.py enforces this table exactly and recomputes axe_examples. + - {axe: bronze, woodcutting_level: 1, attack_level: 1, members: false} + - {axe: iron, woodcutting_level: 1, attack_level: 1, members: false} + - {axe: steel, woodcutting_level: 6, attack_level: 5, members: false} + - {axe: black, woodcutting_level: 11, attack_level: 10, members: false} + - {axe: mithril, woodcutting_level: 21, attack_level: 20, members: false} + - {axe: adamant, woodcutting_level: 31, attack_level: 30, members: false} + - {axe: rune, woodcutting_level: 41, attack_level: 40, members: false} + - {axe: gilded, woodcutting_level: 41, attack_level: 40, members: false} + - {axe: dragon, woodcutting_level: 61, attack_level: 60, members: true} + +axe_examples: + # Committed regression for the equip-vs-carry rule (owner coverage 10288: + # "a 1-Attack/41-Woodcutting account carries and uses a rune axe rather + # than selecting a lower axe"). tools/validate.py recomputes each row. + - {account: "1-Attack / 41-Woodcutting (owner coverage)", woodcutting_level: 41, attack_level: 1, expected_axe: rune, expected_equipped: false} + - {account: "40-Attack / 41-Woodcutting", woodcutting_level: 41, attack_level: 40, expected_axe: rune, expected_equipped: true} + - {account: "40-Attack / 30-Woodcutting (below adamant 31)", woodcutting_level: 30, attack_level: 40, expected_axe: mithril, expected_equipped: true} + - {account: "5-Attack / 31-Woodcutting (adamant usable, not equippable)", woodcutting_level: 31, attack_level: 5, expected_axe: adamant, expected_equipped: false} + - {account: "1-Attack / 1-Woodcutting", woodcutting_level: 1, attack_level: 1, expected_axe: iron, expected_equipped: true} + - {account: "99-Attack / 99-Woodcutting (F2P top; dragon is members-only)", woodcutting_level: 99, attack_level: 99, expected_axe: rune, expected_equipped: true} + +woodcutting_modes: + # Deterministic first-match ladders, ordered min_level DESC. The selected + # mode AND tree are pinned in activity state by a1 (never decided ad hoc). + # MODEL SPLIT (governor 10398, bounce-fix 4): mode-level `model` holds the + # mode-wide constants (drop/deposit/break); each LADDER ROW carries its own + # chop_seconds_per_log / walk_seconds_each_way / respawn_duty / log_price + # so rates derive from the SELECTED row, not a fixed top tree. The mode + # `rates` block is the canonical TOP-ROW reference (ladder[0], axe + # equipped = 28 slots, labeled as such); dynamic per-selected-row rates + # with 27/28 capacity from axe_equipped are committed in + # `woodcutting_fixtures` and recomputed by the validator. + - mode: fast-xp + intent: >- + Maximum Woodcutting XP per hour on the F2P ladder. Willow is the + intended fast-XP target from level 30 (67.5 xp/log, close bank loop, + high chop cadence); oak covers 15-29 and normal trees below 15. The + ladder intentionally does NOT switch to yew at 60 — the F2P guide's + XP recommendation is willow (fast-xp stays on willow; yew is the + banked-income target). + inventory_policy: bank + ladder: + - {tree: willow, min_level: 30, gate: combat-15-plus, spot: draynor-willows, bank: draynor, chop_seconds_per_log: 4.0, walk_seconds_each_way: 12, respawn_duty: 0.92, log_price: 21} + - {tree: willow, min_level: 30, gate: combat-under-15, spot: edgeville-willow, bank: edgeville, chop_seconds_per_log: 4.0, walk_seconds_each_way: 6, respawn_duty: 0.90, log_price: 21} + - {tree: oak, min_level: 15, gate: none, spot: draynor-oaks, bank: draynor, chop_seconds_per_log: 3.5, walk_seconds_each_way: 8, respawn_duty: 0.93, log_price: 40} + - {tree: tree, min_level: 1, gate: none, spot: lumbridge-west, bank: lumbridge, chop_seconds_per_log: 3.0, walk_seconds_each_way: 10, respawn_duty: 0.95, log_price: 23} + model: + drop_seconds_per_log: 0 # bank mode — no dropping + deposit_seconds: 4 + break_duty: 0.94 # scheduled breaks land at the bank + rates: # CANONICAL TOP-ROW reference ONLY: + logs_per_hour: 622 # willow row, axe EQUIPPED (28 slots): 28/(28x4.0+2x12+4=140s)x3600x0.92x0.94 = 622.7; carried-axe/other-row profiles -> woodcutting_fixtures + xp_per_hour: 41985 # 622 x 67.5 (willow xp/log, wiki infobox) + gp_per_hour: 13062 # 622 x 21 (willow log sell, prices.runescape.wiki latest 2026-08-28) + log_price: 21 + price_verified: "2026-08-28" + fallback: "below level 30 -> oak (15+) then normal trees (1+); below combat 15 the Draynor willow row is replaced by the Edgeville willow row (same min_level, different gate)" + + - mode: afk-xp + intent: >- + Low-interaction, quest/location-gated progression: maple at Corsair + Cove Resource Area (100 xp/log, 4 trees, Dragon Slayer I gate) with + dropping instead of banking — the fewest interactions per hour of the + three modes. Maple is selected ONLY when level 45+ AND the quest gate + is satisfied; otherwise the ladder falls back through willow/oak/tree. + DROP mode: logs are dropped at the spot, so REALIZED GP is 0; the log + sell value is carried as reference/opportunity cost only. + inventory_policy: drop + ladder: + - {tree: maple, min_level: 45, gate: dragon-slayer-i, spot: corsair-cove-resource-area, bank: none-drop, chop_seconds_per_log: 6.0, walk_seconds_each_way: 0, respawn_duty: 0.80, log_price: 12} + - {tree: willow, min_level: 30, gate: combat-15-plus, spot: draynor-willows, bank: none-drop, chop_seconds_per_log: 6.0, walk_seconds_each_way: 0, respawn_duty: 0.92, log_price: 21} + - {tree: willow, min_level: 30, gate: combat-under-15, spot: edgeville-willow, bank: none-drop, chop_seconds_per_log: 6.0, walk_seconds_each_way: 0, respawn_duty: 0.90, log_price: 21} + - {tree: oak, min_level: 15, gate: none, spot: draynor-oaks, bank: none-drop, chop_seconds_per_log: 5.5, walk_seconds_each_way: 0, respawn_duty: 0.93, log_price: 40} + - {tree: tree, min_level: 1, gate: none, spot: lumbridge-west, bank: none-drop, chop_seconds_per_log: 5.0, walk_seconds_each_way: 0, respawn_duty: 0.95, log_price: 23} + model: + drop_seconds_per_log: 1.0 # dropping the whole load at the spot; NO bank legs (far-from-bank spot, per the guide's AFK trade-off) + deposit_seconds: 0 + break_duty: 0.94 # scheduled breaks at the spot (drop mode has no bank) + rates: # CANONICAL TOP-ROW reference ONLY: + logs_per_hour: 386 # maple row, axe EQUIPPED (28 slots): 28/(28x(6.0+1.0)=196s)x3600x0.80x0.94 = 386.8; carried-axe (27-slot) profile -> woodcutting_fixtures + xp_per_hour: 38600 # 386 x 100 (maple xp/log) — deliberately below fast-xp per the guide (maple is AFK, not top XP) + gp_per_hour: 0 # DROP MODE: logs are dropped — REALIZED GP IS 0 (validator-enforced); log_price 12 is reference/opportunity cost only, never realized income + log_price: 12 + price_verified: "2026-08-28" + fallback: "below 45 or without Dragon Slayer I -> willow (30) then oak (15) then tree (1); never select maple without the quest gate" + + - mode: banked-income + intent: >- + Banked logs sold at the GE: yew from level 60 (175 xp/log, 111 gp/log + live 2026-08-28, Edgeville south spot ~8s from the bank) when + eligible, with deterministic lower fallbacks (willow/oak/tree banked) + below 60. This is the income mode — the mode split implements the F2P + guide's "cut willow/maple for experience, yew for profit". + inventory_policy: bank + ladder: + - {tree: yew, min_level: 60, gate: none, spot: edgeville-south-yews, bank: edgeville, chop_seconds_per_log: 7.5, walk_seconds_each_way: 8, respawn_duty: 0.52, log_price: 111} + - {tree: willow, min_level: 30, gate: combat-15-plus, spot: draynor-willows, bank: draynor, chop_seconds_per_log: 4.0, walk_seconds_each_way: 12, respawn_duty: 0.92, log_price: 21} + - {tree: willow, min_level: 30, gate: combat-under-15, spot: edgeville-willow, bank: edgeville, chop_seconds_per_log: 4.0, walk_seconds_each_way: 6, respawn_duty: 0.90, log_price: 21} + - {tree: oak, min_level: 15, gate: none, spot: draynor-oaks, bank: draynor, chop_seconds_per_log: 3.5, walk_seconds_each_way: 8, respawn_duty: 0.93, log_price: 40} + - {tree: tree, min_level: 1, gate: none, spot: lumbridge-west, bank: lumbridge, chop_seconds_per_log: 3.0, walk_seconds_each_way: 10, respawn_duty: 0.95, log_price: 23} + model: + drop_seconds_per_log: 0 + deposit_seconds: 4 + break_duty: 0.94 # scheduled breaks at the bank + rates: # CANONICAL TOP-ROW reference ONLY: + logs_per_hour: 214 # yew row, axe EQUIPPED (28 slots): 28/(28x7.5+2x8+4=230s)x3600x0.52x0.94 = 214.2; carried-axe/other-row profiles -> woodcutting_fixtures + xp_per_hour: 37450 # 214 x 175 (yew xp/log) + gp_per_hour: 23754 # 214 x 111 (yew log sell, prices.runescape.wiki latest 2026-08-28) + log_price: 111 + price_verified: "2026-08-28" + fallback: "below 60 -> willow (30) then oak (15) then tree (1), all banked; yew never selected below 60" + +# Dynamic per-selected-row rate proof (governor 10398, bounce-fix 4): the +# mode `rates` blocks above are the canonical TOP-ROW references only; +# these fixtures prove the entry derives logs/xp/gp from the SELECTED +# ladder row AND the actual 27/28-slot capacity from axe_equipped. Each +# row is recomputed by the validator (select_ladder_row + best_axe + +# capacity = 28 - (1 if carried else 0)) and must match within tolerance. +woodcutting_fixtures: + - account: "level-1 fast-xp" + mode: fast-xp + woodcutting_level: 1 + attack_level: 1 + quest_gate: false + combat_level: 3 + expected_tree: tree + expected_axe: iron + expected_equipped: true # 1 WC / 1 Attack: iron axe (tie rank) equipped + expected_logs_per_hour: 833 + expected_xp_per_hour: 20836 + expected_gp_per_hour: 19170 + - account: "level-50 fast-xp" + mode: fast-xp + woodcutting_level: 50 + attack_level: 40 + quest_gate: false + combat_level: 50 + expected_tree: willow + expected_axe: rune + expected_equipped: true # 28 log slots — matches the canonical top-row reference + expected_logs_per_hour: 622 + expected_xp_per_hour: 41985 + expected_gp_per_hour: 13062 + - account: "level-50 afk-xp with Dragon Slayer I (maple)" + mode: afk-xp + woodcutting_level: 50 + attack_level: 1 + quest_gate: true + combat_level: 50 + expected_tree: maple + expected_axe: rune + expected_equipped: false # owner coverage: rune axe CARRIED -> 27 log slots + expected_logs_per_hour: 387 + expected_xp_per_hour: 38674 + expected_gp_per_hour: 0 # drop mode — realized GP is 0 + - account: "level-50 afk-xp WITHOUT Dragon Slayer I (willow fallback)" + mode: afk-xp + woodcutting_level: 50 + attack_level: 1 + quest_gate: false + combat_level: 50 + expected_tree: willow + expected_axe: rune + expected_equipped: false # carried rune axe -> 27 log slots + expected_logs_per_hour: 445 # willow drop row, rune CARRIED (27 slots): 27/(27x(6.0+1.0)=189s)x3600x0.92x0.94 = 444.8 + expected_xp_per_hour: 30021 # 444.8 x 67.5 (willow xp/log) + expected_gp_per_hour: 0 # drop mode — realized GP is 0 + - account: "level-70 banked-income (yew)" + mode: banked-income + woodcutting_level: 70 + attack_level: 40 + quest_gate: false + combat_level: 60 + expected_tree: yew + expected_axe: rune + expected_equipped: true # 28 log slots — matches the canonical top-row reference + expected_logs_per_hour: 214 + expected_xp_per_hour: 37450 + expected_gp_per_hour: 23754 + - account: "level-30 fast-xp under combat 15 (Edgeville willow)" + mode: fast-xp + woodcutting_level: 30 + attack_level: 1 + quest_gate: false + combat_level: 3 + expected_tree: willow + expected_axe: mithril + expected_equipped: false # 30 WC selects mithril (21); Attack 1 < 20 -> CARRY -> 27 log slots + expected_logs_per_hour: 663 + expected_xp_per_hour: 44766 + expected_gp_per_hour: 13927 + +best_practice: + equipment: + - item: "rune axe (best F2P axe: 41 Woodcutting to USE, 40 Attack to EQUIP)" + why: "fastest F2P chop rate; per the wiki axe table Rune axe (41) and Gilded axe (41) are 'the best axes available in Free-to-play' (dragon is members-only). F2P ladder: Bronze(1)/Iron(1)/Steel(6)/Black(11)/Mithril(21)/Adamant(31)/Rune(41)/Gilded(41) — the number is the WOODCUTTING level to use the axe for chopping, NOT an Attack gate. EQUIP is a separate Attack requirement (rune/gilded 40): equip only when the account meets it, otherwise carry that same axe and chop from the inventory — legal at every level, costs one log slot. Never select a lower axe because the Attack level is low." + source: "OSRS wiki — Woodcutting (axe table incl. F2P/members flags) + Rune axe / Gilded axe / Black axe item pages raw wikitext 2026-08-28 ('Level 41 Woodcutting is required to use this axe' / 'Attack level of 40 to wield this axe'; gilded identical; black: 11 to use, 10 to equip; dragon: 61/60 members); governor review comment 10248 + owner decision 10288 (carry-and-use rule)" + techniques: + - name: pick the mode first, then the deterministic ladder + why: "Owner decision 10288: run exactly ONE of fast-xp / afk-xp / banked-income; the ladder inside the mode decides the tree by level + quest/combat gates (first match, min_level DESC — no parenthesized 'or'), and the chosen mode + tree are pinned in activity state. fast-xp never silently becomes income; afk-xp never runs maple without the quest gate; banked-income never claims yew below 60." + source: "owner-guidance comment 10288 (2026-08-28); governor 10248 (deterministic tier field)" + - name: per-tier route table (spots and banks, wiki 2026-08-27/28) + why: "Tree (1) — west of Lumbridge Castle, bank Lumbridge; Oak (15) — two east of Draynor Village bank (alt: south-west of Varrock west bank), bank Draynor; Willow (30) — south-west of Draynor Village bank GATED to combat 15+ (two aggressive level-7 Dark wizards attack under combat 15), sub-15 accounts use the single willow East of Edgeville, bank Edgeville; Maple (45) — Corsair Cove Resource Area (4 trees, only F2P maples, requires Dragon Slayer I), far from a bank; Yew (60) — two south of Edgeville bank, bank Edgeville." + source: "OSRS wiki — Free-to-play Woodcutting training + Draynor Village + tree location lists (2026-08-27, re-verified 2026-08-28)" + - name: drop versus bank is a MODE property, not an ad-hoc choice + why: "fast-xp banks (Draynor loop keeps the bank close and income attached); afk-xp DROPS (Corsair Cove is far from a bank — the guide's AFK trade-off; no bank legs at all); banked-income banks and sells (yew 111 gp/log live). Each mode's rate chain includes its own dropping or banking route (model fields + validator chain)." + source: "owner-guidance comment 10288 (complete dropping or banking route per mode); OSRS wiki — Free-to-play Woodcutting training (drop-vs-bank trade-off)" + - name: bank logs, keep the axe + why: "Logs are F2P-tradeable income: live sell prices 2026-08-28 — willow 21, oak 40, maple 12, yew 111 per log (GE limits 15k/15k/15k/12k per 4h — none binding at modeled rates). deposit_logs deposits logs ONLY — the axe is never deposited (equipped or carried)." + source: "prices.runescape.wiki latest 2026-08-28" + - name: rates are realized, not idealized + why: "Every mode's rates come from its documented model chain: capacity / cycle (chop seconds x capacity + walking + depositing, or + dropping) x 3600 x respawn/switch duty x break duty — including travel, tree respawn, dropping/banking and mandatory breaks. The validator recomputes the chain within 1% and derives xp/gp from it; no hidden 'chop loop only' numbers." + source: "owner-guidance comment 10288 (validators and rate claims must include travel, respawn, dropping or banking, and mandatory breaks)" + +economy: + # Verified 2026-08-28 against prices.runescape.wiki latest + OSRS wiki + # tree-page infoboxes. Logs are pure income (no per-cycle cost; axe is + # one-time equipment). Anchor = fast-xp mode (Draynor willows, combat 15+). + item: Willow logs + item_id: 1519 + cost_per_cycle: 0 # no supplies consumed; axe cost amortised + income_per_cycle: 21 # willow log sell price (latest.high 2026-08-28) + net_per_cycle: 21 + gp_per_hour_low: 13100 # fast-xp realized banked income: 622 logs/hr x 21 (model chain, 2026-08-28) + gp_per_hour_high: 23800 # banked-income realized: 214 yew logs/hr x 111 (model chain, 2026-08-28) + xp_per_hour: 42000 # fast-xp realized: 41,985 (622 willow logs/hr x 67.5; see woodcutting_modes) + supplies: [] + profit_drivers: + - "Logs are the income: sell at the GE (willow 21, oak 40, maple 12, yew 111 per log — live 2026-08-28; all F2P-tradeable with 12k-15k/4h buy limits, none binding at modeled rates)" + - "THREE explicit modes (owner decision 10288), each with a deterministic ladder and realized rates — fast-xp 622 logs/hr -> 41,985 xp/hr (willow, banked); afk-xp 386 logs/hr -> 38,600 xp/hr (maple @45 + Dragon Slayer I, dropped); banked-income 214 logs/hr -> 37,450 xp/hr and 23,754 gp/hr (yew @60, banked)" + - "Mode split implements the F2P guide: willow/maple for experience, yew for profit — fast-xp stays on willow past 60; banked-income takes yew at 60+" + - "Maple (45, Dragon Slayer I, Corsair Cove Resource Area) is an optional AFK F2P tier: 100 xp/log, 4 trees, far from a bank — more AFK, lower xp/hr than willows (modeled accordingly)" + - "Axe economics: best F2P axe by Woodcutting level (rune from 41); one-time GE cost ~7.1-7.3k gp (2026-08-28); equip only when Attack permits, else carry (one log slot)" + +state_machine: + initial: idle + terminal: [complete, failed] + states: + idle: + goal: "logged in at a bank with an axe available (bank or inventory); choose run mode (explicit run parameter) and confirm prerequisites" + next: [arm_run, complete] + arm_run: + goal: "PIN the run in activity state: mode (fast-xp | afk-xp | banked-income), tree, spot, bank, axe, axe_equipped (true/false) — decided once by the a1 ladder, never changed mid-run; re-arm only on fallback events" + next: [prepare, idle] + prepare: + goal: >- + EXECUTABLE axe preparation (governor 10398, bounce-fix 4), run + BEFORE walking to the trees: (1) check inventory and bank for the + pinned best axe (a2); (2) owned -> withdraw it (a3); missing -> + GE purchase chain a4 open-ge -> a5 buy-axe (ONE offer) -> a6 + wait-ge-fill (BOUNDED fill window; on no-fill/partial-fill TIMEOUT + cancel the resting offer and collect the item or refund) -> a7 + collect-axe (ge_collect + has_item verification); (3) equip it + ONLY when level_is Attack >= the tier's equip level (a8, menu_option + Wield) or carry the same axe; (4) build the initial inventory (a9: + axe + empty log slots — nothing else is needed). An axe that cannot + be obtained after the bounded recovery (3 acquisition loops) is a + FAILED run: log ERROR + alert, never a silent lower-axe continue + and never a 'complete'. + next: [walk_to_trees, prepare, failed] + walk_to_trees: + goal: "walk the pinned tier route from the bank to the pinned tree spot" + next: [chopping, idle] + chopping: + goal: "chop the pinned tree until the inventory is full or the tree falls; one chop attempt = click + observe (log gained / tree fell / full / fail) — see a11" + next: [drop_logs, walk_to_bank, walk_to_trees, complete, failed] + drop_logs: + goal: "AFK-XP DROP MODE ONLY: drop the whole load at the spot (a13, menu_option per log group); inventory now empty; no bank legs" + next: [chopping, walk_to_trees] + walk_to_bank: + goal: "walk the pinned route from the tree spot to the pinned bank (bank modes only: fast-xp | banked-income)" + next: [deposit_logs] + deposit_logs: + goal: "deposit ALL logs (a15 — logs only; the axe stays equipped or carried, never deposited); short bank trip lands the scheduled break" + next: [walk_back] + walk_back: + goal: "walk back from the bank to the pinned tree spot" + next: [chopping, idle] + complete: + goal: "XP/level target or GP target reached — collect and stop" + failed: + goal: "hard failure — stop, log ERROR sentinel, alert" + transitions: + - from: idle + when: "mode + prerequisites confirmed and session target not reached" + to: arm_run + - from: idle + when: "session target already reached at session start" + to: complete + - from: arm_run + when: "ladder picked a row (mode, tree, spot, bank pinned in activity state) — axe decided: highest usable by Woodcutting level, equip only when Attack permits else carry" + to: prepare + - from: arm_run + when: "maple row selected but Dragon Slayer I access denied at the gate — re-arm on the next ladder row (fallback) and log the denial" + to: idle + - from: prepare + when: "axe acquired (owned withdraw or GE open/buy/wait/collect verified) AND equipped-or-carried (a8) AND loadout built (a9)" + to: walk_to_trees + - from: prepare + when: "GE buy offer still filling (a6 bounded window polling) or a partial fill re-placed — loop the acquisition chain" + to: prepare + - from: prepare + when: "3 consecutive no-fill GE rounds or the bank also lacks any usable F2P axe — axe unobtainable: log ERROR + alert" + to: failed + - from: walk_to_trees + when: "at the pinned tree spot" + to: chopping + - from: chopping + when: "mode is afk-xp (inventory_policy drop) AND inventory full (28 log slots with the axe equipped, 27 with the axe carried — derived dynamically, never hardcoded)" + to: drop_logs + - from: chopping + when: "mode is fast-xp OR banked-income (inventory_policy bank) AND inventory full — the two full-inventory routes are mutually exclusive by mode" + to: walk_to_bank + - from: chopping + when: "tree fell and no interactable tree at the spot (> 30s empty) — move to the next tree or hop_world (a17)" + to: walk_to_trees + - from: drop_logs + when: "load dropped — inventory empty" + to: chopping + - from: walk_to_bank + when: "at the pinned bank" + to: deposit_logs + - from: deposit_logs + when: "logs deposited (axe still present)" + to: walk_back + - from: walk_back + when: "at the pinned tree spot" + to: chopping + - from: walk_back + when: "spot unavailable on return (crowded > 60s or invalid access) — re-arm on the next ladder row (fallback)" + to: idle + - from: idle + when: "woodcutting level >= target (fast-xp / afk-xp) or GP target banked (banked-income)" + to: complete + +actions: + - id: a1 + step: arm-run + verb: level_is + target: "woodcutting level (mode ladder)" + params: + mode: "fast-xp | afk-xp | banked-income — explicit run parameter, pinned with the chosen tree/spot/bank in activity state (owner 10288)" + ladder: "deterministic first match, min_level DESC (see woodcutting_modes): fast-xp -> willow@30 (combat-15-plus: draynor / combat-under-15: edgeville) -> oak@15 -> tree@1; afk-xp -> maple@45 (dragon-slayer-i gate) -> willow@30 -> oak@15 -> tree@1; banked-income -> yew@60 -> willow@30 -> oak@15 -> tree@1" + spot: "per the chosen row (route table in a10)" + pin: "{mode, tree, spot, bank, axe, axe_equipped} written to activity state — never re-decided mid-run except by explicit fallback events" + on_fail: retry + anti_ban: + delay: [200, 900] + + - id: a2 + step: check-axe + verb: has_item + target: "the pinned best axe tier (by Woodcutting level — see axe_tiers)" + params: + check: "inventory AND bank (bank_open / item_count); branch: owned -> a3 withdraw-axe; missing -> the GE purchase chain a4 -> a5 -> a6 -> a7" + decide: "never a lower axe because the Attack level is low — the WOODCUTTING level selects the tier, Attack only decides equip-or-carry (axe_examples)" + on_fail: retry + anti_ban: + delay: [300, 1000] + + - id: a3 + step: withdraw-axe + verb: bank_inventory + target: bank + params: + withdraw: "1x the pinned best axe tier (owned-tool branch — the axe comes from the bank, never bought twice)" + verify: "has_item axe == true after withdrawal" + on_fail: retry + anti_ban: + delay: [400, 1200] + + - id: a4 + step: open-ge + verb: ge_open + target: grand-exchange + params: + location: "the GE booth (bank side) — REQUIRED before any ge_buy / ge_collect: the exchange interface must be open before an offer is placed or items are collected" + note: "only the purchase branch reaches the GE (a2 missing-tool); owned axes go straight to a3 withdraw-axe" + on_fail: retry + anti_ban: + delay: [600, 1600] + + - id: a5 + step: buy-axe + verb: ge_buy + target: grand-exchange + params: + item: "the pinned best axe tier (Woodcutting-gated)" + qty: 1 + max_price: "live high + 10% cap (rune axe ~7.1-7.3k gp live 2026-08-28; GE limit 40/4h — one axe is trivial)" + note: "place exactly ONE buy offer; the fill waits at most the BOUNDED a6 window — a resting offer is never retried indefinitely" + pair_with: "a4 open-ge (before) then a6 wait-ge-fill (bounded) then a7 collect-axe (collect + verify)" + on_fail: retry + anti_ban: + delay: [1200, 3000] + + - id: a6 + step: wait-ge-fill + verb: ge_offers_done + target: "the axe purchase offer" + params: + until: "the axe offer FILLS (has_item axe) — poll for exactly ONE bounded fill window (3-6 min); on no-fill / partial-fill TIMEOUT: CANCEL the resting offer, collect the item if it finally filled or the gp refund (a7), then verify — fail CLOSED via prepare recovery (3 loops -> failed); never re-place the same resting offer" + on_fail: alert + anti_ban: + delay: [900, 2400] + + - id: a7 + step: collect-axe + verb: ge_collect + target: grand-exchange + params: + collect: "finished GE transactions — the bought axe if the offer filled, and the gp refund if it was cancelled on timeout; collection closes the offer slot" + verify: "has_item axe == true after collection; if still missing (no-fill / cancelled), loop the acquisition chain via prepare (a2 path, 3 loops -> failed) — a failed purchase is diagnosed, never silently re-queued" + on_fail: retry + anti_ban: + delay: [600, 1400] + + - id: a8 + step: equip-or-carry-axe + verb: menu_option + target: "best axe in inventory (decided by Woodcutting level)" + params: + rule: "equip (menu_option 'Wield ') ONLY when Attack >= the axe's equip level (rune/gilded 40, adamant 30, mithril 20, black 10, steel 5); otherwise carry it and chop from the inventory — the same axe, never a lower one because Attack is low (owner coverage: 1-Attack/41-Woodcutting carries and uses rune)" + action: "menu_option axe -> 'Wield '" + capacity: "equipped: 28 log slots; carried: 27 (one slot for the axe)" + on_fail: retry + anti_ban: + delay: [300, 1100] + + - id: a9 + step: build-loadout + verb: has_item + target: "the pinned axe tier in inventory (equipped or carried)" + params: + verify: "inventory holds exactly ONE axe of the pinned tier and the rest is empty log space — the initial inventory needs nothing else (no supplies); the axe is never deposited mid-run" + on_fail: retry + anti_ban: + delay: [300, 900] + + - id: a10 + step: walk-to-trees + verb: walk_to + target: "pinned tier tree spot" + params: + route: "tree -> west of Lumbridge Castle (bank lumbridge); oak -> two east of Draynor Village bank (bank draynor); willow -> south-west of Draynor Village bank, combat 15+ only (bank draynor) OR the single willow East of Edgeville, combat under 15 (bank edgeville); maple -> Corsair Cove Resource Area, Dragon Slayer I gate (no bank — drop mode); yew -> two south of Edgeville bank (bank edgeville)" + method: [walk, teleport] + on_fail: retry + anti_ban: + delay: [400, 1600] + + - id: a11 + step: chop-tree + verb: click_object + target: "tree of the pinned tier" + params: + object: "tree/oak/willow/maple/yew per the pinned ladder row" + attempt: "one chop = click + observe: (a) log gained -> continue; (b) tree fell -> switch to the next interactable tree at the spot; (c) inventory full -> route to drop (afk-xp) or bank (fast-xp / banked-income) by the mode-gated transitions; (d) no interactable tree for > 30s -> walk_to_trees move / hop_world (a17); (e) click failed -> retry up to 3, then alert" + rhythm: "humanised cadence; the SELECTED ladder row's chop_seconds_per_log (woodcutting_modes) — never a fixed top-tree cadence" + on_fail: retry + anti_ban: + delay: [900, 2400] + + - id: a12 + step: check-inventory + verb: item_count + target: "logs in inventory" + params: + item: "logs of the pinned tier" + op: "inventory full -> drop (a13, afk-xp ONLY) or walk_to_bank (a14, fast-xp / banked-income ONLY); available log slots = 28 with the axe equipped, 27 with the axe carried — never hardcode 28" + on_fail: skip + anti_ban: + delay: [150, 700] + + - id: a13 + step: drop-load + verb: menu_option + target: "logs (afk-xp drop mode only)" + params: + action: "drop all logs of the pinned tier at the spot (grouped menu_option drops)" + note: "no bank legs in drop mode — the rate chain uses the drop_seconds_per_log model constant; realized gp from dropped logs is 0 (log value is reference/opportunity cost only)" + on_fail: retry + anti_ban: + delay: [200, 800] + + - id: a14 + step: walk-to-bank + verb: walk_to + target: "pinned bank from the tree spot" + params: + bank: "lumbridge (tree) | draynor (oak / combat-15+ willow) | edgeville (combat-under-15 willow / yew)" + method: [walk] + on_fail: retry + anti_ban: + delay: [400, 1600] + + - id: a15 + step: deposit-logs + verb: bank_inventory + target: bank + params: + deposit: [all logs] # logs ONLY — the axe stays (equipped or carried); never deposit the axe + withdraw: [] + break: "scheduled breaks land here (bank modes) — see a18" + on_fail: alert + + - id: a16 + step: walk-back-to-trees + verb: walk_to + target: "pinned tree spot from the bank" + params: + route: "reverse of a14 — back to the same spot" + spot_check: "if the spot is unavailable on return (crowded > 60s -> hop_world; invalid access -> re-arm on the next ladder row)" + method: [walk] + on_fail: retry + anti_ban: + delay: [400, 1600] + + - id: a17 + step: move-or-hop-empty + verb: hop_world + target: "current spot" + params: + when: "no interactable tree for > 30s at the pinned spot, or the spot is crowded for > 60s (shared spots: Draynor willows, Edgeville yews)" + then: "move to the next tree at the same spot (walk_to), then hop_world if the spot is still empty for > 60s" + on_fail: retry + anti_ban: + delay: [800, 2200] + + - id: a18 + step: take-break + verb: take_break + target: "current location" + params: + duration: "[45, 150] seconds of idle AFK per break (run_control.break.for)" + safe: "bank modes: break lands at the bank after deposit_logs (inventory empty, axe safe); drop mode: break at the spot with an empty inventory — logs dropped, axe in hand; NEVER mid-chop (axe in tree) or mid-walk" + resume: "continue from the pinned activity state (mode/tree/spot unchanged): bank modes -> walk_back (a16) -> chopping; drop mode -> chopping" + on_fail: skip + +run_control: + iteration: "one full inventory cycle: chop load -> (drop at the spot) or (bank + walk back) -> resume chopping the pinned tree; cycle length = 28 x chop_seconds_per_log + drop or bank legs per woodcutting_modes.model" + max_iterations: null + break: + every: [8, 14] # inventory cycles (breaks land at the bank in bank modes, at the spot in drop mode — INDEPENDENT of incidental banking) + for: [45, 150] # seconds of idle AFK + stop_conditions: + - "woodcutting level >= target (fast-xp / afk-xp) or GP target banked (banked-income)" + - "axe lost and unobtainable at bank + GE (recovery exhausted)" + - "3 consecutive empty-tree waits after hop_world (world dead or spot gone — stop and report)" + - "3 consecutive failed clicks (client issue — do not loop)" + + # ================= WORKED EXAMPLES (owner 10288: each mode = preparation -> first + # full-inventory boundary -> first scheduled break -> resume) ================= + # Rates below are the model-chain values from woodcutting_modes + # (validator-pinned; the SELECTED ladder row's chop/walk/respawn + the + # 27/28-slot capacity from axe_equipped — see woodcutting_fixtures). + # + # --- fast-xp @ level 50 (pinned per governor 10248: level-50 example) --- + # Account: 50 Woodcutting, 40+ Attack, combat 15+. Mode fast-xp, ladder + # first match = willow@30 combat-15-plus -> Draynor willows, bank Draynor. + # a1 pins {mode fast-xp, tree willow, spot draynor-willows, bank draynor, + # axe rune, axe_equipped true}. Preparation (prepare state): a2 check-axe + # -> owned -> a3 withdraw-axe; a8 rune axe equip gate — 40 Attack >= 40 -> + # EQUIPPED (28 log slots); a9 loadout verified. + # a11 chop: 28 logs at ~4s each (112s) -> a12 inventory full -> a14 walk to + # Draynor bank (~12s) -> a15 deposit (4s) -> a16 walk back (~12s). + # FIRST FULL-INVENTORY BOUNDARY crossed at 140s: 28 willow logs banked. + # Cycle x respawn 0.92 x break 0.94 = 622 logs/hr model; xp 41,985/hr. + # Every 8-14 cycles (mid ~11 = ~25.7 min), a18 take-break 45-150s LANDS AT + # THE BANK (post-deposit, inventory empty, axe safe) — a scheduled break, + # independent of the incidental per-load banking. Resume: a16 walk back, + # chopping continues from the pinned state. + # Session target 250k xp: ~6h of cycles + 6-7 breaks -> complete. + # + # --- afk-xp @ level 50 with Dragon Slayer I (pinned level-50 example) --- + # Account: 50 Woodcutting, 1 Attack, Dragon Slayer I done. Mode afk-xp, + # ladder first match = maple@45 dragon-slayer-i -> Corsair Cove Resource + # Area. a1 pins {mode afk-xp, tree maple, spot corsair-cove-resource-area, + # bank none-drop, axe rune, axe_equipped FALSE}. a2/a3: rune axe owned -> + # withdrawn; a8: rune axe usable at 41 WC -> selected; 1 Attack < 40 -> + # CARRIED (27 log slots) — the owner coverage case: rune axe used from the + # inventory, never a lower axe. + # a11 chop until 27 logs (~162s at 6s each incl. post-fall reaction); tree + # falls ~60s so the spot's 4 trees rotate; a12 full -> a13 DROP the load at + # the spot (~27s) — no bank legs at all. FIRST FULL-INVENTORY BOUNDARY at + # ~189s (dropped, not banked). Cycle x respawn 0.80 x break 0.94 = 387 + # logs/hr model; xp ~38,670/hr; REALIZED GP 0 (drop mode — log value 12 + # gp/log is reference/opportunity cost only, never realized income); + # ~387 clicks/hr — the lowest-interaction mode. a18 break 45-150s AT THE + # SPOT (inventory empty, axe in hand) every 8-14 cycles, INDEPENDENT of the + # per-load dropping. Resume: a11 continues. + # WITHOUT Dragon Slayer I the same account falls back to willow@30 + # (combat-15-plus) — deterministic, logged, never a silent maple + # (woodcutting_fixtures level-50 no-quest row). + # + # --- banked-income @ level 70 (pinned level-70 example) --- + # Account: 70 Woodcutting, 40+ Attack, combat 15+. Mode banked-income, + # ladder first match = yew@60 -> Edgeville south yews, bank Edgeville. + # a1 pins {mode banked-income, tree yew, spot edgeville-south-yews, bank + # edgeville, axe rune, axe_equipped true}; a2/a3 withdraw owned rune axe; + # a8 equipped (40 Attack). a11 chop: 28 yew logs at ~7.5s each (210s; yew + # falls ~114s, respawns ~59.4s — the 2-tree spot rotates, occasionally + # both down -> respawn_duty 0.52). a12 full -> a14 walk to Edgeville bank + # (~8s) -> a15 deposit (4s) -> a16 walk back (~8s). FIRST FULL-INVENTORY + # BOUNDARY at 230s: 28 yew logs banked. Cycle x respawn 0.52 x break 0.94 + # = 214 logs/hr model; xp 37,450/hr; gp 23,754/hr (111 gp/log). a18 break + # 45-150s AT THE BANK every 8-14 cycles (mid ~11 = ~47 min). Session + # target: 1M gp banked = ~42h of cycles + breaks. + # Below 60 the same mode banks willow@30 (fallback) — yew never below 60. + # + # All three examples: preparation (a1-a9: arm-run -> check-axe -> + # withdraw-or-GE-buy -> collect/verify -> equip-or-carry -> build-loadout) + # -> first full-inventory boundary -> first scheduled break -> correct + # resume from the pinned activity state; tree depletion -> move/hop (a17); + # access denial / missing axe -> explicit recovery (a2-a7 bounded GE loop, + # a16 spot_check, re-arm fallback — an unobtainable axe FAILS the run with + # ERROR + alert, it never completes). + # ====================================================================== + +risk: + ban_level: low + notes: + - "Woodcutting is one of the most botted skills; keep chop cadence humanised and bank rhythm varied (anti_ban delays + per-mode chop_seconds_per_log)." + - "Draynor willow spot is HOSTILE for accounts under combat 15: two aggressive level-7 Dark wizards attack players below that threshold — the ladder routes sub-15 accounts to the Edgeville willow via a separate deterministic row (combat-under-15). The no-combat claim applies ONLY to the gated routes; treat Draynor as a combat zone for low-level accounts." + - "Shared tree spots (Draynor willows, Edgeville yews): crowded > 60s -> hop_world (a11); do not camp a single world for hours." + - "Maple route (Corsair Cove Resource Area): quest-gated (Dragon Slayer I) and far from a bank — afk-xp only, drop mode; never select it without the quest gate (ladder + state machine enforce)." + - "The axe always stays with the account: deposit_logs deposits logs ONLY; an axe is never deposited, alched, or traded away mid-run. Losing the axe is a stop-and-report event, not a silent downgrade." + - "Mode discipline is the anti-detection edge: one pinned mode per run, deterministic ladder, explicit fallbacks — no mid-run mode flips." + +sources: + - "OSRS wiki — Free-to-play Woodcutting training (full tier ladder, 2026-08-27 re-verified 2026-08-28): maple = Corsair Cove Resource Area ONLY for F2P, requires Dragon Slayer I; yew 'earn a profit' but 'recommended to cut willow or maple trees for experience'; 'best place to chop yew trees is south of Edgeville bank'; willow spots 'south-west of the bank in Draynor Village, but beware of aggressive dark wizards' + 'one tree East of Edgeville that is very close to a bank'; oak spots incl. south-west of Varrock west bank; drop-vs-bank trade-off" + - "OSRS wiki — Draynor Village (two aggressive level-7 Dark Wizards near the willows 'will attack any players under combat level 15')" + - "OSRS wiki — Woodcutting (axe table: F2P axes Bronze 1, Iron 1, Steel 6, Black 11, Mithril 21, Adamant 31, Rune 41, Gilded 41; 'best axes available in Free-to-play'; Dragon 61+ members)" + - "OSRS wiki axe pages raw wikitext, verified 2026-08-28: Rune axe 'Level 41 Woodcutting is required to use this axe' + 'Attack level of 40 to wield this axe'; Gilded axe 'Attack level of 40 to wield ... Woodcutting level of 41 to use'; Black axe 'requires level 11 Woodcutting. Level 10 Attack is required to equip'; Dragon axe 'requiring 60 Attack to wield' and '61 Woodcutting to use' (members) — the Woodcutting number is the USE requirement, Attack is the EQUIP requirement" + - "OSRS wiki — Tree (members=No, level 1, 25 xp), Oak tree (15, 37.5 xp), Willow tree (30, 67.5 xp; falls 50 ticks/30s), Maple tree (45, 100 xp; members1=No Corsair Cove Resource Area — only F2P maples; falls 100 ticks/60s), Yew tree (60, 175 xp; falls 190 ticks/114s, respawn 99 ticks/59.4s) — infoboxes 2026-08-27/28" + - "OSRS wiki — Corsair Cove Resource Area ('requires completion of Dragon Slayer I to enter'; 4 maple trees + 3 yew trees; only F2P maple location)" + - "OSRS wiki — Pine tree (scenery in Shayzien/Varlamore, CANNOT be chopped — disproves any '45 Pine' tier)" + - "prices.runescape.wiki mapping + latest 2026-08-28 (live buy/sell + 4h limits: logs 1511 22/23 limit 15000; oak 1521 39/40 limit 15000; willow 1519 20/21 limit 15000; maple 1517 11/12 limit 15000; yew 1515 107/111 limit 12000; rune axe 1359 7140/7293 limit 40; black 1001/1009; adamant 573/600; mithril 350/350)" + - "Governor review comments on PR #12: 10178 (bounce 1 — maple F2P ladder, deterministic tier selection), 10216 (bounce 2 — DS I scoping to the maple tier), 10248 (bounce 3 — axe USE-vs-EQUIP conflation, nondeterministic tier 'or'), 10263 (governor guidance request — mode options A/B/C), 10398 (bounce 4 — executable axe preparation chain, missing-tools -> failed, drop transition gated to afk-xp, per-selected-row dynamic 27/28-slot rates with fixtures, drop-mode realized GP zero)" + - "Owner decision comment 10288 (2026-08-28): three modes fast-xp / afk-xp / banked-income; deterministic lower fallbacks + pinned activity state; level-50 and level-70 worked examples; full per-mode lifecycle (prereqs, axe acquisition, initial inventory, travel, depletion/respawn, one chop attempt, inventory-full, complete dropping or banking route, inventory reconstruction, breaks independent of incidental banking, recovery from unavailable trees / invalid access / missing tools / path failure, completion); realized rates incl. travel/respawn/dropping-or-banking/breaks; axe model 41 WC to use / 40 Attack to equip with the 1-Attack/41-WC rune-axe carry coverage; repair the SAME issue-3 branch, no replacement PR" + - "Owner decision comment 10499 (2026-08-29): authorize one more repair iteration implementing governor 10398 exactly — executable prepare path (check -> withdraw or GE open/buy/bounded-wait/cancel-refund/collect -> verify -> equip-or-carry -> build inventory -> travel), unobtainable axe -> failed with error/alert (never complete), inventory-full drop transition exclusive to the drop mode, per-selected-profile 27/28-capacity rates with level-1 / level-50 quest+no-quest / level-70 fixtures, realized GP zero for drop modes with log value as reference-only; repair this PR, no replacement" +verified: "2026-08-28" # keep quoted (YAML 1.1 date parsing); prices + axe pages re-verified live 2026-08-28 + +related: + - low-alch-f2p + - f2p-alch-catalog + - flip-candidates diff --git a/schemas/entry.schema.json b/schemas/entry.schema.json index 8c51296..532bffb 100644 --- a/schemas/entry.schema.json +++ b/schemas/entry.schema.json @@ -344,6 +344,123 @@ } }, + "axe_tiers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["axe", "woodcutting_level", "attack_level", "members"], + "additionalProperties": false, + "properties": { + "axe": { "type": "string" }, + "woodcutting_level": { "type": "integer", "minimum": 1, "maximum": 99 }, + "attack_level": { "type": "integer", "minimum": 1, "maximum": 99 }, + "members": { "type": "boolean" } + } + } + }, + "axe_examples": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["account", "woodcutting_level", "attack_level", "expected_axe", "expected_equipped"], + "additionalProperties": false, + "properties": { + "account": { "type": "string" }, + "woodcutting_level": { "type": "integer", "minimum": 1, "maximum": 99 }, + "attack_level": { "type": "integer", "minimum": 1, "maximum": 99 }, + "expected_axe": { "type": "string" }, + "expected_equipped": { "type": "boolean" } + } + } + }, + "woodcutting_modes": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "required": ["mode", "intent", "inventory_policy", "ladder", "fallback", "model", "rates"], + "additionalProperties": false, + "properties": { + "mode": { "enum": ["fast-xp", "afk-xp", "banked-income"] }, + "intent": { "type": "string", "minLength": 10 }, + "inventory_policy": { "enum": ["bank", "drop"] }, + "fallback": { "type": "string", "minLength": 3 }, + "ladder": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["tree", "min_level", "gate", "spot", "bank", + "chop_seconds_per_log", "walk_seconds_each_way", + "respawn_duty", "log_price"], + "additionalProperties": false, + "properties": { + "tree": { "enum": ["tree", "oak", "willow", "maple", "yew"] }, + "min_level": { "type": "integer", "minimum": 1, "maximum": 99 }, + "gate": { "enum": ["none", "combat-15-plus", "combat-under-15", "dragon-slayer-i"] }, + "spot": { "type": "string", "minLength": 3 }, + "bank": { "type": "string", "minLength": 3 }, + "chop_seconds_per_log": { "type": "number", "exclusiveMinimum": 0 }, + "walk_seconds_each_way": { "type": "number", "minimum": 0 }, + "respawn_duty": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, + "log_price": { "type": "number", "exclusiveMinimum": 0 } + } + } + }, + "model": { + "type": "object", + "required": ["drop_seconds_per_log", "deposit_seconds", "break_duty"], + "additionalProperties": false, + "properties": { + "drop_seconds_per_log": { "type": "number", "minimum": 0 }, + "deposit_seconds": { "type": "number", "minimum": 0 }, + "break_duty": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 } + } + }, + "rates": { + "type": "object", + "required": ["logs_per_hour", "xp_per_hour", "gp_per_hour", "log_price", "price_verified"], + "additionalProperties": false, + "properties": { + "logs_per_hour": { "type": "integer", "exclusiveMinimum": 0 }, + "xp_per_hour": { "type": "integer", "minimum": 0 }, + "gp_per_hour": { "type": "integer", "minimum": 0 }, + "log_price": { "type": "number", "exclusiveMinimum": 0 }, + "price_verified": { "type": "string" } + } + } + } + } + }, + "woodcutting_fixtures": { + "type": "array", + "items": { + "type": "object", + "required": ["account", "mode", "woodcutting_level", "attack_level", + "quest_gate", "combat_level", "expected_tree", "expected_axe", + "expected_equipped", "expected_logs_per_hour", + "expected_xp_per_hour", "expected_gp_per_hour"], + "additionalProperties": false, + "properties": { + "account": { "type": "string", "minLength": 3 }, + "mode": { "enum": ["fast-xp", "afk-xp", "banked-income"] }, + "woodcutting_level": { "type": "integer", "minimum": 1, "maximum": 99 }, + "attack_level": { "type": "integer", "minimum": 1, "maximum": 99 }, + "quest_gate": { "type": "boolean" }, + "combat_level": { "type": "integer", "minimum": 3 }, + "expected_tree": { "enum": ["tree", "oak", "willow", "maple", "yew"] }, + "expected_axe": { "type": "string" }, + "expected_equipped": { "type": "boolean" }, + "expected_logs_per_hour": { "type": "integer", "exclusiveMinimum": 0 }, + "expected_xp_per_hour": { "type": "integer", "minimum": 0 }, + "expected_gp_per_hour": { "type": "integer", "minimum": 0 } + } + } + }, + "state_machine": { "type": "object", "required": ["initial", "terminal", "states"], diff --git a/tools/validate.py b/tools/validate.py index 8134f43..6bfeb68 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -97,11 +97,24 @@ Runs: mutation of the archived record; economy.ceiling_gp_per_hour is the TYPED economic ceiling and 0 <= gp_per_hour_low <= gp_per_hour_high <= ceiling. + - Woodcutting entries (woodcutting tag, owner 10288 + governor + 10398): canonical F2P axe table + equip-vs-carry rule (41 WC to + USE / 40 Attack to EQUIP, a 1-Attack/41-WC account carries and + uses a rune axe), exactly three deterministic modes with + per-SELECTED-row rate derivation (27/28-slot capacity from + axe_equipped, committed woodcutting_fixtures recomputed by this + validator), drop-mode realized GP zero, and state-machine wiring + (executable prepare -> walk_to_trees, no axe -> failed, drop + transition gated to the afk-xp drop mode). - Regression fixtures (--selftest): a fractional-tax flipping entry must FAIL; an above-cap high-value flip with the correctly capped-tax net must PASS; a non-margin entry carrying only `ge` plus a non-flip tag must PASS; plus per-slot scheduler - dispatch, conservation, buy-limit and nesting fixtures. + dispatch, conservation, buy-limit and nesting fixtures, the + Mining pickaxe suite, the Woodcutting axe/mode/fixture/wiring + suite, and the alchemy rate-model/tier/loadout suite — each + suite runs exactly once per invocation (no aliases or + duplicated executions). 3. Global id uniqueness across the whole codex tree. @@ -1803,6 +1816,552 @@ def ge_lifecycle_errors(rel, data): +# --- Woodcutting structured gates (owner 10288 + governor 10398 shape) --- +# 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} +TIER_RANK = {"bronze": 0, "iron": 1, "steel": 2, "black": 3, "mithril": 4, + "adamant": 5, "rune": 6, "gilded": 6, "dragon": 7} +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} + + +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 select_ladder_row(ladder, wc_level, quest_gate, combat_level): + """Deterministic first match over a mode ladder (rows ordered min_level + DESC per the schema contract): the first row whose min_level the account + meets AND whose gate is satisfied. + + Gates: none -> always; combat-15-plus -> combat_level >= 15; + combat-under-15 -> combat_level < 15 (below the Draynor dark-wizard + threshold); dragon-slayer-i -> quest_gate (Corsair Cove Resource Area). + Returns the matched row dict or None.""" + for r in ladder: + if wc_level < int(r.get("min_level", 0)): + continue + gate = r.get("gate", "none") + if gate == "combat-15-plus" and not (combat_level >= 15): + continue + if gate == "combat-under-15" and not (combat_level < 15): + continue + if gate == "dragon-slayer-i" and not quest_gate: + continue + return r + return None + + +def row_rates(row, model, policy, capacity): + """Per-SELECTED-ROW realized rate derivation (governor 10398 finding 4): + the account's ladder row carries its own chop cadence / bank-walk / + respawn duty / log price, so rates derive from the row actually chosen + (and the actual 27/28 capacity from axe_equipped) — never from a fixed + top tree or a hardcoded 28-log cycle. + + Returns (logs_per_hour, xp_per_hour, gp_per_hour) with gp_per_hour = 0 + for drop modes (realized GP; log value is reference-only).""" + chop = float(row.get("chop_seconds_per_log", 0)) + walk = float(row.get("walk_seconds_each_way", 0)) + respawn = float(row.get("respawn_duty", 0)) + price = float(row.get("log_price", 0)) + drop = float(model.get("drop_seconds_per_log") or 0) + deposit = float(model.get("deposit_seconds") or 0) + brk = float(model.get("break_duty") or 0) + tree = row.get("tree") + if tree not in XP_PER_LOG or chop <= 0 or respawn <= 0 or brk <= 0: + return None + if policy == "drop": + cycle = capacity * (chop + drop) + deposit # no bank legs + else: + cycle = capacity * chop + 2 * walk + deposit + if cycle <= 0: + return None + logs = (capacity / cycle) * 3600.0 * respawn * brk + xp = logs * XP_PER_LOG[tree] + gp = 0.0 if policy == "drop" else logs * price + return logs, xp, gp + + +def woodcutting_errors(rel, data): + """Structured gates for woodcutting entries (owner guidance 10288 + + governor 10398; 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; + - mode `rates` is the canonical TOP-ROW reference (ladder[0], axe + equipped = 28 slots) and must equal that row's model chain within + tolerance, with xp/gp derived from the row's tree xp and log price; + - every LADDER ROW carries its own chop/walk/respawn/log_price and the + committed per-account woodcutting_fixtures rows must match the + selected-row derivation (27/28 capacity from axe_equipped) within + tolerance — governor 10398 finding 4; + - drop-policy modes realize ZERO gp (log value is a reference field, + never realized income) — governor 10398 finding 5; + - the state machine wires axe preparation BEFORE travel (a `prepare` + state with a bounded GE-buy chain leading to walk_to_trees), routes + an unobtainable axe to `failed` (never `complete`), and gates the + inventory-full drop transition to the drop policy mode only — + governor 10398 findings 1-3. + """ + 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 axe 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})") + # governor 10398 finding 4: every row must carry its own rate + # constants — rates derive from the SELECTED row, never from a + # fixed top-tree model. + for field in ("chop_seconds_per_log", "walk_seconds_each_way", + "respawn_duty", "log_price"): + if not isinstance(r.get(field), (int, float)): + errors.append(f"{rel}: {mode} {tree} row must carry " + f"numeric {field} (per-selected-row rates, " + f"governor 10398 finding 4)") + 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") + + # --- canonical TOP-ROW `rates` reference (ladder[0], 28 slots equipped) --- + model = m.get("model") or {} + policy = m.get("inventory_policy") + top = row_rates(ladder[0], model, policy, 28) + if top is None: + errors.append(f"{rel}: {mode} top-row rate chain does not compute " + f"(row constants/model numeric, cycle positive)") + continue + t_logs, t_xp, t_gp = top + rates = m.get("rates") or {} + logs = rates.get("logs_per_hour") + if not isinstance(logs, (int, float)) or abs(logs - t_logs) / t_logs > 0.01: + errors.append( + f"{rel}: {mode} logs_per_hour {logs} must equal the SELECTED-row " + f"chain (top row {ladder[0]['tree']}, 28 slots equipped: " + f"{t_logs:.1f}) within 1% — rates are per-selected-row, never " + f"a fixed 28-log top-tree hardcode (governor 10398 finding 4)") + if abs(rates.get("xp_per_hour", -1) - t_xp) / t_xp > 0.02: + errors.append(f"{rel}: {mode} xp_per_hour {rates.get('xp_per_hour')} " + f"must equal the row chain xp {t_xp:.0f} " + f"(logs x {XP_PER_LOG[ladder[0]['tree']]}) within 2%") + if policy == "drop": + # governor 10398 finding 5: dropped logs realize ZERO gp; the log + # sell price is reference/opportunity cost only. + if rates.get("gp_per_hour", -1) != 0: + errors.append(f"{rel}: {mode} inventory_policy drop must " + f"realize gp_per_hour 0 (dropped logs produce no " + f"income; log_price is reference only) — got " + f"{rates.get('gp_per_hour')}") + else: + price = rates.get("log_price") + if isinstance(logs, (int, float)) and isinstance(price, (int, float)): + gp_expect = t_logs * price + if abs(rates.get("gp_per_hour", -1) - gp_expect) / gp_expect > 0.03: + errors.append(f"{rel}: {mode} gp_per_hour " + f"{rates.get('gp_per_hour')} must equal " + f"logs_per_hour x declared log_price " + f"({gp_expect:.0f}) within 3%") + + # --- per-account fixtures: selected-row + 27/28 capacity derivation --- + for fx in data.get("woodcutting_fixtures") or []: + name = fx.get("account", "?") + mode = fx.get("mode") + wc, atk = fx.get("woodcutting_level"), fx.get("attack_level") + qgate = bool(fx.get("quest_gate")) + combat = fx.get("combat_level", 0) + modes_by_name = {str(m.get("mode")): m for m in modes} + m = modes_by_name.get(str(mode)) + if m is None: + errors.append(f"{rel}: fixture '{name}' references unknown " + f"mode {mode!r}") + continue + row = select_ladder_row(m.get("ladder") or [], wc, qgate, combat) + if row is None: + errors.append(f"{rel}: fixture '{name}' (WC {wc}, quest " + f"{qgate}, combat {combat}) matches no ladder row") + continue + if row["tree"] != fx.get("expected_tree"): + errors.append(f"{rel}: fixture '{name}' selected row " + f"{row['tree']}@{row['min_level']} but declares " + f"expected_tree {fx.get('expected_tree')}") + axe, eq = best_axe(wc, atk) + if axe != fx.get("expected_axe") or eq != fx.get("expected_equipped"): + errors.append(f"{rel}: fixture '{name}' — recomputed axe " + f"{axe}/equipped={eq}, declares " + f"{fx.get('expected_axe')}/equipped=" + f"{fx.get('expected_equipped')}") + capacity = 28 if eq else 27 # governor 10398 finding 4 + rr = row_rates(row, m.get("model") or {}, m.get("inventory_policy"), capacity) + if rr is None: + errors.append(f"{rel}: fixture '{name}' rate chain does not " + f"compute (capacity {capacity})") + continue + f_logs, f_xp, f_gp = rr + # fixture values are pinned to the recomputed chain within the same + # tolerances as the mode rates (logs 1% / xp 2% / gp 3%); drop-mode + # fixtures must realize zero gp. + expect_logs = fx.get("expected_logs_per_hour") + if not isinstance(expect_logs, (int, float)) or abs(expect_logs - f_logs) / f_logs > 0.01: + errors.append(f"{rel}: fixture '{name}' expected_logs_per_hour " + f"{expect_logs} must equal the selected-row chain " + f"({row['tree']}@{row['min_level']}, capacity " + f"{capacity}, respawn {row.get('respawn_duty')}, " + f"equipped={eq}) = {f_logs:.1f} within 1%") + expect_xp = fx.get("expected_xp_per_hour") + if not isinstance(expect_xp, (int, float)) or abs(expect_xp - f_xp) / f_xp > 0.02: + errors.append(f"{rel}: fixture '{name}' expected_xp_per_hour " + f"{expect_xp} != selected-row xp {f_xp:.0f} within 2%") + if m.get("inventory_policy") == "drop": + if fx.get("expected_gp_per_hour", -1) != 0: + errors.append(f"{rel}: fixture '{name}' is drop mode — " + f"realized expected_gp_per_hour must be 0") + else: + expect_gp = fx.get("expected_gp_per_hour") + if expect_gp is None or abs(expect_gp - f_gp) / f_gp > 0.03: + errors.append(f"{rel}: fixture '{name}' expected_gp_per_hour " + f"{expect_gp} != selected-row gp {f_gp:.0f} " + f"within 3%") + + # --- state-machine wiring (governor 10398 findings 1-3) --- + sm = data.get("state_machine") or {} + states = set(sm.get("states", {})) + if "prepare" not in states: + errors.append(f"{rel}: state_machine must declare a `prepare` state " + f"(executable axe acquisition before walking to trees — " + f"governor 10398 finding 1)") + else: + tr = sm.get("transitions") or [] + from_to = {(t.get("from"), t.get("to")) for t in tr} + prep_f = sum(1 for t in tr if t.get("from") == "prepare" and t.get("to") == "walk_to_trees") + if not prep_f: + errors.append(f"{rel}: `prepare` must transition to walk_to_trees " + f"when the axe is acquired (governor 10398 finding 1)") + if ("prepare", "failed") not in from_to: + errors.append(f"{rel}: `prepare` must transition to `failed` when " + f"no axe is obtainable after bounded recovery — a " + f"missing tool is a FAILED run, never `complete` " + f"(governor 10398 finding 2)") + # idle -> complete must not be the no-axe escape hatch + for t in tr: + if t.get("from") == "idle" and t.get("to") == "complete": + if re.search(r"no axe|no-axe|obtainable", str(t.get("when", "")), re.I): + errors.append(f"{rel}: idle -> complete must fire only on " + f"the session target, never on a missing axe " + f"(governor 10398 finding 2)") + # inventory-full drop transition gated to the drop policy mode only + drop_trans = [t for t in (sm.get("transitions") or []) + if t.get("from") == "chopping" and t.get("to") == "drop_logs"] + bank_trans = [t for t in (sm.get("transitions") or []) + if t.get("from") == "chopping" and t.get("to") == "walk_to_bank"] + if drop_trans and "afk-xp" not in str(drop_trans[0].get("when", "")): + errors.append(f"{rel}: chopping -> drop_logs must be gated explicitly " + f"to the afk-xp drop mode (inventory_policy drop) — " + f"governor 10398 finding 3") + if not drop_trans and bank_trans: + # drop mode exists somewhere in the modes; if the entry declares a + # drop policy it must also declare the drop transition + if any(str(m.get("inventory_policy")) == "drop" for m in modes): + errors.append(f"{rel}: a drop-policy mode exists but chopping has " + f"no drop_logs transition (finding 3)") + return errors + + +def run_woodcutting_selftest(verbose=False): + """Regression fixtures for the woodcutting structured gates + (canonical axe table, deterministic per-mode ladders, + per-selected-row 27/28 dynamic rates, drop-mode realized GP zero, + prepare/failed wiring, drop-transition gating); 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}") + + # --- woodcutting structured gates (owner 10288 / governor 10398) --- + 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)) + 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 wc_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}], + "state_machine": {"states": {"idle": {"goal": "start", "next": ["arm_run"]}, + "arm_run": {"goal": "pick", "next": ["prepare"]}, + "prepare": {"goal": "acquire axe", "next": ["walk_to_trees", "failed"]}, + "chopping": {"goal": "chop", "next": ["drop_logs", "walk_to_bank"]}, + "drop_logs": {"goal": "drop", "next": ["chopping"]}, + "walk_to_bank": {"goal": "bank", "next": ["chopping"]}, + "failed": {"goal": "fail"}}, + "initial": "idle", "terminal": ["failed"], + "transitions": [ + {"from": "idle", "when": "session target reached", "to": "complete"}, + {"from": "arm_run", "when": "row pinned", "to": "prepare"}, + {"from": "prepare", "when": "axe acquired", "to": "walk_to_trees"}, + {"from": "prepare", "when": "axe unobtainable", "to": "failed"}, + {"from": "chopping", "when": "mode is afk-xp (inventory_policy drop) and inventory full", "to": "drop_logs"}, + {"from": "chopping", "when": "mode is fast-xp or banked-income and inventory full", "to": "walk_to_bank"}]}, + "woodcutting_fixtures": [ + {"account": "f1", "mode": "fast-xp", "woodcutting_level": 50, "attack_level": 40, + "quest_gate": False, "combat_level": 50, "expected_tree": "willow", + "expected_axe": "rune", "expected_equipped": True, + "expected_logs_per_hour": 622, "expected_xp_per_hour": 41985, + "expected_gp_per_hour": 13062}], + "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", + "chop_seconds_per_log": 4.0, "walk_seconds_each_way": 12, + "respawn_duty": 0.92, "log_price": 21}, + {"tree": "willow", "min_level": 30, "gate": "combat-under-15", + "spot": "edgeville willow", "bank": "edgeville", + "chop_seconds_per_log": 4.0, "walk_seconds_each_way": 6, + "respawn_duty": 0.90, "log_price": 21}, + {"tree": "oak", "min_level": 15, "gate": "none", + "spot": "draynor oaks", "bank": "draynor", + "chop_seconds_per_log": 3.5, "walk_seconds_each_way": 8, + "respawn_duty": 0.93, "log_price": 40}, + {"tree": "tree", "min_level": 1, "gate": "none", + "spot": "lumbridge west", "bank": "lumbridge", + "chop_seconds_per_log": 3.0, "walk_seconds_each_way": 10, + "respawn_duty": 0.95, "log_price": 23}], + "model": {"drop_seconds_per_log": 0, "deposit_seconds": 4, + "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", + "chop_seconds_per_log": 6.0, "walk_seconds_each_way": 0, + "respawn_duty": 0.80, "log_price": 12}, + {"tree": "willow", "min_level": 30, "gate": "combat-15-plus", + "spot": "draynor willows", "bank": "none-drop", + "chop_seconds_per_log": 6.0, "walk_seconds_each_way": 0, + "respawn_duty": 0.92, "log_price": 21}, + {"tree": "oak", "min_level": 15, "gate": "none", + "spot": "draynor oaks", "bank": "none-drop", + "chop_seconds_per_log": 5.5, "walk_seconds_each_way": 0, + "respawn_duty": 0.93, "log_price": 40}, + {"tree": "tree", "min_level": 1, "gate": "none", + "spot": "lumbridge west", "bank": "none-drop", + "chop_seconds_per_log": 5.0, "walk_seconds_each_way": 0, + "respawn_duty": 0.95, "log_price": 23}], + "model": {"drop_seconds_per_log": 1.0, "deposit_seconds": 0, + "break_duty": 0.94}, + "rates": {"logs_per_hour": 386, "xp_per_hour": 38600, + "gp_per_hour": 0, "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", + "chop_seconds_per_log": 7.5, "walk_seconds_each_way": 8, + "respawn_duty": 0.52, "log_price": 111}, + {"tree": "willow", "min_level": 30, "gate": "combat-15-plus", + "spot": "draynor willows", "bank": "draynor", + "chop_seconds_per_log": 4.0, "walk_seconds_each_way": 12, + "respawn_duty": 0.92, "log_price": 21}, + {"tree": "oak", "min_level": 15, "gate": "none", + "spot": "draynor oaks", "bank": "draynor", + "chop_seconds_per_log": 3.5, "walk_seconds_each_way": 8, + "respawn_duty": 0.93, "log_price": 40}, + {"tree": "tree", "min_level": 1, "gate": "none", + "spot": "lumbridge west", "bank": "lumbridge", + "chop_seconds_per_log": 3.0, "walk_seconds_each_way": 10, + "respawn_duty": 0.95, "log_price": 23}], + "model": {"drop_seconds_per_log": 0, "deposit_seconds": 4, + "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 (per-row rates, drop-GP-zero, prepare wiring) passes every structured gate", + woodcutting_errors("selftest/wc-good", wc_fake_entry()) == []) + bad = wc_fake_entry(woodcutting_modes=wc_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/wc-missing", bad))) + bad = wc_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/wc-badex", bad))) + bad = wc_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 wc_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/wc-maple45", bad))) + bad = wc_fake_entry(woodcutting_modes=[dict(m, ladder=[dict(r, gate="none") for r in m["ladder"]]) + for m in wc_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/wc-maplegate", bad))) + bad = wc_fake_entry(woodcutting_modes=[dict(m, ladder=[dict(r) for r in m["ladder"][::-1]]) + for m in wc_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/wc-order", bad))) + badm = [dict(m, rates=dict(m["rates"], logs_per_hour=9999)) + for m in wc_fake_entry()["woodcutting_modes"]] + check("modes: a rate far above the model chain (9999/hr) is rejected", + any("SELECTED-row" in e for e in woodcutting_errors("selftest/wc-rate", wc_fake_entry(woodcutting_modes=badm)))) + bad = wc_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/wc-axes", bad))) + badm = wc_fake_entry()["woodcutting_modes"] + badm[0] = dict(badm[0], rates=dict(badm[0]["rates"], xp_per_hour=40000)) + 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/wc-xp", wc_fake_entry(woodcutting_modes=badm)))) + # governor 10398 finding 4: a fixture whose expected logs/XP do not match + # the selected-row chain with the 27/28 capacity is rejected. + bad = wc_fake_entry(woodcutting_fixtures=[ + {"account": "carried-rune-27", "mode": "afk-xp", "woodcutting_level": 50, "attack_level": 1, + "quest_gate": True, "combat_level": 50, "expected_tree": "maple", + "expected_axe": "rune", "expected_equipped": False, + "expected_logs_per_hour": 9999, "expected_xp_per_hour": 999999, + "expected_gp_per_hour": 0}]) + check("modes: a fixture whose declared rates do not match the selected-row 27-slot chain is rejected (governor 10398 finding 4)", + any("selected-row chain" in e for e in woodcutting_errors("selftest/wc-fxrate", bad))) + # governor 10398 finding 5: a drop mode declaring non-zero realized GP. + badm = wc_fake_entry()["woodcutting_modes"] + badm[1] = dict(badm[1], rates=dict(badm[1]["rates"], gp_per_hour=4632)) + check("modes: drop mode with non-zero realized gp_per_hour is rejected (dropped logs realize 0 gp — governor 10398 finding 5)", + any("realize gp_per_hour 0" in e for e in woodcutting_errors("selftest/wc-dropgp", wc_fake_entry(woodcutting_modes=badm)))) + # governor 10398 finding 2: idle -> complete must not be the no-axe hatch. + bad = wc_fake_entry(state_machine=dict( + wc_fake_entry()["state_machine"], + transitions=[{"from": "idle", "when": "no axe obtainable", "to": "complete"}])) + check("modes: idle -> complete on a missing axe is rejected (unobtainable axe must go to failed — governor 10398 finding 2)", + any("never on a missing axe" in e for e in woodcutting_errors("selftest/wc-idlecomplete", bad))) + # governor 10398 finding 3: the drop transition must be gated to afk-xp. + bad = wc_fake_entry(state_machine=dict( + wc_fake_entry()["state_machine"], + transitions=[{"from": "chopping", "when": "inventory full", "to": "drop_logs"}])) + check("modes: an ungated chopping -> drop_logs transition is rejected (drop route exclusive to the afk-xp drop mode — governor 10398 finding 3)", + any("gated explicitly" in e for e in woodcutting_errors("selftest/wc-dropgate", bad))) + + return errors + + def run_selftest(verbose=False): """Regression fixtures for the cross-field gates; error strings.""" errors = [] @@ -2418,11 +2977,13 @@ def main(): errors += run_selftest(verbose=True) errors += run_flip_selftest(verbose=True) errors += run_mining_selftest(verbose=True) + errors += run_woodcutting_selftest(verbose=True) print(f"RESULT: selftest, {len(errors)} errors, 0 warnings") return 1 if errors else 0 # The fixes themselves are gated: all regression suites run on every - # invocation (alchemy/citation, mining model, GE flip lifecycle), + # invocation (alchemy/citation, mining model, GE flip lifecycle, + # woodcutting), # not just --selftest. A nested validator spawned by the # _end_to_end_bad_mapping fixture must not re-run the suites (that # would recurse); the fixture's child scans the tree only. @@ -2430,6 +2991,7 @@ def main(): errors += run_selftest(verbose=False) errors += run_flip_selftest(verbose=False) errors += run_mining_selftest(verbose=False) + errors += run_woodcutting_selftest(verbose=False) entries = load_entries() if not entries: print("ERROR: no codex entries found under codex/") @@ -2524,6 +3086,11 @@ def main(): # (merged from low-alch-members PR #8) errors += alch_worked_example_regression(rel, data, path.read_text()) errors += alch_accounting_regression(rel, data, path.read_text()) + # Woodcutting structured gates (owner 10288 / governor 10398 + # shape: canonical axe table, deterministic per-mode ladders, + # per-selected-row dynamic rates with 27/28 capacity, drop-mode + # realized GP zero, prepare/failed wiring). + errors += woodcutting_errors(rel, data) # 3. staleness / research TODOs if data.get("status") == "verified":